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