JellyfinMusicVideoLibraryScanner did add/update only — a music video (or artist) deleted on the Jellyfin side lingered in ErsatzTV forever and could still be scheduled. Adds a trash sweep at the end of ScanLibrary (TrashMissingMusicVideos).
Approach (option 1 from the issue)
Identity = (LibraryPathId, path), not server itemId. Music videos carry no JellyfinMusicVideo entity / ItemId/Etag, and the scanner injects the localIMusicVideoRepository (no itemId-keyed existing-set or flag seam). So the sweep diffs incoming local paths against FindMusicVideoPaths(libraryPath), hard-deletes the remainder via DeleteByPath + IScannerProxy.RemoveMediaItems, and cleans now-empty artists via IArtistRepository.DeleteEmptyArtists. Hard delete (not soft FileNotFound trash) because there is no per-item FileNotFound seam on this path and Done-when is "removed".
Cross-kind safety is a query property, not a media-kind property.MediaItem is TPT with LibraryPathId on the base, so a Movie/Show/MusicVideo can share one LibraryPath (mixed library). FindMusicVideoPaths and DeleteByPath both filter LibraryPathIdand join the concrete MusicVideo table, so the sweep can only see/delete music videos. Pinned by a cross-delete test.
Reuses the #477 empty-fetch guard (MediaServerReconciliationGuard.ShouldFlagMissing): a successful-but-empty fetch does not wipe the library. Pinned as a negative control.
Known limitation (deferred)
MusicVideoRepository.GetOrAdd dedups a path globally (no LibraryPathId predicate), so a file served by two libraries with overlapping local paths is a single row owned by whichever library scanned it first; if that owner later stops reporting it while another library still serves it, the sweep removes the shared row. Proper fix = per-library music-video identity (a JellyfinMusicVideo etag entity + migration) — the issue's "option 2". Follow-up filed.
Coordination
File-disjoint from the in-flight #489/PR #493 (mixed libraries) — that PR touches the dispatch/wiring, not JellyfinMusicVideoLibraryScanner.cs. The #493 session's four warnings (esp. the global-GetOrAdd identity trap) are addressed above.
Tests
Integration tests (real ArtistRepository/MusicVideoRepository/LibraryRepository over in-memory SQLite, extending the #488 harness): removal, empty-artist cleanup, cross-kind safety, empty-fetch guard. Proven non-vacuous (all fail pre-fix except the guard control). Full local pass: Scanner.Tests 1481, ErsatzTV.Tests 1748, 0 failures.
Docs
docs/decisions.md records the identity choice + deferred follow-up.
Fixes #494.
`JellyfinMusicVideoLibraryScanner` did add/update only — a music video (or artist) deleted on the Jellyfin side lingered in ErsatzTV forever and could still be scheduled. Adds a trash sweep at the end of `ScanLibrary` (`TrashMissingMusicVideos`).
## Approach (option 1 from the issue)
- **Identity = (LibraryPathId, path), not server itemId.** Music videos carry no `JellyfinMusicVideo` entity / `ItemId`/`Etag`, and the scanner injects the *local* `IMusicVideoRepository` (no itemId-keyed existing-set or flag seam). So the sweep diffs incoming local paths against `FindMusicVideoPaths(libraryPath)`, hard-deletes the remainder via `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists via `IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no per-item FileNotFound seam on this path and Done-when is "removed".
- **Cross-kind safety is a query property, not a media-kind property.** `MediaItem` is TPT with `LibraryPathId` on the base, so a Movie/Show/MusicVideo can share one `LibraryPath` (mixed library). `FindMusicVideoPaths` and `DeleteByPath` both filter `LibraryPathId` **and** join the concrete `MusicVideo` table, so the sweep can only see/delete music videos. Pinned by a cross-delete test.
- **Reuses the #477 empty-fetch guard** (`MediaServerReconciliationGuard.ShouldFlagMissing`): a successful-but-empty fetch does not wipe the library. Pinned as a negative control.
## Known limitation (deferred)
`MusicVideoRepository.GetOrAdd` dedups a path **globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is a single row owned by whichever library scanned it first; if that owner later stops reporting it while another library still serves it, the sweep removes the shared row. Proper fix = per-library music-video identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2". Follow-up filed.
## Coordination
File-disjoint from the in-flight #489/PR #493 (mixed libraries) — that PR touches the dispatch/wiring, not `JellyfinMusicVideoLibraryScanner.cs`. The #493 session's four warnings (esp. the global-`GetOrAdd` identity trap) are addressed above.
## Tests
Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory SQLite, extending the #488 harness): removal, empty-artist cleanup, cross-kind safety, empty-fetch guard. Proven non-vacuous (all fail pre-fix except the guard control). Full local pass: Scanner.Tests 1481, ErsatzTV.Tests 1748, 0 failures.
## Docs
`docs/decisions.md` records the identity choice + deferred follow-up.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
JellyfinMusicVideoLibraryScanner did add/update only, so a music video (or
artist) deleted on the Jellyfin side lingered in ErsatzTV forever and could
still be scheduled. Add a trash sweep at the end of ScanLibrary
(TrashMissingMusicVideos) mirroring the MediaServer* base scanners' "gone
upstream => remove" pattern.
Identity is library-scoped by (LibraryPathId, path) rather than server itemId:
music videos carry no JellyfinMusicVideo entity / ItemId, so the sweep diffs the
incoming local paths against FindMusicVideoPaths(libraryPath) and hard-deletes
the remainder via DeleteByPath + RemoveMediaItems, then cleans now-empty artists
via DeleteEmptyArtists. Both repo queries filter LibraryPathId AND join the
concrete MusicVideo table, so a Movie/Show sharing the same LibraryPath (a mixed
library) can never be cross-deleted. Gated by the #477 MediaServerReconciliationGuard
so a successful-but-empty fetch does not wipe the library.
Known limitation: MusicVideoRepository.GetOrAdd dedups paths globally, so a file
served by two libraries with overlapping local paths is a single row owned by the
first library; a proper fix needs per-library music-video identity (deferred).
Integration tests (real repos over in-memory SQLite) pin removal, empty-artist
cleanup, cross-kind safety, and the empty-fetch guard. decisions.md records the
identity choice and the deferred follow-up.
fixes#494
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the #493 session's field finding: a mixed Jellyfin library runs the
music-video arm with a legitimately empty incoming set on every scan while
Movies/Shows exist under the same LibraryPath (the real "Standup" case). Add
ScanLibrary_Should_Not_Touch_Movies_Or_Shows_When_No_MusicVideos_Present — seeds
a Movie (with a real MediaFile path) + Show, scans with zero incoming, asserts
both survive and RemoveMediaItems is never called. This pins that the
MusicVideo-joined FindMusicVideoPaths (not just the empty-fetch guard) is what
keeps existing.Except([]) from wiping the movies/episodes.
Also tighten the removal test to pin the trashed music-video id in the
RemoveMediaItems assertion (cold-review nit), and name the deferred per-library
identity follow-up (#496) in decisions.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-context adversarial review (independent, review-only agent — mandatory here: write-path + hard-delete semantics) completed on 2351841f: no Blocker/High, verdict MERGEABLE. It independently confirmed the path-diff symmetry (GetLocalPath ↔ stored MF.Path), partial-set protection (incoming paths added before ProcessMusicVideo so an errored-but-present item is never trashed), the #477 empty-fetch guard, cross-library row exclusion, and DeleteEmptyArtists scoping.
Addressed the two nits in fdef9089:
Tightened the removal test to pin the trashed music-video id in the RemoveMediaItems assertion.
AddedScanLibrary_Should_Not_Touch_Movies_Or_Shows_When_No_MusicVideos_Present — the #493 session's real "Standup" case (mixed library, zero incoming music videos, Movies+Show present), proving the MusicVideo-joined FindMusicVideoPaths (not just the guard) prevents the wipe.
Deferred (low, consistent with every existing scanner): RemoveMediaItems/DeleteEmptyArtists failures warn rather than abort after the DB row is deleted — a pre-existing repo-wide pattern, out of #494 scope.
The delta 2351841f..fdef9089 is test-only + one docs line; the production scanner file is byte-identical, so the verdict carries.
Cold-context adversarial review (independent, review-only agent — mandatory here: write-path + hard-delete semantics) completed on `2351841f`: **no Blocker/High**, verdict MERGEABLE. It independently confirmed the path-diff symmetry (GetLocalPath ↔ stored MF.Path), partial-set protection (incoming paths added *before* ProcessMusicVideo so an errored-but-present item is never trashed), the #477 empty-fetch guard, cross-library row exclusion, and `DeleteEmptyArtists` scoping.
Addressed the two nits in `fdef9089`:
- **Tightened** the removal test to pin the trashed music-video id in the `RemoveMediaItems` assertion.
- **Added** `ScanLibrary_Should_Not_Touch_Movies_Or_Shows_When_No_MusicVideos_Present` — the #493 session's real "Standup" case (mixed library, zero incoming music videos, Movies+Show present), proving the `MusicVideo`-joined `FindMusicVideoPaths` (not just the guard) prevents the wipe.
Deferred (low, consistent with every existing scanner): `RemoveMediaItems`/`DeleteEmptyArtists` failures warn rather than abort after the DB row is deleted — a pre-existing repo-wide pattern, out of #494 scope.
The delta `2351841f..fdef9089` is **test-only + one docs line**; the production scanner file is byte-identical, so the verdict carries.
Review-verdict: MERGEABLE @ fdef90892979e2d4054195c520c6bece34f68057
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.
Fixes #494.
JellyfinMusicVideoLibraryScannerdid add/update only — a music video (or artist) deleted on the Jellyfin side lingered in ErsatzTV forever and could still be scheduled. Adds a trash sweep at the end ofScanLibrary(TrashMissingMusicVideos).Approach (option 1 from the issue)
JellyfinMusicVideoentity /ItemId/Etag, and the scanner injects the localIMusicVideoRepository(no itemId-keyed existing-set or flag seam). So the sweep diffs incoming local paths againstFindMusicVideoPaths(libraryPath), hard-deletes the remainder viaDeleteByPath+IScannerProxy.RemoveMediaItems, and cleans now-empty artists viaIArtistRepository.DeleteEmptyArtists. Hard delete (not softFileNotFoundtrash) because there is no per-item FileNotFound seam on this path and Done-when is "removed".MediaItemis TPT withLibraryPathIdon the base, so a Movie/Show/MusicVideo can share oneLibraryPath(mixed library).FindMusicVideoPathsandDeleteByPathboth filterLibraryPathIdand join the concreteMusicVideotable, so the sweep can only see/delete music videos. Pinned by a cross-delete test.MediaServerReconciliationGuard.ShouldFlagMissing): a successful-but-empty fetch does not wipe the library. Pinned as a negative control.Known limitation (deferred)
MusicVideoRepository.GetOrAdddedups a path globally (noLibraryPathIdpredicate), so a file served by two libraries with overlapping local paths is a single row owned by whichever library scanned it first; if that owner later stops reporting it while another library still serves it, the sweep removes the shared row. Proper fix = per-library music-video identity (aJellyfinMusicVideoetag entity + migration) — the issue's "option 2". Follow-up filed.Coordination
File-disjoint from the in-flight #489/PR #493 (mixed libraries) — that PR touches the dispatch/wiring, not
JellyfinMusicVideoLibraryScanner.cs. The #493 session's four warnings (esp. the global-GetOrAddidentity trap) are addressed above.Tests
Integration tests (real
ArtistRepository/MusicVideoRepository/LibraryRepositoryover in-memory SQLite, extending the #488 harness): removal, empty-artist cleanup, cross-kind safety, empty-fetch guard. Proven non-vacuous (all fail pre-fix except the guard control). Full local pass: Scanner.Tests 1481, ErsatzTV.Tests 1748, 0 failures.Docs
docs/decisions.mdrecords the identity choice + deferred follow-up.🤖 Generated with Claude Code
Cold-context adversarial review (independent, review-only agent — mandatory here: write-path + hard-delete semantics) completed on
2351841f: no Blocker/High, verdict MERGEABLE. It independently confirmed the path-diff symmetry (GetLocalPath ↔ stored MF.Path), partial-set protection (incoming paths added before ProcessMusicVideo so an errored-but-present item is never trashed), the #477 empty-fetch guard, cross-library row exclusion, andDeleteEmptyArtistsscoping.Addressed the two nits in
fdef9089:RemoveMediaItemsassertion.ScanLibrary_Should_Not_Touch_Movies_Or_Shows_When_No_MusicVideos_Present— the #493 session's real "Standup" case (mixed library, zero incoming music videos, Movies+Show present), proving theMusicVideo-joinedFindMusicVideoPaths(not just the guard) prevents the wipe.Deferred (low, consistent with every existing scanner):
RemoveMediaItems/DeleteEmptyArtistsfailures warn rather than abort after the DB row is deleted — a pre-existing repo-wide pattern, out of #494 scope.The delta
2351841f..fdef9089is test-only + one docs line; the production scanner file is byte-identical, so the verdict carries.Review-verdict: MERGEABLE @
fdef908929