276 lines
11 KiB
Markdown
276 lines
11 KiB
Markdown
|
|
# 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`.
|