ARRFLIX/docs/15-force-english.md
s8n 14f63e8027 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>
2026-05-08 04:22:04 +01:00

8 KiB

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):

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:

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.