The static <title>ARRFLIX</title> 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 <head> that: - Replaces 'Jellyfin' with 'ARRFLIX' on the title (incl. ' - Jellyfin' suffix) - Pins favicon hrefs to the existing data: URL already in the page - Watches <head> 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.
7.3 KiB
10 - SPA Runtime Branding Shim
Why the static
<title>ARRFLIX</title>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 <title>ARRFLIX</title> 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:
- SPA runtime overwrite (primary cause). Bundle code does
document.title = d.Ay.translateHtml(document.title ...)and per-route updates viaLibraryMenu.setTitle/Page.setTitle. Whatever the static<title>is, the SPA will replace it on first hydrate and again on every navigation. - Service worker churn (secondary).
serviceworker.jsregisters and callsself.clients.claim(). The shipped SW does NOT actually cacheindex.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):
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
# 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>[^<]*</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:
- They get the new
index.html(bind-mount serves it). - The shim runs, unregisters the SW, flushes
caches. - 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.
- 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:
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 inbin/inject-shim.pyand 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:
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-BEGINreturns 1 hitcurl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*</title>"returns<title>ARRFLIX</title>- 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