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.
11 KiB
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:
- No user — admin or non-admin — can flip the UI to a non-English locale,
either through the settings drawer or by deleting their
UICulturevalue and lettingAccept-Languagewin. - No new user created (via
bin/add-jellyfin-user.sh, the web admin panel, or a future API integration) starts in any state other thanen-US. - 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.languagebefore any authentication. Without intervention, ade-*browser sees German login chrome. - User settings drawer language switcher. Even with
UICulturepinned, a user can technically reopenMyProfile/Displayand 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 ofindex.htmlbefore the bundle initialises. Overridesnavigator.language,navigator.languages, and pinslocalStorage["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:
UICultureis pinned per-user (layer 2), so Jellyfin ignores the header for any authenticated request.navigator.languageis 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.shand so misses the wrapper's English defaults). - Re-apply on a schedule for paranoia / drift detection.
One-shot run
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.
# ~/.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
# ~/.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):
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):
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):
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:
-
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: everyjellyfin/jellyfinimage rebuild would have to repeat the bundle swap. Deferred indefinitely; thenavigator.languageoverride inenglish-lockdown.jsis sufficient for current threat model. -
Native mobile clients (Jellyfin Android / iOS apps). These read per-user
UICulturecorrectly, 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). -
Library-level
PreferredMetadataLanguage/MetadataCountryCodeoverrides. 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. -
Subtitle / track display language vs preference language.
SubtitleLanguagePreference=engselects 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 whyConfiguration.UICultureis 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 deliversenglish-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 perdocs/15.