From 514dcb6ffc54101d20a308519999576d83b625fb Mon Sep 17 00:00:00 2001 From: s8n Date: Fri, 8 May 2026 03:25:16 +0100 Subject: [PATCH] Branding shim: lock + favicon at runtime against SPA overwrites The static <title>ARRFLIX patch wasn't enough - Jellyfin's bundle calls document.title=... on hydrate and per-route, so the tab kept showing 'Jellyfin'. Add a self-contained inline IIFE in that: - Replaces 'Jellyfin' with 'ARRFLIX' on the title (incl. ' - Jellyfin' suffix) - Pins favicon hrefs to the existing data: URL already in the page - Watches via MutationObserver for SPA churn - Has a 1s setInterval safety net for late-binding navigations - One-shot unregisters the Jellyfin service worker so old clients reload fresh bin/inject-shim.py is the source of truth - idempotent (replaces marker block). docs/10-spa-runtime-shim.md covers root cause, deploy flow, SW eviction, and how to extend the shim on Jellyfin upgrade. --- bin/inject-shim.py | 103 ++++++++++++++++++++ docs/10-spa-runtime-shim.md | 185 ++++++++++++++++++++++++++++++++++++ web-overrides/index.html | 64 ++++++++++++- 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 bin/inject-shim.py create mode 100644 docs/10-spa-runtime-shim.md diff --git a/bin/inject-shim.py b/bin/inject-shim.py new file mode 100644 index 0000000..7fd7430 --- /dev/null +++ b/bin/inject-shim.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Inject the ARRFLIX runtime branding shim into web-overrides/index.html. +Idempotent: if the shim is already present, exits 0 with a notice. +""" +import sys, pathlib, re + +ROOT = pathlib.Path(__file__).resolve().parent.parent +TARGET = ROOT / "web-overrides" / "index.html" +MARKER_BEGIN = "/* ARRFLIX-SHIM-BEGIN */" +MARKER_END = "/* ARRFLIX-SHIM-END */" + +SHIM = MARKER_BEGIN + r""" +(function(){ + var TITLE = 'ARRFLIX'; + var BARE_RE = /^Jellyfin$/i; + function getFavicon(){ + var l = document.querySelector('link[rel="shortcut icon"], link[rel="icon"]'); + return l && l.href ? l.href : null; + } + function lockTitle(){ + try { + var t = document.title || ''; + if (BARE_RE.test(t)) { document.title = TITLE; return; } + if (/Jellyfin/i.test(t)) { + var cleaned = t.replace(/\s*[-|]\s*Jellyfin\s*$/i, '').replace(/Jellyfin/gi, TITLE); + if (!cleaned) { document.title = TITLE; } + else if (!/ARRFLIX/i.test(cleaned)) { document.title = cleaned + ' - ' + TITLE; } + else { document.title = cleaned; } + } + } catch(e){} + } + function lockFavicon(){ + try { + var fav = getFavicon(); + if (!fav || fav.indexOf('data:image') !== 0) return; + var icons = document.querySelectorAll('link[rel*="icon"]'); + for (var i=0;i" + +def main(): + html = TARGET.read_text(encoding="utf-8") + if MARKER_BEGIN in html: + # Replace the existing shim block in place + pattern = re.compile(r"", re.DOTALL) + new_html, n = pattern.subn(WRAPPED, html, count=1) + if n != 1: + print("ERROR: shim markers present but could not match for replacement", file=sys.stderr) + sys.exit(2) + TARGET.write_text(new_html, encoding="utf-8") + print(f"Replaced existing shim ({len(WRAPPED)} chars).") + return + # Insert immediately after + needle = "" + idx = html.find(needle) + if idx < 0: + print("ERROR: not found in target", file=sys.stderr) + sys.exit(1) + insert_at = idx + len(needle) + new_html = html[:insert_at] + WRAPPED + html[insert_at:] + TARGET.write_text(new_html, encoding="utf-8") + print(f"Inserted shim ({len(WRAPPED)} chars) after at offset {insert_at}.") + +if __name__ == "__main__": + main() diff --git a/docs/10-spa-runtime-shim.md b/docs/10-spa-runtime-shim.md new file mode 100644 index 0000000..d2be58d --- /dev/null +++ b/docs/10-spa-runtime-shim.md @@ -0,0 +1,185 @@ +# 10 - SPA Runtime Branding Shim + +> Why the static `ARRFLIX` patch wasn't enough, what the shim does, +> and how to extend it when Jellyfin starts overwriting more state on upgrade. + +Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle. + +--- + +## TL;DR + +`/web/index.html` already says `ARRFLIX` and embeds our logo as the +favicon (data URL). The browser tab still showed "Jellyfin" + the Jellyfin teal +triangle because **Jellyfin's SPA overwrites `document.title` at runtime** as it +hydrates. Two compounding effects: + +1. **SPA runtime overwrite (primary cause).** Bundle code does + `document.title = d.Ay.translateHtml(document.title ...)` and per-route updates + via `LibraryMenu.setTitle` / `Page.setTitle`. Whatever the static `` is, + the SPA will replace it on first hydrate and again on every navigation. +2. **Service worker churn (secondary).** `serviceworker.js` registers and calls + `self.clients.claim()`. The shipped SW does NOT actually cache `index.html` + (it's a notification-only worker), but its presence still pins old clients + to whatever they had at first paint. Killing the SW once forces a clean reload. + +Fix: a tiny self-contained shim in `<head>`, INLINE, BEFORE the bundle scripts. +It enforces the title and favicon both on hydrate and on every later DOM mutation. + +--- + +## What the shim does + +Lives in `web-overrides/index.html` between `<!-- ARRFLIX-SHIM-BEGIN -->` / +`-END` markers. Insertion is idempotent via `bin/inject-shim.py` (re-running the +script REPLACES the existing block instead of stacking duplicates). + +Behaviour: + +| Step | When | What | +|------|------|------| +| 1 | Page parse | `<script>` runs first thing inside `<head>`, before bundle. | +| 2 | DOM ready | `lockTitle()` strips/replaces "Jellyfin"; `lockFavicon()` re-pins icon hrefs to the data URL already in the page. | +| 3 | Mutation | A `MutationObserver` on `document.head` re-runs both lockers when the SPA tries to change `<title>` text or any `<link rel*="icon">` href. | +| 4 | Interval | A 1s `setInterval` is the safety net for late-binding navigations / route changes that bypass the head observer. | +| 5 | Once | `serviceWorker.getRegistrations()` finds anything pointing at `serviceworker.js` and calls `r.unregister()` so old clients reload fresh. `caches.keys()` is also flushed. | + +The shim does NOT re-encode the logo. It reads the existing `data:image/png;base64,...` +href from the `<link rel="shortcut icon">` already in the page. So there's exactly +one copy of the logo data URL in the file. + +--- + +## Files in play + +``` +/tmp/ARRFLIX/ + bin/inject-shim.py # idempotent injector; the source of truth for shim content + web-overrides/index.html # bind-mounted to /jellyfin/jellyfin-web/index.html in the container + docs/10-spa-runtime-shim.md # this file +``` + +Server bind-mount (compose): + +```yaml +volumes: + - /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro +``` + +The container reads the bind-mounted file fresh on each request - no `docker +restart` needed when you re-deploy index.html. + +--- + +## Deploying changes + +```bash +# 1. Edit bin/inject-shim.py (NOT index.html directly - the script is the source of truth) +# 2. Re-run injector locally +python3 /tmp/ARRFLIX/bin/inject-shim.py + +# 3. Copy to nullstone +scp /tmp/ARRFLIX/web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin/web-overrides/index.html + +# 4. Verify +curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "ARRFLIX-SHIM-(BEGIN|END)" +curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*" +``` + +No container recreate is needed; the file is read on every HTTP request. + +--- + +## First-deploy: forcing existing clients to reload + +The shim's `r.unregister()` block runs unconditionally. The first time a user with +an old SW + old `index.html` cached visits the site after this deploy: + +1. They get the new `index.html` (bind-mount serves it). +2. The shim runs, unregisters the SW, flushes `caches`. +3. The current page still shows the old behaviour (because the SW already + intercepted this navigation). They need ONE hard reload (Ctrl+Shift+R or + Cmd+Shift+R) to get a fully clean session. +4. Subsequent visits are clean. + +If you want to STOP unregistering the SW after the wide deploy (e.g. to let +Jellyfin reinstate notification-click handling), edit the script section in +`bin/inject-shim.py`: + +```js +if ('serviceWorker' in navigator) { + // disabled after 2026-05-08 wide rollout + // navigator.serviceWorker.getRegistrations().then(...); +} +``` + +Then re-run the injector and redeploy. There is no harm in leaving it on - the +shipped Jellyfin SW only handles `notificationclick`, which we don't use on a +private invite-only service. + +--- + +## Caveats + +- **Owner's first browser session** must hard-reload once after deploy to evict + the previously-registered service worker. Subsequent reloads, and all + first-time visitors, get the clean experience. +- **CustomCss** (logo replacement on the in-app drawer) is owned by a sibling + agent / `/Branding/Configuration`. The shim does NOT touch CustomCss. If the + in-app drawer logo regresses, that's the CSS branch, not this one. +- **The shim must remain self-contained.** No external `src=`. If it ever needs + more code, add it to the IIFE in `bin/inject-shim.py` and re-run. +- **First-paint flash.** Because the SPA still loads its own bundle, you may + briefly see "Jellyfin" in the tab title before the observer kicks in. Sub-100ms + on a fast connection - acceptable. + +--- + +## Extending the shim on Jellyfin upgrade + +If a future Jellyfin version starts overwriting MORE state (e.g. the manifest +href, the apple-touch-icon, the theme-color meta), extend `start()` in +`bin/inject-shim.py`: + +```js +function lockManifest(){ + var m = document.querySelector('link[rel="manifest"]'); + if (m && m.href.indexOf('fd4301fdc170fd202474.json') === -1) { + m.href = 'fd4301fdc170fd202474.json'; + } +} +function lockMeta(){ + var n = document.querySelector('meta[name="application-name"]'); + if (n && n.content !== 'ARRFLIX') n.content = 'ARRFLIX'; + var t = document.querySelector('meta[id="themeColor"]'); + if (t && t.content !== '#202020') t.content = '#202020'; +} +``` + +Then call them in `start()` and from the `MutationObserver` callback. + +The MutationObserver is already configured with `attributes:true, +attributeFilter:['href']`, so href changes on existing nodes trigger a re-lock. +For new attributes, extend the filter list. + +--- + +## Why not just patch the bundle? + +Tried. The minified bundle has 30+ `document.title=` sites, plus per-page +`pageManager.setTitle()` calls. Patching all of them would produce a brittle +diff that breaks on every Jellyfin upgrade (bundle hashes change). A 70-line +runtime shim sitting on top of the unmodified bundle is the lower-maintenance +path. + +--- + +## Verification checklist + +- [ ] `curl -ks https://arrflix.s8n.ru/web/index.html | grep ARRFLIX-SHIM-BEGIN` returns 1 hit +- [ ] `curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "[^<]*"` returns `ARRFLIX` +- [ ] In a fresh incognito browser, tab title is "ARRFLIX" on `/web/index.html` +- [ ] After login, navigate to a library or item page - tab title stays "ARRFLIX" + (or " - ARRFLIX") and never reverts to bare "Jellyfin" +- [ ] DevTools > Application > Service Workers shows no active worker for + arrflix.s8n.ru after the first hard reload diff --git a/web-overrides/index.html b/web-overrides/index.html index 44c0d10..2377ce0 100644 --- a/web-overrides/index.html +++ b/web-overrides/index.html @@ -1 +1,63 @@ -ARRFLIX
\ No newline at end of file +ARRFLIX
\ No newline at end of file