ARRFLIX/web-overrides/ENGLISH-LOCKDOWN.md

6.4 KiB

English Lockdown — Web-side Shim

Browser-side belt for the per-user UICulture pin documented in docs/15-force-english.md. The server-side POST sets the authoritative value; this shim removes every escape hatch the SPA exposes to the user so they can't unpin it from the browser.

Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle, arrflix.s8n.ru.


What this does

The English-lockdown logic lives inside the existing ARRFLIX runtime shim (one self-contained IIFE per docs/10-spa-runtime-shim.md — "the shim must remain self-contained"). Source of truth: bin/inject-shim.py. Compiled output lands in web-overrides/index.html between the ARRFLIX-SHIM-BEGIN / -END markers.

It runs synchronously, before the Jellyfin bundle parses, and pins the UI to English by:

# Mechanism Why
1 localStorage.setItem for appLanguage, selectedlanguage, selectedlocale, language, locale, culture → all set to en-US Belt-and-braces; covers every key Jellyfin web has shipped under across versions.
2 Object.defineProperty(Navigator.prototype, 'language' / 'languages') returning 'en-US' / ['en-US','en'] Jellyfin's pre-auth bundle reads navigator.language to pick the splash translation file. Overriding the prototype getter beats the bundle to it.
3 fetch wrapper — strips Accept-Language header on outbound requests; rewrites POST /Users/{id}/Configuration body to force UICulture: 'en-US' before send Defensive: even if a future Jellyfin build offers a "save language" UI we don't know about, the POST gets rewritten in-flight. The user can't opt out.
4 XMLHttpRequest wrapper — same Accept-Language strip + same Configuration POST rewrite Older Jellyfin bundle code paths use XHR rather than fetch. Belt for the fetch suspenders.
5 pinLocale() re-runs on every start() call AND on the existing 1s setInterval safety net Re-pin storage keys if the SPA tries to clear/rewrite them.

A companion CSS block in the existing critical-path <style> tag (top of web-overrides/index.html) hides every language-switcher widget in the UI: profile prefs dropdown, login page locale picker, header userMenu locale flag, and (via :has()) any future <select> that contains a de-DE/fr-FR/es-ES option.

Where it gets injected from

bin/inject-shim.py            # source of truth for the JS shim (run after edits)
web-overrides/index.html      # the IIFE lives here, between ARRFLIX-SHIM-BEGIN/-END
                              # the CSS hide rules live in the <style> at the top

Container bind-mount (compose, unchanged from docs/10):

volumes:
  - /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro

Deploy workflow

# 1. Edit bin/inject-shim.py  (NOT the IIFE inside index.html directly)
# 2. Re-run injector locally
python3 bin/inject-shim.py
# 3. scp to nullstone
scp web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin/web-overrides/index.html
# 4. Hard-refresh a browser. No container restart needed (single-file bind mount).

CSS-only edits (the <style> block at the very top of index.html) are edited directly — the injector only owns the <script> IIFE.

Verification (operator runs after deploy)

In a fresh incognito browser at https://arrflix.s8n.ru, open DevTools console and run:

localStorage.getItem('appLanguage')      // expect: "en-US"
localStorage.getItem('selectedlanguage') // expect: "en-US"
navigator.language                       // expect: "en-US"
navigator.languages                      // expect: ["en-US", "en"]

Curl-side the file is loading the new shim:

curl -ks https://arrflix.s8n.ru/web/index.html | grep -c english-lockdown
# expect: 2  (one in CSS comment header, one in JS comment header)
curl -ks https://arrflix.s8n.ru/web/index.html | grep -c pinLocale
# expect: 4  (definition + 3 call-sites: start, setInterval, comment)

UI-side:

  • User profile prefs page shows no "Display Language" dropdown.
  • Login page shows no language picker.
  • Header userMenu shows no locale flag/text.
  • After auth, every Play / Resume / Settings label is English even from a browser sending Accept-Language: de-DE,de;q=0.9.

Known limitations

  1. First-paint flash on cold load. The pre-bundle splash strings (login form: "Sign In" / "Username" / "Password") are loaded by the bundle from a static JSON file based on the browser's locale-detection fallback BEFORE the IIFE's Object.defineProperty can intercept. Modern Chromium / Firefox respect the prototype redefinition fast enough that this is sub-50ms in practice — but on a slow connection you may briefly see German login labels before the English bundle replaces them. Acceptable; matches the existing first-paint flash caveat in docs/10.

  2. Accept-Language strip is best-effort. Most browsers prevent JS from removing or modifying the Accept-Language request header on outbound fetch / XHR. The wrapper attempts the delete; if the browser silently ignores it, no harm done — the per-user UICulture pin (server-side, see docs/15) wins regardless.

  3. Object.defineProperty may fail on some embedded WebViews that freeze Navigator.prototype. The shim has a fallback that retries on the navigator instance directly. If both fail, the navigator getters still return browser values, but the localStorage pin and the user-config-save rewrite still hold the line.

  4. CSS :has() has the same Chromium 105+ / Firefox 121+ / Safari 15.4+ floor as the existing drawer-Settings rules. On older browsers the option[value="de-DE"]-conditional hide degrades silently — the simpler select[name="language"] rules still hide the standard dropdown.

Why this is layered with the per-user UICulture POST

The server-side fix (docs/15-force-english.md) is the authoritative mechanism: when a user has Configuration.UICulture = "en-US", the SPA honours it on every login. This shim exists because:

  • New users created outside the wrapper might land without the pin.
  • A future Jellyfin web release might add a "change language" affordance inside the player or a settings deeplink we haven't audited.
  • The pre-auth splash bundle ignores UICulture (the user isn't logged in yet) and reads navigator.language directly.

CSS hide + JS lockdown belt; per-user POST suspenders. Both are needed.