CSS selectors in CustomCss (a[href*=mypreferencesmenu], :has(...) wrappers) weren't reliably hiding the entry — bundle renders it via MUI ListItemButton + React Router NavLink and the rendered DOM didn't match the wrapper rules. Add nukeSettings() to the runtime shim: queries any a[href*=mypreferencesmenu] / [to*=mypreferencesmenu], walks up to closest li/.MuiListItem-root/[role=menuitem] and sets display:none. Wired into start(), a new MutationObserver on document.body, and the existing 1s setInterval. CustomCss rules left in place as belt-and-braces. Doc: extend 10-spa-runtime-shim.md with the diagnosis, the bind-mount inode gotcha (single-file binds + os.replace orphans the container's view), and the nsenter-based recovery path.
15 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. |
| 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):
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.
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/<pid>/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:
-
In-place truncate (preferred). Edit with
cat > /opt/.../index.htmlredirection ortruncate -s 0thencat >>. This reuses the existing inode, so the bind mount keeps showing the new content. Thescpcommand in step 3 above DOES truncate-in-place (not rename), so the standard workflow is safe. -
Re-bind via nsenter (if you accidentally orphaned the inode). Requires privileged container + nsenter into the jellyfin mount namespace:
# 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:
- 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.
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:
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:
- The bundle source (
main.jellyfin.bundle.js) renders the Settings link as(0,a.jsxs)(T.A,{component:C.N_,to:"/mypreferencesmenu.html",...})whereT.Ais MUIListItemButtonandC.N_is React RouterNavLink. - NavLink in hash-router mode renders
<a href="#/mypreferencesmenu.html">. - The
a[href*="mypreferencesmenu"]selector SHOULD match. It does at the link level, but MUI'sListItemButtonputs the click target on the wrappingliwhich is what the user actually sees as the drawer row. - 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. - 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 <head> 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 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. 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 <style> tag — sits immediately AFTER <head> and
BEFORE the shim <script>. It exists to kill the ~500ms-1s flash of Jellyfin
default chrome (dark blue + grey + MUI blue submit button) that was visible on
first paint at https://arrflix.s8n.ru before the Cineplex @import from
/Branding/Configuration (CustomCss) finished arriving.
Why CustomCss alone is too slow
CustomCss is fetched via the SPA bundle's call to /Branding/Configuration —
i.e. AFTER the JS bundle parses, executes, and hydrates. So the network
sequence is:
- HTML parses → first paint uses Jellyfin built-in CSS (dark blue / grey).
- JS bundle downloads, parses, executes.
- Bundle calls
/Branding/Configuration, getsCustomCssbody. CustomCssdoes@import url("...jsdelivr.net/...cineplex.css").- jsDelivr round-trip → Cineplex CSS arrives → re-paint to ARRFLIX brand.
Steps 1-4 are dead time. The inline <style> runs at step 1 and pre-paints the
black background + Netflix-red submit button so the gap is invisible.
What's in the block
Lives between <head> and the <script> shim. Contents:
:rootoverrides for--primary-background-color/--background-colorto#000000.html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPagesforced to black bg / white text..raised, .button-submit, .emby-button[type=submit], button[type=submit]forced to Netflix red#E50914so the login submit button doesn't flash MUI blue before the bundle skins it.
It is INTENTIONALLY tiny. It only handles the "kill the wrong colours" critical-path goal. All real branding (logos, fonts, posters, header layout, hover states, etc.) still comes from Cineplex + ARRFLIX CustomCss as before.
Maintenance warning on Jellyfin upgrade
This block targets specific Jellyfin class names. If a future Jellyfin web
release renames .skinBody, .skinHeader, .preload, #reactRoot,
.mainAnimatedPages, .emby-button, or the --primary-background-color
CSS variable, the pre-bundle paint will regress to defaults until the
selectors here are updated.
After every jellyfin/jellyfin image bump:
- Open
https://arrflix.s8n.ruin incognito with throttled 3G in DevTools. - Confirm the page goes from blank → black (not blue/grey).
- Confirm the login submit button is red (not blue) before bundle finishes.
- If either regresses, inspect the new bundle's body class names and update
web-overrides/index.htmlselectors accordingly.
The block does NOT live in bin/inject-shim.py — it's a static <style>,
not a script. Edit web-overrides/index.html directly and redeploy via the
scp step in "Deploying changes" above.
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
curl -ks https://arrflix.s8n.ru/web/index.html | grep -c nukeSettingsreturns 4 (function definition + 3 call-sites: start, body-observer, setInterval)- As non-admin user (
marco), the drawer (hamburger menu) does NOT show a "Settings" entry between Profile and Quick Connect / About