# 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=&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 | **No** — `LibraryOptions` 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: `) 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](https://github.com/awesome-jellyfin/awesome-jellyfin/blob/main/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. Recommended approach — #2 in detail ### 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 `` 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 `` before ``. - 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) ```js // 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: '': '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. ```css /* === 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-`: | 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 (~10–20 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 - [Jellyfin CSS Customization (official)](https://jellyfin.org/docs/general/clients/css-customization/) — confirms global-only scope. - [awesome-jellyfin THEMES.md](https://github.com/awesome-jellyfin/awesome-jellyfin/blob/main/THEMES.md) — full theme catalogue. - [ElegantFin (lscambo13)](https://github.com/lscambo18/ElegantFin) — current global theme. - [JellyFlix (prayag17)](https://github.com/prayag17/JellyFlix) — Netflix harvest source (archived). - [JellyFlix (Automationxperts)](https://github.com/Automationxperts/jellyflix) — active Netflix fork. - [DarkFlix (DevilsDesigns)](https://github.com/DevilsDesigns/Jellyfin-DarkFlix-Theme) — Netflix dark variant. - [Jellyfish (n00bcodr)](https://github.com/n00bcodr/Jellyfish) — multi-palette donor. - [JellySkin (prayag17)](https://github.com/prayag17/JellySkin) — icon donor. - [Jellyfin-JavaScript-Injector (n00bcodr)](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector) — recommended JS delivery plugin. - [johnpc/jellyfin-plugin-custom-javascript](https://github.com/johnpc/jellyfin-plugin-custom-javascript) — deprecated, fork above. - [JellyWatch — Best Jellyfin Themes 2026](https://jellywatch.app/blog/best-jellyfin-themes-skin-manager-2026) — Skin Manager overview. - [BobHasNoSoul/jellyfin-mods](https://github.com/BobHasNoSoul/jellyfin-mods) — patching patterns for `index.html`. - [Crunchyroll subtitle style (Jellyfin forum)](https://forum.jellyfin.org/t-crunchyroll-subtitle-style) — pairs with anime block. - Live verification: `grep` of `/jellyfin/jellyfin-web/*.js` on the running 10.10.3 container — confirmed body.classList strings, `topParentId`/`collectionType` URL templates, and absence of any per-library DOM hook. - Live verification: `GET /Library/VirtualFolders` — confirmed LibraryOptions has no CustomCss field.