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.
202 lines
7.2 KiB
Bash
Executable file
202 lines
7.2 KiB
Bash
Executable file
#!/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
|