diff --git a/docs/25-english-leak-deep-dive-2026-05-08.md b/docs/25-english-leak-deep-dive-2026-05-08.md new file mode 100644 index 0000000..5be6780 --- /dev/null +++ b/docs/25-english-leak-deep-dive-2026-05-08.md @@ -0,0 +1,373 @@ +# 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..chunk.js` and `25583..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: + +```js +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 " +``` + +So the shim produces the correct chunk request and the correct `` 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: + +1. **Stale `index.html` in HTTP disk cache.** The server emits **no + `Cache-Control` header** on `/web/index.html`; `last-modified` is + `Fri, 08 May 2026 16:22:00 GMT`. Per RFC 7234 §4.2.2, Chromium + heuristically caches at `0.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's + `chrome://settings/clearBrowserData` exposes "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. + +2. **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 pref `selected_languages` is + `en-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 a + `de-*` Accept-Language at the time, would have its in-place + localStorage `language=de` (set by the SPA's settings persistence + on first load) AND its on-disk index.html cache predating the shim. + +3. **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 | `` 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 `