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.
9.6 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-Languagethe browser sends.
Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle, arrflix.s8n.ru.
Status as of 2026-05-08 — superseded by lockdown sweep
This doc captured the first pass: identifying that Configuration.UICulture
was the per-user knob, building bin/force-english-all-users.sh, and
patching bin/add-jellyfin-user.sh. That was a partial fix — it pinned the
existing five accounts but did not cover server-wide defaults, the web SPA
pre-auth bundle, or a re-apply mechanism that survives Jellyfin restarts /
new users created out-of-band / config drift over time.
A multi-agent lockdown sweep ran 2026-05-08 to close the remaining gaps:
- Audit baseline:
docs/19-english-only-audit.md— every surface inventoried, current state per layer, "still drifts" notes. - Lockdown procedure + persistence:
docs/20-english-only-lockdown.md— the canonical operator doc going forward. Covers server / per-user / web SPA / Accept-Language layers, ships the idempotent re-apply runner atbin/english-lockdown-runner.sh, and documents the systemd timer the operator can drop in if they want weekly auto re-application. - Web-side overrides:
web-overrides/english-lockdown.{js,css}— pinnavigator.language, hide the language switcher, force-load the en-us bundle pre-auth. (Sibling agent, separate commit.) - Live server settings: UICulture + PreferredMetadataLanguage +
MetadataCountryCode pushed to the live
arrflix.s8n.ruserver config. (Sibling agent, separate commit.)
The body below is preserved verbatim as historical context for why the
per-user POST mechanism exists. For day-to-day operations, jump to
docs/20-english-only-lockdown.md.
TL;DR
- Owner saw German "Abspielen" on the detail-page Play button.
- Root cause: every Jellyfin user on this server has
Configuration.UICultureunset (key is absent fromGET /Users/{id}JSON, not just empty string). When that field is missing, the Jellyfin web SPA falls back to the browser'sAccept-Languageheader. A browser sendingde-*→ 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}/ConfigurationwithUICulturepinned to"en-US"for every existing user, and updatebin/add-jellyfin-user.shso 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=<JELLYFIN_API_TOKEN>
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)
-
Pre-auth splash bundle strings. Before the user logs in, the web SPA loads a translation file based on
navigator.language/ browserAccept-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-userUICulturekicks 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 overridesnavigator.languagebefore the bundle initialises (similar to the existinginject-shim.pytitle locker), or (b) replacing the Germande.jsontranslation 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. -
Reverse-proxy doesn't strip
Accept-Language. Traefik passes the header through unchanged. We could in theory rewrite it toen-USat 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-userUICultureis cleaner. -
Subtitle/audio language preferences are already pinned to
engfor every user via the wrapper, so playback selection is unaffected byUICulture. We are only fixing the UI chrome (button labels, menus, tooltips) here, not media language defaults. -
Native mobile clients (Jellyfin Android/iOS apps) read
UICulturethe 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: sameUser.Configuration.UICulturefield is the authoritative locale on every official client.
Cleanup steps (owner-triggered)
- Review this doc and
bin/force-english-all-users.sh. - Run the script with the admin token in env:
JELLYFIN_TOKEN=<JELLYFIN_API_TOKEN> bin/force-english-all-users.sh - Hard-refresh each browser (Ctrl-Shift-R) to clear any cached locale bundle the SPA loaded on previous visit.
- Verify by visiting any movie detail page — the button should now read
"Play" in every browser, including ones still sending
de-*. - Apply the wrapper diff to
bin/add-jellyfin-user.shso future users inherit the pin.
No container restart needed. No web bundle rebuild needed. No reverse-proxy config change needed.