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.
214 lines
9.6 KiB
Markdown
214 lines
9.6 KiB
Markdown
# 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.
|
|
|
|
---
|
|
|
|
## 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 at
|
|
`bin/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}` — pin
|
|
`navigator.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.ru` server 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.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=<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:
|
|
|
|
```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=<JELLYFIN_API_TOKEN> 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.
|