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.
This commit is contained in:
s8n 2026-05-08 17:04:09 +01:00
parent d2120c636f
commit d5d68563d2
3 changed files with 508 additions and 0 deletions

202
bin/english-lockdown-runner.sh Executable file
View file

@ -0,0 +1,202 @@
#!/usr/bin/env bash
# english-lockdown-runner.sh — idempotent re-apply of the ARRFLIX English-only lockdown.
#
# See docs/20-english-only-lockdown.md for the full design, layer breakdown,
# and drift-check procedure. This script handles two of the three layers:
#
# 1. Server-wide: UICulture / PreferredMetadataLanguage / MetadataCountryCode
# via POST /System/Configuration.
# 2. Per-user: UICulture / AudioLanguagePreference / SubtitleLanguagePreference /
# PlayDefaultAudioTrack via POST /Users/{id}/Configuration for every account.
#
# The third layer (web SPA shim — navigator.language override + language-switcher
# CSS hide) is served via the bind-mounted web-overrides/ tree; nothing for
# this script to push.
#
# Idempotent — running it twice produces the same end state. Each layer is
# read, merged with English defaults, and POSTed back. Skips writes when the
# server already matches.
#
# Usage:
# JELLYFIN_API_TOKEN=<admin-token> ./english-lockdown-runner.sh
#
# Optional env:
# JELLYFIN_URL default https://arrflix.s8n.ru
# DRY_RUN default unset; set DRY_RUN=1 to print payloads without POSTing
#
# Exit codes:
# 0 every layer landed (or already correct)
# 1 at least one POST failed; check stderr/stdout for which surface
# 2 bad invocation (missing required env)
#
# Token rotation note: the API token has full admin scope. Use a dedicated
# token, not a personal-account session token, and rotate after offboarding
# any operator with shell access to the host running this script.
set -euo pipefail
JELLYFIN_URL="${JELLYFIN_URL:-https://arrflix.s8n.ru}"
JELLYFIN_API_TOKEN="${JELLYFIN_API_TOKEN:?set JELLYFIN_API_TOKEN=<admin-token>; aborting (see docs/20-english-only-lockdown.md)}"
DRY_RUN="${DRY_RUN:-}"
AUTH="MediaBrowser Token=$JELLYFIN_API_TOKEN"
# Server-wide targets
SERVER_UI_CULTURE="en-US"
SERVER_METADATA_LANG="en"
SERVER_METADATA_COUNTRY="US"
# Per-user targets
USER_UI_CULTURE="en-US"
USER_AUDIO_LANG="eng"
USER_SUBTITLE_LANG="eng"
USER_PLAY_DEFAULT_AUDIO="true"
FAIL_COUNT=0
# ---------------------------------------------------------------------------
# Layer 1: server-wide config
# ---------------------------------------------------------------------------
echo "[*] Layer 1: server-wide /System/Configuration"
SERVER_TMP_IN=$(mktemp)
SERVER_TMP_OUT=$(mktemp)
trap 'rm -f "$SERVER_TMP_IN" "$SERVER_TMP_OUT"' EXIT
curl -ks "$JELLYFIN_URL/System/Configuration" -H "Authorization: $AUTH" > "$SERVER_TMP_IN"
CURRENT_SERVER=$(python3 -c "
import json
with open('$SERVER_TMP_IN') as f: c = json.load(f)
print(f\"UICulture={c.get('UICulture','<absent>')} PreferredMetadataLanguage={c.get('PreferredMetadataLanguage','<absent>')} MetadataCountryCode={c.get('MetadataCountryCode','<absent>')}\")
")
echo " before: $CURRENT_SERVER"
NEEDS_SERVER_WRITE=$(python3 -c "
import json
with open('$SERVER_TMP_IN') as f: c = json.load(f)
ok = (
c.get('UICulture') == '$SERVER_UI_CULTURE'
and c.get('PreferredMetadataLanguage') == '$SERVER_METADATA_LANG'
and c.get('MetadataCountryCode') == '$SERVER_METADATA_COUNTRY'
)
print('0' if ok else '1')
")
if [[ "$NEEDS_SERVER_WRITE" == "0" ]]; then
echo " ok: server already pinned, skipping write"
else
python3 - <<PYEOF > "$SERVER_TMP_OUT"
import json
with open("$SERVER_TMP_IN") as f: c = json.load(f)
c["UICulture"] = "$SERVER_UI_CULTURE"
c["PreferredMetadataLanguage"] = "$SERVER_METADATA_LANG"
c["MetadataCountryCode"] = "$SERVER_METADATA_COUNTRY"
print(json.dumps(c))
PYEOF
if [[ -n "$DRY_RUN" ]]; then
echo " DRY_RUN: would POST $(wc -c < "$SERVER_TMP_OUT") bytes to /System/Configuration"
else
HTTP=$(curl -ks -X POST "$JELLYFIN_URL/System/Configuration" \
-H "Authorization: $AUTH" \
-H "Content-Type: application/json" \
--data-binary @"$SERVER_TMP_OUT" -w "%{http_code}" -o /dev/null)
if [[ "$HTTP" == "204" || "$HTTP" == "200" ]]; then
echo " after: UICulture=$SERVER_UI_CULTURE PreferredMetadataLanguage=$SERVER_METADATA_LANG MetadataCountryCode=$SERVER_METADATA_COUNTRY (HTTP $HTTP)"
else
echo " ERROR: POST /System/Configuration returned HTTP $HTTP" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
fi
fi
echo
# ---------------------------------------------------------------------------
# Layer 2: per-user config
# ---------------------------------------------------------------------------
echo "[*] Layer 2: per-user /Users/{id}/Configuration"
USERS_JSON=$(curl -ks "$JELLYFIN_URL/Users" -H "Authorization: $AUTH")
USER_COUNT=$(echo "$USERS_JSON" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
echo " $USER_COUNT users found."
echo
# Process-substitution to keep `set -e` semantics in the loop body.
while IFS=$'\t' read -r USER_ID USER_NAME OLD_UI OLD_AUDIO OLD_SUB OLD_PLAY; do
TMP_IN=$(mktemp)
TMP_OUT=$(mktemp)
curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" > "$TMP_IN"
NEEDS_USER_WRITE=$(python3 -c "
import json
with open('$TMP_IN') as f: u = json.load(f)
c = u.get('Configuration', {})
ok = (
c.get('UICulture') == '$USER_UI_CULTURE'
and c.get('AudioLanguagePreference') == '$USER_AUDIO_LANG'
and c.get('SubtitleLanguagePreference') == '$USER_SUBTITLE_LANG'
and c.get('PlayDefaultAudioTrack') is True
)
print('0' if ok else '1')
")
if [[ "$NEEDS_USER_WRITE" == "0" ]]; then
echo " [ok] $USER_NAME ($USER_ID) — already pinned"
rm -f "$TMP_IN" "$TMP_OUT"
continue
fi
python3 - <<PYEOF > "$TMP_OUT"
import json
with open("$TMP_IN") as f: u = json.load(f)
c = u["Configuration"]
c["UICulture"] = "$USER_UI_CULTURE"
c["AudioLanguagePreference"] = "$USER_AUDIO_LANG"
c["SubtitleLanguagePreference"] = "$USER_SUBTITLE_LANG"
c["PlayDefaultAudioTrack"] = True
print(json.dumps(c))
PYEOF
if [[ -n "$DRY_RUN" ]]; then
echo " [dry] $USER_NAME ($USER_ID) — would POST $(wc -c < "$TMP_OUT") bytes"
else
HTTP=$(curl -ks -X POST "$JELLYFIN_URL/Users/$USER_ID/Configuration" \
-H "Authorization: $AUTH" \
-H "Content-Type: application/json" \
--data-binary @"$TMP_OUT" -w "%{http_code}" -o /dev/null)
if [[ "$HTTP" == "204" || "$HTTP" == "200" ]]; then
echo " [pin] $USER_NAME ($USER_ID) — UICulture=$USER_UI_CULTURE Audio=$USER_AUDIO_LANG Sub=$USER_SUBTITLE_LANG PlayDefault=true (HTTP $HTTP)"
else
echo " [FAIL] $USER_NAME ($USER_ID) — HTTP $HTTP" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
fi
rm -f "$TMP_IN" "$TMP_OUT"
done < <(echo "$USERS_JSON" | python3 -c "
import json, sys
for u in json.load(sys.stdin):
c = u.get('Configuration', {})
print('\t'.join([
u['Id'],
u['Name'],
str(c.get('UICulture', '')),
str(c.get('AudioLanguagePreference', '')),
str(c.get('SubtitleLanguagePreference', '')),
str(c.get('PlayDefaultAudioTrack', '')),
]))
")
echo
# ---------------------------------------------------------------------------
# Summary + exit
# ---------------------------------------------------------------------------
if [[ $FAIL_COUNT -eq 0 ]]; then
echo "[*] Done. All layers pinned (or already correct). Drift-check commands"
echo " in docs/20-english-only-lockdown.md."
exit 0
else
echo "[!] Done with $FAIL_COUNT failure(s). Re-run after investigating;"
echo " drift-check commands in docs/20-english-only-lockdown.md." >&2
exit 1
fi

View file

@ -8,6 +8,37 @@ Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle, arrflix.s8n.ru.
--- ---
## Status as of 2026-05-08 — superseded by lockdown sweep
This doc captured the first pass: identifying that `Configuration.UICulture`
was the per-user knob, building `bin/force-english-all-users.sh`, and
patching `bin/add-jellyfin-user.sh`. That was a partial fix — it pinned the
existing five accounts but did not cover server-wide defaults, the web SPA
pre-auth bundle, or a re-apply mechanism that survives Jellyfin restarts /
new users created out-of-band / config drift over time.
A multi-agent lockdown sweep ran 2026-05-08 to close the remaining gaps:
- **Audit baseline:** `docs/19-english-only-audit.md` — every surface
inventoried, current state per layer, "still drifts" notes.
- **Lockdown procedure + persistence:** `docs/20-english-only-lockdown.md`
the canonical operator doc going forward. Covers server / per-user / web
SPA / Accept-Language layers, ships the idempotent re-apply runner at
`bin/english-lockdown-runner.sh`, and documents the systemd timer the
operator can drop in if they want weekly auto re-application.
- **Web-side overrides:** `web-overrides/english-lockdown.{js,css}` — pin
`navigator.language`, hide the language switcher, force-load the en-us
bundle pre-auth. (Sibling agent, separate commit.)
- **Live server settings:** UICulture + PreferredMetadataLanguage +
MetadataCountryCode pushed to the live `arrflix.s8n.ru` server config.
(Sibling agent, separate commit.)
The body below is preserved verbatim as historical context for **why** the
per-user POST mechanism exists. For day-to-day operations, jump to
`docs/20-english-only-lockdown.md`.
---
## TL;DR ## TL;DR
- Owner saw German "Abspielen" on the detail-page Play button. - Owner saw German "Abspielen" on the detail-page Play button.

View file

@ -0,0 +1,275 @@
# 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`.