The media-server television scanner (MediaServerTelevisionLibraryScanner, shared base for Jellyfin/Plex/Emby) reconciles removed items at three levels via existing.Except(incoming) → FlagFileNotFound{Shows,Seasons,Episodes}. But the season and episode sweeps live inside the per-parent loops (ScanSeasons inside the show loop; ScanEpisodes inside the season loop), which only iterate parents present in the incoming list. A show (or season) gone from the media server is absent from that list → never visited → its descendants are never swept and keep their last state. On Jellyfin/Emby (ServerSupportsRemoteStreaming) that state is RemoteOnly, which PlayoutBuilder does not skip even with PlayoutSkipMissingItems on → every orphaned episode keeps getting scheduled as a guaranteed tune-in failure (the #473 repro; 717 stale prod rows across 10 removed shows).
Fix
Cascade the flag by parent MediaItem.Id. Two provider-agnostic repo helpers (FlagFileNotFoundSeasonsForShows, FlagFileNotFoundEpisodesForSeasons — Season.ShowId / Episode.SeasonId are on the base tables, so no provider join is needed), and the scanner drives them after each parent sweep:
show removed → cascade to its seasons and episodes (InternalScanLibrary cleanup);
season removed while show present → cascade to its episodes (ScanSeasons cleanup) — the sibling gap.
All affected ids (parents + cascaded descendants) are reindexed. The shared abstract base fixes Jellyfin/Plex/Emby at once. Movie/OtherVideo scanners are flat (single top-level sweep, no hierarchy) → no such gap; left unchanged.
Tests
ErsatzTV.Scanner.Tests: asserts the scanner cascades through the substituted repository (removed-show scenario). Verified non-vacuous — stubbing out the cascade calls makes it fail.
ErsatzTV.Tests/Integration: exercises the real cascade SQL against the schema (in-memory SQLite), proving it flags only the targeted subtree and leaves a sibling show untouched, and no-ops on empty input.
Verification of the 717 prod rows
The stale rows move to FileNotFound on the next scan (a rescan now cascades); to be confirmed by an operator rescan post-deploy.
Docs
No docs-update trigger (no convention/route/API/decision change — a scanner bug fix).
Review
Independent adversarial review pending (touches a DB-mutating write path). Review-verdict: comment to follow on the current head.
Fixes #476.
## Root cause
The media-server television scanner (`MediaServerTelevisionLibraryScanner`, shared base for Jellyfin/Plex/Emby) reconciles removed items at three levels via `existing.Except(incoming) → FlagFileNotFound{Shows,Seasons,Episodes}`. But the **season** and **episode** sweeps live *inside* the per-parent loops (`ScanSeasons` inside the show loop; `ScanEpisodes` inside the season loop), which only iterate parents present in the **incoming** list. A show (or season) gone from the media server is absent from that list → never visited → its descendants are never swept and keep their last state. On Jellyfin/Emby (`ServerSupportsRemoteStreaming`) that state is `RemoteOnly`, which `PlayoutBuilder` does **not** skip even with `PlayoutSkipMissingItems` on → every orphaned episode keeps getting scheduled as a guaranteed tune-in failure (the #473 repro; 717 stale prod rows across 10 removed shows).
## Fix
Cascade the flag by parent `MediaItem.Id`. Two provider-agnostic repo helpers (`FlagFileNotFoundSeasonsForShows`, `FlagFileNotFoundEpisodesForSeasons` — `Season.ShowId` / `Episode.SeasonId` are on the base tables, so no provider join is needed), and the scanner drives them after each parent sweep:
- **show removed** → cascade to its seasons **and** episodes (`InternalScanLibrary` cleanup);
- **season removed while show present** → cascade to its episodes (`ScanSeasons` cleanup) — the sibling gap.
All affected ids (parents + cascaded descendants) are reindexed. The shared abstract base fixes Jellyfin/Plex/Emby at once. **Movie/OtherVideo scanners are flat** (single top-level sweep, no hierarchy) → no such gap; left unchanged.
## Tests
- `ErsatzTV.Scanner.Tests`: asserts the scanner cascades through the **substituted** repository (removed-show scenario). Verified **non-vacuous** — stubbing out the cascade calls makes it fail.
- `ErsatzTV.Tests/Integration`: exercises the **real cascade SQL** against the schema (in-memory SQLite), proving it flags only the targeted subtree and leaves a sibling show untouched, and no-ops on empty input.
## Verification of the 717 prod rows
The stale rows move to `FileNotFound` on the next scan (a rescan now cascades); to be confirmed by an operator rescan post-deploy.
## Docs
No docs-update trigger (no convention/route/API/decision change — a scanner bug fix).
## Review
Independent adversarial review pending (touches a DB-mutating write path). `Review-verdict:` comment to follow on the current head.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The media-server television scanner reconciles removed items at three levels
via existing.Except(incoming), but the season and episode sweeps live inside
the per-parent loops (ScanSeasons inside the show loop, ScanEpisodes inside the
season loop). Those loops only iterate parents present in the incoming list, so
a show (or season) that is gone from the media server is never visited and its
descendants are never swept — they keep their last state. On Jellyfin/Emby that
is RemoteOnly, which PlayoutBuilder does NOT skip even with PlayoutSkipMissingItems
on, so every orphaned episode keeps getting scheduled as a guaranteed tune-in
failure (the #473 reproduction; 717 stale prod rows across 10 removed shows).
Fix: cascade the flag by parent MediaItem.Id. Two provider-agnostic repo helpers
(Season.ShowId / Episode.SeasonId are on the base tables) flag descendants and the
scanner drives them after each parent sweep — show → seasons → episodes, and
season → episodes for the show-present case. Shared abstract base fixes
Jellyfin/Plex/Emby at once; Movie/OtherVideo are flat and have no such gap.
Tests: a Scanner.Tests case asserts the scanner cascades through the (substituted)
repository (non-vacuous — fails if the cascade calls are removed), and Integration
tests exercise the real cascade SQL against the schema, proving it flags only the
targeted subtree and no-ops on empty input.
fixes#476
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-context adversarial review (review-only agent, different context) of the full diff.
Traced the scanner loop structure; cross-checked the new cascade SQL against the existing FlagFileNotFound{Shows,Seasons,Episodes} queries; confirmed MediaItemState.FileNotFound = 1 and that Season.ShowId/Episode.SeasonId are the real base-table FKs; validated both test files against the real APIs.
No correctness or safety bugs. Highlights:
SQL scope: cascade isn't library-scoped in SQL, but doesn't need to be — parentIds come from the library-scoped FlagFileNotFoundShows, a Season belongs to exactly one Show, and MediaItem.Id is one global PK, so scope is inherited transitively. No cross-library/non-descendant flagging possible.
Empty/no-match: guarded (Count == 0 early-return; empty IN is a safe no-op).
Normal scan (nothing removed): FlagFileNotFoundShows returns [] → both cascades short-circuit → no live items touched.
Provider parity: identical FlagFileNotFoundByParent helper in all three repos; Plex Key-vs-ItemId irrelevant (cascade keys off MediaItem.Id).
Tests non-vacuous: scanner test fails if the cascade calls are removed; integration test asserts the sibling show stays RemoteOnly.
Non-blocking notes (accepted, not fixed): LOW — a persistently-removed show re-reindexes its whole descendant tree each cleanup scan (extends the pre-existing already-flagged-reindex pattern; performance only, and EmptyTrash removes such rows); NIT — the empty-input test doesn't separately cover the non-empty-parents/zero-children branch (relies on Dapper empty-IN).
Cold-context adversarial review (review-only agent, different context) of the full diff.
Traced the scanner loop structure; cross-checked the new cascade SQL against the existing `FlagFileNotFound{Shows,Seasons,Episodes}` queries; confirmed `MediaItemState.FileNotFound = 1` and that `Season.ShowId`/`Episode.SeasonId` are the real base-table FKs; validated both test files against the real APIs.
**No correctness or safety bugs.** Highlights:
- **SQL scope**: cascade isn't library-scoped in SQL, but doesn't need to be — `parentIds` come from the library-scoped `FlagFileNotFoundShows`, a Season belongs to exactly one Show, and `MediaItem.Id` is one global PK, so scope is inherited transitively. No cross-library/non-descendant flagging possible.
- **Empty/no-match**: guarded (`Count == 0` early-return; empty `IN` is a safe no-op).
- **Normal scan (nothing removed)**: `FlagFileNotFoundShows` returns `[]` → both cascades short-circuit → no live items touched.
- **Provider parity**: identical `FlagFileNotFoundByParent` helper in all three repos; Plex `Key`-vs-`ItemId` irrelevant (cascade keys off `MediaItem.Id`).
- **Tests non-vacuous**: scanner test fails if the cascade calls are removed; integration test asserts the sibling show stays `RemoteOnly`.
Non-blocking notes (accepted, not fixed): **LOW** — a persistently-removed show re-reindexes its whole descendant tree each cleanup scan (extends the pre-existing already-flagged-reindex pattern; performance only, and EmptyTrash removes such rows); **NIT** — the empty-input test doesn't separately cover the non-empty-parents/zero-children branch (relies on Dapper empty-`IN`).
Review-verdict: MERGEABLE @ 61bd3744943ee1ae7d8760b2d72c7727e84a75da
timothy
merged commit 0d70425572 into main2026-07-19 22:19:38 +02:00
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 #476.
Root cause
The media-server television scanner (
MediaServerTelevisionLibraryScanner, shared base for Jellyfin/Plex/Emby) reconciles removed items at three levels viaexisting.Except(incoming) → FlagFileNotFound{Shows,Seasons,Episodes}. But the season and episode sweeps live inside the per-parent loops (ScanSeasonsinside the show loop;ScanEpisodesinside the season loop), which only iterate parents present in the incoming list. A show (or season) gone from the media server is absent from that list → never visited → its descendants are never swept and keep their last state. On Jellyfin/Emby (ServerSupportsRemoteStreaming) that state isRemoteOnly, whichPlayoutBuilderdoes not skip even withPlayoutSkipMissingItemson → every orphaned episode keeps getting scheduled as a guaranteed tune-in failure (the #473 repro; 717 stale prod rows across 10 removed shows).Fix
Cascade the flag by parent
MediaItem.Id. Two provider-agnostic repo helpers (FlagFileNotFoundSeasonsForShows,FlagFileNotFoundEpisodesForSeasons—Season.ShowId/Episode.SeasonIdare on the base tables, so no provider join is needed), and the scanner drives them after each parent sweep:InternalScanLibrarycleanup);ScanSeasonscleanup) — the sibling gap.All affected ids (parents + cascaded descendants) are reindexed. The shared abstract base fixes Jellyfin/Plex/Emby at once. Movie/OtherVideo scanners are flat (single top-level sweep, no hierarchy) → no such gap; left unchanged.
Tests
ErsatzTV.Scanner.Tests: asserts the scanner cascades through the substituted repository (removed-show scenario). Verified non-vacuous — stubbing out the cascade calls makes it fail.ErsatzTV.Tests/Integration: exercises the real cascade SQL against the schema (in-memory SQLite), proving it flags only the targeted subtree and leaves a sibling show untouched, and no-ops on empty input.Verification of the 717 prod rows
The stale rows move to
FileNotFoundon the next scan (a rescan now cascades); to be confirmed by an operator rescan post-deploy.Docs
No docs-update trigger (no convention/route/API/decision change — a scanner bug fix).
Review
Independent adversarial review pending (touches a DB-mutating write path).
Review-verdict:comment to follow on the current head.🤖 Generated with Claude Code
Cold-context adversarial review (review-only agent, different context) of the full diff.
Traced the scanner loop structure; cross-checked the new cascade SQL against the existing
FlagFileNotFound{Shows,Seasons,Episodes}queries; confirmedMediaItemState.FileNotFound = 1and thatSeason.ShowId/Episode.SeasonIdare the real base-table FKs; validated both test files against the real APIs.No correctness or safety bugs. Highlights:
parentIdscome from the library-scopedFlagFileNotFoundShows, a Season belongs to exactly one Show, andMediaItem.Idis one global PK, so scope is inherited transitively. No cross-library/non-descendant flagging possible.Count == 0early-return; emptyINis a safe no-op).FlagFileNotFoundShowsreturns[]→ both cascades short-circuit → no live items touched.FlagFileNotFoundByParenthelper in all three repos; PlexKey-vs-ItemIdirrelevant (cascade keys offMediaItem.Id).RemoteOnly.Non-blocking notes (accepted, not fixed): LOW — a persistently-removed show re-reindexes its whole descendant tree each cleanup scan (extends the pre-existing already-flagged-reindex pattern; performance only, and EmptyTrash removes such rows); NIT — the empty-input test doesn't separately cover the non-empty-parents/zero-children branch (relies on Dapper empty-
IN).Review-verdict: MERGEABLE @
61bd374494