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>
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-Languagethe 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.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=*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)
-
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=*redacted* 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.