ARRFLIX/docs/20-english-only-lockdown.md
s8n d5d68563d2 docs+bin: English-only lockdown — re-apply runner + doc 20
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.
2026-05-08 17:04:12 +01:00

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:

  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

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:

  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.