ARRFLIX/docs/06-per-library-themes.md
s8n 1f5ba31483 Rename: nasflix → ARRFLIX + apply Cineplex theme
Domain + repo rename: nasflix.s8n.ru → arrflix.s8n.ru, NASFLIX → ARRFLIX
(Forgejo repo, Pi-hole DNS, Traefik file+label routes, compose env+labels,
onyx /etc/hosts, branding LoginDisclaimer, all repo refs, logo asset).

Theme: ElegantFin → Cineplex v1.0.6 (MRunkehl, pinned). Picked by research
agent over JellyFlix (halted), DarkFlix (10.8.x only), Theme Park (no
Netflix preset). Real #E50914 + Netflix Sans webfont + transform:scale
hover + gradient login backdrop. Doc 04 updated with full candidate
matrix, theme-history subsection, rollback-to-ElegantFin snippet.

Logo asset saved at assets/logo.png (235x85 RGBA).

Live: https://arrflix.s8n.ru → 302. tv.s8n.ru + nasflix.s8n.ru retired (404).
2026-05-08 02:57:34 +01:00

19 KiB
Raw Blame History

06 — Per-Library Themes (Movies = Netflix, Anime = Crunchyroll, Music = Spotify)

Scope of this doc: research only. No live changes. Targets Jellyfin 10.10.3 at https://arrflix.s8n.ru with the current global theme ElegantFin v25.12.31 in /System/Configuration/branding CustomCss.


TL;DR — Verdict

