feat(489): support Jellyfin mixed-content libraries #493

Merged
timothy merged 10 commits from feat/489-jellyfin-mixed-libraries into main 2026-07-20 18:34:52 +02:00
Owner

Implements plan Tasks 1–5 and 7 for #489. Spec + plan landed in #490.

A Jellyfin library typed mixed was dropped by JellyfinApiClient.Project()'s _ => None with no log line — which is why /data/music never reached ErsatzTV (#474) and why /data/standup needed a local-library workaround. Both libraries are genuinely mixed (shows, concert movies and music videos together), so this models that rather than re-typing them at the source.

What changed

  • LibraryMediaKind.Mixed = 8; Project() maps "mixed", absent, empty and whitespace CollectionType onto it. "music" and other unknown types still fall through to None.
  • SynchronizeJellyfinLibraryByIdHandler gains a Mixed arm composing the three existing Jellyfin scanners. No new scanner, no DB migration.
  • Targeted Jellyfin show scans accept Mixed (a mixed library legitimately contains shows).
  • Both dispatchers' _ => Unit.Default replaced with _ => BaseError.New(...). That default returned success for an unhandled kind and stamped LastScan as though a scan had run — exactly how a missing Mixed arm would have hidden.
  • Mixed is rejected for local libraries at the API boundary.

Segregation falls out of the model: a library is a place (one path ↔ one Jellyfin library ↔ one ErsatzTV library), so music/standup content cannot leak into Movies / TV Shows.

⚠️ Mergeable but NOT fully verifiable yet

