# 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. | | 6 | Drawer | `nukeSettings()` finds any `a[href*="mypreferencesmenu"]` / `[to*="mypreferencesmenu"]` node, walks up to the closest `li` / `.MuiListItem-root` / `[role="menuitem"]` wrapper and sets `display:none`. Wired into `start()`, the head observer body-twin, and the 1s setInterval. | 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. ### Single-file bind mount inode gotcha The compose volume binds a SINGLE file (not a directory). Docker resolves the bind once at container start and pins to that inode. If your editor or a `python os.replace()` writes via temp-file-and-rename, the canonical path on the host now points to a NEW inode while the container still holds the OLD inode (visible as `Links: 0` in `stat` inside the container, and as a `//deleted` suffix in `/proc//mountinfo` on the host). Symptom: you `scp` a new file, the host shows the new content, but `curl -ks https://arrflix.s8n.ru/web/index.html` still returns the OLD content. Two safe fixes: 1. **In-place truncate (preferred).** Edit with `cat > /opt/.../index.html` redirection or `truncate -s 0` then `cat >>`. This reuses the existing inode, so the bind mount keeps showing the new content. The `scp` command in step 3 above DOES truncate-in-place (not rename), so the standard workflow is safe. 2. **Re-bind via nsenter (if you accidentally orphaned the inode).** Requires privileged container + nsenter into the jellyfin mount namespace: ```bash # Copy current host file into the container's writable /tmp first docker cp /opt/docker/jellyfin/web-overrides/index.html \ jellyfin:/tmp/arrflix-index.html CPID=$(docker inspect jellyfin --format "{{.State.Pid}}") docker run --rm --privileged --userns=host --pid=host -v /:/host \ -e CPID=$CPID debian:stable-slim sh -c ' nsenter -t $CPID -m -p -- umount /jellyfin/jellyfin-web/index.html nsenter -t $CPID -m -p -- mount --bind -o ro \ /tmp/arrflix-index.html /jellyfin/jellyfin-web/index.html ' ``` This relies on the docker-group sudo-bypass trick. The new bind survives until the next container restart, after which Docker re-resolves from the host path natively. --- ## 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. --- ## Why the JS shim handles drawer Settings (not CustomCss) The CustomCss block in `/Branding/Configuration` already includes belt-and-braces selectors for hiding the drawer Settings entry: ```css a[href*="mypreferencesmenu"], li:has(> a[href*="mypreferencesmenu"]), .MuiListItem-root:has(a[href*="mypreferencesmenu"]), .MuiListItemButton-root:has(a[href*="mypreferencesmenu"]), [to="/mypreferencesmenu.html"], .MuiListItem-root:has([to="/mypreferencesmenu.html"]) { display: none !important; } ``` In testing as a non-admin user (`marco`), the entry STILL rendered in the drawer despite all of those rules. Root cause investigation: 1. The bundle source (`main.jellyfin.bundle.js`) renders the Settings link as `(0,a.jsxs)(T.A,{component:C.N_,to:"/mypreferencesmenu.html",...})` where `T.A` is MUI `ListItemButton` and `C.N_` is React Router `NavLink`. 2. NavLink in hash-router mode renders ``. 3. The `a[href*="mypreferencesmenu"]` selector SHOULD match. It does at the link level, but MUI's `ListItemButton` puts the click target on the wrapping `li` which is what the user actually sees as the drawer row. 4. The `:has()` selectors targeting that wrapper depend on browser support for CSS `:has()` (Chromium 105+, Firefox 121+, Safari 15.4+) AND on the bundle's class names matching `.MuiListItem-root` — which the production build may minify or restructure across MUI versions. 5. CustomCss is fetched AFTER the SPA hydrates (see "Pre-bundle critical-path styles" above). Even when matching, there's a brief flash where the entry is visible. The JS shim's `nukeSettings()` sidesteps all of that. It runs in `` before the bundle, on every body mutation, and once per second. It walks up via `closest()` so it finds the wrapper regardless of MUI class minification, and it doesn't rely on `:has()` browser support. The CustomCss rules are kept in place as belt; the JS shim is the suspenders. Both the server-side `EnableUserPreferenceAccess=false` policy AND the drawer hide are needed: the policy stops `/mypreferencesmenu.html` from rendering its controls, the shim stops the link from showing in the first place. --- ## 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. For the colour flash (dark blue / grey before Cineplex CSS arrives), see "Pre-bundle critical-path styles" below. --- ## Pre-bundle critical-path styles A second inline block — a `