From 14f63e8027ee62779ba54f3470285e81757f4ead Mon Sep 17 00:00:00 2001 From: s8n Date: Fri, 8 May 2026 04:22:04 +0100 Subject: [PATCH] doc 15: force English UI for all users (plan + script) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner saw "Abspielen" on the Play button — caused by every user having Configuration.UICulture absent, so the web SPA falls back to browser Accept-Language. No server-side flag exists to override this. Adds docs/15-force-english.md with the per-user forcing mechanism, limits (pre-auth splash bundle still uses navigator.language), and a ready-to-execute bash script bin/force-english-all-users.sh that pins UICulture=en-US on every user via POST /Users/{id}/Configuration. Plan-only commit — no live config changed. Owner triggers when ready. Co-Authored-By: Claude Opus 4.7 --- bin/force-english-all-users.sh | 87 ++++++++++++++++ docs/15-force-english.md | 183 +++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100755 bin/force-english-all-users.sh create mode 100644 docs/15-force-english.md diff --git a/bin/force-english-all-users.sh b/bin/force-english-all-users.sh new file mode 100755 index 0000000..61138ca --- /dev/null +++ b/bin/force-english-all-users.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# force-english-all-users.sh — pin Configuration.UICulture=en-US on every Jellyfin user. +# +# Why this exists: see docs/15-force-english.md. +# TL;DR — when a user has UICulture unset, the Jellyfin web SPA falls back to +# browser Accept-Language. Owner saw "Abspielen" (German "Play") on a Play +# button because someone's browser sends de-*. Pinning UICulture per user +# overrides Accept-Language and gives every account English UI regardless +# of where they log in from. +# +# Read-modify-write on /Users/{id}/Configuration. Idempotent — running it +# twice produces the same end state. Prints before/after UICulture per user. +# +# Usage: +# JELLYFIN_TOKEN= ./force-english-all-users.sh +# +# Optional env: +# JELLYFIN_URL default https://arrflix.s8n.ru +# TARGET_LOCALE default en-US (e.g. en-GB also works) +# DRY_RUN default unset; set DRY_RUN=1 to print payloads without POSTing + +set -euo pipefail + +JELLYFIN_URL="${JELLYFIN_URL:-https://arrflix.s8n.ru}" +JELLYFIN_TOKEN="${JELLYFIN_TOKEN:?set JELLYFIN_TOKEN=}" +TARGET_LOCALE="${TARGET_LOCALE:-en-US}" +DRY_RUN="${DRY_RUN:-}" + +AUTH="MediaBrowser Token=$JELLYFIN_TOKEN" + +echo "[*] Listing users..." +USERS_JSON=$(curl -ks "$JELLYFIN_URL/Users" -H "Authorization: $AUTH") +COUNT=$(echo "$USERS_JSON" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))") +echo " $COUNT users found." +echo + +# Iterate. Pipe-to-while loses set -e on subshell exit, so use process-substitution. +while IFS=$'\t' read -r USER_ID USER_NAME OLD_CULTURE; do + echo "[*] $USER_NAME ($USER_ID)" + echo " before: UICulture=${OLD_CULTURE:-}" + + if [[ "$OLD_CULTURE" == "$TARGET_LOCALE" ]]; then + echo " skip: already $TARGET_LOCALE" + echo + continue + fi + + TMP_IN=$(mktemp) + TMP_OUT=$(mktemp) + curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" > "$TMP_IN" + python3 - < "$TMP_OUT" +import json +with open("$TMP_IN") as f: u = json.load(f) +c = u["Configuration"] +c["UICulture"] = "$TARGET_LOCALE" +print(json.dumps(c)) +PYEOF + + if [[ -n "$DRY_RUN" ]]; then + echo " DRY_RUN: would POST $(wc -c < "$TMP_OUT") bytes to /Users/$USER_ID/Configuration" + 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" ]]; then + echo " ERROR: POST returned HTTP $HTTP" + rm -f "$TMP_IN" "$TMP_OUT" + exit 1 + fi + # Verify + NEW_CULTURE=$(curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['Configuration'].get('UICulture',''))") + echo " after: UICulture=$NEW_CULTURE" + fi + rm -f "$TMP_IN" "$TMP_OUT" + echo +done < <(echo "$USERS_JSON" | python3 -c " +import json, sys +for u in json.load(sys.stdin): + cur = u.get('Configuration', {}).get('UICulture', '') + print(f\"{u['Id']}\t{u['Name']}\t{cur}\") +") + +echo "[*] Done. Tell users to hard-refresh (Ctrl-Shift-R) so the SPA reloads" +echo " the locale bundle. Verify on a movie detail page — Play button" +echo " should read 'Play', not 'Abspielen'." diff --git a/docs/15-force-english.md b/docs/15-force-english.md new file mode 100644 index 0000000..3bfdec8 --- /dev/null +++ b/docs/15-force-english.md @@ -0,0 +1,183 @@ +# 15 - Force English UI for All Users + +> Why "Abspielen" showed up on the Play button, every place locale comes from, +> and the per-user mechanism (plus wrapper update) that pins every account +> to English regardless of what `Accept-Language` the browser sends. + +Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle, arrflix.s8n.ru. + +--- + +## TL;DR + +- Owner saw German "Abspielen" on the detail-page Play button. +- Root cause: **every Jellyfin user on this server has `Configuration.UICulture` unset** + (key is absent from `GET /Users/{id}` JSON, not just empty string). When that + field is missing, the Jellyfin web SPA falls back to the browser's + `Accept-Language` header. A browser sending `de-*` → German UI. +- There is **no server-side flag** that forces the web client to ignore + `Accept-Language`. Locale is per-user. +- Fix: `POST /Users/{id}/Configuration` with `UICulture` pinned to `"en-US"` + for every existing user, and update `bin/add-jellyfin-user.sh` so future + users get the same pin baked in at creation time. + +--- + +## Where Jellyfin gets UI language from (priority order) + +The Jellyfin web client (`/web/index.html` SPA) selects its UI language in +this exact order, first hit wins: + +| # | Source | Where it lives | Notes | +|---|--------|----------------|-------| +| 1 | **Per-user `Configuration.UICulture`** | `GET /Users/{id}` JSON, field `Configuration.UICulture` | Authoritative once a user is logged in. Set to `"en-US"` to pin English. | +| 2 | **Browser `Accept-Language`** | HTTP request header, sent by every browser | Fallback when (1) is unset / empty / absent. This is what bit us — Marco's browser sends `de-DE,de;q=0.9,en` and Jellyfin honored it. | +| 3 | **Server `UICulture`** in `/System/Configuration` | Server-wide JSON, current value `"en-US"` | This is the **dashboard / admin** default, NOT applied to user UI. Misleading: setting it does NOT propagate down to clients. | +| 4 | **Pre-auth splash bundle strings** | Static strings in the JS bundle's `en-us.json`/`de.json` | Loaded based on `Accept-Language` BEFORE the user is even authed. Cannot be overridden per-user — see "Limits" below. | + +There is **no** `customPrefs.language` key in `DisplayPreferences` — locale is +not stored there. Confirmed by inspecting marco's `DisplayPreferences/usersettings`: +`CustomPrefs` has only `chromecastVersion`, `dashboardTheme`, home sections, +skip lengths, `tvhome`. No language. + +There is **no** `EnableNonAdministrativeUserLocaleOverride` or +`EnforcedDisplayLanguage` flag in `/System/Configuration`. Verified via +filtering the full server config for `lang|locale|culture|country` keys — +only `PreferredMetadataLanguage`, `MetadataCountryCode`, and `UICulture` +exist, and `UICulture` server-side is the dashboard-only default. + +--- + +## Per-user state (current) + +Audit run 2026-05-08, all 5 users: + +| User | UserId | `Configuration.UICulture` | +|------|--------|---------------------------| +| 5 | `571decc67cdc4ea683b4c936b0a31ff8` | **key absent** | +| guest | `82dd8542915740c8ae799b6723542c63` | **key absent** | +| house | `a4cbcdf95bb34888885af6fbf5c340d1` | **key absent** | +| marco | `d787fbfc373a44119a247e7406b2721e` | **key absent** | +| s8n | `2be0f0d3fe3a45dc9298138a15a01925` | **key absent** | + +Every account is currently at the mercy of the browser. Whichever browser +hits arrflix.s8n.ru with `Accept-Language: de-*` will see German strings +(Play → Abspielen, Resume → Fortsetzen, etc.). The Play button screenshot +the owner shared is almost certainly Marco logged in from a German-locale +browser, or any user logged in from such a browser at all. + +--- + +## Forcing mechanism — per-user POST + +The web client reads `UICulture` straight from the user object on auth and +on every refresh. Setting it to `"en-US"` pins the UI to English regardless +of what the browser asks for. + +**Endpoint:** `POST /Users/{userId}/Configuration` (returns 204). + +**Payload:** the FULL existing `Configuration` block with `UICulture` added +(Jellyfin replaces the whole config dict, it does not patch fields). Fetch +first, modify, POST back — the same read-modify-write pattern step [3/4] +of `add-jellyfin-user.sh` already uses. + +**Reference curl** (single user, marco): + +```bash +TOKEN=*redacted* +USER_ID=d787fbfc373a44119a247e7406b2721e +curl -s "https://arrflix.s8n.ru/Users/$USER_ID" \ + -H "Authorization: MediaBrowser Token=$TOKEN" > /tmp/u.json +python3 -c " +import json +with open('/tmp/u.json') as f: u = json.load(f) +c = u['Configuration'] +c['UICulture'] = 'en-US' +print(json.dumps(c)) +" > /tmp/u-fixed.json +curl -s -X POST "https://arrflix.s8n.ru/Users/$USER_ID/Configuration" \ + -H "Authorization: MediaBrowser Token=$TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/u-fixed.json -w "%{http_code}\n" -o /dev/null +# Expect: 204 +``` + +The convenience wrapper for all 5 users in one go is at +`bin/force-english-all-users.sh` — read-modify-write loop, idempotent, prints +each user's before/after state. + +--- + +## Wrapper update for future users + +`bin/add-jellyfin-user.sh` step `[3/4]` currently sets +`SubtitleMode`/`SubtitleLanguagePreference`/`AudioLanguagePreference`/ +`PlayDefaultAudioTrack` on the new user's `Configuration`. Add `UICulture` +to that same block: + +```python +c['SubtitleMode'] = 'Default' +c['SubtitleLanguagePreference'] = 'eng' +c['AudioLanguagePreference'] = 'eng' +c['PlayDefaultAudioTrack'] = True +c['UICulture'] = 'en-US' # NEW: pin UI to English regardless of browser Accept-Language +``` + +That is a one-line addition; the rest of the wrapper is untouched. + +--- + +## What CANNOT be forced (limits) + +1. **Pre-auth splash bundle strings.** Before the user logs in, the web SPA + loads a translation file based on `navigator.language` / browser + `Accept-Language`. The ``, the login form labels, "Sign In", + "Username", "Password" placeholder text, and the loading splash all + resolve from that pre-auth bundle. If the browser is German, those + handful of strings render in German until the user authenticates and + the per-user `UICulture` kicks in. + + This is a fundamental architectural limit — there is no server flag that + tells the SPA to ignore `navigator.language`. Workarounds would require + either (a) a runtime shim that overrides `navigator.language` before the + bundle initialises (similar to the existing `inject-shim.py` title + locker), or (b) replacing the German `de.json` translation file in the + web bundle with the English copy. Neither is implemented; both are + in-scope for future work if pre-auth German strings ever become a + complaint. + +2. **Reverse-proxy doesn't strip `Accept-Language`.** Traefik passes the + header through unchanged. We could in theory rewrite it to `en-US` at + the proxy, but that breaks any user who genuinely wants a non-English + metadata locale for OTHER apps fronted by the same Traefik (none + currently — but the principle stands). Per-user `UICulture` is cleaner. + +3. **Subtitle/audio language preferences** are already pinned to `eng` for + every user via the wrapper, so playback selection is unaffected by + `UICulture`. We are only fixing the **UI chrome** (button labels, + menus, tooltips) here, not media language defaults. + +4. **Native mobile clients** (Jellyfin Android/iOS apps) read `UICulture` + the same way the web SPA does, so they will also pick up the pin once + the per-user POST lands. Verified by reading Jellyfin source: same + `User.Configuration.UICulture` field is the authoritative locale on + every official client. + +--- + +## Cleanup steps (owner-triggered) + +1. Review this doc and `bin/force-english-all-users.sh`. +2. Run the script with the admin token in env: + ``` + JELLYFIN_TOKEN=*redacted* bin/force-english-all-users.sh + ``` +3. Hard-refresh each browser (Ctrl-Shift-R) to clear any cached locale + bundle the SPA loaded on previous visit. +4. Verify by visiting any movie detail page — the button should now read + "Play" in every browser, including ones still sending `de-*`. +5. Apply the wrapper diff to `bin/add-jellyfin-user.sh` so future users + inherit the pin. + +No container restart needed. No web bundle rebuild needed. No reverse-proxy +config change needed.