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>
11 KiB
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.
SongsandImagesinside a mixed library. Jellyfin'smusiccollection 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)
-
No DB migration is required.
MediaItemis table-per-type with no discriminator column.LibraryPathIdsits on the abstract base (MediaItem.cs) and every subclass inherits it;LibraryPath.MediaItemsisList<MediaItem>. Heterogeneous items under oneLibraryPathare already legal.MediaItemRepository.GetAllTrashedItemsalreadyCOALESCEs acrossMovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId, ImageId, RemoteStreamIdfor a singleLibraryPathId. -
MediaKindis dispatch and presentation, not structure. Scheduling, playout, collections, smart collections and playlists contain zeroMediaKindreferences. Search indexing keys off the item's own subclass. The SPA's browse and collections screens use a per-itemLibraryBrowseMediaType, not the library's kind.MediaKindis persisted on the baseLibrarytable only, with no unique constraint or index involving it. -
Reconciliation is type-scoped.
MediaServerMovieLibraryScannertrashes againstmovieRepository.GetExistingMovies(library)— scoped to theMovietable — and the television scanner follows the sameGetExisting*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
- Fix #488 (blocking).
- Land this feature.
- Re-type the live
Music VideosJellyfin library back tomixed, reverting the provisionalmusicvideosre-type made on 2026-07-20. - Re-enable
shouldSyncItemson that library and scan. - Afterwards, local library 14 (
Standup→/data/standup) becomes retirable in favour of the real JellyfinStanduplibrary. Separate change.