diff --git a/README.md b/README.md index fd6a7e3..3a49def 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Detailed playbooks (research-grade, with API curls, failure modes, recovery): | [`docs/02-metadata-and-titles.md`](docs/02-metadata-and-titles.md) | Filename parsing, Identify flow, locking the right show, language cascade, multi-episode files | | [`docs/03-subtitles.md`](docs/03-subtitles.md) | OpenSubtitles plugin (.com), sidecar naming, ffmpeg/mkvextract extraction, per-user prefs | | [`docs/04-theming-and-users.md`](docs/04-theming-and-users.md) | ElegantFin theme, branding API, multi-user policies, SyncPlay, friend account playbook | +| [`docs/05-file-structure-rules.md`](docs/05-file-structure-rules.md) | Authoritative folder/filename rules for movies, TV, anime, stand-up, concerts, docs, extras, NFO, artwork overrides | +| [`docs/06-per-library-themes.md`](docs/06-per-library-themes.md) | Per-library theming research: JS-injector plugin shim + scoped CSS for Movies/Anime/Music looks | ## State as of 2026-05-08 diff --git a/docs/05-file-structure-rules.md b/docs/05-file-structure-rules.md new file mode 100644 index 0000000..d4a5541 --- /dev/null +++ b/docs/05-file-structure-rules.md @@ -0,0 +1,1165 @@ +# 05 — File & Folder Structure Rules (tv.s8n.ru) + +Last updated: 2026-05-08 +Server: Jellyfin 10.10.3 on nullstone, container `jellyfin` +Library root inside container: `/media` +Library root on host: `/home/user/media` + +This document is the authoritative ruleset for laying media out on disk so +Jellyfin scrapes it correctly the first time, every time. Cross-linked to: + +- [`01-artwork-and-images.md`](01-artwork-and-images.md) — image scrapers and on-disk override files +- [`02-metadata-and-titles.md`](02-metadata-and-titles.md) — filename parsing, `RemoteSearch/Apply`, the lock-the-series flow +- [`03-subtitles.md`](03-subtitles.md) — sidecar `.srt` / `.ass` naming and the OpenSubtitles plugin +- [`04-theming-and-users.md`](04-theming-and-users.md) — multi-user policies and library access + +Sources of truth (check these BEFORE this doc — they update): + +- +- +- +- +- +- +- Source: `Emby.Naming.dll` ships in the container at `/jellyfin/Emby.Naming.dll`. Rules below match the Emby.Naming regex chain referenced by the docs. +- `CollectionType.cs` (master): `unknown, movies, tvshows, music, musicvideos, trailers, homevideos, boxsets, books, photos, livetv, playlists, folders` (enum int values 0–12). + +--- + +## 0. Top-level rules (apply to everything) + +These are non-negotiable. Most "Jellyfin won't match my file" tickets are +caused by violating one of these: + +1. **One library = one `CollectionType`.** Never mix Movies and TV in the same + library. Mixed libraries technically exist (`mixed`) but lose half the + scrapers and most edge-case parsing — do not use. +2. **One folder per item.** A movie lives in its own folder. A series lives in + its own folder. A music album lives in its own folder. Loose files in the + library root will scrape, but extras / NFO / artwork sidecars cannot attach + to a loose file. +3. **Forbidden filename characters:** `< > : " / \ | ? *` + These are illegal on Windows and Jellyfin's parser refuses to canonicalise + them. Use `--` for `:`, drop quotes entirely. +4. **No accents/non-ASCII in folder names** unless you are sure the underlying + filesystem (`ext4` here) and SMB/NFS clients all support UTF-8. We're on + `ext4` + LAN-only HTTP, so accents are safe — but avoid them where the + ASCII title is well-known (e.g. `Amelie (2001)` not `Amélie (2001)`). +5. **Always include the year** for movies/series whose title is not unique: + `The Office (2005)` vs `The Office (2001)` (UK), `It (2017)` vs `It (1990)`. + Year goes in parentheses immediately after the title with a single space. +6. **Year is parsed only when in `( )`** — `Movie 2005.mkv` does NOT bind 2005 + as the year, it becomes part of the title. `Movie (2005).mkv` does. +7. **Provider-ID overrides win over filename guessing.** If a title is + ambiguous or the scraper repeatedly picks the wrong show, embed the ID: + `Series Name (2023) [tmdbid-12345]/`. Doc 02 covers the `RemoteSearch/Apply` + path for fixing this after-the-fact via API. +8. **`SeriesName/Season XX/SeriesName SXXEYY.ext` is the canonical TV layout.** + Anything flatter or deeper has corner cases. Stick to it. +9. **Dots, dashes, underscores, and spaces are interchangeable** between tokens + for the parser. `Futurama.s01e01.pl.mkv` parses identically to + `Futurama - S01E01 - pl.mkv`. Pick one and be consistent inside a library. +10. **Refresh after a rename.** Renaming a file on disk does NOT auto-refresh + the existing Jellyfin item — it creates a new "missing" record. Either + rename BEFORE first scan, or `POST /Library/Refresh` after. + +--- + +## 1. Movies + +### 1.1 Folder structure + +``` +/media/movies/ +├── Blade Runner (1982)/ +│ └── Blade Runner (1982).mkv +├── Blade Runner 2049 (2017)/ +│ ├── Blade Runner 2049 (2017) - 2160p.mkv +│ ├── Blade Runner 2049 (2017) - 1080p.mkv +│ └── Blade Runner 2049 (2017) - Theatrical.mkv +├── Dune (1984)/ +│ └── Dune (1984) [imdbid-tt0087182].mkv +├── Dune (2021)/ +│ └── Dune (2021).mkv +└── Lord of the Rings - Fellowship (2001)/ + ├── Lord of the Rings - Fellowship (2001) - cd1.mkv + └── Lord of the Rings - Fellowship (2001) - cd2.mkv +``` + +One folder per movie. Folder name = movie name. + +### 1.2 Filename pattern + +- **Pattern:** `^ \((?<year>\d{4})\)( \[(imdbid|tmdbid|tvdbid)-[^\]]+\])?( - <Label>)?\.<ext>$` +- **Title** is whatever you put — but it must **byte-for-byte match** the + parent folder name when using multi-version naming. +- **Year** in `(YYYY)` is technically optional but **required for this deploy**. +- **Provider-ID block** `[imdbid-ttNNNNNNN]` / `[tmdbid-NNNN]` / `[tvdbid-NNNN]` + is optional; use it when the title is ambiguous or scraper picks wrong. +- **Label** for multi-version movies: ` - <free text>` or ` - [<free text>]`. + Resolution labels ending in `p` or `i` (`2160p`, `1080p`, `720i`) sort + descending by resolution; everything else sorts alphabetically. + +#### Examples that WORK + +``` +Blade Runner (1982).mkv +Blade Runner (1982) [imdbid-tt0083658].mkv +Blade Runner 2049 (2017) - 2160p.mkv +Blade Runner 2049 (2017) - Directors Cut.mkv +Blade Runner 2049 (2017) - [Extended].mkv +Lord of the Rings - Fellowship (2001) - cd1.mkv +Lord of the Rings - Fellowship (2001) - part 1.mkv +Lord of the Rings - Fellowship (2001).part.1.mkv +``` + +#### Examples that BREAK + +``` +Blade Runner.1982.mkv ← year not in parens; title becomes "Blade Runner 1982" +Blade.Runner.(1982).mkv ← parses but folder/file mismatch will void multi-version +BR (1982).mkv ← title too cryptic, scraper guesses wrong +Blade Runner (1982) - Directors Cut.mkv ← in folder "BladeRunner1982" → mismatch +LOTR: Fellowship (2001).mkv ← `:` is illegal +Movies/Blade Runner (1982).mkv ← no per-movie folder; extras/NFO can't attach +``` + +### 1.3 Multi-disc / multi-part rips + +Use part separators `cd|dvd|part|pt|disc|disk` followed by a number, optionally +preceded by space/`.`/`-`/`_`: + +``` +Lord of the Rings - Fellowship (2001) - cd1.mkv +Lord of the Rings - Fellowship (2001) - cd2.mkv +``` + +**Limitation (from upstream docs, verbatim):** "This does not work with +multiple versions or merging." → if you have a 2-disc 2160p AND a 2-disc 1080p +of the same movie, you must remux into single files; the parser cannot encode +both axes. + +### 1.4 Foreign-language audio dubs + +Jellyfin matches on the original title (English/release-language) regardless +of audio. Polish-dubbed `Futurama` would be: + +``` +/media/movies/Futurama - Bender's Big Score (2007)/ +└── Futurama - Bender's Big Score (2007).mkv ← Polish audio inside +``` + +Do NOT put `.pl` in the filename — the audio language tag is a track-level +attribute (read from the mkv stream), not a filename token. If you must +distinguish two language rips of the same film, use the multi-version pattern: + +``` +Movie (2020) - PL Dub.mkv +Movie (2020) - Original.mkv +``` + +### 1.5 Year disambiguation + +When two films share a title, year alone is what the scraper uses. If both +are 1980, fall back to provider IDs: + +``` +/media/movies/Bad Movie (1980) [imdbid-tt0080000]/ +/media/movies/Bad Movie (1980) [imdbid-tt0080001]/ +``` + +### 1.6 Scrapers + +- **Primary:** TheMovieDb (TMDb) — bundled, on by default. +- **Fallback / cross-reference:** OMDb (IMDb-backed; ships with Jellyfin core). +- **Image-only:** TheMovieDb covers most posters/backdrops. Add the + **Fanart.tv** plugin if you need clearart, disc, logo overrides — see + `01-artwork-and-images.md` § 4. +- **Trailers:** TheMovieDb attaches YouTube trailer links automatically; the + **AniList** / **TheTVDB** plugins do not apply here. + +### 1.7 Edge cases + +- **`VIDEO_TS` / `BDMV` rips** are supported but **lose multi-version, multi-part, + and external subtitles**. Avoid for new rips; remux to mkv. +- **Pre-release / unofficial cuts** (Snyder Cut, Final Cut Pro, etc.) → use the + multi-version label, not a separate folder. +- **Movies that became series** (e.g. Fargo) — the original film goes in + `/media/movies/`, the show in `/media/tv/`. Provider IDs prevent cross-match. +- **Anime films that are part of a TV show** (One Piece: Stampede) — see § 3.6. + +--- + +## 2. TV shows + +### 2.1 Folder structure (canonical) + +``` +/media/tv/ +├── Futurama (1999)/ +│ ├── Season 00/ ← specials live here +│ │ └── Futurama (1999) S00E01 - Christmas Special.mkv +│ ├── Season 01/ +│ │ ├── Futurama (1999) S01E01.mkv +│ │ ├── Futurama (1999) S01E02.mkv +│ │ └── Futurama (1999) S01E03-E04.mkv ← multi-episode file +│ ├── Season 02/ +│ └── tvshow.nfo ← optional, doc § 11 +└── The Office (2005)/ + └── Season 01/ + └── The Office (2005) S01E01.mkv +``` + +**Per-season folders are mandatory** for this deploy. Flat (no season folders) +parses but loses the per-season-poster override path (§ 10) and breaks for +shows >2 seasons. + +### 2.2 Filename pattern + +- **Pattern:** `^.*?[Ss](?<season>\d{1,2})[Ee](?<episode>\d{1,3})(-[Ee]?(?<end>\d{1,3}))?(\s.*)?\.<ext>$` +- Season + episode tokens recognised by the parser (case-insensitive, dots + and dashes equivalent to spaces — verified in doc 02 § 2): + +| Pattern | Example | Result | +|---|---|---| +| `S##E##` | `Futurama S01E01.mkv` | s1e1 | +| `s##e##` | `Futurama.s01e01.pl.mkv` | s1e1 (current Futurama) | +| `Season ## Episode ##` | `Futurama Season 1 Episode 1.mkv` | s1e1 | +| `##x##` | `Futurama 1x01.mkv` | s1e1 | +| `S##E##-E##` | `Futurama S01E01-E02.mkv` | one file, eps 1+2 | +| `S##E## - S##E##` | `Futurama S01E01 - S01E02.mkv` | multi range | + +Series name comes from the **parent folder** (preferred) or the filename +prefix before the `S##E##` token. If both are present, folder wins. + +#### Examples that WORK + +``` +Futurama (1999)/Season 01/Futurama (1999) S01E01.mkv +Futurama (1999)/Season 01/Futurama.s01e01.pl.mkv +Futurama (1999)/Season 01/Futurama 1x01 Space Pilot 3000.mkv +Futurama (1999)/Season 01/Futurama S01E01-E02.mkv +Futurama (1999)/Season 00/Futurama (1999) S00E01 - Bender Big Score.mkv +``` + +#### Examples that BREAK + +``` +Futurama/Futurama-Pilot.mkv ← no S##E## token, ungrabbable as episode +Futurama/Season1/... ← "Season1" — needs space: "Season 1" or "Season 01" +Futurama/Specials/... ← "Specials" doesn't match; use "Season 00" +Futurama/Season 01/01.mkv ← parser sees no season+episode token, only "01" +Futurama/S01/Futurama_S01E01.mkv ← top-level folder is "S01", series name = "S01" +Futurama (1999) S01E01.mkv ← in /media/tv/ root; no series folder +``` + +### 2.3 Specials (Season 0) + +- Folder: `Season 00` (zero-padded). `Specials/`, `Season 0/`, `Season Specials/` + do **not** match the parser. +- Filename: `Series (year) S00E01 - Title.mkv` — `S00` is required; without + it the file falls into "no season" and is ignored. +- For specials that should appear inside a regular season (e.g. between S03E04 + and S03E05), use NFO `<airsbefore_season>` / `<airsafter_season>` / + `<airsbefore_episode>` tags AND enable "Display specials within their + series" in library settings. + +### 2.4 Multi-episode files + +Two formats accepted: + +``` +Futurama (1999) S01E01-E02.mkv ← preferred +Futurama (1999) S01E01 - S01E02.mkv ← also accepted +``` + +Both tag the file as "stacked" — Jellyfin shows it as one entry on the +episode list and plays the entire file when either episode is clicked. + +### 2.5 Date-based / daily shows + +The official docs do not define a date-based pattern as of 2026-05. The +practical workaround for daily shows (talk shows, news) is to fake them +into seasonal numbering by year: + +``` +The Daily Show/ +├── Season 2024/ +│ ├── The Daily Show S2024E001 - 2024-01-02.mkv +│ └── The Daily Show S2024E002 - 2024-01-03.mkv +``` + +Episode number = day-of-year (001–366). Ugly but parser-clean. If the +metadata provider (TVDB) supports the date-based show, NFO sidecars can +override the episode title to the actual airdate. + +### 2.6 Scrapers + +- **Primary:** TheTVDB. +- **Secondary:** TheMovieDb (TMDb has good TV coverage too). +- **Order matters:** Library options → Metadata Fetchers → drag the order. + For Futurama on this deploy we used TMDB primary because TVDB had stale + episode-still URLs. +- **Image:** TVDB ships posters and episode stills; TMDB has higher-resolution + backdrops; Fanart.tv has clearart + clearlogo. + +### 2.7 Edge cases + +- **Series whose name STARTS with a year** (e.g. "1923") — wrap in folder + `1923 (2022)/` so the parser doesn't confuse the series-name year with + the disambiguation year. +- **Shows that re-run/reboot** (`Doctor Who`, `Battlestar Galactica`) — keep + reboots in separate folders, year disambiguation is mandatory: + `Doctor Who (1963)/` and `Doctor Who (2005)/`. +- **Mini-series / limited series** — treat as TV, single season is fine + (`Chernobyl (2019)/Season 01/...`). +- **Episode title inside filename is ignored** once the series is identified; + TMDB/TVDB title overwrites it (see doc 02 § 4). +- **`(year)` is required only at the series level**, not on every episode. + Including it on every episode is harmless but verbose. + +--- + +## 3. Anime + +Anime is the area with the highest "scraper picks the wrong thing" risk +because TVDB / TMDB / AniDB / AniList disagree on how to slice multi-cours +shows into seasons. Two distinct strategies — pick **one per show**, never +mix. + +### 3.1 Strategy A — TVDB seasonal numbering (default for this deploy) + +Use this when: +- Show has ≤ 100 episodes. +- TVDB's season split matches the official Blu-ray / streaming split. +- You're not running Shoko. + +#### Folder structure + +``` +/media/anime/ +├── Cowboy Bebop (1998)/ +│ └── Season 01/ +│ ├── Cowboy Bebop (1998) S01E01.mkv +│ └── Cowboy Bebop (1998) S01E02.mkv +└── Mushishi (2005)/ + ├── Season 01/ + └── Season 02/ ← Mushishi Zoku Shou maps to S02 on TVDB +``` + +#### Filename pattern + +Identical to TV shows § 2.2. Include `(year)` of first broadcast. + +#### Edge case — episodes >99 in a season + +`S01E100` works for the parser. `S01E001` (3-digit) also works — see [Issue +#17 in jellyfin-plugin-anime](https://github.com/jellyfin-archive/jellyfin-plugin-anime/issues/17). +But **absolute numbering across multiple seasons** (where the show has 1099 +episodes spanning many "seasons" on disk) breaks the seasonal model. Use +Strategy B. + +### 3.2 Strategy B — Absolute numbering with Shoko Server + +Use this when: +- Show has > 100 episodes (One Piece, Naruto, Detective Conan). +- You want AniDB matching, MAL/AniList sync, exact tag accuracy. +- You don't mind running an extra container. + +Shoko hashes files by content (ED2K) and identifies them regardless of +filename. With Shoko + the Jellyfin Shoko plugin, **filenames don't matter**. + +``` +/media/anime-shoko/ +└── (any layout you like; Shoko walks the tree and hashes everything) +``` + +This deploy does **not currently run Shoko**. If/when added, it lives at +`/opt/docker/shoko/` and exposes `/media/anime-shoko/` to Jellyfin via the +plugin, separate from `/media/anime/`. + +### 3.3 Strategy C — Absolute numbering with naive Jellyfin (avoid) + +Naming files `One Piece - 1099.mkv` with no season folders works for the +**very first 99 episodes** then breaks: the parser sees ep 100+ as "ep 1 +of S00 (Specials)" and shuffles. Documented in upstream issue #17. Don't. + +### 3.4 Sub vs Dub + +Two acceptable patterns: + +**Pattern 1 — separate libraries** (recommended, clean): + +``` +/media/anime/Death Note (2006)/Season 01/Death Note (2006) S01E01.mkv ← original Japanese w/ subs +/media/anime-dub/Death Note (2006)/Season 01/Death Note (2006) S01E01.mkv ← English dub +``` + +Two libraries; user picks which to browse. + +**Pattern 2 — multi-version filenames** (single library, may confuse scraper): + +``` +/media/anime/Death Note (2006)/Season 01/ +├── Death Note (2006) S01E01.mkv ← default (sub) +└── Death Note (2006) S01E01 - Dub.mkv ← extra version, label "Dub" +``` + +Beware: multi-version on TV episodes (vs movies) is partial in Jellyfin +10.10 — the dub version may not be selectable from all clients. Pattern 1 +is safer. + +### 3.5 OVAs / OADs / specials + +OVAs go in `Season 00` of the parent series, with descriptive titles: + +``` +Mushishi (2005)/ +├── Season 00/ +│ ├── Mushishi (2005) S00E01 - Hihamukage OVA.mkv +│ └── Mushishi (2005) S00E02 - Bell of Stillness OVA.mkv +└── Season 01/ +``` + +### 3.6 Anime films that are "part of" a show + +Two camps: + +- **Canon to plot, watch-order matters** (e.g. *Code Geass: Lelouch of the + Re;surrection*) → put in `Season 00` of the show as a special. +- **Standalone film**, parallel universe (most One Piece movies, Pokémon + films) → put in `/media/movies/` with year. Provider IDs prevent + cross-matching with the parent series. + +### 3.7 Japanese vs English titles + +Folder name uses the title that matches your **primary metadata provider's +preferred display language**. On this deploy, library `MetadataLanguage` is +`pl` for Futurama; if you add an Anime library set it to `en` or `ja-JP`. + +Practical rule: use **the romaji or English title that the show's English +Wikipedia article uses as its primary heading**. That's what TVDB/TMDB +search will resolve. Set provider ID to lock if both titles match +something: + +``` +/media/anime/Steins;Gate (2011) [tvdbid-244061]/ +``` + +(Note `;` is illegal on Windows but allowed on `ext4`. Avoid for portability.) + +### 3.8 Scrapers + +- **Primary (sub library):** TheTVDB (anime detection enabled). +- **Optional plugin:** **AniDB** + **AniList** plugins — install via + Dashboard → Plugins → Catalog. Enable per-library. AniDB has better + episode-level metadata for older shows; AniList has better + current-airing data. +- **With Shoko:** Shoko replaces all of the above; AniDB IDs are canonical. + +### 3.9 Library type + +For this deploy, the Anime library uses `CollectionType: tvshows` (NOT a +separate "anime" type — Jellyfin doesn't have one). Set +`PreferredMetadataLanguage` and the metadata-provider order at library +creation. See § 12 for the API call. + +--- + +## 4. Stand-up comedy specials + +### 4.1 Folder structure + +Treat as **movies** (one folder per special). Each comedian's specials are +peers — do not nest by performer. + +``` +/media/movies/ +├── Bo Burnham - Inside (2021)/ +│ └── Bo Burnham - Inside (2021).mkv +├── Bo Burnham - Make Happy (2016)/ +│ └── Bo Burnham - Make Happy (2016).mkv +└── Norm Macdonald - Nothing Special (2022)/ + └── Norm Macdonald - Nothing Special (2022).mkv +``` + +### 4.2 Filename pattern + +Same as Movies (§ 1.2). Convention: `<Performer> - <Special Title> (year)`. + +#### Examples that WORK + +``` +Bo Burnham - Inside (2021).mkv +Hannah Gadsby - Nanette (2018) [imdbid-tt8465676].mkv +``` + +#### Examples that BREAK + +``` +Bo Burnham/Inside (2021).mkv ← no per-movie folder +Inside (2021).mkv ← title too generic; TMDB picks horror film "Inside" +Bo Burnham: Inside (2021).mkv ← `:` is illegal +``` + +### 4.3 Scrapers + +- **Primary:** TheMovieDb (TMDb has stand-up special listings under "Movies"). +- TVDB has a Stand-Up category but the Jellyfin TVDB integration treats + everything in a movies library as a movie — leave it. + +### 4.4 Edge cases + +- **Specials that aren't on TMDB** (small comedians, festival recordings) → + write a `movie.nfo` (§ 11) and let it stand alone. Jellyfin won't fetch + remote data without an ID. +- **Optional separate library:** if you want stand-up out of the main movies + grid, create a second library with `CollectionType: movies` rooted at + `/media/standup/` — same scrapers, just a different shelf. +- **Tagging:** add `<tag>Stand-up</tag>` to the NFO or use a Jellyfin + Collection (BoxSet) called "Stand-up Specials" to group them. + +--- + +## 5. Concerts / music videos + +### 5.1 Folder structure + +``` +/media/musicvideos/ +├── Daft Punk/ +│ ├── Get Lucky/ +│ │ └── Daft Punk - Get Lucky.mp4 +│ └── Around the World/ +│ └── Daft Punk - Around the World.mp4 +└── Pink Floyd/ + └── Pulse Concert (1995)/ + ├── Pink Floyd - Pulse (1995).mkv + └── poster.jpg +``` + +The library can nest as deep as you like — verbatim from upstream: +"The folders and video files can be named however you want, since no +metadata fetching is performed." + +### 5.2 Filename pattern + +- **Pattern:** anything. Free-form. The display name is the literal filename + minus extension. +- **Convention for this deploy:** `<Artist> - <Track Title>.<ext>` (or + `<Artist> - <Concert Name> (year).<ext>` for full concerts). + +#### Examples that WORK + +Anything not containing `< > : " / \ | ? *`. + +#### Examples that BREAK (parser-wise — none, but UX-wise) + +``` +01.mp4 ← display name "01", useless +videoplayback (1).mp4 ← yt-dlp default; rename before scan +``` + +### 5.3 Scrapers + +- **None by default.** `musicvideos` library type has no built-in remote + metadata fetcher — Jellyfin uses filenames + folder structure. +- Embedded ID3-style tags in `mp4` (artist, title) ARE read. +- Plugins: there is no first-party music-video scraper. Some users use + the **MusicBrainz** plugin to cross-reference, but coverage is poor. + +### 5.4 Edge cases + +- **Full live concerts** are sometimes better as `tvshows` (one episode per + song) or `movies` (single file). For this deploy use **movies** for + full concert recordings, **musicvideos** for individual song clips. +- **Fan-made / unofficial videos** — fine here, since no scraper to + mismatch. +- **Music VIDEOS attached to a music album** — Jellyfin doesn't link a + music-video item to a music-album item natively. Live with the + separation. + +--- + +## 6. Documentaries + +Documentaries split on form: + +### 6.1 Single-film documentaries → Movies library + +``` +/media/movies/ +└── Free Solo (2018)/ + └── Free Solo (2018).mkv +``` + +Same rules as § 1. TMDB classifies most documentary films as "Movies". + +### 6.2 Multi-episode documentary series → TV library + +``` +/media/tv/ +└── Planet Earth II (2016)/ + └── Season 01/ + ├── Planet Earth II (2016) S01E01.mkv + └── Planet Earth II (2016) S01E02.mkv +``` + +Same rules as § 2. TVDB classifies most documentary series as "Series". + +### 6.3 Optional separate libraries + +For users who want documentaries off the main Movies/TV shelves: + +- `/media/docs-movies/` with `CollectionType: movies` +- `/media/docs-tv/` with `CollectionType: tvshows` + +Same scrapers as the parent type — the tag is purely UI. + +### 6.4 Scrapers + +- **Films:** TMDb (primary), OMDb (fallback). +- **Series:** TVDB (primary), TMDb (secondary). +- The same NFO override rules apply (§ 11) for obscure docs that aren't on + any provider. + +### 6.5 Edge cases + +- **Mini-series in 1 long file** (e.g. *The Vietnam War* PBS, 18 hours, one + rip) — treat as a movie or split into episodes. Jellyfin does not chapter- + split a single file into N episode entries. +- **Lecture series** (Crash Course, Khan Academy) — treat as TV. Use + `Crash Course (2011)/Season 01/` etc. + +--- + +## 7. Home videos / personal media + +The goal: keep Jellyfin from "fixing" your wedding videos by pulling the +poster of an unrelated 2015 movie called *Wedding*. + +### 7.1 Folder structure + +``` +/media/home/ +├── 2024/ +│ ├── 2024-06-15 Berlin Trip/ +│ │ ├── 2024-06-15 Berlin Trip - clip01.mp4 +│ │ └── 2024-06-15 Berlin Trip - clip02.mp4 +│ └── 2024-12-25 Christmas/ +│ └── 2024-12-25 Christmas.mp4 +└── 2025/ + └── 2025-08-30 Wedding/ + └── 2025-08-30 Wedding.mp4 +``` + +### 7.2 Filename pattern + +Free-form. Date-prefix recommended (`YYYY-MM-DD <event>`) — Jellyfin will +parse the date and use it as the item's date. + +### 7.3 Library type + +`CollectionType: homevideos` — **critical**. This is the only collection +type that disables all remote metadata fetchers. With `homevideos`: + +- No TMDB / TVDB / OMDb scrapers run. +- Image fetchers are off (use sidecar `.jpg` if you want a thumb). +- The library shows up under "Photos & home videos" in the UI sidebar. + +### 7.4 Scrapers + +**None.** Local-only. NFO sidecars (§ 11) work if you want to label +individual clips, but no remote lookups. + +### 7.5 Edge cases + +- **Photos in the same folder as videos** — `homevideos` library accepts + both. Use `.jpg`/`.png`/`.heic` sidecars; they appear in the slideshow. +- **Don't drop home videos in `/media/movies/`.** Even with no provider IDs, + Jellyfin will scan and try to match titles against TMDB. The + `homevideos` library is the only safe place. +- **Smart phone clips** named `IMG_1234.MOV` are fine; they display by + filename. Bulk-rename to `2024-06-15 - IMG_1234.mov` if you want them + sorted by event date. + +--- + +## 8. Extras / special features + +Extras attach to a parent item (movie or series) via two mechanisms: +filename suffix, or named subfolder. Either works; mixing is fine. + +### 8.1 Suffix method + +Append one of these tokens to the filename **before** the extension: + +| Suffix | Type | Example | +|---|---|---| +| `-trailer` `.trailer` `_trailer` ` trailer` | Trailer | `Blade Runner (1982) - 1982 Theatrical-trailer.mp4` | +| `-sample` `.sample` `_sample` ` sample` | Sample | `Movie-sample.mp4` | +| `-scene` | Deleted scene / vignette | `Inception (2010) - Hallway-scene.mp4` | +| `-clip` | Promo clip | `Movie - TV Spot-clip.mp4` | +| `-interview` | Interview | `Movie - Director Interview-interview.mp4` | +| `-behindthescenes` | BTS featurette | `Movie - VFX Breakdown-behindthescenes.mp4` | +| `-deleted` `-deletedscene` | Deleted scene | `Movie - Cut Diner Scene-deleted.mp4` | +| `-featurette` | Featurette | `Movie - Anatomy of a Stunt-featurette.mp4` | +| `-short` | Short film | `Movie - Prequel Short-short.mp4` | +| `-other` `-extra` | Catch-all | `Movie - Ephemera-other.mp4` | + +A lone trailer/sample file can also be named just `trailer.mp4` or +`sample.mp4` and dropped in the parent item folder. + +### 8.2 Folder method + +Inside the parent item folder, any of these named subfolders are picked up +and the files inside are tagged with the matching extra type: + +``` +Inception (2010)/ +├── Inception (2010).mkv +├── behind the scenes/ +│ └── VFX Breakdown.mp4 +├── deleted scenes/ +│ ├── Diner Cut.mp4 +│ └── Hotel Hallway Cut.mp4 +├── featurettes/ +│ └── Dreams Within Dreams.mp4 +├── interviews/ +│ └── Christopher Nolan.mp4 +├── scenes/ +├── shorts/ +├── samples/ +├── trailers/ +│ └── Theatrical Trailer.mp4 +├── clips/ +├── theme-music/ +│ └── theme.mp3 ← see § 8.3 +├── backdrops/ +│ └── 2.mp4 ← rotating video backdrops +├── other/ +└── extras/ ← generic catch-all +``` + +Subfolder names **must match exactly** (case-insensitive on `ext4`+Jellyfin): +`Behind the Scenes/` works; `BTS/` does not; `behind-the-scenes/` does not. + +### 8.3 Theme music + +`theme-music/theme.mp3` plays a track on hover/auto in supported clients +(Swiftfin, JellyfinMediaPlayer). One file per item. + +### 8.4 Backdrops + +Video backdrops (rotating background loops) go in `backdrops/` as numbered +mp4s. Falls back to image backdrops if not present. + +### 8.5 Edge cases + +- **Extras attached to a series vs a season vs an episode** — folder method + works at any level: drop `behind the scenes/` inside `Futurama (1999)/` + for series-wide extras, inside `Season 01/` for season extras, or + use suffix on a sibling file for episode extras. +- **Show-level trailers** — Jellyfin's TV scraper auto-attaches trailer + YouTube links from TMDB. You don't need to download them. + +--- + +## 9. Subtitles (sidecar) + +See [`03-subtitles.md`](03-subtitles.md) for the full rules. Quick reference: + +``` +<videobasename>.<lang>[.flag].<ext> +``` + +- `<videobasename>` = the video filename minus extension. +- `<lang>` = ISO-639-1 (`en`, `pl`) or ISO-639-2 (`eng`, `pol`). +- `<flag>` = optional, any combination of `forced`, `default`, `sdh`, `cc`. +- `<ext>` = `srt`, `ass`, `ssa`, `vtt`, `sub` (+ `.idx` for VobSub). + +Examples next to `Futurama.s01e01.pl.mkv`: + +``` +Futurama.s01e01.pl.eng.srt ← English regular +Futurama.s01e01.pl.en.forced.srt ← English forced (foreign-scene captions) +Futurama.s01e01.pl.en.sdh.srt ← English SDH +Futurama.s01e01.pl.en.default.srt ← marked default, auto-selects +Futurama.s01e01.pl.pl.srt ← Polish (matches the language of the audio) +``` + +After dropping subs on disk, run `POST /Library/Refresh` (or wait for the +nightly scan) — Jellyfin discovers them and attaches. + +--- + +## 10. Artwork override files + +Jellyfin scrapes artwork from TMDB/TVDB/Fanart by default (see doc 01). +Override per-item by dropping a sidecar image with one of these recognised +filenames in the item's folder. + +### 10.1 Movie / generic item folder + +| Filename | ImageType | Notes | +|---|---|---| +| `poster.jpg` / `poster.png` | Primary | Main poster (vertical 2:3). | +| `folder.jpg` | Primary | Alias of poster (Windows / Plex compat). | +| `cover.jpg` | Primary | Alias of poster (also used in music). | +| `default.jpg` | Primary | Alias. | +| `movie.jpg` | Primary | Alias. | +| `backdrop.jpg` | Backdrop | Hero image (16:9 fanart). | +| `backdrop1.jpg`, `backdrop2.jpg`, ... | Backdrop | Multiple backdrops, numbered. | +| `fanart.jpg` | Backdrop | Plex/Kodi compat alias. | +| `logo.png` | Logo | Transparent text-logo overlay. | +| `clearlogo.png` | Logo | Alias. | +| `banner.jpg` | Banner | Wide ~758×140 strip. | +| `thumb.jpg` | Thumb | 16:9 still. Used as episode thumbnail at item level. | +| `landscape.jpg` | Thumb | Alias. | +| `disc.png` | Disc | DVD/Blu-ray hub icon. | +| `clearart.png` | Art | Transparent character cutout. | + +### 10.2 TV series folder (additional) + +``` +Futurama (1999)/ +├── poster.jpg +├── backdrop.jpg +├── logo.png +├── banner.jpg +├── season-all-poster.jpg ← shared across all seasons +├── season01-poster.jpg ← Season 01 specific +├── season02-poster.jpg +├── season-specials-poster.jpg ← Season 00 +├── Season 01/ +│ ├── Futurama (1999) S01E01.mkv +│ ├── Futurama (1999) S01E01.jpg ← episode thumb (basename match) +│ └── poster.jpg ← also valid as season poster +└── tvshow.nfo +``` + +The two season-poster paths are equivalent; pick one style. Episode-level +thumbs use `<basename>.jpg` (i.e. drop a `.jpg` next to the `.mkv` with +matching name). + +### 10.3 When to override + +- Use sidecar files when the scraper's choice is wrong AND you don't want + to upload via the Web UI (which writes into `/config/metadata/library/...` + — wiped on container rebuild). +- Sidecars in the media folder **survive `docker rm`** because they live on + the user's data volume. +- Sidecars take precedence over remote scraper images on next refresh + ONLY if "Save artwork into media folders" is enabled in Dashboard → + Libraries → (each library) → Library options. This deploy has it ON. + +### 10.4 Edge cases + +- `.png` and `.jpg` are both accepted; `.webp` works for backdrops but + not all clients render it — prefer `.jpg`. +- Image larger than 4K is downscaled by the API on serve. Don't bother with + >2160p source images. +- **Don't put `poster.jpg` in `/media/movies/`** (the library root) — it + becomes the library's primary image, often unwanted. + +--- + +## 11. NFO sidecars + +NFO files are XML metadata, written next to the media. They override remote +scrapers entirely. From upstream: "Local metadata will always be fetched and +has priority over remote metadata providers like TMDb." + +### 11.1 Filenames + +| Item type | Required filename | +|---|---| +| Movie | `movie.nfo` (in the movie folder), OR `<videobasename>.nfo` next to the file, OR `VIDEO_TS.nfo` for DVD rips | +| TV series | `tvshow.nfo` in the series folder | +| TV season | `season.nfo` in the season folder | +| TV episode | `<episode_filename>.nfo` (e.g. `Futurama (1999) S01E01.nfo`) | +| Music artist | `artist.nfo` | +| Music album | `album.nfo` | + +### 11.2 When to write one + +- Obscure indie film not on TMDB / IMDB → `movie.nfo` lets you fill the + metadata yourself. +- Show whose IDs scrape wrong every time → `tvshow.nfo` with locked + `<tmdbid>` / `<tvdbid>` is faster than the API `RemoteSearch/Apply` + workflow (doc 02 § 5). +- Home videos / personal — usually not needed (homevideos lib doesn't + scrape) but useful for nice titles. + +### 11.3 Minimal `movie.nfo` example + +```xml +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<movie> + <title>The Obscure Film + Niejasny film + 2014 + A short description that overrides whatever TMDB returns. + Drama + 97 + Jane Director + + Lead Actor + Protagonist + + tt12345678 + +``` + +### 11.4 Minimal `tvshow.nfo` example + +```xml + + + Futurama + 1999 + ... + 615 + 73871 + +``` + +### 11.5 NFO Saver (write-back) + +Enable Dashboard → Libraries → (each) → Metadata Savers → "Nfo". Jellyfin +will write `*.nfo` next to media files whenever metadata changes. This is +how you survive container rebuilds without losing manual fixes — the NFO +on disk is canonical, the SQLite DB is regenerable. + +### 11.6 Edge cases + +- **Empty/malformed NFO** stops the scraper from running on that item AT + ALL. Either write valid XML or delete the file. +- **NFO + provider ID conflict** — local always wins. If you set + `615` and the filename has `[tmdbid-9999]`, NFO wins. +- **Episode `.nfo` per file is verbose.** Most people only write `tvshow.nfo` + and let the episode metadata come from the provider. + +--- + +## 12. CollectionType-to-scraper mapping (library creation) + +Verbatim values from `Jellyfin.Data/Enums/CollectionType.cs` (master, +verified 2026-05). These are the strings to pass when creating a library +via API. (`unknown` is reserved; `tvshowseries`+ are virtual aggregates not +used at library creation.) + +| `CollectionType` | UI name | Default scrapers (10.10.x) | Use for | +|---|---|---|---| +| `movies` | Movies | TMDb, OMDb, Fanart.tv (plugin) | Films, stand-up, doc films | +| `tvshows` | Shows | TVDB, TMDb, Fanart.tv, OMDb | TV, anime, doc series, kids' shows | +| `music` | Music | MusicBrainz, AudioDB | Albums, tracks | +| `musicvideos` | Music Videos | none (filename only) | Music videos, short concerts | +| `homevideos` | Home Videos & Photos | none | Personal recordings, photo albums | +| `boxsets` | Collections | TMDb collections | Manually-curated cross-library box sets | +| `books` | Books | requires "Bookshelf" plugin | epub, mobi, pdf, comics | +| `photos` | Photos | none | Photo-only library | +| `livetv` | Live TV | tuner-driven | Real-time TV (HDHomeRun etc.) | +| `trailers` | Trailers | bundled | Standalone trailers library (rare) | +| `playlists` | Playlists | n/a | Internal Jellyfin construct | +| `folders` | Folders | n/a | Internal Jellyfin construct | +| `mixed` (no enum, legacy) | Mixed | TMDb + TVDB | Don't use — drops most parsing rules | + +### 12.1 Creating libraries via API + +```bash +TOKEN=*redacted* +H="-H \"X-Emby-Token: ${TOKEN}\"" +B="https://tv.s8n.ru" + +# Movies library +curl -s -X POST $H "$B/Library/VirtualFolders?name=Movies&collectionType=movies" \ + -H "Content-Type: application/json" \ + -d '{"LibraryOptions":{"PathInfos":[{"Path":"/media/movies"}],"EnableInternetProviders":true,"PreferredMetadataLanguage":"en","MetadataCountryCode":"US","SaveLocalMetadata":true,"SubtitleDownloadLanguages":["eng"]}}' + +# TV library +curl -s -X POST $H "$B/Library/VirtualFolders?name=Shows&collectionType=tvshows" \ + -H "Content-Type: application/json" \ + -d '{"LibraryOptions":{"PathInfos":[{"Path":"/media/tv"}],"EnableInternetProviders":true,"PreferredMetadataLanguage":"en","SaveLocalMetadata":true,"SubtitleDownloadLanguages":["eng"]}}' + +# Anime library (still tvshows type) +curl -s -X POST $H "$B/Library/VirtualFolders?name=Anime&collectionType=tvshows" \ + -H "Content-Type: application/json" \ + -d '{"LibraryOptions":{"PathInfos":[{"Path":"/media/anime"}],"EnableInternetProviders":true,"PreferredMetadataLanguage":"en","SaveLocalMetadata":true,"SubtitleDownloadLanguages":["eng"]}}' + +# Music videos +curl -s -X POST $H "$B/Library/VirtualFolders?name=Music%20Videos&collectionType=musicvideos" \ + -H "Content-Type: application/json" \ + -d '{"LibraryOptions":{"PathInfos":[{"Path":"/media/musicvideos"}]}}' + +# Home videos +curl -s -X POST $H "$B/Library/VirtualFolders?name=Home%20Videos&collectionType=homevideos" \ + -H "Content-Type: application/json" \ + -d '{"LibraryOptions":{"PathInfos":[{"Path":"/media/home"}],"EnableInternetProviders":false}}' +``` + +After creation, trigger an initial scan: `POST /Library/Refresh`. + +--- + +## 13. Canonical layout for THIS deploy + +### 13.1 Architecture decision + +**Adopted: Architecture A** — flat by category at `/home/user/media/`, one +Jellyfin library per category. + +``` +/home/user/media/ +├── movies/ ← collectionType: movies +├── tv/ ← collectionType: tvshows +├── anime/ ← collectionType: tvshows (separate library) +├── musicvideos/ ← collectionType: musicvideos +├── music/ ← collectionType: music (future) +├── docs-movies/ ← collectionType: movies (future, optional) +├── docs-tv/ ← collectionType: tvshows (future, optional) +└── home/ ← collectionType: homevideos +``` + +### 13.2 Why Architecture A (not B or C) + +**B (nested, `media/video/{movies,tv,anime}/...`)** — rejected. Adds a +useless directory level. Jellyfin's library config takes a path; nesting +buys nothing on the client side. Increases the chance of a typo in the +container's bind-mount. + +**C (split disks: `/media-fast/` NVMe + `/media-slow/` HDD)** — rejected +**for now**. Nullstone has a single 2 TB NVMe and 4 TB HDD. Total media +today is ~50 GB (Futurama + future). When the library exceeds 1 TB, we'll +revisit and migrate cold catalogue (older movies, finished anime) to the +HDD, mounted as a second library path: + +``` +LibraryOptions.PathInfos = [ + {"Path": "/media/movies"}, ← /home/user/media/movies on NVMe + {"Path": "/media-archive/movies"} ← /mnt/hdd/media/movies +] +``` + +Jellyfin natively merges multiple paths into one logical library. No URL +or client-facing change needed at migration time. + +**A wins because:** + +- One library per `collectionType` is the simplest correct mapping. +- Anime as its own library lets us set provider order and language + preference independently from `tv/`. +- `home/` MUST be a separate library to get `homevideos` collection type + (the only way to disable scrapers for personal media). + +### 13.3 Concrete mkdir commands + +Run on nullstone as `user` (not root — the existing tree is already owned +by `user:user`): + +```bash +ssh user@192.168.0.100 'mkdir -p \ + /home/user/media/movies \ + /home/user/media/tv \ + /home/user/media/anime \ + /home/user/media/musicvideos \ + /home/user/media/music \ + /home/user/media/home' +``` + +Verify: + +```bash +ssh user@192.168.0.100 'ls -la /home/user/media/' +``` + +### 13.4 Container bind mounts + +`/opt/docker/jellyfin/docker-compose.yml` should mount each as read-only +under `/media/`: + +```yaml +volumes: + - /home/user/media/movies:/media/movies:ro + - /home/user/media/tv:/media/tv:ro + - /home/user/media/anime:/media/anime:ro + - /home/user/media/musicvideos:/media/musicvideos:ro + - /home/user/media/music:/media/music:ro + - /home/user/media/home:/media/home:ro +``` + +The current compose only mounts `movies` and `tv`; extend it before adding +the new libraries via API. + +### 13.5 Initial state after applying + +``` +Movies library → /media/movies (empty, ready) +TV Shows library → /media/tv (Futurama 1999, S01-S03, 44 eps) +Anime library → /media/anime (empty, ready) +Music Videos lib → /media/musicvideos (empty, ready) +Music library → /media/music (empty, ready) +Home Videos lib → /media/home (empty, ready) +``` + +--- + +## 14. Verification checklist + +Before declaring a new addition "done": + +1. Filename matches the regex anchor for the category (§ 1–7). +2. Year is in `(YYYY)` and matches the actual release year. +3. Folder name byte-for-byte matches the filename prefix (movies multi-version). +4. No forbidden chars (`< > : " / \ | ? *`). +5. Per-item folder exists (no loose files in library root, except music videos). +6. `tvshow.nfo` / `movie.nfo` exists IFF you needed to override the scraper. +7. Subtitles use `..srt` (doc 03). +8. Scan: `curl -s -X POST -H "X-Emby-Token: $TOKEN" https://tv.s8n.ru/Library/Refresh`. +9. Wait ~30 s, check item via `/Items?searchTerm=...` — verify `ProviderIds` + is populated. Empty `ProviderIds` = filename didn't disambiguate; doc 02 + § 5 has the manual-lock recipe. +10. Refresh (`Items/{id}/Refresh?ReplaceAllMetadata=true`) AFTER fixing the + provider ID — otherwise the wrong cached metadata sticks. + +--- + +## 15. Quick reference card + +| Category | Folder | Filename | CollectionType | Scraper | +|---|---|---|---|---| +| Movie | `movies/Title (year)/` | `Title (year).mkv` | `movies` | TMDb | +| Movie multi-version | `movies/Title (year)/` | `Title (year) - 1080p.mkv` | `movies` | TMDb | +| Movie multi-disc | `movies/Title (year)/` | `Title (year) - cd1.mkv` | `movies` | TMDb | +| TV episode | `tv/Show (year)/Season 01/` | `Show (year) S01E01.mkv` | `tvshows` | TVDB | +| TV multi-ep | `tv/Show (year)/Season 01/` | `Show (year) S01E01-E02.mkv` | `tvshows` | TVDB | +| TV special | `tv/Show (year)/Season 00/` | `Show (year) S00E01.mkv` | `tvshows` | TVDB | +| Anime (seasonal) | `anime/Show (year)/Season 01/` | `Show (year) S01E01.mkv` | `tvshows` | TVDB+AniDB plugin | +| Anime (Shoko) | `anime-shoko/` | any | `tvshows` | Shoko | +| Stand-up | `movies/Comedian - Title (year)/` | `Comedian - Title (year).mkv` | `movies` | TMDb | +| Music video | `musicvideos/Artist/Track/` | `Artist - Track.mp4` | `musicvideos` | none | +| Doc film | `movies/Title (year)/` | `Title (year).mkv` | `movies` | TMDb | +| Doc series | `tv/Show (year)/Season 01/` | `Show (year) S01E01.mkv` | `tvshows` | TVDB | +| Home video | `home/YYYY/YYYY-MM-DD Event/` | any | `homevideos` | none | +| Extra (suffix) | `movies/Title (year)/` | `Anything-behindthescenes.mp4` | (parent) | n/a | +| Extra (folder) | `movies/Title (year)/behind the scenes/` | any | (parent) | n/a | + +--- + +## 16. Top three gotchas (in order of frequency) + +1. **No per-item folder.** Loose `Movie (2020).mkv` directly in `/media/movies/` + parses, but extras / NFO / artwork sidecars cannot attach. Always make a + folder. +2. **Year not in parens.** `Movie 2020.mkv` → year is part of the title; + scraper search for "Movie 2020" returns wrong results. Always + `Movie (2020).mkv`. +3. **Anime absolute numbering > 99 episodes** without Shoko, mixed with + season folders → episodes shuffle into Season 0. Either split by + TVDB seasons OR run Shoko. Never half-and-half. + +--- + +End of doc 05. For questions about parsing edge cases not covered here, +read `Emby.Naming.xml` inside the container (`docker exec jellyfin cat +/jellyfin/Emby.Naming.xml`) — it has the canonical regex chain. diff --git a/docs/06-per-library-themes.md b/docs/06-per-library-themes.md new file mode 100644 index 0000000..aa67314 --- /dev/null +++ b/docs/06-per-library-themes.md @@ -0,0 +1,319 @@ +# 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://tv.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.tv.s8n.ru`, `anime.tv.s8n.ru`, `music.tv.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: *redacted*`) 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.tv.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.