fix(476): cascade FileNotFound from removed shows/seasons to descendants #481

Merged
timothy merged 1 commits from feat/476-cascade-filenotfound into main 2026-07-19 22:19:38 +02:00
Owner

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

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)
timothy added 1 commit 2026-07-19 21:50:34 +02:00
fix(476): cascade FileNotFound from removed shows/seasons to descendants
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 24s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m52s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
61bd374494
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>
Author
Owner

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 @ 61bd374494

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 main 2026-07-19 22:19:38 +02:00
timothy deleted branch feat/476-cascade-filenotfound 2026-07-19 22:19:38 +02:00
Sign in to join this conversation.