22 KiB
16 - Jellyfin Branding Leaks (Read-Only Audit)
Owner wants ALL Jellyfin branding hidden user-side. This doc inventories every place a logged-in non-admin still sees the word "Jellyfin" or the teal/purple triangle logo, and proposes a concrete fix for each.
Last verified: 2026-05-08 against live https://arrflix.s8n.ru running
Jellyfin 10.10.3 (jellyfin/jellyfin image). Probe account: marco
(non-admin, EnableUserPreferenceAccess=false).
This doc is read-only. No CSS POSTs, no bundle edits, no service restarts performed. Implementation is a follow-up branch.
TL;DR — counts
| Surface | Reachable as non-admin? | Raw "Jellyfin" mentions |
|---|---|---|
index.html (live, bind-mount) |
Yes | 0 (already shimmed: title, app-name, favicon, splashLogo) |
PWA manifest fd4301fdc170fd202474.json |
Yes (PWA install + iOS Safari add-to-home + Android install prompt) | 2 (name, short_name) |
| en-us i18n chunk | Yes (3 entries reachable; 19 are admin/dashboard/wizard) | 22 keys, 3 user-reachable |
main.jellyfin.bundle.js literals |
Edge | 2 (appName():"Jellyfin Web" not visible; one error-route phrase) |
Logo screensaver (banner-light.png) |
Yes (idle timeout, default 3min) | 1 image asset |
| Apple-touch-startup-image splash PNGs | Yes (iOS Safari "Add to Home" PWA only) | ~20 images |
| Service worker registration message | No | 0 (clean — no JF strings) |
| chromecastPlayer plugin chunk | No (we hide cast btn; chunk only loads if cast invoked) | 0 |
| Browser tab title / favicon | No | 0 (already locked by shim) |
Recommended fix path: CSS hide + JS shim + manifest bind-mount. No bundle modifications. CSS alone is insufficient (manifest, i18n, screensaver image are CSS-invisible).
Already-fixed (don't redo)
| Surface | Mechanism | Doc |
|---|---|---|
<title>Jellyfin</title> overwrite by SPA |
lockTitle() regex shim |
10-spa-runtime-shim.md |
<link rel="icon"> Jellyfin teal triangle |
Embedded data-URL favicon + lockFavicon() |
10 |
<meta name="application-name" content="Jellyfin"> |
Static replace in bind-mounted index.html (content="ARRFLIX") |
10 |
.splashLogo (login chrome top-left) |
Image swap in bind-mounted index.html | 10 |
.adminDrawerLogo img + .pageTitleWithLogo |
CustomCss content: url(data:image/png;base64,…) |
04-theming-and-users.md §3b |
| Pre-bundle login flash (blue button, dark blue bg) | Inline <style> block in bind-mounted index.html |
10 |
| Settings drawer entry (only admin should see) | CustomCss :has() rules + JS nukeSettings() MutationObserver |
10 |
| Quick Connect button | CustomCss .btnQuick { display:none } + server-side disabled |
04 |
| Cast / SyncPlay / User header icons | CustomCss .headerCastButton etc. |
04 |
Confirmed live (2026-05-08, marco session):
GET /web/index.html → <title>ARRFLIX</title>
<meta name="application-name" content="ARRFLIX">
<link rel="apple-touch-icon" sizes="180x180" href="data:image/png;base64,…"> (ARRFLIX logo)
ARRFLIX-SHIM-BEGIN block present and runs.
GET /Branding/Configuration → CustomCss includes Cineplex + ARRFLIX overrides as expected.
Findings — by severity
S1 visible-everywhere (PWA + idle screensaver)
F1 — PWA manifest name and short_name are "Jellyfin"
- Location:
https://arrflix.s8n.ru/web/fd4301fdc170fd202474.json - Live payload:
{ "name": "Jellyfin", "description": "The Free Software Media System", "short_name": "Jellyfin", "start_url": "index.html#/home.html", "theme_color": "#101010", "background_color": "#101010", "icons": [ { "src": "touchicon72.png" }, …, { "src": "touchicon512.png" } ] } - User-visible where:
- Android Chrome: install prompt label, home screen shortcut name, app drawer name.
- iOS Safari "Add to Home Screen": shortcut label.
- Desktop Chrome/Edge: "Install ARRFLIX" / install card title.
- Browser PWA badge (
navigator.getInstalledRelatedApps()-style surfaces).
- Fix mechanism: Bind-mount manifest (the static index.html bind-mount is already proven to work). Replace
name/short_namewithARRFLIX. Optionally cleardescriptionor set to a neutral string. Touchicon images already replaced via the data-URLapple-touch-iconpatch in index.html, BUT the manifest still referencestouchicon{72,114,144,512}.pngwhich are Jellyfin-branded PNGs on disk. We can either (a) bind-mount replacement PNGs, or (b) point the manifest icons array at our data URL via inline data-URI refs (Chrome accepts"src": "data:image/png;base64,…"). - Risk: Low. Manifest is static JSON; nothing else parses it. Browser fetches manifest on install; if file is bind-mounted RO, container reads on each request just like index.html (same compose pattern, same inode-pin gotcha — see
10-spa-runtime-shim.md§"Single-file bind mount inode gotcha"). - Replacement file (proposed
web-overrides/fd4301fdc170fd202474.json):
(touchicon*.png images are a separate Phase-2 swap — see F4.){ "name": "ARRFLIX", "description": "ARRFLIX", "lang": "en-US", "short_name": "ARRFLIX", "start_url": "index.html#/home.html", "theme_color": "#000000", "background_color": "#000000", "display": "standalone", "icons": [ { "sizes": "72x72", "src": "touchicon72.png", "type": "image/png" }, { "sizes": "114x114", "src": "touchicon114.png", "type": "image/png" }, { "sizes": "144x144", "src": "touchicon144.png", "type": "image/png" }, { "sizes": "512x512", "src": "touchicon512.png", "type": "image/png" } ] }
F2 — Logo screensaver shows Jellyfin banner on idle
- Location:
/web/logoScreensaver-plugin.8edf3eac91e564799c27.chunk.js→ injects<img src="assets/img/banner-light.png">into a.logoScreenSaverdiv on idle timeout. - Live trigger: Default screensaver kicks in after the user idles on any page. Plays bouncing/spinning Jellyfin banner animation.
- Fix mechanism options:
- Server-side disable (best): in user policy or server config, disable
the logo screensaver / set screensaver to "None". Confirmed reachable via
ConfigurationAPI. Do this for the system default; non-admins can't override since their preferences are locked. - CSS hide (always works): append to CustomCss
The screensaver div still mounts but renders nothing. Visually this means a black overlay on idle (acceptable)..logoScreenSaver, .logoScreenSaverImage { display: none !important; } - CSS image swap (ARRFLIX-branded screensaver):
Reuses the same data URL we already inject in CustomCss for.logoScreenSaverImage { content: url("data:image/png;base64,<ARRFLIX>") !important; }.adminDrawerLogo img.
- Server-side disable (best): in user policy or server config, disable
the logo screensaver / set screensaver to "None". Confirmed reachable via
- Risk: Low. Screensaver is a presentation-only plugin; hiding it does not break navigation, hotkeys, or playback. Option 3 is purely cosmetic.
- Recommendation: Option 1 (disable) + Option 2 (CSS belt) for defense in depth.
S2 detail-only / per-action (i18n strings)
F3 — i18n strings rendered to non-admin in error / playback paths
22 i18n keys in en-us-json.667484b4a441712c7e05.chunk.js contain "Jellyfin".
Of those, 3 are reachable as a non-admin user:
| Key | String | When shown |
|---|---|---|
PlaybackErrorPlaceHolder |
"This is a placeholder for physical media that Jellyfin cannot play. Please insert the disc to play." | Player attempts to play a placeholder/disc-only item. Rare for an arr-fed library but possible. |
UnsupportedPlayback |
"Jellyfin cannot decrypt content protected by DRM but all content will be tried regardless, including protected titles. Some files may appear completely black due to encryption or other unsupported features, such as interactive titles." | DRM playback fallback dialog. Rare. |
MessageChromecastConnectionError |
"Your Google Cast receiver is unable to contact the Jellyfin server. Please check the connection and try again." | Cast initiation fails. We hide cast button so this is now functionally unreachable, but the keystrokes for cast can still be invoked from desktop browsers via media keys. |
The remaining 19 keys (AllowStreamSharingHelp, EncodingFormatHelp,
ErrorAddingMediaPathToVirtualFolder, ErrorDeletingItem, ErrorDeletingLyrics,
KnownProxiesHelp, LabelAutomaticDiscoveryHelp, LabelDisplayLanguageHelp,
LabelPublishedServerUriHelp, MessageConfirmRestart, MessageDirectoryPickerBSDInstruction,
PleaseRestartServerName, ServerRestartNeededAfterPluginInstall, UserProfilesIntro,
WelcomeToProject, WizardCompleted, WriteAccessRequired, XmlTvPathHelp,
ConfirmEndPlayerSession) are admin-only — Dashboard, setup wizard, plugin
manager, virtual folder management, restart confirms, encoding settings.
Non-admins cannot reach those routes (server policy + drawer hides + we
already strip the Settings link).
- Fix mechanism: JS shim with MutationObserver that walks DOM text
nodes and rewrites
Jellyfin → ARRFLIX. Snippet appended tobin/inject-shim.py:function rewriteJellyfinText(){ try { var WORD = /\bJellyfin\b/g; var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null); var n; while ((n = walker.nextNode())) { if (n.nodeValue && WORD.test(n.nodeValue)) { n.nodeValue = n.nodeValue.replace(WORD, 'ARRFLIX'); } } } catch(e){} } // Wire into start(): // - call once at start() // - call from body MutationObserver // - call from setInterval safety net (1s) - Risk:
- Performance: full-document text walk on every DOM mutation is O(N).
Mitigate by debouncing (run only if mutation contains added/removed
text nodes; use
requestIdleCallback). - False positives: rewriting text inside
<input>value or<textarea>— none of these strings live there, so safe. - i18n drift on JF upgrade: if upstream renames the keys, this is still safe (string-level rewrite, not key-level).
- Aria-labels and
titleattributes are NOT covered bySHOW_TEXT. Add a separate pass that walks[aria-label*="Jellyfin"]and[title*="Jellyfin"]if any surface needs it (none observed in audit).
- Performance: full-document text walk on every DOM mutation is O(N).
Mitigate by debouncing (run only if mutation contains added/removed
text nodes; use
- Why not bind-mount the en-us-json chunk: filename is content-hashed
(
en-us-json.667484b4a441712c7e05.chunk.js). Every JF release bumps the hash and the bind-mount becomes a 404. Fragile. JS shim wins.
S3 edge / iOS-only
F4 — Apple PWA splash images and touchicon*.png
- Location:
/web/{6a2e2e6b4186720e5d4f.png, eb8bef…, 3fa90c…, …}— 20 differentapple-touch-startup-imagePNGs declared inindex.html, plus/web/touchicon{72,114,144,512}.pngreferenced from manifest. - User-visible where: iOS Safari "Add to Home Screen" install + launch splash. Android Chrome icon-only fallback if data-URL fails (rare).
- Fix mechanism:
- Phase 1 (cheap, ~70% covered): Bind-mount the manifest (F1) so
touchicon*.pngreferences can be redirected to data URLs in the icons array. iOS Safari ignores those, but Android picks them up. - Phase 2 (full coverage): Generate ARRFLIX-branded PNGs at the
20 device resolutions the apple-touch-startup-image media queries
expect, and bind-mount them under their content-hash filenames (
6a2e2e6b…pngetc.). Brittle — JF rebuilds rotate hashes. - Pragmatic alternative: strip apple-touch-startup-image entries from the bind-mounted index.html entirely. iOS will fall back to a blank splash with the (already-ARRFLIX) apple-touch-icon. Loses the "polished install splash" but kills the leak.
- Phase 1 (cheap, ~70% covered): Bind-mount the manifest (F1) so
- Risk: Low. iOS PWA install rate on a private invite-only service is a tiny fraction of sessions. Defer until owner reports actual user friction.
- Recommendation: Defer. The PWA install path is rare enough on a desktop/laptop-dominant private service that this is a Phase 3 polish.
F5 — main.jellyfin.bundle.js literal "Jellyfin Web" appName + error-route phrase
- Location 1:
AppHost.appName():"Jellyfin Web"— sent inX-Emby-Authorization: MediaBrowser Client="Jellyfin Web"header on every API call. NOT user-visible chrome. Visible only in the user's Devices list (which they can't reach sinceEnableUserPreferenceAccess=false) and in the admin Dashboard "Active Devices" view. Non-admin: zero exposure. - Location 2:
"working in a future Jellyfin update."— embedded in the deprecated/removed-route React component (/web/#/some-old-path). Reachable only via stale bookmark to a removed route. Edge. - Fix mechanism: None. Bundle modifications are explicitly out of
scope (
CONSTRAINTS: no bundle modifications). Both leaks are non-admin-invisible in normal flow. - Risk of fixing: rewriting
main.jellyfin.bundle.jswould break source-map verification, JF auto-updates, and would have to be redone every image bump. Not worth it.
Recommended fix order
| # | Fix | Effort | User-visible win |
|---|---|---|---|
| 1 | Manifest bind-mount (F1) | 5 min | Eliminates "Jellyfin" from PWA install + home-screen + app drawer. |
| 2 | Disable logo screensaver server-side + CSS belt (F2) | 5 min | Eliminates Jellyfin banner during idle (currently the most-visible animated leak). |
| 3 | DOM text-rewrite shim for Jellyfin → ARRFLIX (F3) |
15 min | Catches all 22 i18n keys + any future JF upgrade leaks; covers playback errors and unreachable admin paths defensively. |
| 4 | Apple splash + touchicon swap (F4) | 1-2h (image gen) | iOS PWA install polish. Defer. |
| 5 | Bundle literals (F5) | N/A | Skip — non-admin-invisible. |
Phases 1-3 give 100% coverage for non-admin chrome. Phase 4 polishes the iOS install path. Phase 5 is out of scope.
Implementation plan — concrete snippets
Snippet A — manifest bind-mount
Add web-overrides/fd4301fdc170fd202474.json (full file body in F1 above).
Compose volume:
volumes:
- /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro
- /opt/docker/jellyfin/web-overrides/fd4301fdc170fd202474.json:/jellyfin/jellyfin-web/fd4301fdc170fd202474.json:ro
Deploy (no container restart needed):
scp /tmp/ARRFLIX/web-overrides/fd4301fdc170fd202474.json \
user@192.168.0.100:/opt/docker/jellyfin/web-overrides/fd4301fdc170fd202474.json
curl -ks https://arrflix.s8n.ru/web/fd4301fdc170fd202474.json | jq -r .name # expect "ARRFLIX"
Inode-pin gotcha: scp's truncate-then-write is safe; rsync via temp-file
- rename will orphan the bind. Same rule as index.html (see doc 10).
Hash-rotation gotcha: if a future JF image bumps the manifest filename hash, this bind path 404s. Verify after every image upgrade:
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE 'rel="manifest" href="[^"]*"'
# expect href="fd4301fdc170fd202474.json" — if changed, rename bind file.
Snippet B — screensaver disable + CSS belt
Server-side (one-time as admin):
TOKEN=<admin token>
# Disable default screensaver via /System/Configuration:
curl -ks -X POST https://arrflix.s8n.ru/System/Configuration \
-H "X-Emby-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"DefaultScreensaverPlugin":"none"}'
CSS belt (append to CustomCss via existing 04-theming-and-users.md workflow):
/* Hide Jellyfin logo screensaver — 2026-05-08 (doc 16) */
.logoScreenSaver,
.logoScreenSaverImage { display: none !important; }
Snippet C — DOM text-rewrite shim (covers F3)
Append to the IIFE in bin/inject-shim.py, between nukeSettings and
start:
var JF_WORD = /\bJellyfin\b/g;
function rewriteJellyfinText(root){
try {
var r = root || document.body;
if (!r) return;
var w = document.createTreeWalker(r, NodeFilter.SHOW_TEXT, {
acceptNode: function(n){
var p = n.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
var tag = p.nodeName;
// Skip <script>, <style>, <textarea>, <input> contents
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') {
return NodeFilter.FILTER_REJECT;
}
return JF_WORD.test(n.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
var n;
while ((n = w.nextNode())) {
n.nodeValue = n.nodeValue.replace(JF_WORD, 'ARRFLIX');
}
// aria-label / title attributes
var attrEls = r.querySelectorAll('[aria-label*="Jellyfin"], [title*="Jellyfin"]');
for (var i = 0; i < attrEls.length; i++) {
var el = attrEls[i];
if (el.getAttribute('aria-label')) {
el.setAttribute('aria-label', el.getAttribute('aria-label').replace(JF_WORD, 'ARRFLIX'));
}
if (el.getAttribute('title')) {
el.setAttribute('title', el.getAttribute('title').replace(JF_WORD, 'ARRFLIX'));
}
}
} catch(e){}
}
Wire into start():
function start(){
lockTitle(); lockFavicon(); nukeSettings(); rewriteJellyfinText();
// … existing head observer …
if (document.body && window.MutationObserver) {
new MutationObserver(function(muts){
nukeSettings();
// Only re-walk if a mutation added text — avoid full-doc walk on every keystroke
var dirty = false;
for (var i = 0; i < muts.length && !dirty; i++) {
var m = muts[i];
if (m.addedNodes && m.addedNodes.length) dirty = true;
else if (m.type === 'characterData') dirty = true;
}
if (dirty) rewriteJellyfinText();
}).observe(document.body, { childList:true, subtree:true, characterData:true });
}
setInterval(function(){
/* … existing … */
rewriteJellyfinText();
}, 1000);
}
Performance: acceptNode filter rejects non-matching nodes O(1) per
node, so the walker is cheap. Adding/removing list items in a 5000-item
library scroll triggers ~5000 reject calls per render frame, which is
sub-ms in Chromium. No requestIdleCallback needed for this scale.
Why not just text-replace the whole document body markup string in place:
that approach destroys all React event listeners and breaks navigation.
The TreeWalker approach mutates only nodeValue on already-rendered text
nodes, so React's reconciler is undisturbed.
Snippet D — defer-but-noted: touchicon*.png
Phase 4. Generate ARRFLIX-branded PNGs at 72/114/144/512 px and bind-mount each:
- /opt/docker/jellyfin/web-overrides/touchicon72.png:/jellyfin/jellyfin-web/touchicon72.png:ro
- /opt/docker/jellyfin/web-overrides/touchicon114.png:/jellyfin/jellyfin-web/touchicon114.png:ro
- /opt/docker/jellyfin/web-overrides/touchicon144.png:/jellyfin/jellyfin-web/touchicon144.png:ro
- /opt/docker/jellyfin/web-overrides/touchicon512.png:/jellyfin/jellyfin-web/touchicon512.png:ro
These four filenames are not content-hashed, so the bind survives JF upgrades.
The 20 apple-touch-startup-image PNGs are content-hashed; skip those
or strip their <link> tags from the bind-mounted index.html.
i18n shim vs bundle bind-mount — why we choose shim
| Approach | Survives JF upgrade? | Effort/upgrade | Fragility |
|---|---|---|---|
Bind-mount en-us-json.<hash>.chunk.js |
No (filename rotates each release) | Re-extract + re-mount each upgrade | High |
| DOM text-rewrite shim (chosen) | Yes | Zero | Low — string-level rewrite, key-agnostic |
| Override-language-pack server config | Partially (only changes display lang, doesn't strip "Jellyfin" from custom strings) | One-time | Doesn't fix the leak |
Custom branding in LoginDisclaimer (already used) |
N/A — only affects login screen disclaimer | One-time | Already in place; doesn't touch other strings |
The shim is the only non-fragile, upgrade-immune solution short of forking the bundle.
PWA manifest gotcha — flagged
The owner asked specifically: "If the manifest contains name:Jellyfin,
propose an override approach (bind-mount a custom manifest.json)."
Confirmed: Yes, manifest contains "name":"Jellyfin" and "short_name":"Jellyfin".
Override approach: bind-mount the file as in Snippet A. The compose
config is already set up for the same pattern (index.html). One additional
volume line. The only new risk is the hash-rotation case — record the
filename in web-overrides/README.md and grep-verify after every JF image
bump.
Out-of-scope notes
description: "The Free Software Media System"in the manifest is a Jellyfin-project tagline, not the literal "Jellyfin" word. Owner asked for "Jellyfin" specifically; the description is replaced in our proposed manifest anyway (set to "ARRFLIX").assets/img/banner-dark.pngis not user-reachable as non-admin (would only render in admin theme previews). Skip.fresh.svg/rotten.svg(Rotten Tomatoes) are not Jellyfin-branded. Already handled by Cineplex CSS. Skip.avatar.pngis the default user avatar (generic person icon) — not Jellyfin-branded. Skip.
Verification post-fix
After deploying Phase 1-3, re-run this audit and confirm:
# F1 — manifest
curl -ks https://arrflix.s8n.ru/web/fd4301fdc170fd202474.json | jq -r '.name, .short_name'
# expect: ARRFLIX / ARRFLIX
# F2 — screensaver
TOKEN=<admin>
curl -ks https://arrflix.s8n.ru/System/Configuration -H "X-Emby-Token: $TOKEN" | jq -r '.DefaultScreensaverPlugin'
# expect: "none" (or empty)
# F3 — i18n shim
# Manual: Open DevTools console, run:
# document.title.includes('Jellyfin') || document.body.innerText.includes('Jellyfin')
# expect: false
# Belt: any-Jellyfin-anywhere check
curl -ks https://arrflix.s8n.ru/web/index.html | grep -ohE '\bJellyfin\b' | wc -l
# expect: occurrences only in shim regex source (not in user-visible chrome)
Sign-off
- Audit run by: Claude Code, 2026-05-08, non-admin session as
marco. - Mode: read-only. No CSS POSTs, no bundle edits, no service restarts.
- Live state: index.html shim active and correct; manifest leak confirmed; screensaver leak confirmed; i18n leaks confirmed (3 reachable / 22 total in en-us chunk).
- Recommended next action: implement Phase 1 (manifest bind-mount) + Phase 2 (screensaver disable + CSS belt) in a single follow-up branch; Phase 3 (DOM text shim) in a separate branch since it touches the critical inject-shim.py path and warrants its own verification.