doc 15: force English UI for all users (plan + script)
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 <noreply@anthropic.com>
This commit is contained in:
parent
7a0eb93a0a
commit
14f63e8027
2 changed files with 270 additions and 0 deletions
87
bin/force-english-all-users.sh
Executable file
87
bin/force-english-all-users.sh
Executable file
|
|
@ -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=<admin-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=<admin-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:-<absent>}"
|
||||
|
||||
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 - <<PYEOF > "$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','<absent>'))")
|
||||
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'."
|
||||
183
docs/15-force-english.md
Normal file
183
docs/15-force-english.md
Normal file
|
|
@ -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 `<title>`, 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.
|
||||
Loading…
Reference in a new issue