# 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 — USER-A'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 USER-A'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 | `SCRUBBED-USER-ID` | **key absent** |
| USER-F | `SCRUBBED-USER-ID` | **key absent** |
| USER-G | `SCRUBBED-USER-ID` | **key absent** |
| USER-A | `SCRUBBED-USER-ID` | **key absent** |
| s8n | `SCRUBBED-USER-ID` | **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 USER-A 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, USER-A):

```bash
TOKEN=<JELLYFIN_API_TOKEN>
USER_ID=SCRUBBED-USER-ID
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.
