# 20 - English-Only Lockdown

> Operator doc for the multi-layer English-only lockdown on arrflix.s8n.ru.
> Goal: everything English only, no opt-out, no drift. Server, per-user,
> and web-SPA layers all pinned; idempotent re-apply runner ships in this
> repo so a Jellyfin restart, container recreate, or new-user-out-of-band
> can never quietly reintroduce another locale.

Date: 2026-05-08
Jellyfin version: 10.10.3 (`jellyfin/jellyfin` image)
Live target: `https://arrflix.s8n.ru`

---

## Goal

**Everything English only, no opt-out, no drift.**

Three things this means in practice:

1. No user — admin or non-admin — can flip the UI to a non-English locale,
   either through the settings drawer or by deleting their `UICulture` value
   and letting `Accept-Language` win.
2. No new user created (via `bin/add-jellyfin-user.sh`, the web admin panel,
   or a future API integration) starts in any state other than `en-US`.
3. No server-side default (UI, metadata language, metadata country) drifts
   away from English over time, regardless of Jellyfin upgrades, container
   recreates, or admin-panel touches.

The earlier first-pass attempt (`docs/15-force-english.md`,
`bin/force-english-all-users.sh`) only covered point (2) for the five
existing users at the time it ran. Points (1) and (3) and the persistence
mechanism are handled here.

Audit baseline for "what each layer looked like before this lockdown" is in
`docs/19-english-only-audit.md`.

---

## Layers covered

The Jellyfin locale story is layered, and **each layer must be pinned
independently** — fixing one does not protect the others. The lockdown
covers all four:

### 1. Server-wide

Three keys in `/System/Configuration` (the JSON returned by
`GET /System/Configuration`):

| Key | Pinned value | What it controls |
|---|---|---|
| `UICulture` | `en-US` | Dashboard / admin UI default. Does NOT propagate to user UI (that's per-user — see layer 2) but is still pinned for consistency and so admin chrome never drifts. |
| `PreferredMetadataLanguage` | `en` | Default language for metadata fetched from TMDB / TVDB / etc. when a library has no per-library override. |
| `MetadataCountryCode` | `US` | Default country code for region-specific metadata (release dates, ratings boards, etc.). |

The runner POSTs these via `/System/Configuration` (full read-modify-write —
Jellyfin replaces the whole config dict).

### 2. Per-user

Four keys in each user's `Configuration` object (the nested object inside
`GET /Users/{id}` JSON):

| Key | Pinned value | What it controls |
|---|---|---|
| `UICulture` | `en-US` | The actual UI language the web SPA renders for this user. **This is what fixes the "Abspielen" Play-button bug from doc 15.** |
| `AudioLanguagePreference` | `eng` | Default audio track selection for playback. |
| `SubtitleLanguagePreference` | `eng` | Default subtitle language for playback. |
| `PlayDefaultAudioTrack` | `true` | Play the file's default audio track when languages match — keeps playback deterministic. |

The runner iterates `GET /Users` and POSTs the merged config to
`/Users/{id}/Configuration` for every account.

### 3. Web SPA (pre-auth + UI affordance)

Pinning per-user `UICulture` only kicks in **after** authentication. Two
extra surfaces are pre-auth or user-controllable:

- **Pre-auth bundle strings** (login form, splash, "Sign In" button). The
  SPA picks the bundle based on `navigator.language` before any
  authentication. Without intervention, a `de-*` browser sees German
  login chrome.
- **User settings drawer language switcher.** Even with `UICulture` pinned,
  a user can technically reopen `MyProfile/Display` and pick another
  language — the pin protects the default but not the switcher.

Both are handled by the web overrides shipped in
`web-overrides/english-lockdown.{js,css}` (sibling-agent commit, separate
file from this doc):

- **`english-lockdown.js`** — runs at the top of `index.html` before the
  bundle initialises. Overrides `navigator.language`, `navigator.languages`,
  and pins `localStorage["language"]` to `"en-us"` so the bundle's pre-auth
  locale loader picks English regardless of browser headers.
- **`english-lockdown.css`** — hides the language `<select>` in the user
  settings drawer (`MyProfile/Display`) so users cannot switch off English
  via the UI.

The shim is bind-mounted into the live container the same way the existing
`web-overrides/index.html` is — see `docs/10-spa-runtime-shim.md` for the
mount mechanism, and `docs/19-english-only-audit.md` for the per-surface
inventory the shim covers.

### 4. DNS / `Accept-Language`

Browsers always negotiate locale via the `Accept-Language` HTTP request
header. We deliberately do NOT strip or rewrite it at Traefik (would break
unrelated backends fronted by the same proxy). Instead the server is now
authoritative because:

- `UICulture` is pinned per-user (layer 2), so Jellyfin ignores the header
  for any authenticated request.
- `navigator.language` is overridden in the SPA shim (layer 3), so the
  pre-auth bundle loader doesn't honor the header either.

Net effect: `Accept-Language: de-DE,de;q=0.9,en` arriving from a browser
gets parsed by Jellyfin / the SPA, but every layer that would have used it
has been pinned to English first.

---

## Re-apply procedure

The runner is **idempotent** — running it on an already-locked-down server
is a no-op (each layer is set to its target value, the script verifies and
moves on). It exists to:

- Re-apply after a Jellyfin upgrade (some upgrades reset metadata defaults).
- Re-apply after container recreate (`docker compose down && up`).
- Re-apply after a new user is created via the admin panel (which doesn't
  go through `bin/add-jellyfin-user.sh` and so misses the wrapper's
  English defaults).
