ad0497ccd6 feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)
* 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>
2026-03-18 12:16:10 -04:00
2026-03-01 11:52:03 -05:00

Sofa
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.

Title page screenshot

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:

  1. Copy the example environment file:
cp .env.example .env
  1. 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
  1. Start the container:
docker compose up -d
  1. Open http://localhost:3000 and 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 like ghcr.io/jakejarvis/sofa:<version> or use the matching jakejarvis/sofa:<version> image on Docker Hub. Multi-arch images are published for linux/amd64 and linux/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 test
  • bun run lint
  • bun run check-types
  • cd packages/db && bun run db:generate
  • cd packages/db && bun run db:migrate

TMDB notice

This product uses the TMDB API but is not endorsed or certified by TMDB.

License

MIT

S
Description
🛋️ Self-hosted movie and TV show scrobbler.
https://sofa.watch Readme MIT
9.3 MiB
Languages
TypeScript 99.1%
CSS 0.4%
Swift 0.3%
Dockerfile 0.1%