diff --git a/bin/english-lockdown-runner.sh b/bin/english-lockdown-runner.sh new file mode 100755 index 0000000..49b1534 --- /dev/null +++ b/bin/english-lockdown-runner.sh @@ -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= ./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=; 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','')} PreferredMetadataLanguage={c.get('PreferredMetadataLanguage','')} MetadataCountryCode={c.get('MetadataCountryCode','')}\") +") +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 - < "$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 - < "$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 diff --git a/docs/15-force-english.md b/docs/15-force-english.md index 1bf59f5..2cb08eb 100644 --- a/docs/15-force-english.md +++ b/docs/15-force-english.md @@ -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. diff --git a/docs/20-english-only-lockdown.md b/docs/20-english-only-lockdown.md new file mode 100644 index 0000000..b7bb2a6 --- /dev/null +++ b/docs/20-english-only-lockdown.md @@ -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 `