No mixed library has been scanned end-to-end. The Mixed arm invokes JellyfinMusicVideoLibraryScanner, which currently throws ArgumentNullException on its first item (#488, being fixed in parallel). The unit tests prove the dispatch logic; they do not prove ingestion. Plan Task 6 — trace the music-video reconciliation, then live-E2E with a segregation assertion and a second scan for idempotency — is deliberately not in this PR and must run before #489 is closed.

Green CI here should not be read as "mixed libraries work."

Verification

gate result
Full solution 3,922 passed, 0 failed (Scanner 1484 · ErsatzTV 1741 · Core 599 · Infrastructure 73 · FFmpeg 20 · Architecture 5)
SPA typecheck ✅, 862 tests ✅, check:api exit 0
dotnet format --verify-no-changes 9 files, exit 0
BOM clean

Negative controls (every new assertion proven non-vacuous by reverting its fix): projection → 2 of 4 fail; dispatch arm → 3 of 5 fail; local-Mixed validation → 1 fails. Pre-existing tests keep passing in all three.

Adversarial review — verdict MERGEABLE, findings fixed

A cold review-only pass verified all four design claims independently against the code, and corrected one premise of mine:

  • M1 (fixed) — Mixed was accepted by POST /api/v1/libraries/local, creating a library with no scanner that would fail and ERROR-log on every scheduler tick forever. The invariant was asserted in prose twice and enforced nowhere; omitting it from the SPA dropdown is not enforcement when machine clients are a stated /api/v1 audience.
  • M3 (fixed) — empty/whitespace CollectionType didn't match the null pattern and fell through to None, so on Jellyfin versions that serialize it that way the feature silently no-opped: no library, no log line — the identical symptom this issue exists to fix.
  • L4 (fixed) — the cancellation test asserted IsLeft but not that ScanCanceled reaches the caller's info-log branch. Folding it into an aggregate BaseError would silently demote a user cancellation to an ERROR log (#410). Now pinned both ways.
  • Nit (fixed) — reverted the show-guard message to match the byte-identical Plex/Emby guards.

Correction to my own spec: I flagged JellyfinMusicVideoLibraryScanner's reconciliation as an untraced cross-deletion risk. The reviewer read all 216 lines — it performs no reconciliation at all. It cannot cross-delete, but for the opposite reason to the one I assumed, and the flip side is that a music video removed from Jellyfin is never removed from ErsatzTV. Documented rather than left as false reassurance.

Accepted limitations, recorded in docs/decisions.md rather than ignored: scan progress resets once per kind (the Jellyfin ScanLibrary signature has no progressMin/progressMax); and one permanently-failing kind forces the healthy two to rescan every interval — accepted because stamping LastScan on partial success would mask a broken kind.

Rollout (after #488)

Re-type the live Music Videos Jellyfin library back to mixed (reverting tonight's provisional musicvideos change), re-enable sync, scan. Local library 14 (Standup) then becomes retirable — its own issue.

fixes #489

Implements plan Tasks 1–5 and 7 for #489. Spec + plan landed in #490. A Jellyfin library typed `mixed` was dropped by `JellyfinApiClient.Project()`'s `_ => None` **with no log line** — which is why `/data/music` never reached ErsatzTV (#474) and why `/data/standup` needed a local-library workaround. Both libraries are genuinely mixed (shows, concert movies and music videos together), so this models that rather than re-typing them at the source. ## What changed - `LibraryMediaKind.Mixed = 8`; `Project()` maps `"mixed"`, absent, empty and whitespace `CollectionType` onto it. `"music"` and other unknown types still fall through to `None`. - `SynchronizeJellyfinLibraryByIdHandler` gains a `Mixed` arm composing the three **existing** Jellyfin scanners. No new scanner, **no DB migration**. - Targeted Jellyfin show scans accept `Mixed` (a mixed library legitimately contains shows). - Both dispatchers' `_ => Unit.Default` replaced with `_ => BaseError.New(...)`. That default returned **success** for an unhandled kind and stamped `LastScan` as though a scan had run — exactly how a missing `Mixed` arm would have hidden. - `Mixed` is rejected for **local** libraries at the API boundary. Segregation falls out of the model: a library is *a place* (one path ↔ one Jellyfin library ↔ one ErsatzTV library), so music/standup content cannot leak into `Movies` / `TV Shows`. ## ⚠️ Mergeable but NOT fully verifiable yet **No mixed library has been scanned end-to-end.** The `Mixed` arm invokes `JellyfinMusicVideoLibraryScanner`, which currently throws `ArgumentNullException` on its first item (#488, being fixed in parallel). The unit tests prove the dispatch logic; they do not prove ingestion. Plan **Task 6** — trace the music-video reconciliation, then live-E2E with a segregation assertion and a second scan for idempotency — is deliberately **not** in this PR and must run before #489 is closed. Green CI here should not be read as "mixed libraries work." ## Verification | gate | result | |---|---| | Full solution | **3,922 passed, 0 failed** (Scanner 1484 · ErsatzTV 1741 · Core 599 · Infrastructure 73 · FFmpeg 20 · Architecture 5) | | SPA | typecheck ✅, 862 tests ✅, `check:api` exit 0 | | `dotnet format --verify-no-changes` | 9 files, exit 0 | | BOM | clean | **Negative controls** (every new assertion proven non-vacuous by reverting its fix): projection → 2 of 4 fail; dispatch arm → 3 of 5 fail; local-`Mixed` validation → 1 fails. Pre-existing tests keep passing in all three. ## Adversarial review — verdict MERGEABLE, findings fixed A cold review-only pass verified all four design claims independently against the code, and corrected one premise of mine: - **M1 (fixed)** — `Mixed` was accepted by `POST /api/v1/libraries/local`, creating a library with no scanner that would fail and ERROR-log on every scheduler tick forever. The invariant was asserted in prose twice and enforced nowhere; omitting it from the SPA dropdown is not enforcement when machine clients are a stated `/api/v1` audience. - **M3 (fixed)** — empty/whitespace `CollectionType` didn't match the `null` pattern and fell through to `None`, so on Jellyfin versions that serialize it that way the feature silently no-opped: no library, no log line — the *identical* symptom this issue exists to fix. - **L4 (fixed)** — the cancellation test asserted `IsLeft` but not that `ScanCanceled` reaches the caller's info-log branch. Folding it into an aggregate `BaseError` would silently demote a user cancellation to an ERROR log (#410). Now pinned both ways. - **Nit (fixed)** — reverted the show-guard message to match the byte-identical Plex/Emby guards. **Correction to my own spec**: I flagged `JellyfinMusicVideoLibraryScanner`'s reconciliation as an untraced cross-deletion risk. The reviewer read all 216 lines — it performs **no reconciliation at all**. It cannot cross-delete, but for the opposite reason to the one I assumed, and the flip side is that a music video removed from Jellyfin is never removed from ErsatzTV. Documented rather than left as false reassurance. **Accepted limitations**, recorded in `docs/decisions.md` rather than ignored: scan progress resets once per kind (the Jellyfin `ScanLibrary` signature has no `progressMin`/`progressMax`); and one permanently-failing kind forces the healthy two to rescan every interval — accepted because stamping `LastScan` on partial success would mask a broken kind. ## Rollout (after #488) Re-type the live `Music Videos` Jellyfin library back to `mixed` (reverting tonight's provisional `musicvideos` change), re-enable sync, scan. Local library 14 (`Standup`) then becomes retirable — its own issue. fixes #489
Author
Owner

CI green @ 9affd4b9 — status, not a merge verdict

All jobs pass: Build & test (.NET), EF migration integrity (SQLite + MySql), Functional E2E, Formatting, API docs in sync, decisions.md append-only, CI image pin. (Build & push image skipped, as expected for a PR.)

No Review-verdict: line is being posted yet, on purpose. Two things are true and neither should be papered over:

  1. Task 6 has not run. No mixed library has been scanned end-to-end, because the Mixed arm invokes JellyfinMusicVideoLibraryScanner, which is still broken until #488 lands. Green CI here proves the dispatch logic and the projection; it does not prove ingestion works.
  2. 9affd4b9 itself has not been independently reviewed. It contains the fixes the second review prescribed — which is precisely the class of commit the #242 lesson says to re-review, since a prescribed fix is still new code. Issuing a verdict against a head whose diff no reviewer has seen would defeat the point of the H10 convention.

So the verdict should be issued against the head that will actually merge — after #488 lands, this branch rebases, and Task 6 runs.

Review chain so far

round scope verdict outcome
1 full branch diff MERGEABLE M1/M3/L4 + nit fixed in ec782d29
2 fix commits ec782d29+4211db91 MERGEABLE L-1/L-2 fixed in 9affd4b9
3 9affd4b9 not yet run —

Round 2 was worth running: it found that my own non-vacuity assertion was itself vacuous. The ScanCanceled negative log check matched by string equality, but the regression it claimed to pin produces a different message — so it could never fail. Now a prefix match, and proven non-vacuous by neutralizing the early-return and watching the test fail (which it previously did not).

It also confirmed the finding I most wanted checked: M1 is complete, not a half-fix. Every writer of Library.MediaKind was enumerated — UpdateLocalLibrary has no MediaKind field, MoveLocalLibraryPath never writes it, and Mixed = 8 is new so no legacy row can hold it.

One unrelated change in this PR, flagged rather than buried

LibrariesScreen.test.tsx failed on run 889. That is not this branch's doing: the only web change here is one line of generated v1.d.ts (types, erased at runtime), and LibrariesScreen is untouched. It passes 3/3 locally; CI runs the SPA suite ~8x slower (98s vs 12s) against waitFor's 1000ms default. I bumped those two waits per-test rather than raising the global default.

Caveat stated honestly: this run passing does not prove the timeout was the cure — a flake can simply not recur. The types-only argument is the real evidence that the failure wasn't mine. If it recurs elsewhere, the per-test bump is the wrong layer and it needs its own issue.

My first hypothesis — that the docs-only PR #490 failed the same way — was wrong; that run's Build & test succeeded via the docs-only skip path. Recording that because it would have been a convenient and false explanation.

## CI green @ `9affd4b9` — status, not a merge verdict All jobs pass: Build & test (.NET), EF migration integrity (SQLite + MySql), Functional E2E, Formatting, API docs in sync, decisions.md append-only, CI image pin. (`Build & push image` skipped, as expected for a PR.) **No `Review-verdict:` line is being posted yet, on purpose.** Two things are true and neither should be papered over: 1. **Task 6 has not run.** No mixed library has been scanned end-to-end, because the `Mixed` arm invokes `JellyfinMusicVideoLibraryScanner`, which is still broken until #488 lands. Green CI here proves the dispatch logic and the projection; it does **not** prove ingestion works. 2. **`9affd4b9` itself has not been independently reviewed.** It contains the fixes the second review prescribed — which is precisely the class of commit the #242 lesson says to re-review, since a prescribed fix is still new code. Issuing a verdict against a head whose diff no reviewer has seen would defeat the point of the H10 convention. So the verdict should be issued against the head that will actually merge — after #488 lands, this branch rebases, and Task 6 runs. ### Review chain so far | round | scope | verdict | outcome | |---|---|---|---| | 1 | full branch diff | MERGEABLE | M1/M3/L4 + nit fixed in `ec782d29` | | 2 | fix commits `ec782d29`+`4211db91` | MERGEABLE | L-1/L-2 fixed in `9affd4b9` | | 3 | `9affd4b9` | *not yet run* | — | Round 2 was worth running: it found that **my own non-vacuity assertion was itself vacuous.** The `ScanCanceled` negative log check matched by string equality, but the regression it claimed to pin produces a *different* message — so it could never fail. Now a prefix match, and proven non-vacuous by neutralizing the early-return and watching the test fail (which it previously did not). It also confirmed the finding I most wanted checked: **M1 is complete, not a half-fix.** Every writer of `Library.MediaKind` was enumerated — `UpdateLocalLibrary` has no `MediaKind` field, `MoveLocalLibraryPath` never writes it, and `Mixed = 8` is new so no legacy row can hold it. ### One unrelated change in this PR, flagged rather than buried `LibrariesScreen.test.tsx` failed on run 889. That is **not** this branch's doing: the only web change here is one line of generated `v1.d.ts` (types, erased at runtime), and `LibrariesScreen` is untouched. It passes 3/3 locally; CI runs the SPA suite ~8x slower (98s vs 12s) against `waitFor`'s 1000ms default. I bumped those two waits per-test rather than raising the global default. Caveat stated honestly: this run passing does **not** prove the timeout was the cure — a flake can simply not recur. The types-only argument is the real evidence that the failure wasn't mine. If it recurs elsewhere, the per-test bump is the wrong layer and it needs its own issue. My first hypothesis — that the docs-only PR #490 failed the same way — was **wrong**; that run's `Build & test` succeeded via the docs-only skip path. Recording that because it would have been a convenient and false explanation.
timothy force-pushed feat/489-jellyfin-mixed-libraries from 9affd4b9fc to cba6d7a156 2026-07-20 16:14:39 +02:00 Compare
timothy force-pushed feat/489-jellyfin-mixed-libraries from cba6d7a156 to 39966ec616 2026-07-20 17:50:28 +02:00 Compare
timothy added 1 commit 2026-07-20 18:14:25 +02:00
test(489): widen the LibrariesScreen test budget to 20s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m18s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m42s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m53s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
bf48b64ad5
Round-4 review (Low): 2 x 5s waits plus a CI-slowed render/findByText/
runPollTick prelude comes to ~13-14s against a 15s budget, so the generic
vitest-timeout failure mode was made unlikely rather than eliminated. Took the
reviewer's suggested 20s budget, which leaves real headroom and keeps waitFor
-- not vitest -- as the thing that reports a failure.

Also correcting a claim in the previous commit message: CollectionsScreen.test.tsx
uses the options-object form it(name, {timeout}, fn), not the trailing-number
form; only TrashScreen.test.tsx:172 matches literally. Both are test-level
timeouts so the substance was right, but the citation was not.

Refs #489
Author
Owner

Review-verdict: MERGEABLE @ bf48b64ad5

Basis for the verdict

CI green overall on bf48b64a — all 8 jobs pass (image pin, docs reminder, formatting, decisions.md append-only, Build & test .NET, API docs in sync, Functional E2E, EF migration integrity; image push correctly skipped for a PR). Rebased onto current main, which includes #488 and #494.

Four independent cold review rounds, every one MERGEABLE, each scoped to code the previous round had not seen — per the #242 lesson that a fix commit is new code:

round scope outcome
1 full branch diff M1 — Mixed accepted by POST /api/v1/libraries/local, creating an unscannable library that ERROR-logs every scheduler tick; M3 — blank/whitespace CollectionType fell through to None, silently no-oping the whole feature on some Jellyfin versions; L4, nit. All fixed.
2 round-1 fix commits L-1 — my own "non-vacuity" assertion was itself vacuous (equality match against a message the regression doesn't produce); L-2 — OpenAPI advertised Mixed for local create with no note that it 422s. Both fixed.
3 round-2 fix commit M-1 — my waitFor {timeout:10000} was unreachable (vitest caps the test at 5s), degrading failure output to a generic timeout with no assertion diff — worse than the default it replaced. Fixed with the repo's established test-level-timeout pattern.
4 round-3 fix commit (test-only) No Blocker/High/Medium. Verified timeout semantics empirically (probed that it(…, 15000) survives a 7s sleep while the bare form dies at 5000ms). One Low on budget margin — taken, hence this head.

Round 4 finding nothing substantive is the signal the loop converged. Notably, every defect the reviews found in my own work was in test code, not production code.

Task 6 — live E2E, complete

Full evidence on #489. Summary:

  • Both of Jellyfin's mixed representations discovered as Mixed — "mixed" (marker file) and null (no marker, which is what the Jellyfin UI produces today). The null case exists on the operator's live server; without the M3 fix it would have been silently dropped, reproducing the exact bug this PR fixes.
  • Mixed scan: 1438 Movies + 1 Show + 59 Episodes into one library; zero items in Movies/TV Shows (segregation); no unhandled exceptions; second scan byte-identical (idempotent).
  • #493 × #494 interaction verified live: scanning mixed Standup (music-video arm receives an empty incoming set, movies present) left all 1437 music videos intact and added 146 movies to a single library. #494's MusicVideo-join design confirmed empirically, not just by argument.

Scope honesty

Deliberately Jellyfin-only — local mixed libraries are unsupported by design and Mixed is rejected at the local-library API. Three accepted limitations are recorded in docs/decisions.md rather than glossed: per-kind progress resets, one failing kind forcing the healthy kinds to rescan, and JellyfinMusicVideoLibraryScanner performing no reconciliation (true when written; #494 has since changed this).

Side findings filed rather than folded in: #497 (metadata updates dropped for existing music videos) and a reshaped #487.

All ## Done-when boxes on #489 are ticked against evidence.

Review-verdict: MERGEABLE @ bf48b64ad5feb40e848e59338b03c01755a962df ## Basis for the verdict **CI green overall** on `bf48b64a` — all 8 jobs pass (image pin, docs reminder, formatting, decisions.md append-only, Build & test .NET, API docs in sync, Functional E2E, EF migration integrity; image push correctly skipped for a PR). Rebased onto current `main`, which includes #488 and #494. **Four independent cold review rounds, every one MERGEABLE**, each scoped to code the previous round had not seen — per the #242 lesson that a fix commit is new code: | round | scope | outcome | |---|---|---| | 1 | full branch diff | **M1** — `Mixed` accepted by `POST /api/v1/libraries/local`, creating an unscannable library that ERROR-logs every scheduler tick; **M3** — blank/whitespace `CollectionType` fell through to `None`, silently no-oping the whole feature on some Jellyfin versions; **L4**, nit. All fixed. | | 2 | round-1 fix commits | **L-1** — my own "non-vacuity" assertion was itself vacuous (equality match against a message the regression doesn't produce); **L-2** — OpenAPI advertised `Mixed` for local create with no note that it 422s. Both fixed. | | 3 | round-2 fix commit | **M-1** — my `waitFor {timeout:10000}` was unreachable (vitest caps the test at 5s), degrading failure output to a generic timeout with no assertion diff — worse than the default it replaced. Fixed with the repo's established test-level-timeout pattern. | | 4 | round-3 fix commit (test-only) | No Blocker/High/Medium. Verified timeout semantics **empirically** (probed that `it(…, 15000)` survives a 7s sleep while the bare form dies at 5000ms). One Low on budget margin — taken, hence this head. | Round 4 finding nothing substantive is the signal the loop converged. Notably, **every defect the reviews found in my own work was in test code**, not production code. ## Task 6 — live E2E, complete Full evidence on #489. Summary: - Both of Jellyfin's mixed representations discovered as `Mixed` — `"mixed"` (marker file) **and `null`** (no marker, which is what the Jellyfin UI produces today). The `null` case exists on the operator's live server; without the M3 fix it would have been silently dropped, reproducing the exact bug this PR fixes. - Mixed scan: 1438 Movies + 1 Show + 59 Episodes into **one** library; **zero** items in Movies/TV Shows (segregation); no unhandled exceptions; second scan byte-identical (idempotent). - **#493 × #494 interaction verified live**: scanning mixed `Standup` (music-video arm receives an empty incoming set, movies present) left all 1437 music videos intact and added 146 movies to a single library. #494's `MusicVideo`-join design confirmed empirically, not just by argument. ## Scope honesty Deliberately **Jellyfin-only** — local mixed libraries are unsupported by design and `Mixed` is rejected at the local-library API. Three accepted limitations are recorded in `docs/decisions.md` rather than glossed: per-kind progress resets, one failing kind forcing the healthy kinds to rescan, and `JellyfinMusicVideoLibraryScanner` performing no reconciliation (true when written; #494 has since changed this). Side findings filed rather than folded in: **#497** (metadata updates dropped for existing music videos) and a reshaped **#487**. All `## Done-when` boxes on #489 are ticked against evidence.
timothy merged commit 2cf90fb44f into main 2026-07-20 18:34:52 +02:00
timothy deleted branch feat/489-jellyfin-mixed-libraries 2026-07-20 18:34:52 +02:00
Sign in to join this conversation.