ARRFLIX/docs/06-per-library-themes.md
s8n 937589c7a2 redact: scrub leaked Jellyfin admin API token from public repo
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/.
2026-05-08 15:36:14 +01:00

319 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.