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

11 KiB
Raw Blame History

Jellyfin mixed-content library support — design

Issue: #489 Blocked by: #488 (JellyfinMusicVideoLibraryScanner crashes on its first item) Related: #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:

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:

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 COALESCEs 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/mixedMixed
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_OPTIONSMixed 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.