* feat: add i18n with LingUI, Crowdin integration, and typed error codes - Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities, and test helpers - Wire `@lingui/vite-plugin` + babel macro plugin for web, and `@lingui/metro-transformer` + babel config for native - Add `I18nProvider` to both app roots with locale auto-detection (navigator.language / expo-localization) and persistence (localStorage / MMKV) - Wrap all ~512 user-facing strings across web and native with LingUI macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`) - Add language switcher to Settings in both apps - Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and `appErrorData()` helper for contract `.errors()` schemas - Update all oRPC contract error definitions with typed `data` fields; convert import procedure `throw new Error()` to `ORPCError` with codes - Add per-app `error-messages.ts` utilities that map error codes to localized strings; update global `QueryCache.onError` handlers to stop leaking raw `error.message` to users - Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for automated source upload on merge and translation pull via dispatch - Add `lingui.config.ts` at repo root with `bun run i18n:extract` and `bun run i18n:compile` convenience scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add Crowdin language mapping and ignore context file * fix(i18n): address 20 localization quality issues - Stop leaking raw error.message to users in change-password and account-section; use localized fallbacks instead - Add typed error data to discover contract's .errors() schema - Replace all manual English plural suffixes with LingUI plural() macro in episode counts, backup counts, star ratings, title/image counts - Replace date-fns formatDistanceToNow and hardcoded date patterns with locale-aware formatDate/formatRelativeTime from @sofa/i18n/format - Convert integration-configs from module-scope translations (frozen at import time) to lazy getIntegrationConfigs(i18n) function - Combine split Trans fragments into single translatable units in backup-schedule retention sentence and stats-display period labels - Localize TV media type badge and full episode accessibilityLabel - Replace Android-incompatible Alert.alert language picker (max 3 buttons) with zeego DropdownMenu - Await async locale initialization before hiding splash screen so non-English users don't see English flash on cold start - Add @lingui/core as direct native app dependency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns - Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting - Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size - Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit - Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json` - Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes - Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile` - Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen * fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal - Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English - Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it - Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load - Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider` - Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings * chore(i18n): crowdin sync --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sofa — Self-hosted movie & TV tracker
Sofa is a self-hosted movie and TV tracker for nerds. Track what you've watched, discover what's next, and plug your data into your existing home media stack.
What it does
- Track episode-level progress for TV series and pick shows back up from a dedicated "Continue Watching" view
- Mark movies as watched to discover more like them
- Rate titles, browse cast and crew, and get recommendations based on what you are already tracking
- Search TMDB and explore trending movies and shows without leaving your own instance
- Show streaming availability from TMDB's US provider data
- Automatically log completed watches from Plex, Jellyfin, or Emby webhooks
- Import existing watch history, ratings, and watchlists from Trakt, Simkl, or Letterboxd
- Expose your watchlist as import lists for Sonarr and Radarr
- Runs on SQLite with local image caching, built-in backups, and no external database requirement
- Supports local accounts or OIDC SSO for private instances
Note
Sofa is extremely US-centric right now, in terms of streaming providers, content rating systems, etc. Contributions to address this are more than welcome!
Quick start
A minimal docker-compose.yml is provided in this repo. For most setups, the shortest path is:
- Copy the example environment file:
cp .env.example .env
- Fill in the required values:
TMDB_API_READ_ACCESS_TOKEN=your_tmdb_read_access_token
BETTER_AUTH_SECRET=generate_a_long_random_secret
BETTER_AUTH_URL=http://localhost:3000
- Start the container:
docker compose up -d
- Open
http://localhost:3000and create the first account. The first account becomes the admin automatically, and registration closes after that by default.
The included Compose file uses
ghcr.io/jakejarvis/sofa:edge. If you prefer to pin releases, switch to a published version tag likeghcr.io/jakejarvis/sofa:<version>or use the matchingjakejarvis/sofa:<version>image on Docker Hub. Multi-arch images are published forlinux/amd64andlinux/arm64.
Required setup
TMDB
Sofa uses TMDB for metadata, posters, cast, recommendations, and streaming availability. You need a TMDB API Read Access Token before the app can do anything useful.
Create one here:
Tip
We want the API Read Access Token, not the shorter API key.
Auth secret
BETTER_AUTH_SECRET should be a long random string. To generate one:
npx @better-auth/cli@latest secret
# or
openssl rand -base64 32
Public URL
Set BETTER_AUTH_URL to the real external URL of your instance. This especially matters for login flows and OIDC callbacks behind a reverse proxy (like nginx or Traefik).
Configuration
| Variable | Required | Notes |
|---|---|---|
TMDB_API_READ_ACCESS_TOKEN |
Yes | TMDB metadata and discovery |
BETTER_AUTH_SECRET |
Yes | Session and auth secret |
BETTER_AUTH_URL |
Yes | Public base URL of the app |
DATA_DIR |
No | Root data directory. Defaults to /data in the container |
IMAGE_CACHE_ENABLED |
No | Defaults to enabled. Set to false to use TMDB images directly instead of caching them locally |
LOG_LEVEL |
No | error, warn, info, or debug |
OIDC_CLIENT_ID |
No | Enable OIDC when set with the matching secret and issuer |
OIDC_CLIENT_SECRET |
No | OIDC client secret |
OIDC_ISSUER_URL |
No | OIDC issuer URL |
OIDC_PROVIDER_NAME |
No | Login button label. Defaults to SSO |
OIDC_AUTO_REGISTER |
No | Defaults to true |
DISABLE_PASSWORD_LOGIN |
No | Set to true to hide email/password login when OIDC is configured |
See .env.example for the full list.
Integrations
Sofa ships with two kinds of integrations (for now): incoming watch activity and outgoing import lists.
Incoming watch activity
- Plex: logs completed watches through a webhook URL generated in Sofa. Requires an active Plex Pass license.
- Jellyfin: works through the Jellyfin Webhook plugin.
- Emby: logs completed watches through webhooks. Requires Emby Server 4.7.9+ and an Emby Premiere subscription.
These integrations are user-specific, so each user can connect their own media server account and watch history.
Outgoing import lists
- Sonarr: expose your Sofa TV watchlist as a custom import list
- Radarr: expose your Sofa movie watchlist as a custom import list
Development
For local development:
bun install
cp .env.example .env
bun run dev
Useful commands:
bun run testbun run lintbun run check-typescd packages/db && bun run db:generatecd packages/db && bun run db:migrate
TMDB notice
This product uses the TMDB API but is not endorsed or certified by TMDB.
