# 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: `USER-A`
(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, USER-A 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:**
  ```json
  { "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`):**
  ```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
     ```css
     .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):
     ```css
     .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`. SnipUSER-E appended to
  `bin/inject-shim.py`:
  ```js
  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.

---

## 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 snipUSER-Es

### SnipUSER-E A — manifest bind-mount

Add `web-overrides/fd4301fdc170fd202474.json` (full file body in F1 above).

Compose volume:
```yaml
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):
```bash
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:
```bash
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE 'rel="manifest" href="[^"]*"'
# expect href="fd4301fdc170fd202474.json" — if changed, rename bind file.
```

### SnipUSER-E B — screensaver disable + CSS belt

Server-side (one-time as admin):
```bash
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):
```css
/* Hide Jellyfin logo screensaver — 2026-05-08 (doc 16) */
.logoScreenSaver,
.logoScreenSaverImage { display: none !important; }
```

### SnipUSER-E C — DOM text-rewrite shim (covers F3)

Append to the IIFE in `bin/inject-shim.py`, between `nukeSettings` and
`start`:

```js
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()`:
```js
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.

### SnipUSER-E D — defer-but-noted: touchicon\*.png

Phase 4. Generate ARRFLIX-branded PNGs at 72/114/144/512 px and bind-mount
each:
```yaml
- /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 SnipUSER-E 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:

```bash
# 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:** s8n, 2026-05-08, non-admin session as `USER-A`.
- **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.
