Files
ersatztv/docs/superpowers/specs/2026-07-20-jellyfin-mixed-libraries-design.md
T
timothyandtimothy 56dff2c49f
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
docs(489): spec + implementation plan for Jellyfin mixed-content libraries (#490)
Design spec and bite-sized TDD implementation plan for #489, whose
implementation landed in #493.

Docs-only. Kept as the record of how the design was reached: that Jellyfin
classifies mixed-library items server-side via includeItemTypes (so no
inference is needed), that MediaItem is TPT keyed on LibraryPathId (so no
migration is needed), that MediaKind is dispatch + presentation only, and why
the feature is deliberately scoped to Jellyfin rather than local libraries.

Also records the open risk the plan carried -- the music-video scanner's
untraced reconciliation -- which #494 subsequently answered.

Refs #489

Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-20 16:36:02 +00:00

214 lines
11 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.
# Jellyfin mixed-content library support — design
**Issue:** [#489](http://192.168.1.95:3000/timothy/ersatztv/issues/489)
**Blocked by:** [#488](http://192.168.1.95:3000/timothy/ersatztv/issues/488) (`JellyfinMusicVideoLibraryScanner` crashes on its first item)
**Related:** [#474](http://192.168.1.95:3000/timothy/ersatztv/issues/474) (music channels dead — the issue this arose from)
**Date:** 2026-07-20
## Problem
`JellyfinApiClient.Project()` maps a Jellyfin library's `CollectionType` onto a `LibraryMediaKind`
and returns `None` for anything it does not recognise:
```csharp
response.CollectionType?.ToLowerInvariant() switch
{
"tvshows" => LibraryMediaKind.Shows ,
"movies" => LibraryMediaKind.Movies ,
"musicvideos" => LibraryMediaKind.MusicVideos ,
"boxsets" => CacheCollectionLibraryId(response.ItemId),
_ => None // mixed lands here, with no log line
};
```
A library whose content type is **mixed** is therefore dropped silently. It never appears in
ErsatzTV and nothing explains why. On the live system that is two libraries:
| Jellyfin library | marker | path | contents |
|---|---|---|---|
| Music Videos | `mixed.collection` | `/data/music` | shows (`Top of the Pops`, `Old Grey Whistle Test`, `Soul Train`, `The Midnight Special`), concert movies (`Concert Films`, `Kraftwerk Minimum Maximum`, `Underworld`), and genuine single-performance music videos |
| Standup | `mixed.collection` | `/data/standup` | a mix of shows and movies |
Both are genuinely mixed. `mixed` is the honest content type, not a mislabelling.
The existing workaround for `Standup` is local library 14 pointed straight at `/data/standup` and
typed `Movies`. It bypasses Jellyfin, scans the content a second time, models shows as movies, and
forfeits Jellyfin's metadata. `/data/music` never received even that treatment, which is #474.
## Goal
Ingest mixed Jellyfin libraries while keeping their content **segregated** from the main `Movies`
and `TV Shows` libraries.
The organising principle: **a library is a place.** One physical path ↔ one Jellyfin library ↔ one
ErsatzTV library. Its contents are heterogeneous. This replaces the implicit "a library is a media
kind" model.
Segregation falls out of that directly: music and standup content lives in its own libraries, so it
cannot leak into `Movies` or `TV Shows`.
## Non-goals
- **Local (filesystem) mixed libraries.** See "Scope" below — deliberately excluded.
- **Emby and Plex mixed libraries.** Neither has music-video support at all today; adding mixed
support there is a separate, larger piece of work.
- **`Songs` and `Images` inside a mixed library.** Jellyfin's `music` collection type is already
unsupported (`// TODO: ??? for music libraries`) and out of scope here.
- Retiring local library 14 (`Standup`). That becomes possible afterwards, but is its own change.
## Scope decision: Jellyfin-only, and why
This is the load-bearing choice in the design.
**Remotely, classification is authoritative.** `IJellyfinApi` already queries items by `parentId` +
`includeItemTypes`:
```csharp
GetMovieLibraryItems(, string includeItemTypes = "Movie", )
GetShowLibraryItems(, string includeItemTypes = "Series", )
GetMusicVideoLibraryItems(, string includeItemTypes = "MusicVideo", )
```
`parentId` is the library's `ItemId`. A mixed library can therefore be queried once per type, and
Jellyfin returns disjoint, authoritative sets. `JellyfinLibraryItemResponse` also carries a per-item
`Type` field, which `ProjectToCollectionMediaItem` already switches on for boxsets. There is no
inference and no guessing.
**Locally, the same approach is unsafe.** Every local video scanner shares
`LocalFolderScanner.VideoFileExtensions`, so pointing the movie scanner and the television scanner
at one folder tree means each claims the other's files. Worse, `LibraryFolder` rows are keyed by
`LibraryPathId` with no notion of kind, so two scanners over one path would thrash each other's
etags via `LibraryRepository.SetEtag` / `CleanEtagsForLibraryPath`, producing either perpetual full
rescans or skipped scans.
That hazard does not exist remotely: among the remote scanners, only
`JellyfinMusicVideoLibraryScanner` touches `LibraryFolder` at all.
Both mixed libraries on the live system are Jellyfin libraries, so this scope costs nothing against
the goal.
There is an existing precedent worth noting for the local case:
`LocalLibraryHandlerBase.AreSubPaths` already permits an `Images` library and an `OtherVideos`
library to share one physical directory. That is two libraries over one tree — the same etag
contention described above — and it is the closest existing analogue if local mixed support is ever
revisited.
## Enabling facts (verified, not assumed)
1. **No DB migration is required.** `MediaItem` is table-per-type with no discriminator column.
`LibraryPathId` sits on the abstract base (`MediaItem.cs`) and every subclass inherits it;
`LibraryPath.MediaItems` is `List<MediaItem>`. Heterogeneous items under one `LibraryPath` are
already legal. `MediaItemRepository.GetAllTrashedItems` already `COALESCE`s across
`MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId, ImageId, RemoteStreamId` for a single
`LibraryPathId`.
2. **`MediaKind` is dispatch and presentation, not structure.** Scheduling, playout, collections,
smart collections and playlists contain zero `MediaKind` references. Search indexing keys off the
item's own subclass. The SPA's browse and collections screens use a per-item
`LibraryBrowseMediaType`, not the library's kind. `MediaKind` is persisted on the base `Library`
table only, with no unique constraint or index involving it.
3. **Reconciliation is type-scoped.** `MediaServerMovieLibraryScanner` trashes against
`movieRepository.GetExistingMovies(library)` — scoped to the `Movie` table — and the television
scanner follows the same `GetExisting*` pattern. Several scanners over one library therefore
cannot cross-delete.
## Design
### Model
Add `LibraryMediaKind.Mixed = 8`.
`JellyfinApiClient.Project()` maps a null or `"mixed"` `CollectionType` to it, producing one
`JellyfinLibrary` exactly as the recognised types do. One Jellyfin library yields one ErsatzTV
library, preserving the path ↔ Jellyfin library ↔ ErsatzTV library correspondence.
### Dispatch
`SynchronizeJellyfinLibraryByIdHandler` gains a `Mixed` arm that runs the three existing Jellyfin
scanners in sequence against the same library:
```
LibraryMediaKind.Mixed => movie scanner, then television scanner, then music-video scanner
```
Each scanner issues its own `includeItemTypes` query and reconciles only its own type. **No new
scanner is written** — this is composition of three that already exist.
Sequential rather than parallel: they share a `TvContext` factory and the `EntityLocker`, and the
ordering keeps failure attribution simple. Throughput is not a concern at this library size.
### Error handling
Both `ScanLocalLibraryHandler` and `SynchronizeJellyfinLibraryByIdHandler` currently end their
dispatch switch with `_ => Unit.Default`, which returns **success** for an unhandled kind and stamps
`LastScan` as though a scan had run. This is precisely how a missing `Mixed` arm would hide, and it
is fixed as part of this work: an unhandled kind logs and returns a `BaseError`.
Within the `Mixed` arm, a failure in one scanner is reported but does not abort the remaining
scanners — a broken music-video scan should not prevent the movies and shows in the same library
from being ingested. The library's overall result is an error if any arm failed.
### Touch points
| Area | Change |
|---|---|
| `ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs` | add `Mixed = 8` |
| `ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs` | map null/`mixed``Mixed` |
| `ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs` | `Mixed` arm; remove silent-success default |
| `ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs` | remove silent-success default |
| `SynchronizeJellyfinShowByIdHandler.cs` | relax the `"is not a TV show library"` guard to accept `Mixed` |
| `ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs` | verify rediscovery cannot flip a `Mixed` library's kind |
| `web/src/screens/LibrariesScreen.tsx` | icon + label for `Mixed` |
| `web/src/screens/LocalLibraryEditScreen.tsx` | `MEDIA_KIND_OPTIONS``Mixed` must **not** be creatable for a local library |
| `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts` | regenerate |
| `docs/decisions.md` | record the decision and the Jellyfin-only scope |
Untouched: scheduling, playout, collections, smart collections, search, browse.
### A note on the SPA
`LibraryMediaKind` is a generated enum shared by local and remote libraries, so adding `Mixed`
exposes it to the local-library create screen, where it is meaningless. The media-kind select must
exclude it. `RemoteLibrariesEditScreen` keys its drafts on `(name, mediaKind)`, which continues to
work unchanged.
## Testing
**Unit — `JellyfinApiClientTests`.** A `mixed` `CollectionType`, and a null one, each project to a
`JellyfinLibrary` with `MediaKind = Mixed`. An unrecognised type still yields `None`.
**Unit — dispatch.** `SynchronizeJellyfinLibraryByIdHandlerTests` (the file already exists): a
`Mixed` library invokes all three scanners; an unhandled kind returns an error rather than success.
**Regression — #488.** Covered by that issue, but this feature depends on it: a Jellyfin
music-video scan must complete against a `LibraryPath` whose `LibraryFolders` navigation is not
eager-loaded.
**Cross-type deletion.** The critical test. Seed a single library with a movie, a show and a music
video; run the full `Mixed` scan with one type absent from the Jellyfin response; assert only that
type is flagged missing and the others are untouched. Prove non-vacuous by temporarily widening a
reconciliation query and watching the test fail.
**Live E2E.** Re-type the live `Music Videos` library back to `mixed`, scan, and confirm movies,
shows and music videos all land in that one library with nothing appearing in `Movies` or
`TV Shows`. This is a write-path scanner change, so live E2E is required per `docs/e2e-local.md`.
## Open risk
`JellyfinMusicVideoLibraryScanner`'s reconciliation has **not** been traced. It is the odd one out —
standalone, not derived from `MediaServer*LibraryScanner`, with no `ItemId`/`Etag` to key on, so it
identifies items by path-replaced local path. It is the one place cross-type deletion could still
hide, and it must be traced before the `Mixed` arm is trusted. This is a precondition of the
cross-type deletion test above, not a follow-up.
## Rollout
1. Fix #488 (blocking).
2. Land this feature.
3. Re-type the live `Music Videos` Jellyfin library back to `mixed`, reverting the provisional
`musicvideos` re-type made on 2026-07-20.
4. Re-enable `shouldSyncItems` on that library and scan.
5. Afterwards, local library 14 (`Standup``/data/standup`) becomes retirable in favour of the real
Jellyfin `Standup` library. Separate change.