Files
ersatztv/docs/decisions/records/scan/jellyfin-mixed-content-library.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

5.7 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
scan.jellyfin-mixed-content-library 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds active 2026-07-20 none none A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. mixed-content library, `LibraryMediaKind.Mixed`, per-kind sequential scan, per-kind scan dispatch, silent-success bug · paths: `JellyfinApiClient.Project()`, `ScanMixedLibrary`, `SynchronizeJellyfinLibraryByIdHandler`, `ScanLocalLibraryHandler` · issues: #489, #474, #488 local mixed libraries deliberately unsupported (`LocalFolderScanner.VideoFileExtensions` hazard); `LibraryMediaKind.Mixed` dispatch in the Jellyfin sync handlers; per-kind scanners queried by `parentId` + `includeItemTypes`

A Jellyfin library whose collection type is mixed — or absent — now maps to LibraryMediaKind.Mixed instead of being dropped by JellyfinApiClient.Project()'s _ => None. Scanning it runs the movie, television and music-video scanners in sequence against that one library.

  • A library is a place, not a media kind. One physical path ↔ one Jellyfin library ↔ one ErsatzTV library, whose contents are heterogeneous. This is what keeps music and standup content segregated from the main Movies and TV Shows libraries, which was the actual goal (#474). The previous workaround was a local library pointed at the same tree, which bypassed Jellyfin, scanned the content twice and mis-modelled shows as movies.
  • The classification is Jellyfin's, not ours. Each scanner queries parentId + includeItemTypes ("Movie" / "Series" / "MusicVideo"), so the three passes receive disjoint, authoritative sets. Nothing is inferred from folder shape or NFO contents. This is why mixed support is tractable at all — an earlier reading that it would require guessing per item was wrong.
  • No migration. MediaItem is table-per-type with no discriminator and LibraryPathId on the abstract base, so heterogeneous items under one LibraryPath were always legal; MediaItemRepository.GetAllTrashedItems already COALESCEs across every subclass id for a single path.
  • No cross-deletion. Reconciliation is type-scoped (GetExistingMovies(library) and friends), so a pass over one kind cannot trash another kind's items in the same library.
  • MediaKind is dispatch + presentation only. Scheduling, playout, collections, smart collections and search hold zero references to it; search keys off the item's own subclass and the SPA's browse screens use a per-item LibraryBrowseMediaType. Adding a kind is therefore cheap.

Deliberately scoped to Jellyfin. Local mixed libraries are NOT supported and Mixed is absent from the SPA's local-library media-kind options. Locally the same approach is unsafe: every local scanner shares LocalFolderScanner.VideoFileExtensions, so the movie scanner would claim episode files, and LibraryFolder rows are keyed by LibraryPathId with no notion of kind, so two scanners over one path would thrash each other's etags. Neither hazard exists remotely — among the remote scanners only JellyfinMusicVideoLibraryScanner touches LibraryFolder at all. (The existing Images + OtherVideos shared-path exemption in LocalLibraryHandlerBase.AreSubPaths is the closest local analogue, and it already carries that etag contention.)

Failure semantics of the Mixed arm: one kind failing does not skip the others — a broken music-video scan must not prevent the movies in the same library from being ingested — but the library as a whole then reports failure and LastScan is not stamped. ScanCanceled aborts the sequence immediately, since a user cancellation is not one kind failing.

Also fixed here: ScanLocalLibraryHandler and SynchronizeJellyfinLibraryByIdHandler both ended their dispatch switch with _ => Unit.Default, returning success for an unhandled kind and stamping LastScan as though a scan had run. Both now return a BaseError. That silent success is precisely how a missing Mixed arm would have hidden, so removing it is part of the feature, not a drive-by.

Known limitations of the Mixed arm, surfaced by the adversarial review and accepted rather than fixed here:

  • Scan progress resets twice. Each per-kind scanner independently drives _scannerProxy.UpdateProgress from 0 to 1 over its own item set, so a mixed library's progress bar fills and resets three times. The local scanner solves this by threading progressMin/progressMax per path (ScanLocalLibraryHandler), but the Jellyfin ScanLibrary signature has no such parameter, so fixing it means changing three scanner interfaces. Cosmetic, and deliberately out of scope.
  • One permanently-failing kind forces the healthy kinds to rescan forever. ScanMixedLibrary returns Left if any arm failed, so LastScan is never stamped and the whole library re-scans every interval. Single-kind libraries already behave this way; Mixed widens the blast radius to the other two kinds. Accepted: the alternative — stamping LastScan on partial success — would silently mask a broken kind, which is worse.
  • JellyfinMusicVideoLibraryScanner performs no reconciliation at all — no GetExisting*, no trash sweep. This is why it cannot cross-delete in a mixed library, but it also means a music video removed from Jellyfin is never removed from ErsatzTV. Pre-existing and orthogonal to this change; it belongs with that scanner's other gaps (no ItemId/Etag, path-keyed identity — see #488).