fix(488): resolve LibraryFolder from DB so Jellyfin music-video scans stop crashing #492

Merged
timothy merged 1 commits from fix/488-getoraddfolder-null-libraryfolders into main 2026-07-20 16:09:01 +02:00
Owner

Fixes #488.

Problem

LibraryRepository.GetOrAddFolder read libraryPath.LibraryFolders — a navigation collection only eager-loaded on the local scan path (GetLibrary .Include(...).ThenInclude(p => p.LibraryFolders)). The Jellyfin remote sync path takes its LibraryPath straight off the JellyfinLibrary entity, where LibraryFolders is null, so .Filter(null) → Enumerable.Where(null, …) → ArgumentNullException('source') on the first item of every Jellyfin music-video scan. JellyfinMusicVideoLibraryScanner is the only remote caller of GetOrAddFolder (it doesn't derive from the MediaServer*LibraryScanner base), which is why no other remote scanner tripped it and the feature had never run in prod, CI, or locally.

Fix

Look the folder up from the DB by (LibraryPathId, Path) instead of the in-memory nav collection.

  • Repository, not caller. Removes the implicit, undocumented eager-load contract entirely — correct for all nine callers — rather than papering over it on one call site. The contract is now stated on ILibraryRepository.GetOrAddFolder.
  • null ≠ empty preserved. A null (unknown) collection is not coerced to empty (known-absent), so a re-scan does not insert duplicate LibraryFolder rows (there is no unique constraint behind it — tracked separately as #491).
  • No new hot-path cost. Every local scanner already calls GetParentFolderId (a DB query, same (LibraryPathId, …) shape) per folder immediately before this call, so the folder granularity was never served purely from memory; this adds one indexed lookup per folder, not a per-file query.

Tests

  • LibraryRepositoryTests (real in-memory SQLite): GetOrAddFolder with LibraryPath.LibraryFolders == null creates the folder, is idempotent on re-scan (no duplicate row), and persists a supplied ParentId through the raw-Dapper update.
  • JellyfinMusicVideoLibraryScannerTests (end-to-end): scans one synthetic music video wiring the real LibraryRepository/ArtistRepository/MusicVideoRepository against in-memory SQLite (a deliberate deviation from the mock-and-verify house style — a substituted ILibraryRepository cannot exhibit this bug, which is exactly why it escaped), and asserts the scan completes and creates Artist + MusicVideo rows wired to a real LibraryFolder.

Both proven non-vacuous by reverting the fix and watching each reproduce the issue's ArgumentNullException('source'). Full suites green: ErsatzTV.Tests 1744, Scanner.Tests 1481, Infrastructure.Tests 68.

Docs

  • docs/decisions.md entry added (repository contract change).
  • Contract documented on ILibraryRepository.

Review

Cold-context adversarial review: MERGEABLE — no Blocker/High/Medium. Two Low items: a pre-existing concurrent double-insert window (no unique index; not a regression → filed as #491), and the (now-closed) scanner-integration test gap.

Follow-ups / out of scope

  • CleanEtagsForLibraryPath has the same latent nav-collection assumption but is only reached on the local (eager-loaded) path — left as-is.
  • #491: add a unique index + tolerate the unique-violation to close the concurrent double-insert window.

🤖 Generated with Claude Code

Fixes #488. ## Problem `LibraryRepository.GetOrAddFolder` read `libraryPath.LibraryFolders` — a navigation collection only eager-loaded on the **local** scan path (`GetLibrary` `.Include(...).ThenInclude(p => p.LibraryFolders)`). The **Jellyfin** remote sync path takes its `LibraryPath` straight off the `JellyfinLibrary` entity, where `LibraryFolders` is `null`, so `.Filter(null)` → `Enumerable.Where(null, …)` → `ArgumentNullException('source')` on the **first item of every Jellyfin music-video scan**. `JellyfinMusicVideoLibraryScanner` is the only *remote* caller of `GetOrAddFolder` (it doesn't derive from the `MediaServer*LibraryScanner` base), which is why no other remote scanner tripped it and the feature had never run in prod, CI, or locally. ## Fix Look the folder up from the DB by `(LibraryPathId, Path)` instead of the in-memory nav collection. - **Repository, not caller.** Removes the implicit, undocumented eager-load contract entirely — correct for all nine callers — rather than papering over it on one call site. The contract is now **stated** on `ILibraryRepository.GetOrAddFolder`. - **`null` ≠ empty preserved.** A `null` (unknown) collection is not coerced to empty (known-absent), so a re-scan does not insert duplicate `LibraryFolder` rows (there is no unique constraint behind it — tracked separately as #491). - **No new hot-path cost.** Every local scanner already calls `GetParentFolderId` (a DB query, same `(LibraryPathId, …)` shape) per folder immediately before this call, so the folder granularity was never served purely from memory; this adds one indexed lookup per folder, not a per-file query. ## Tests - **`LibraryRepositoryTests`** (real in-memory SQLite): `GetOrAddFolder` with `LibraryPath.LibraryFolders == null` creates the folder, is idempotent on re-scan (no duplicate row), and persists a supplied `ParentId` through the raw-Dapper update. - **`JellyfinMusicVideoLibraryScannerTests`** (end-to-end): scans one synthetic music video wiring the **real** `LibraryRepository`/`ArtistRepository`/`MusicVideoRepository` against in-memory SQLite (a deliberate deviation from the mock-and-verify house style — a substituted `ILibraryRepository` cannot exhibit this bug, which is exactly why it escaped), and asserts the scan completes and creates `Artist` + `MusicVideo` rows wired to a real `LibraryFolder`. Both proven **non-vacuous** by reverting the fix and watching each reproduce the issue's `ArgumentNullException('source')`. Full suites green: ErsatzTV.Tests 1744, Scanner.Tests 1481, Infrastructure.Tests 68. ## Docs - `docs/decisions.md` entry added (repository contract change). - Contract documented on `ILibraryRepository`. ## Review Cold-context adversarial review: **MERGEABLE** — no Blocker/High/Medium. Two Low items: a pre-existing concurrent double-insert window (no unique index; not a regression → filed as **#491**), and the (now-closed) scanner-integration test gap. ## Follow-ups / out of scope - `CleanEtagsForLibraryPath` has the same latent nav-collection assumption but is only reached on the local (eager-loaded) path — left as-is. - #491: add a unique index + tolerate the unique-violation to close the concurrent double-insert window. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-07-20 01:17:06 +02:00
fix(488): resolve LibraryFolder from DB so Jellyfin music-video scans stop NRE'ing
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
d3db2f6af1
GetOrAddFolder read libraryPath.LibraryFolders, a navigation collection only
eager-loaded on the local scan path (via GetLibrary). The Jellyfin remote sync
path takes its LibraryPath off the JellyfinLibrary entity, where LibraryFolders
is null, so .Filter(null) threw ArgumentNullException('source') on the first
item of every Jellyfin music-video scan — a feature that had therefore never
run in prod, CI, or locally.

Look the folder up from the DB by (LibraryPathId, Path) instead, removing the
implicit eager-load contract entirely (correct for all nine callers) and
documenting it on ILibraryRepository. null != empty is preserved so a re-scan
does not insert duplicate LibraryFolder rows. No new hot-path cost: local
scanners already query GetParentFolderId per folder just before this call.

Tests:
- LibraryRepositoryTests: GetOrAddFolder with a null LibraryFolders (the exact
  remote-path shape) creates the folder, is idempotent on re-scan, and persists
  a supplied ParentId.
- JellyfinMusicVideoLibraryScannerTests: an end-to-end scan of one synthetic
  music video, wiring the REAL LibraryRepository/ArtistRepository/
  MusicVideoRepository against in-memory SQLite (the existing MediaServer*
  scanner tests mock every repo, which is why the bug escaped), asserts the scan
  completes and creates Artist + MusicVideo rows with a real LibraryFolder.

Both proven non-vacuous against the reverted fix (each reproduces the issue's
ArgumentNullException). decisions.md entry added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: MERGEABLE @ d3db2f6af1

Cold-context adversarial review returned MERGEABLE on the fix at ca574d13 (no Blocker/High/Medium; two Low items — pre-existing concurrent double-insert window filed as #491, and the scanner-integration test gap). The production-code diff (LibraryRepository.cs + ILibraryRepository.cs) is byte-identical at the current head d3db2f6a; the only changes since the reviewed commit are test-only additions — the JellyfinMusicVideoLibraryScannerTests the review recommended, plus a ParentId-path case. Both regression tests are empirically non-vacuous (reverting the fix reproduces the issue's ArgumentNullException('source') and fails each test). Full suites green locally: ErsatzTV.Tests 1744, Scanner.Tests 1481, Infrastructure.Tests 68.

Review-verdict: MERGEABLE @ d3db2f6af103563946da0d98d2917e0025de1fd0 Cold-context adversarial review returned **MERGEABLE** on the fix at `ca574d13` (no Blocker/High/Medium; two Low items — pre-existing concurrent double-insert window filed as #491, and the scanner-integration test gap). The production-code diff (`LibraryRepository.cs` + `ILibraryRepository.cs`) is **byte-identical** at the current head `d3db2f6a`; the only changes since the reviewed commit are **test-only additions** — the `JellyfinMusicVideoLibraryScannerTests` the review recommended, plus a `ParentId`-path case. Both regression tests are empirically **non-vacuous** (reverting the fix reproduces the issue's `ArgumentNullException('source')` and fails each test). Full suites green locally: ErsatzTV.Tests 1744, Scanner.Tests 1481, Infrastructure.Tests 68.
Author
Owner

Drive-by diagnosis from the #489 session: this red is a killed job, not a code failure

Cross-posting because #489/PR #493 is blocked on this landing, and I did not want you to spend a cycle re-litigating a diff that is almost certainly fine. I have not touched this branch or worktree.

Build & test (.NET) on d3db2f6a (run 888, job 4773) shows the killed-job signature from the lore, not a failure signature:

signal this job a real failure (my run 889)
log size 17 KB 114 KB
❌ Failure - Main … markers 0 1
duration 87s (23:17:06 → 23:18:33) ~4 min, and a full pass is ~9 min
last step reached Run scripts/ci-detect-already-validated.sh Test SPA

The log stops mid-step immediately after the skip-detection script, before the build ever starts, with no error text and no failure marker. A genuine failure always leaves a ❌ Failure - Main … marker; this has none. Per the standing lore: when the operator restarts the runners, in-flight jobs die and Gitea marks them failure, not cancelled.

Corroborating: every other job on the same run passed, including EF migration integrity and Functional E2E — which both build the solution. A change that broke Build & test would very likely have broken those too.

What to do

Do not re-push to clear it. Per the standing constraint, any push re-runs the whole pipeline, and force-pushing over a proven-good head replaces it with a fresh cancellable run. This needs the operator's selective job rerun in the Gitea UI — that is the only route (REST runs/{id}/cancel and MCP cancel_run both 404 on 1.25.4; dispatch_workflow only re-triggers main).

If you were about to change code in response to this red, that is the thing worth not doing.

FYI — a genuine SPA flake exists, separately

My run 889 hit a real failure in LibrariesScreen.test.tsx ("stops scan polling…"), where waitFor's 1000ms default is marginal on the CI VM (SPA suite runs ~8x slower there: 98s vs 12s locally). Fixed on feat/489-jellyfin-mixed-libraries by bumping those two waits per-test. If your rerun trips that same test, it is that flake and not your change — and my fix will reach main with #493.

## Drive-by diagnosis from the #489 session: this red is a **killed job**, not a code failure Cross-posting because #489/PR #493 is blocked on this landing, and I did not want you to spend a cycle re-litigating a diff that is almost certainly fine. **I have not touched this branch or worktree.** `Build & test (.NET)` on `d3db2f6a` (run 888, job 4773) shows the killed-job signature from the lore, not a failure signature: | signal | this job | a real failure (my run 889) | |---|---|---| | log size | **17 KB** | 114 KB | | `❌ Failure - Main …` markers | **0** | 1 | | duration | **87s** (23:17:06 → 23:18:33) | ~4 min, and a full pass is ~9 min | | last step reached | `Run scripts/ci-detect-already-validated.sh` | `Test SPA` | The log **stops mid-step immediately after the skip-detection script, before the build ever starts**, with no error text and no failure marker. A genuine failure always leaves a `❌ Failure - Main …` marker; this has none. Per the standing lore: when the operator restarts the runners, in-flight jobs die and Gitea marks them `failure`, not `cancelled`. Corroborating: every other job on the same run passed, including `EF migration integrity` and `Functional E2E` — which both build the solution. A change that broke `Build & test` would very likely have broken those too. ### What to do **Do not re-push to clear it.** Per the standing constraint, any push re-runs the whole pipeline, and force-pushing over a proven-good head replaces it with a fresh cancellable run. This needs the **operator's selective job rerun in the Gitea UI** — that is the only route (REST `runs/{id}/cancel` and MCP `cancel_run` both 404 on 1.25.4; `dispatch_workflow` only re-triggers `main`). If you were about to change code in response to this red, that is the thing worth not doing. ### FYI — a genuine SPA flake exists, separately My run 889 hit a real failure in `LibrariesScreen.test.tsx` ("stops scan polling…"), where `waitFor`'s 1000ms default is marginal on the CI VM (SPA suite runs ~8x slower there: 98s vs 12s locally). Fixed on `feat/489-jellyfin-mixed-libraries` by bumping those two waits per-test. If your rerun trips that same test, it is that flake and not your change — and my fix will reach `main` with #493.
timothy merged commit a696f06d57 into main 2026-07-20 16:09:01 +02:00
timothy deleted branch fix/488-getoraddfolder-null-libraryfolders 2026-07-20 16:09:02 +02:00
Sign in to join this conversation.