Branding shim: lock <title> + favicon at runtime against SPA overwrites
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.
This commit is contained in:
parent
6bf78d66e1
commit
514dcb6ffc
3 changed files with 351 additions and 1 deletions
103
bin/inject-shim.py
Normal file
103
bin/inject-shim.py
Normal file
|
|
@ -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<icons.length;i++){ if (icons[i].href !== fav) icons[i].href = fav; }
|
||||
} catch(e){}
|
||||
}
|
||||
function start(){
|
||||
lockTitle(); lockFavicon();
|
||||
try {
|
||||
var head = document.head || document.querySelector('head');
|
||||
if (head && window.MutationObserver) {
|
||||
new MutationObserver(function(){ lockTitle(); lockFavicon(); }).observe(head, { childList:true, subtree:true, characterData:true, attributes:true, attributeFilter:['href'] });
|
||||
}
|
||||
} catch(e){}
|
||||
setInterval(function(){
|
||||
var t = document.title || '';
|
||||
if (BARE_RE.test(t) || /Jellyfin/i.test(t)) lockTitle();
|
||||
var fav = getFavicon();
|
||||
if (fav && fav.indexOf('data:image') !== 0) lockFavicon();
|
||||
}, 1000);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once:true });
|
||||
} else { start(); }
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs){
|
||||
regs.forEach(function(r){
|
||||
try {
|
||||
var url = (r.active && r.active.scriptURL) || '';
|
||||
if (url.indexOf('serviceworker.js') !== -1) { r.unregister(); }
|
||||
} catch(e){}
|
||||
});
|
||||
}).catch(function(){});
|
||||
if (window.caches && caches.keys) {
|
||||
caches.keys().then(function(keys){ keys.forEach(function(k){ caches.delete(k); }); }).catch(function(){});
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
})();
|
||||
""" + MARKER_END
|
||||
|
||||
WRAPPED = "<script>" + SHIM + "</script>"
|
||||
|
||||
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"<script>" + re.escape(MARKER_BEGIN) + r".*?" + re.escape(MARKER_END) + r"</script>", 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 <head>
|
||||
needle = "<head>"
|
||||
idx = html.find(needle)
|
||||
if idx < 0:
|
||||
print("ERROR: <head> 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 <head> at offset {insert_at}.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
185
docs/10-spa-runtime-shim.md
Normal file
185
docs/10-spa-runtime-shim.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# 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:
|
||||
|
||||
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 `<title>` 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>[^<]*</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 "<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 "<page name> - ARRFLIX") and never reverts to bare "Jellyfin"
|
||||
- [ ] DevTools > Application > Service Workers shows no active worker for
|
||||
arrflix.s8n.ru after the first hard reload
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue