22 KiB
25 - English Leak Deep-Dive (Post-Lockdown "Abspielen" Persistence)
Investigation triggered after the 2026-05-08 multi-agent English-only lockdown sweep landed (server-wide UICulture, per-user UICulture for 9/9, DisplayPreferences CustomPrefs.language for 32 entries, web shim with
navigator.language+ localStorage + Accept-Language strip + CSS hide of language switchers). Operator hard-killed Trivalent (cache + LS + SW wiped) and restarted, yet the Play button STILL renders "Abspielen". Audio + subtitle preferences correctly render English (proof the per-user preference layer IS landing for non-UI surfaces).
Date: 2026-05-08 Investigator: deep-dive sibling agent Mode: read-only on Jellyfin live state, read-only on container, no restarts, no shim modifications. Headless-Chromium reproductions used to prove behaviour rather than theorise.
Prior reading (do not repeat findings from):
docs/15-force-english.md, docs/19-english-only-audit.md,
docs/20-english-only-lockdown.md, docs/22-jellyfin-runtime-perf-audit.md.
1. Executive Summary — actual root cause, with proof
The multi-layer lockdown's per-user Configuration.UICulture pin is
inert with respect to the web SPA's UI-string locale. The web SPA's
jellyfin-web bundle does not read Configuration.UICulture from the
authenticated user object at all — that field is referenced in exactly two
chunks (wizard-start.<hash>.chunk.js and 25583.<hash>.chunk.js), both
of which are admin dashboard forms for the SERVER-WIDE UICulture (the
"Display Language" admin setting), and neither is loaded on a normal user
session. Verified live:
$ docker exec jellyfin grep -lE "UICulture" /jellyfin/jellyfin-web/*.js
/jellyfin/jellyfin-web/index.html # ARRFLIX shim text only
$ docker exec jellyfin grep -lE "UICulture" /jellyfin/jellyfin-web/*.chunk.js
/jellyfin/jellyfin-web/25583.95a80bf8834e61a9a8e4.chunk.js
/jellyfin/jellyfin-web/wizard-start.a4dfcf169516d40c4e52.chunk.js
$ docker exec jellyfin grep -oE ".{40}UICulture.{60}" \
/jellyfin/jellyfin-web/wizard-start.a4dfcf169516d40c4e52.chunk.js
up/Configuration")).then((function(n){n.UICulture=$("#selectLocalizationLanguage",t).val(),
e.ajax({type:"POST...
Both occurrences are POSTs to /System/Configuration (the server-wide
dashboard form), not reads from /Users/{id}.Configuration.
The SPA's actual locale resolver (decompiled from
main.jellyfin.bundle.js) is:
function g(){
return document.documentElement.getAttribute("data-culture")
|| (navigator.language ? navigator.language
: navigator.userLanguage ? navigator.userLanguage
: (navigator.languages?.length) ? navigator.languages[0]
: "en-us");
}
function w(){
var e;
try { e = i.currentSettings.language() } // localStorage.getItem("language")
catch(e){ }
b(e = e || g());
l = S(e); // S = lowercase + replace _ with -
document.documentElement.setAttribute("lang", l);
...
}
i.currentSettings.language() reads localStorage.getItem("language")
(no user prefix — verified via key:"language" lookup with t=false
prefix flag in the Settings.get implementation). Per-user
Configuration.UICulture is never copied into this localStorage key by
any code path in the bundle.
The ARRFLIX shim is the ONLY layer that actually pins the SPA UI
language, by overriding Navigator.prototype.language,
Navigator.prototype.languages, and pre-seeding localStorage.language
to en-US. Headless Trivalent reproductions with explicit
--lang=de-DE --accept-lang=de-DE,de,en confirm the shim works correctly:
$ trivalent --headless=new --lang=de-DE --accept-lang=de-DE,de,en \
--user-data-dir=/tmp/clean-profile --enable-logging=stderr --v=1 \
https://arrflix.s8n.ru/web/index.html
$ grep -E "json.*chunk.js" /tmp/headless.log
...NotifyBeforeURLRequest: https://arrflix.s8n.ru/web/en-us-json.667484b4a441712c7e05.chunk.js
$ grep "<html" /tmp/headless.dump
<html class="preload layout-desktop" dir="ltr" lang="en-us">
So the shim produces the correct chunk request and the correct <html lang> attribute when running against a freshly-isolated browser profile
that never hit arrflix.s8n.ru before. The de-json chunk is never
fetched in this scenario.
Therefore the operator's persistent "Abspielen" is not a leak in any server-side or shim-side layer — it is stale browser-side state that predates the shim deploy (web-overrides/index.html mtime 2026-05-08 17:22:00) and survived the operator's wipe. The candidate stale-state vectors, in order of likelihood:
-
Stale
index.htmlin HTTP disk cache. The server emits noCache-Controlheader on/web/index.html;last-modifiedisFri, 08 May 2026 16:22:00 GMT. Per RFC 7234 §4.2.2, Chromium heuristically caches at0.1 * (now − Last-Modified)≈ 24 minutes when an asset has no Cache-Control. If the operator hit/web/between the shim deploy at 17:22 and the wipe attempt, then for the ~24-minute heuristic window the OLD pre-shim index.html could reload from disk on subsequent visits without a network round-trip. Mullvad-style "clear site data" wipes (cookies, LS, IndexedDB, SW) do NOT always include the HTTP cache for the eTLD+1 — Chromium'schrome://settings/clearBrowserDataexposes "Cached images and files" as a separate checkbox from "Cookies and other site data", and a partial-wipe would leave a stale shim-less index.html in place. The operator's reported wipe path matters here — if it was "Clear site data" via DevTools (LS/SW only) or a Mullvad-style per-origin wipe scoped to storage but not cache, the index.html survives. -
A second browser profile / second browser the operator forgot. The operator has multiple Chromium-family browsers installed (
~/.var/app/{com.google.Chrome, com.google.ChromeDev, org.chromium.Chromium, io.github.ungoogled_software.ungoogled_chromium, net.mullvad.MullvadBrowser}) plus Trivalent at~/.config/trivalent. Trivalent's prefselected_languagesisen-GB,en-US,en(not German), so the German rendering must come from a browser the operator hasn't wiped. A profile that hit arrflix.s8n.ru weeks ago, when no shim was in place, with ade-*Accept-Language at the time, would have its in-place localStoragelanguage=de(set by the SPA's settings persistence on first load) AND its on-disk index.html cache predating the shim. -
The operator's screenshot was captured BEFORE the wipe. "I wiped and still see Abspielen" can be the operator restating an older screenshot rather than reproducing a fresh one. Verifiable only by asking the operator to take a new screenshot post-wipe.
Severity: LOW. The shim is functioning correctly; this is a
deploy-day-only stale-cache window. The clean fix is two lines of
docker-compose / Traefik headers config to set proper Cache-Control
on /web/index.html so future shim deploys propagate without manual
operator wiping.
2. Per-Hypothesis Verdicts
| # | Hypothesis | Verdict | Probe + evidence |
|---|---|---|---|
| 1 | <link rel="prefetch"> for the de chunk fires before the inline shim runs |
Ruled out | grep -ciE 'rel="?(prefetch|preload|modulepreload)' index_de.html → 0. The bundle uses <script defer>, which executes after parsing, AFTER the inline shim has already run. The shim is the first executable JS in the document. |
| 2 | Service Worker pre-cached the de chunk | Ruled out | serviceworker.js is 768 bytes and contains only a notificationclick handler + clients.claim(). No fetch event listener, no precache, no cache.put. Cannot intercept locale chunk loads. Source: curl https://arrflix.s8n.ru/web/serviceworker.js. |
| 3 | Bundle reads <html lang> attr on first paint |
Ruled out | The bundle's locale resolver g() reads document.documentElement.getAttribute("data-culture") — NOT lang. The lang attribute is set by the bundle (via document.documentElement.setAttribute("lang", l) in w()), it is not read. The served HTML opens with <html class="preload" dir="ltr"> — no data-culture, no lang. |
| 4 | Bundle reads document.cookie for locale |
Ruled out | grep -ciE 'document.cookie.{0,40}lang|locale|culture' main.bundle.js → 0. No cookie-based locale path in any bundle. |
| 5 | Hard-coded de-DE fallback in the bundle |
Ruled out | The hard-coded fallback is var f="en-us" (decompiled from main.bundle.js) — used when navigator.language, navigator.languages, navigator.userLanguage, and data-culture are all absent. Falls back to English, not German. |
| 6 | Server sends Content-Language: de |
Ruled out | curl -sI https://arrflix.s8n.ru/web/index.html returns no Content-Language header. |
| 7 | Traefik/upstream content-negotiates locale (Vary: Accept-Language) |
Ruled out | curl -sI returns no Vary header. Both Accept-Language: de-DE,de;q=0.9,en;q=0.5 and Accept-Language: en-US return byte-identical 65485-byte HTML (same etag 1dcdf06cc053bcd). Confirmed via diff -q of two captures. |
| 8 | Per-user DisplayPreferences.CustomPrefs.language writes the wrong key |
Inconclusive (irrelevant) | DisplayPreferences is read by the bundle for chromecastVersion, dashboardTheme, home-section ordering, etc. — not for UI locale. The locale-related code paths (g(), w(), i.currentSettings.language()) read from Navigator.prototype.language, data-culture, and localStorage.getItem("language") only. Per-user DisplayPreferences.CustomPrefs.language could be set to anything and the SPA UI would not change. The 32 entries written by sibling A2 are a no-op for the Abspielen bug. |
| 9 | Cineplex theme injects German strings via CSS content: |
Ruled out | grep -ciE 'content:.{0,80}(Abspielen|Fortsetzen|Anzeigen)' /opt/jellyfin/config/branding/*.css 2>&1 returns 0. Themes are CSS-only and Jellyfin's branding CustomCss is plain CSS, not capable of localising button labels. |
| 10 | Plugin contributes the Play string | Ruled out | GET /Plugins lists 6 plugins (AudioDB, MusicBrainz, OMDb, Open Subtitles, Studio Images, TMDb). All are metadata-source plugins with server-side string surfaces only; none ship web-bundle UI strings. Verified by inspecting each plugin's Plugin.{xml,json} for web/ or client/ resources — none. |
| 11 | Pre-auth chunk request races the shim | Ruled out by reproduction | Headless Trivalent run with --lang=de-DE --accept-lang=de-DE,de,en against a freshly-created --user-data-dir, capturing the full network log: the ONLY locale chunk requested is en-us-json.667484b4a441712c7e05.chunk.js. The de chunk URL is never touched. The shim's Object.defineProperty(Navigator.prototype, 'language', …) runs synchronously during HTML parsing (inline non-defer script), before any deferred bundle script executes (HTML5 spec §4.12.1 — defer scripts execute after parsing in document order; inline scripts execute when the parser reaches them). The locale resolver g() runs inside the deferred bundle, so the override is in effect by the time g() is called. |
| 12 | Browser sends Accept-Language: de-DE and the SPA reads it via fetch echo |
Ruled out | The SPA's locale resolver does NOT make any pre-bundle network request to read its own Accept-Language. The resolver is purely synchronous and only reads navigator.language, navigator.userLanguage, navigator.languages[0], plus the data-culture DOM attr and localStorage.language. Confirmed by full-text grep -ciE 'fetch.{0,200}accept.language|XMLHttpRequest.{0,200}accept.language' main.bundle.js → matches only the ARRFLIX shim's STRIP code, no read. |
3. Concrete remediation, ranked by blast radius
R1 — Add Cache-Control: no-cache on /web/index.html (Traefik header) — RECOMMENDED FIRST
Smallest blast radius. Forces every browser to revalidate the index.html on every visit, so future shim updates propagate within one tab refresh instead of a 10–55 minute heuristic-cache window.
# In /opt/docker/jellyfin/docker-compose.yml under the jellyfin service labels:
- "traefik.http.routers.jellyfin.middlewares=jellyfin-nocache-html@docker"
- "traefik.http.middlewares.jellyfin-nocache-html.headers.customresponseheaders.Cache-Control=no-cache, must-revalidate"
Caveat: this header would be applied to ALL responses on the
jellyfin router, including the immutable hashed chunk files. Chunks
SHOULD remain cacheable forever (they're hash-fingerprinted). Therefore
either:
-
Path A (simpler): apply
no-cacheonly to/web/index.htmlvia a path-scoped middleware, leaving everything else alone:- "traefik.http.middlewares.jellyfin-nocache-html.headers.customresponseheaders.Cache-Control=no-cache, must-revalidate" - "traefik.http.routers.jellyfin-html.rule=Host(`arrflix.s8n.ru`) && Path(`/web/index.html`)" - "traefik.http.routers.jellyfin-html.middlewares=jellyfin-nocache-html@docker" - "traefik.http.routers.jellyfin-html.priority=100" # higher than the catch-all - "traefik.http.routers.jellyfin-html.service=jellyfin@docker" -
Path B (cleaner, better long-term): apply
Cache-Control: public, max-age=31536000, immutableto all/web/*.{js,css,chunk.js}(which Jellyfin upstream already fingerprints) ANDCache-Control: no-cache, must-revalidateto/web/index.htmland/web/manifest.json. This is the conventional SPA cache strategy; we get the best of both worlds (instant chunk load + always-fresh shim).
Do NOT install yet — operator decision required on Path A vs B.
R2 — Operator-side: document the precise wipe procedure for shim updates
Add to docs/20-english-only-lockdown.md "Re-apply procedure" section:
When updating the web shim (
web-overrides/index.htmlor any file bind-mounted into/jellyfin/jellyfin-web/), every active browser session must be wiped with "Clear browsing data" → tick BOTH "Cookies and other site data" AND "Cached images and files" for thearrflix.s8n.ruorigin. DevTools "Storage → Clear site data" alone does NOT clear HTTP disk cache in all Chromium variants; the all-time wipe viachrome://settings/clearBrowserDatais required.
This closes the operator-process gap that left a stale index.html in the operator's browser.
R3 — Stop investing in per-user Configuration.UICulture POSTs
Per the proof in §1, this field has no effect on the web SPA's UI language. It controls only:
- The user object the API returns (so the dashboard form for "edit user" displays the correct value if anyone ever opens it).
- Server-side string surfaces that DO honour per-user culture (Live TV EPG metadata for the API caller, some plugin responses), but NOT the web client UI strings.
Keep bin/force-english-all-users.sh and the
bin/add-jellyfin-user.sh UICulture line for cosmetic consistency
and future-proofing (Jellyfin upstream might wire it up someday), but
stop expecting it to fix UI-string leaks. The shim is the only
thing pinning the UI.
R4 — Defense-in-depth: bind-mount empty de.json chunk stubs
Doc 19 §"Files to Delete" (Path B) proposed this. Still valid as a
belt for Path R1, but high-maintenance (chunk hashes rotate on every
Jellyfin upgrade — currently de-json.1afccc006ab8bb6c5953.chunk.js
but a jellyfin/jellyfin image bump could change it). Defer
indefinitely unless the operator wants the German strings physically
unreachable for paranoia.
R5 — Accept-Language rewrite at Traefik (doc 19 §"Path A — Traefik middleware")
- "traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9"
- "traefik.http.routers.jellyfin.middlewares=arrflix-lang"
Useful but redundant with the existing shim. The shim already
strips Accept-Language on outbound fetch/XHR (verified live in shim
source). The browser-issued INITIAL request to /web/index.html is
the only one that would ever carry Accept-Language, and the index.html
is byte-identical regardless of header (proven in hypothesis 7). So
this rewrite would prevent future Jellyfin upstream behaviour
changes that start using Accept-Language on the index.html response,
but doesn't fix anything currently broken.
4. Why prior audits (15, 19, 20) missed this
Doc 15 correctly diagnosed that the SPA "falls back to
Accept-Language when UICulture is unset" — a CONJECTURE based on
observing that German appeared and that Configuration.UICulture was
absent on every user. The conjecture was never tested by GREPPING THE
WEB BUNDLE for UICulture, which would have shown immediately that
the SPA never reads it. Doc 19 inherited the conjecture verbatim. Doc
20 codified it into the lockdown procedure. Three audits in a row,
all assuming a causal link that doesn't exist.
The actual causal layer (Navigator.prototype.language →
<lang>-json chunk selection) was correctly identified in doc 19's
§"Layer 18" remediation suggestion — which is what shipped as the
shim. The shim is the fix; the per-user UICulture pin is theatre.
After this deep dive, doc 20's "Layer 2" (per-user) and "Layer 1"
(server-wide) sections should be re-labelled as "metadata-affecting"
rather than "UI-affecting", and doc 19's primary-fix table-row should
be flipped from "Layer 5 (per-user UICulture) is the biggest impact"
to "Layer 18 (navigator.language shim) is the only impact on UI
strings".
A subtler lesson: when doc 19 said "all 8 users have UICulture absent
→ that's why German leaks", the audit also noted (table row 16)
that 92 non-English locale chunks are reachable and contain
"Play":"Abspielen" etc. That observation alone, combined with the
chunk-loading code, would have shown the actual mechanism. The fix
was correctly proposed (shim with navigator.language override),
but the diagnosis text emphasised the wrong layer.
5. New hypotheses uncovered during probe
H13 — index.html is served with NO Cache-Control header
Already covered above (R1). Not a leak per se but the mechanism by which ANY future shim deploy can fail to propagate without operator wiping. Critical to fix before the next shim iteration to avoid this whole "why is it still German" dance recurring.
H14 — Operator may have multiple browser profiles/binaries with stale state
Operator has Trivalent (~/.config/trivalent), Chromium
(~/.config/chromium with profile Default and Profile 1),
~/.config/google-chrome-for-testing, plus Flatpak: Chrome,
ChromeDev, Chromium, ungoogled-Chromium, MullvadBrowser. Eight
Chromium-family installs. A wipe of "the browser" plausibly missed at
least one. Probe to ask operator: which exact browser binary +
which exact profile produced the screenshot? "Trivalent default
profile with all storage wiped including HTTP cache" yields a
different conclusion from "Mullvad ad-hoc surgical wipe targeting
storage only".
H15 — Per-user Configuration.UICulture lockdown layer is doing literal nothing
Documented in R3. Worth flagging because the op cost (running
bin/english-lockdown-runner.sh weekly via systemd timer) is nonzero
and we now know the only layer that matters is the shim, which
doesn't need re-running because it's a static bind-mount.
H16 — Chunk URLs in the require.context are immutable per Jellyfin upgrade
Verified: n(73125) maps ./en-us.json → [20233, 79754] and
./de.json → [99810, 89409]. These ID pairs are baked into the
runtime.bundle.js at Jellyfin build time. So a Jellyfin image upgrade
WILL change the chunk hashes (filename, e.g.
de-json.<hash>.chunk.js) but NOT the chunk-id mapping in
runtime.bundle.js. Any defense-in-depth via 1-byte stub bind-mounts
(R4) MUST be regenerated after every image bump — not just the
filename, but if the chunk-id stub-content (self.webpackChunk = …) .push([[CHUNK_ID], {}]) also depends on the chunk-id, then those
lines need re-emission too. Treat as part of the upgrade runbook,
not a one-shot install.
H17 — localStorage.language is shared across all Jellyfin users on the same browser
The settings store reads localStorage.getItem("language") with NO
user-prefix when the prefix flag is false (verified in key: "language" getter signature with t = false). All other
preferences are stored as <userId>-<key>, but language is
specifically global. So if user A on a browser with German pref
loads the SPA pre-shim and the SPA writes localStorage.language = "de" into the user-settings store, then user B on the same browser
inherits the German preference until either the shim runs (which
overwrites it on every load) or the storage is wiped. The shim's
pinLocale() belt re-pins on every visibility change, so this isn't
exploitable, but it IS the ONE persistence mechanism that survives
both server-side UICulture pinning and per-user-DisplayPreferences
writes.
Sign-off
- Mode: read-only on Jellyfin live state (no POST/PATCH/PUT).
Read-only on container (zero
docker execwrites). Shim file unchanged. Headless Trivalent test runs used/tmp/eng-deep-dive/cprofile{,2}isolated profiles, no production browser state touched. - Live evidence captures:
/tmp/eng-deep-dive/index_de.html(65485 bytes, has shim block)/tmp/eng-deep-dive/runtime.bundle.js+main.bundle.js+37869.bundle.js(decompiled to confirm locale-resolver code path)/tmp/eng-deep-dive/headless2.log(only en-us-json chunk requested under explicit German Accept-Language)
- Recommendation order: R1 (Cache-Control no-cache on index.html) → R2 (document wipe procedure) → R3 (stop investing in per-user UICulture for UI). R4 and R5 are optional defense-in-depth, not required to fix the screenshot.
- Next-action owner: operator decides Path A vs B for R1; web agent then applies the chosen Traefik label diff in a single commit.
- Severity: LOW — shim is functioning, this is a stale-cache process gap, not a continuous leak.