#!/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