JellyfinMusicVideoLibraryScanner crashes immediately: ArgumentNullException from null LibraryPath.LibraryFolders (feature has never worked) #488
Closed
opened 2026-07-20 00:26:07 +02:00 by timothy
·
2 comments
No Branch/Tag Specified
main
renovate/lucene.net
renovate/dotnet-monorepo
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
renovate/meziantou.analyzer-3.x
renovate/cliwrap-3.x
913-scripted-playout-401
release/v26.15.0-notes
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Blocks
Reference: timothy/ersatztv#488
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found while working #474 on prod (2026-07-20). A Jellyfin library correctly typed
musicvideosis discovered, mapped and dispatched fine — and then the scan crashes on the very first item, every time. Zero items ingested.The fork's Jellyfin music-video support has, as far as the evidence goes, never successfully run — not in prod, not in CI, not locally.
Repro
Music videos(prod: id 15 →/data/music, 30 artist folders).POST /api/v1/media-sources/jellyfin/2/refresh-libraries→ appears asMediaKind = MusicVideos✅PUT /api/v1/media-sources/jellyfin/2/libraries→shouldSyncItems: true✅POST /api/v1/libraries/15/scan→ 202, scanner process dies immediately.Artist= 0,MusicVideo= 0 after the scan.Stack
Root cause
LibraryRepository.GetOrAddFolder(line 173) reads the navigation collection off the passed entity:It assumes
LibraryFoldersis loaded. That assumption holds for every local folder scanner, because the local scan path loads the library throughLibraryRepository.GetLibrary(int):The Jellyfin sync path does not.
SynchronizeJellyfinLibraryByIdHandlervalidates viaJellyfinLibraryMustExist, andJellyfinMusicVideoLibraryScanner.ScanLibrarythen takes itsLibraryPathstraight off that entity:Pathsis populated;Paths[].LibraryFoldersis null.Filter→Enumerable.Where(null, …)→ArgumentNullException('source').So the defect is an implicit, undocumented loading contract on
GetOrAddFolder— "caller must have eager-loadedLibraryFolders" — that is satisfied by exactly one of its two call-site families.JellyfinMusicVideoLibraryScanneris the only remote scanner that callsGetOrAddFolder; every other caller is a local*FolderScanner. That is precisely why no other remote scanner trips it.Fix — needs a decision, not just a null guard
Do not simply coerce null → empty.
GetOrAddFolderfalls through toCreateNewFolderwhen the lookup misses, so treating a null (= unknown) collection as an empty (= known-absent) one would make every scan create a duplicateLibraryFolderrow for a path that already exists. Null and empty are not the same thing here, and there is no unique constraint standing behind it.Two candidate shapes:
LibraryPathwithLibraryFoldersincluded on the Jellyfin sync path. Smallest, matches how the local path already works, leaves the implicit contract intact (and therefore leaves the next remote scanner free to trip it again).GetOrAddFolderself-sufficient: queryLibraryFolderby(LibraryPathId, Path)from the DB instead of trusting a navigation collection. Removes the implicit contract entirely and is correct for every caller, at the cost of a query per folder on the local hot path (currently served from memory).My inclination is (2) with the in-memory collection kept as a fast path when it is non-null, but that is a real design call and shouldn't be made unilaterally.
Whichever is chosen, the loading contract should be stated on
ILibraryRepository.GetOrAddFolderrather than left implicit.Why nothing caught this
Worth recording, because the gap is structural rather than an oversight on one PR:
ErsatzTV.Scanner.TestscoversMediaServerMovieLibraryScanner,MediaServerTelevisionLibraryScanner,MediaServerOtherVideoLibraryScannerandMovieFolderScanner— andJellyfinMusicVideoLibraryScanneris a standalone class that does not derive from theMediaServer*LibraryScannerbase, so it inherits none of that coverage. There is noJellyfinMusicVideoLibraryScannerTests.GetOrAddFoldermocks it away:MovieFolderScannerTests.cs:88—_libraryRepository.GetOrAddFolder(Arg.Any<LibraryPath>(), …). A substituted repository cannot exhibit a null-navigation bug, so no local-scanner test could ever have caught it either.scripts/e2e-local.shseeds a local TV library (tiny ffmpeg testsrc MKVs +LibraryPathrows + scan) perdocs/e2e-local.md. There is no Jellyfin in the E2E harness, so no remote scanner runs at all.musicvideos(the one on disk wasmixed, whichJellyfinApiClient.Projectdrops silently — see #474), so this code path had never been reached on the live system either.Net: an unused, untested, un-E2E'd code path that fails on its first line of real work. The instructive part is that it was reachable only by a configuration change, so no amount of diff review would have found it.
Resolution (PR #492)
Went with option 2 (repository self-sufficient), but as a pure DB lookup rather than the hybrid with an in-memory fast path. Rationale: the reporter's hesitation about (2) — "a query per folder on the local hot path (currently served from memory)" — does not actually hold. Every local
*FolderScanneralready callsGetParentFolderId(a DB query with the same(LibraryPathId, …)shape) once per folder immediately beforeGetOrAddFolder, so folder granularity was never served purely from memory; the fix adds one indexed lookup per folder, not a per-file query. The pure lookup also removes the implicit contract with one code path (nois not nullbranch) and is more correct — it sees folders created earlier in the same scan, which a load-time in-memory snapshot cannot. Full reasoning in thedocs/decisions.mdentry.Done-when
musicvideoslibrary scans to completion and createsArtist+MusicVideorows — proven byJellyfinMusicVideoLibraryScannerTests(end-to-end scan of a synthetic music video wiring the realLibraryRepository/ArtistRepository/MusicVideoRepository). Live re-enable of prod library 15 sync is the operator's follow-up step (see Prod state below).LibraryFolderorMusicVideorows (LibraryFolderidempotency pinned byLibraryRepositoryTests;MusicVideoGetOrAddde-dupes by path, unchanged behavior).GetOrAddFolderloading contract is decided, implemented, and documented on the interface (ILibraryRepository.GetOrAddFolderremark).JellyfinMusicVideoLibraryScannerwith a non-includedLibraryFolders(proven non-vacuous by reverting the fix) — bothJellyfinMusicVideoLibraryScannerTestsandLibraryRepositoryTestsreproduce theArgumentNullException('source')when the fix is reverted.docs/decisions.mdentry (repository contract changed).Review-verdict).Prod state
Sync on library 15 has been set back to
shouldSyncItems: falseso the cadence scan does not crash-loop. The library row remains. Main container unaffected —restarts=0, the scanner is a separate process. Re-enable when this is fixed.Blocks #474 (and transitively #487).
Follow-ups
LibraryFolderhas no unique index on(LibraryPathId, Path); the pre-existing concurrent double-insert window (not a regression).CleanEtagsForLibraryPathreadslibraryPath.LibraryFoldersthe same way, but is only reached on the local (eager-loaded) path, so it is not affected; left as-is.Claiming (Claude Code / Opus 4.8 orchestrator session). Starting from the diagnosis in the body — will read
LibraryRepository.GetOrAddFolder, the Jellyfin sync load path, and the localGetLibraryinclude chain, then decide between the two fix shapes (leaning toward option 2, repository self-sufficient with the in-memory collection kept as a non-null fast path) before implementing + a non-vacuous regression test.Closed by PR #492 (merged to
mainasa696f06d).What was done
LibraryRepository.GetOrAddFoldernow resolves the folder from the DB by(LibraryPathId, Path)instead of reading the caller'sLibraryPath.LibraryFoldersnavigation collection. Chose option 2 (repository self-sufficient) as a pure DB lookup — see the "Resolution" section in the issue body and thedocs/decisions.mdentry for why the pure form beat the caller-side fix and the hybrid fast-path.Root cause
An implicit, undocumented loading contract on
GetOrAddFolder: "caller must have eager-loadedLibraryFolders." Satisfied by the local*FolderScannerfamily (which loads viaGetLibrary's.Include(...).ThenInclude(p => p.LibraryFolders)) but not by the Jellyfin sync path, which takes itsLibraryPathstraight off theJellyfinLibraryentity where the collection isnull.JellyfinMusicVideoLibraryScanneris the only remote caller, so it was the only one that trippedEnumerable.Where(null, …). The deeper reason it escaped every net: the scanner is a standalone class (not derived fromMediaServer*LibraryScanner), had zero test coverage, the one test touchingGetOrAddFoldermocks it away, E2E has no Jellyfin, and no prod library was ever typedmusicvideos.Files changed
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs— the fix.ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs— contract documented on the interface.ErsatzTV.Tests/Integration/LibraryRepositoryTests.cs— repo-level regression (null-nav create, idempotent re-scan, ParentId persist).ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests.cs— end-to-end scan (real repos + in-memory SQLite) asserting Artist + MusicVideo row creation.docs/decisions.md— decision entry.Both regression tests proven non-vacuous (reverting the fix reproduces the
ArgumentNullException('source')).Docs updated
docs/decisions.md(repository contract change) + the interface XML-doc contract.Follow-ups filed
priority: low) —LibraryFolderhas no unique index on(LibraryPathId, Path); the pre-existing concurrent double-insert window (not a regression from this fix).priority: medium) —JellyfinMusicVideoLibraryScannerdoes no reconciliation, so items removed from Jellyfin are never removed from ErsatzTV (orthogonal to this crash; surfaced by #489/#493's review).CleanEtagsForLibraryPathreadslibraryPath.LibraryFoldersthe same way but is reached only on the local (eager-loaded) path, so it is unaffected.Operator follow-up (not code)
Prod library 15 sync was set to
shouldSyncItems: falseto stop the crash-loop. It can be re-enabled now that the fix is onmainand deployed. Unblocks #474 (and transitively #487), and #489/PR #493 (whose Mixed arm composes this scanner) can now validate end-to-end.