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:
parent
d2120c636f
commit
d5d68563d2
3 changed files with 508 additions and 0 deletions
202
bin/english-lockdown-runner.sh
Executable file
202
bin/english-lockdown-runner.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
||||
- Owner saw German "Abspielen" on the detail-page Play button.
|
||||
|
|
|
|||
275
docs/20-english-only-lockdown.md
Normal file
275
docs/20-english-only-lockdown.md
Normal 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`.
|
||||
Loading…
Reference in a new issue