- Re-apply on a schedule for paranoia / drift detection.

### One-shot run

```bash
export JELLYFIN_API_TOKEN=<admin-token>           # required
export JELLYFIN_URL=https://arrflix.s8n.ru        # optional, this is the default
bin/english-lockdown-runner.sh
```

Output is a one-line summary per surface: server config block, then one
line per user. Exit code 0 means every layer landed; exit code 1 means at
least one POST failed (script prints which).

### Optional: weekly via systemd timer

If you want automatic re-application (paranoia / catch admin-panel drift),
drop a user-level systemd timer pair. The repo deliberately does not ship
these unit files — it's an operator decision how often to run, and where
the API token comes from on a given host.

```ini
# ~/.config/systemd/user/jellyfin-english-lockdown.service
[Unit]
Description=Re-apply ARRFLIX English-only lockdown
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=%h/.config/arrflix/lockdown.env
ExecStart=%h/code/ARRFLIX/bin/english-lockdown-runner.sh
```

```ini
# ~/.config/systemd/user/jellyfin-english-lockdown.timer
[Unit]
Description=Weekly ARRFLIX English-only lockdown re-apply

[Timer]
OnCalendar=weekly
Persistent=true

[Install]
WantedBy=timers.target
```

`~/.config/arrflix/lockdown.env` should contain
`JELLYFIN_API_TOKEN=<token>` (chmod 600). Enable with
`systemctl --user enable --now jellyfin-english-lockdown.timer`.

---

## Drift-check procedure

Quick verification — run any time without touching state:

**Server-wide (UICulture / metadata):**

```bash
curl -ks "$JELLYFIN_URL/System/Configuration" \
  -H "Authorization: MediaBrowser Token=$JELLYFIN_API_TOKEN" \
  | python3 -c "import json,sys; c=json.load(sys.stdin); print({k:c.get(k) for k in ('UICulture','PreferredMetadataLanguage','MetadataCountryCode')})"
# Expect: {'UICulture': 'en-US', 'PreferredMetadataLanguage': 'en', 'MetadataCountryCode': 'US'}
```

**Per-user (every account):**

```bash
curl -ks "$JELLYFIN_URL/Users" \
  -H "Authorization: MediaBrowser Token=$JELLYFIN_API_TOKEN" \
  | python3 -c "
import json, sys
for u in json.load(sys.stdin):
    c = u.get('Configuration', {})
    print(f\"{u['Name']:10s} UI={c.get('UICulture','<absent>')} A={c.get('AudioLanguagePreference','<absent>')} S={c.get('SubtitleLanguagePreference','<absent>')}\")
"
# Expect every line: UI=en-US A=eng S=eng
```

**Web SPA shim (live bind-mount):**

```bash
curl -ks https://arrflix.s8n.ru/web/english-lockdown.js | head -1
# Expect: an actual JS line, not 404
```

If any of those checks comes back wrong, run the runner:
`JELLYFIN_API_TOKEN=<token> bin/english-lockdown-runner.sh`.

---

## Known gaps

These are explicitly **not** covered by the lockdown. They are documented
here so future operators know what's still possible-but-deferred:

1. **Jellyfin web bundle locale files.** The web bundle still ships
   `de.json`, `fr.json`, `es.json`, etc. inside the immutable Docker image.
   Replacing those bundle files with English copies would harden the
   pre-auth layer further (no German strings on disk → no German strings
   possible) but is **destructive to upstream upgrades**: every
   `jellyfin/jellyfin` image rebuild would have to repeat the bundle swap.
   Deferred indefinitely; the `navigator.language` override in
   `english-lockdown.js` is sufficient for current threat model.

2. **Native mobile clients (Jellyfin Android / iOS apps).** These read
   per-user `UICulture` correctly, so the per-user layer protects them.
   They do NOT load the web SPA shim, so the pre-auth layer does not
   apply (but pre-auth on mobile is just the login form, served from
   client-side localized resources Jellyfin ships in the app — not under
   our control).

3. **Library-level `PreferredMetadataLanguage` / `MetadataCountryCode`
   overrides.** Each library can override the server defaults. The runner
   pins **server** defaults only — library overrides set in the admin
   panel are preserved. Worth a periodic audit
   (`GET /Library/VirtualFolders`) but not part of this lockdown.

4. **Subtitle / track *display* language vs *preference* language.**
   `SubtitleLanguagePreference=eng` selects English subs when present.
   It does NOT translate non-English subs to English. Out of scope —
   that's a media-pipeline concern, not a UI lockdown concern.

---

## Cross-references

- `docs/15-force-english.md` — historical first pass (UICulture per-user
  POST mechanism, "Abspielen" Play-button diagnosis). Read for context on
  *why* `Configuration.UICulture` is the authoritative knob.
- `docs/16-jellyfin-branding-leaks.md` — related lockdown sweep
  (Jellyfin-name and logo redaction). Same pattern: multi-layer pin +
  re-apply runner.
- `docs/19-english-only-audit.md` — pre-lockdown baseline. Per-surface
  state before the sweep ran.
- `docs/10-spa-runtime-shim.md` — explains the web-overrides bind-mount
  mechanism that delivers `english-lockdown.{js,css}` into the live
  container.
- `bin/english-lockdown-runner.sh` — idempotent re-apply runner.
  Run it any time the server might have drifted.
- `bin/add-jellyfin-user.sh` — wrapper for new user creation; already
  bakes in English defaults per `docs/15`.
