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/.
319 lines
19 KiB
Markdown
319 lines
19 KiB
Markdown
# 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 | **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](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 `<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)
|
||
|
||
```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: '<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.
|
||
|
||
```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-<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 (~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.
|