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
|
## TL;DR
|
||||||
|
|
||||||
- Owner saw German "Abspielen" on the detail-page Play button.
|
- 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