ARRFLIX/docs/06-per-library-themes.md

320 lines
19 KiB
Markdown
Raw Normal View 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 | **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 (~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
- [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.