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
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>
214 lines
11 KiB
Markdown
214 lines
11 KiB
Markdown
# 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.
|