Token 76858153...f8b1 was committed across 9 docs + 1 snapshot RESTORE.md and exposed via the brief public window of this repo. Replaced with `<JELLYFIN_API_TOKEN>` placeholder. WARNING: this commit only redacts HEAD — the token remains in git history. Anyone who cloned during the public window has the full value. Treat the old token as compromised and rotate at Jellyfin Dashboard > API Keys. Original value backed up to private s8n/secrets/ARRFLIX/.
19 KiB
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/brandingCustomCss.
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 |
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: <JELLYFIN_API_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)
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
- A small JS payload runs on every page load and on every
hashchange. - It reads
window.location.hash, parses outtopParentIdandcollectionType, and writes them to<body>as both a class and adata-attribute. - 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-Injectorvia repo URLhttps://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.htmlonce at install. Jellyfin upgrades that ship a newjellyfin-webpackage re-extractindex.htmland the plugin re-patches on next start. Works the same way asCustom CSS Branding.
B. Bind-mount patched index.html (no plugin)
- Add
<script src="/web/custom-shim.js"></script>before</head>. - Mount
custom-shim.jsinto/jellyfin/jellyfin-web/custom-shim.js. - Mount the patched
index.htmlover/jellyfin/jellyfin-web/index.html. - Pin
jellyfin/jellyfinimage tag — every minor upgrade may rotate the hashed bundle filenames inindex.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-radiuson 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/collectionTypeURL 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
collectionTypeis one of the eight known types. If it's a tvshows library you want to brand differently (anime), add one line toLIB_OVERRIDESwith the libraryItemId. - Plugin replacement: if
Jellyfin-JavaScript-Injectoris 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.rucannot 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
/mediamounts but with different libraries enabled.
Otherwise approach #2 wins on every other axis.
4. Open questions / things to verify before implementing
- 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/brandingbefore committing. - 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.
- 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
CustomCssis already running this version, so this is presumably already verified. - The
Jellyfin-JavaScript-Injectorplugin 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) — confirms global-only scope.
- awesome-jellyfin THEMES.md — full theme catalogue.
- ElegantFin (lscambo13) — current global theme.
- JellyFlix (prayag17) — Netflix harvest source (archived).
- JellyFlix (Automationxperts) — active Netflix fork.
- DarkFlix (DevilsDesigns) — Netflix dark variant.
- Jellyfish (n00bcodr) — multi-palette donor.
- JellySkin (prayag17) — icon donor.
- Jellyfin-JavaScript-Injector (n00bcodr) — recommended JS delivery plugin.
- johnpc/jellyfin-plugin-custom-javascript — deprecated, fork above.
- JellyWatch — Best Jellyfin Themes 2026 — Skin Manager overview.
- BobHasNoSoul/jellyfin-mods — patching patterns for
index.html. - Crunchyroll subtitle style (Jellyfin forum) — pairs with anime block.
- Live verification:
grepof/jellyfin/jellyfin-web/*.json the running 10.10.3 container — confirmed body.classList strings,topParentId/collectionTypeURL templates, and absence of any per-library DOM hook. - Live verification:
GET /Library/VirtualFolders— confirmed LibraryOptions has no CustomCss field.