Partially feasible. Jellyfin 10.10 has no native per-library theming. CustomCss is a single site-wide blob; LibraryOptions has no CustomCss field; the web client never sets a body class or data attribute reflecting the current library or collectionType. However, the URL hash does encode both (#/movies.html?topParentId=<id>&collectionType=movies etc.), which means a tiny JS shim that mirrors that URL state onto the body class is enough to let one CustomCss blob carry multiple scoped sub-themes (body.lib-movies { … } body.lib-anime { … } body.lib-music { … }).

Recommended path: approach #2 — JS-shim + scoped CSS, delivered via the Jellyfin-JavaScript-Injector plugin (or, if no plugin is acceptable, a bind-mounted patched index.html). Visual fidelity is good but not pixel-perfect Netflix/Crunchyroll/Spotify — those brands have unique fonts, layouts and animations a CSS-only override cannot fully reproduce. Expect "tinted, branded, recognisable" rather than "indistinguishable".

If true brand-grade fidelity is required, only approach #5 (subdomain split into separate Jellyfin instances) delivers it — at the cost of running 3 servers and either duplicating libraries or using user-policy library hiding.


1. Five approaches at a glance

# Approach Feasibility 10.10.3 Maintenance Fidelity UX cost
1 Pure CSS scoping by route (no JS) No — Jellyfin web sets no body/HTML attributes that reflect library or collectionType. URL hash is invisible to CSS. n/a n/a n/a
2 JS shim → body class → scoped CSS Yes — URL hash includes topParentId+collectionType, easy to mirror onto body Low — ~30 lines of JS, stable across upgrades because it consumes URL params, not DOM internals Good (8/10) — full CSS variable + layout override per library; falls short of perfect brand mimicry (fonts, motion design) Sub-100ms class flip on hashchange; no flicker if rules use the right specificity
3 Per-library Branding/CustomCss via API NoLibraryOptions schema has no CustomCss / theme field. Confirmed against /Library/VirtualFolders response. n/a n/a n/a
4 Existing community plugin promising per-library theming No — none exists. Skin Manager, JellySkin, ElegantFin, Jellyfish, JellyFlix, DarkFlix are all server-wide. Closest building block: Jellyfin-JavaScript-Injector (plugin route to deliver approach #2). Low if used as injector for #2 Same as #2 Same as #2
5 Subdomain split — movies.arrflix.s8n.ru, anime.arrflix.s8n.ru, music.arrflix.s8n.ru (3 Jellyfin containers) Yes — straightforward Traefik + 3 stacks High — 3× DBs, 3× scans, 3× upgrades, user accounts to sync Perfect (10/10) — each instance is just a normal Jellyfin with one global theme Users must bookmark/jump between subdomains; no unified library

Why approach #1 fails

The bundled JS only ever calls body.classList.add/remove with these strings:

bodyWithPopupOpen, dashboardDocument, force-scroll, hide-scroll,
noScroll, screensaver-noScroll, withSectionTabs

(Verified by grep of /jellyfin/jellyfin-web/*.js on the running container.) None of them encode library, collection type, item type, or route. CSS selectors like body[data-libraryid="…"] or body.collectionType-music therefore match zero elements.

CSS cannot read window.location or the URL hash on its own (no :url() selector; :has()/[href*=…] operate on element attributes, not the address bar). So without JS, the information needed to scope styles is simply not in the DOM.

Why approach #3 fails

GET /Library/VirtualFolders (auth X-Emby-Token: *redacted*) returns LibraryOptions containing only metadata/scan/subtitle settings. No CustomCss, no Theme, no Branding per library. The single global CustomCss field at /System/Configuration/branding is the only knob the server exposes.

Why approach #4 — note on Skin Manager

Jellyfin-plugin-skin-manager and JellyWatch's "Skin Manager" only swap which single server-wide theme is active. None of the catalogued community themes (awesome-jellyfin/THEMES.md) ship per-library scoping. A handful (e.g. Kaleidochrome) auto-tint based on currently-viewed artwork but that is colour-only, not layout.

The only useful plugin in this space is n00bcodr/Jellyfin-JavaScript-Injector (the maintained fork of the deprecated johnpc/jellyfin-plugin-custom-javascript, MIT, last release 2025-12-08). It patches index.html server-side to inject arbitrary JS — perfect delivery vehicle for the shim in approach #2.


2.1 Mechanism

  1. A small JS payload runs on every page load and on every hashchange.
  2. It reads window.location.hash, parses out topParentId and collectionType, and writes them to <body> as both a class and a data- attribute.
  3. CustomCss carries three scoped style blocks keyed off those classes.

The URL→body-class mapping is the stable contract: it consumes URL parameters that the SPA itself constructs from server-supplied data (verified — bundled JS contains literal templates #/movies.html?topParentId=…&collectionType=…). It does not depend on internal React state, private DOM structure, MUI class hashes, or webpack chunk names — all of which churn between Jellyfin upgrades. This is what makes the maintenance cost low.

2.2 Delivery options (pick one)

A. Plugin route (preferred — no file mounts)

  • Install n00bcodr/Jellyfin-JavaScript-Injector via repo URL https://raw.githubusercontent.com/n00bcodr/Jellyfin-JavaScript-Injector/main/manifest.json.
  • Paste the shim (below) into the plugin's textarea.
  • Survives container rebuilds; no bind-mounts to maintain.
  • Caveat: plugin patches index.html once at install. Jellyfin upgrades that ship a new jellyfin-web package re-extract index.html and the plugin re-patches on next start. Works the same way as Custom CSS Branding.

B. Bind-mount patched index.html (no plugin)

  • Add <script src="/web/custom-shim.js"></script> before </head>.
  • Mount custom-shim.js into /jellyfin/jellyfin-web/custom-shim.js.
  • Mount the patched index.html over /jellyfin/jellyfin-web/index.html.
  • Pin jellyfin/jellyfin image tag — every minor upgrade may rotate the hashed bundle filenames in index.html, breaking your patch. Approach A avoids this because the plugin re-applies the patch against the upgraded file.

2.3 The JS shim (~30 lines)

// Per-library body-class shim — applies on initial load and every hashchange.
// Stable contract: consumes URL query params (topParentId, collectionType) that
// the Jellyfin web SPA constructs from server data — not DOM internals.
(function () {
  const KNOWN = new Set(['movies', 'tvshows', 'music', 'homevideos',
                         'boxsets', 'livetv', 'books', 'playlists']);
  // Optional: per-libraryId override (use IDs from /Library/VirtualFolders).
  // Lets you treat one tvshows library as "anime" while leaving the other as "tv".
  const LIB_OVERRIDES = {
    // Example: '<itemid-of-anime-library>': 'anime',
  };

  function apply() {
    const h = window.location.hash || '';
    const q = h.includes('?') ? h.slice(h.indexOf('?') + 1) : '';
    const params = new URLSearchParams(q);
    const ct  = (params.get('collectionType') || '').toLowerCase();
    const tpi = params.get('topParentId') || '';

    const body = document.body;
    // Strip any prior lib-* / ct-* classes to avoid stacking on hashchange.
    body.className = body.className
      .split(/\s+/)
      .filter(c => !c.startsWith('lib-') && !c.startsWith('ct-'))
      .join(' ');

    let label = LIB_OVERRIDES[tpi] || (KNOWN.has(ct) ? ct : '');
    if (label) {
      body.classList.add('lib-' + label);
      body.classList.add('ct-' + label);
      body.dataset.libraryId = tpi;
      body.dataset.collectionType = label;
    } else {
      delete body.dataset.libraryId;
      delete body.dataset.collectionType;
    }
  }

  apply();
  window.addEventListener('hashchange', apply);
  // Some early-load races: re-apply once DOM is ready and once after route settles.
  document.addEventListener('DOMContentLoaded', apply);
  setTimeout(apply, 250);
})();

2.4 CustomCss skeleton

Keep ElegantFin as the global base, then append three scoped blocks. This is appended after the existing ElegantFin import so cascade order favours the per-library overrides.

/* === BASE: ElegantFin v25.12.31 (already present) === */
@import url("https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css");

/* === Movies → Netflix-tinted === */
body.lib-movies {
  --primary-accent-color: #e50914;          /* Netflix red */
  --primary-bg-color: #141414;
  --secondary-bg-color: #181818;
  --card-bg-color: #232323;
  font-family: "Netflix Sans", "Helvetica Neue", Arial, sans-serif;
}
body.lib-movies .skinHeader               { background: linear-gradient(180deg, rgba(0,0,0,.85), transparent) !important; }
body.lib-movies .button-submit,
body.lib-movies .raised.button-submit     { background-color: #e50914 !important; color: #fff !important; }
body.lib-movies .cardOverlayContainer     { background: rgba(20,20,20,.92) !important; }
body.lib-movies .itemBackdrop             { filter: brightness(.55) saturate(1.15); }

/* Optional: pull JellyFlix's Netflix layout overrides on top */
@supports selector(body.lib-movies) {
  body.lib-movies { --jf-import-marker: 1; }
}

/* === Anime libraries → Crunchyroll-tinted === */
/* Use LIB_OVERRIDES in the shim to flag *which* tvshows libraries are "anime" */
body.lib-anime {
  --primary-accent-color: #f47521;          /* Crunchyroll orange */
  --primary-bg-color: #0b0b0b;
  --secondary-bg-color: #1a1a1a;
  font-family: "Lato", "Helvetica Neue", Arial, sans-serif;
}
body.lib-anime .skinHeader                { background: #0b0b0b !important; border-bottom: 2px solid #f47521; }
body.lib-anime .button-submit             { background-color: #f47521 !important; color: #fff !important; }
body.lib-anime .cardText-first            { font-weight: 700; letter-spacing: .02em; }
body.lib-anime .itemBackdrop              { filter: saturate(1.25) contrast(1.05); }

/* === Music → Spotify-tinted === */
body.lib-music {
  --primary-accent-color: #1db954;          /* Spotify green */
  --primary-bg-color: #121212;
  --secondary-bg-color: #181818;
  --card-bg-color: #282828;
  font-family: "Circular", "Helvetica Neue", Arial, sans-serif;
}
body.lib-music .skinHeader                { background: #000 !important; }
body.lib-music .button-submit,
body.lib-music .raised.button-submit      { background-color: #1db954 !important; border-radius: 999px !important; padding: .6em 1.6em !important; }
body.lib-music .cardImageContainer        { border-radius: 4px !important; }    /* square-ish covers */
body.lib-music .listItem                  { border-radius: 6px; transition: background-color .15s ease; }
body.lib-music .listItem:hover            { background-color: rgba(255,255,255,.07); }

2.5 Source CSS shopping list

Don't write Netflix/Crunchyroll/Spotify-grade CSS from scratch. Lift from existing themes and re-scope each rule under body.lib-<x>:

Source URL Style License Last update Compat note
JellyFlix (prayag17) https://github.com/prayag17/JellyFlix Netflix Not stated 2022-03-20, "development halted" Old; harvest selectors only, expect to fix-up for 10.10 DOM
JellyFlix fork (Automationxperts) https://github.com/Automationxperts/jellyflix Netflix Not stated Active-ish (11 commits) Newer than upstream; uses Netflix Sans font
DarkFlix (DevilsDesigns) https://github.com/DevilsDesigns/Jellyfin-DarkFlix-Theme Dark Netflix Not stated Active Built on JellyFlix base
JellyFlixCustomCSS (xenoncolt) https://github.com/xenoncolt/JellyFlixCustomCSS Netflix-ish Not stated Recent One-line import
JellyfinCSS (jackheteng) https://github.com/jackheteng/JellyfinCSS Netflix font + hover Not stated Small surface; useful font/hover snippets
ElegantFin (in use) https://github.com/lscambo13/ElegantFin Elegant base GPL-2.0 v25.12.31, tested 10.11.5 Keep as global base
Jellyfish (n00bcodr) https://github.com/n00bcodr/Jellyfish Modern, multi-colour LICENSE.md 10.10.7-targeted 11 colour schemes — useful palette donor
JellySkin (prayag17) https://github.com/prayag17/JellySkin Vibrant minimal Active Custom icon set worth scavenging
Crunchyroll subtitle style https://forum.jellyfin.org/t-crunchyroll-subtitle-style Subtitles only Forum post Pair with anime block for full effect

For Crunchyroll and Spotify there is no off-the-shelf Jellyfin theme. Build those from:

  • official brand colours (above),
  • Crunchyroll: Lato/Open Sans, orange accents, dark slate background, square-ish poster cards.
  • Spotify: Circular/Helvetica Neue, pill-shaped buttons, green accents, black backdrop, rounded but small border-radius on covers, hover-lighten on rows.

2.6 Failure modes & rollback

Failure Symptom Rollback
Shim hits a route before DOM ready First navigation after refresh shows base theme for ~250 ms Already mitigated by setTimeout(apply, 250) + DOMContentLoaded listener; if still flaky, call apply() from MutationObserver watching #mainAnimatedPages
Jellyfin upgrade rotates URL param names (topParentId → other) Body class never sets, all libraries fall back to ElegantFin Plugin disable + remove the appended CSS blocks; UI returns to vanilla ElegantFin instantly
CustomCss specificity loses to ElegantFin's !important rules Per-library tints not visible on some elements Increase specificity (html body.lib-movies …) or add !important; harvest the selector list from JellyFlix CSS for accurate targets
Anime library mis-classified as plain tvshows Tinted as TV instead of Crunchyroll-orange Populate LIB_OVERRIDES with the anime library ItemId from GET /Library/VirtualFolders
Plugin update breaks injection (deprecated upstream) Shim no longer loads Switch to bind-mount delivery (option B in §2.2) — same shim, different vehicle

Hard rollback (any failure): clear CustomCss back to the original ElegantFin @import line in Dashboard → Branding, disable the JavaScript-Injector plugin. Site returns to current state in one page refresh. No DB or filesystem state is touched.

2.7 Maintenance burden estimate

  • Per Jellyfin minor upgrade (~quarterly): smoke-test that topParentId/collectionType URL params still appear on hash transitions. ~5 minutes. Has been stable since 10.7.
  • Per ElegantFin upgrade: re-test that scoped overrides still win the cascade. ~10 minutes.
  • New library added: zero work if its collectionType is one of the eight known types. If it's a tvshows library you want to brand differently (anime), add one line to LIB_OVERRIDES with the library ItemId.
  • Plugin replacement: if Jellyfin-JavaScript-Injector is itself deprecated, switch to bind-mount delivery (option B). One-time ~30-minute migration.

Total ongoing burden: ~1 hour/year. Compared with running 3 separate Jellyfin instances (approach #5), that's roughly two orders of magnitude less work.


3. When to pick approach #5 instead

Choose subdomain split if any of these are true:

  • You want true Netflix UX (autoplay trailers on hover, exact card geometry, top-10 row, "skip intro" branded affordances) — CSS alone cannot deliver these regardless of approach.
  • You want fully isolated user accounts per "service" (e.g. kid account on anime.arrflix.s8n.ru cannot see movies subdomain at all).
  • You're prepared to either (a) duplicate libraries (3× disk metadata, 3× scans) or (b) maintain a per-user library policy on a single backend that mirrors content into 3 frontend instances — Jellyfin doesn't support multi-frontend-one-backend natively, so (b) means 3 full Jellyfin containers each pointing at the same /media mounts but with different libraries enabled.

Otherwise approach #2 wins on every other axis.


4. Open questions / things to verify before implementing

  1. Whether the CustomCss field has a length cap that will fit ElegantFin (~120 KB minified) + three sub-themes (~1020 KB each). Worth confirming via API GET on /System/Configuration/branding before committing.
  2. Whether per-user CustomCss exists in 10.10 (admin-only?) — affects whether kid-vs-adult users could see different sub-themes. Last checked: 10.10 still has only the global field.
  3. ElegantFin v25.12.31 is tested on 10.11.5. We're on 10.10.3. Spot-check that the import URL resolves and renders correctly before adding library scopes on top — the global CustomCss is already running this version, so this is presumably already verified.
  4. The Jellyfin-JavaScript-Injector plugin deprecation chain (johnpc → n00bcodr) has happened once already. Plan for the possibility of a future re-fork; keep the shim source under version control somewhere outside the plugin so it's portable.

5. Sources