ARRFLIX/docs/16-jellyfin-branding-leaks.md

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_name with ARRFLIX. Optionally clear description or set to a neutral string. Touchicon images already replaced via the data-URL apple-touch-icon patch in index.html, BUT the manifest still references touchicon{72,114,144,512}.png which 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):
    {
      "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" }
      ]
    }
    
    (touchicon*.png images are a separate Phase-2 swap — see F4.)

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 .logoScreenSaver div 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:
    1. Server-side disable (best): in user policy or server config, disable the logo screensaver / set screensaver to "None". Confirmed reachable via Configuration API. Do this for the system default; non-admins can't override since their preferences are locked.
    2. CSS hide (always works): append to CustomCss
      .logoScreenSaver, .logoScreenSaverImage { display: none !important; }
      
      The screensaver div still mounts but renders nothing. Visually this means a black overlay on idle (acceptable).
    3. CSS image swap (ARRFLIX-branded screensaver):
      .logoScreenSaverImage { content: url("data:image/png;base64,<ARRFLIX>") !important; }
      
      Reuses the same data URL we already inject in CustomCss for .adminDrawerLogo img.
  • 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 to bin/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 title attributes are NOT covered by SHOW_TEXT. Add a separate pass that walks [aria-label*="Jellyfin"] and [title*="Jellyfin"] if any surface needs it (none observed in audit).
  • 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 different apple-touch-startup-image PNGs declared in index.html, plus /web/touchicon{72,114,144,512}.png referenced 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*.png references 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…png etc.). 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.
  • 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 in X-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 since EnableUserPreferenceAccess=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.js would break source-map verification, JF auto-updates, and would have to be redone every image bump. Not worth it.

# 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.png is 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.png is 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.