doc 20 covers the multi-layer pin (server / per-user / web SPA / Accept- Language), the idempotent re-apply runner, drift-check curl one-liners, known gaps, and a systemd-timer suggestion for weekly auto re-application. bin/english-lockdown-runner.sh: idempotent runner that POSTs server-wide UICulture / PreferredMetadataLanguage / MetadataCountryCode and per-user UICulture / Audio+Subtitle prefs / PlayDefaultAudioTrack. Reads JELLYFIN_API_TOKEN from env (set -u, refuses to run without it). One-line summary per surface; exit 0 on full success, 1 on any failure. doc 15 prefaced with a "Status as of 2026-05-08" section noting the multi-agent lockdown sweep and cross-linking the audit baseline (doc 19, sibling) and the new lockdown procedure (doc 20). Original body preserved verbatim as historical context.
275 lines
11 KiB
Markdown
275 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`.
|