fix(477): guard media-server library sweeps against successful-but-empty fetches
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
A successful fetch returning zero items made existing.Except([]) flag the ENTIRE library FileNotFound in one scan — feeding EmptyTrashHandler's permanent delete and emptying every affected collection (dead channels). Add a shared MediaServerReconciliationGuard that skips (and logs a Warning) the sweep when incoming==0 while items exist, wired into the three library-level sweeps (Television shows / Movie / OtherVideo). An empty incoming set is indistinguishable at scan time from a mid-restore / emptied-upstream error (both report a zero total), so this deliberately overrides #476's degenerate "last item removed => empty incoming => flag" case. #476's cascade still fires for partial deletions (survivors present); its characterization test moves from an empty incoming to a survivor+removed partial-deletion case. Tests: policy table (MediaServerReconciliationGuardTests) + per-scanner integration proving the wiring (empty incoming + non-empty existing flags/reindexes nothing). Proven non-vacuous by neutralizing the guard. Nested TV season/episode sweeps left unguarded (bounded blast radius); ratio-threshold + projection-failure detection deferred to a follow-up. docs/decisions.md updated. Fixes #477 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2140,3 +2140,49 @@ server** returns the new `PlayoutItemNotAvailableFromMediaServer` error instead
|
||||
media servers — that is a reasonable prior but an *unverified* one. A HEAD-with-GET-fallback would avoid
|
||||
the side effect; deferred rather than guessed at, since it trades a known-working request for an
|
||||
untested one.
|
||||
|
||||
## 2026-07-19 — A media-server library sweep refuses to flag when a successful fetch returns zero items, rather than nuking the whole library (#477)
|
||||
|
||||
Each media-server scanner reconciles "gone upstream" as `existing.Except(incoming)` and flags the result
|
||||
`FileNotFound`. If a *successful* fetch returns **zero** items — the server is up but mid-restore /
|
||||
mid-rebuild, or the library was genuinely emptied upstream — then `existing.Except([])` is **every** item,
|
||||
so one scan flags the entire library. That is data-loss-adjacent: `EmptyTrashHandler` permanently deletes
|
||||
`state:FileNotFound` rows (a user clicking Empty Trash after a bad scan), and `PlayoutSkipMissingItems`
|
||||
empties every affected collection (dead channels). There was no zero-count / ratio / server-total guard;
|
||||
the only thing that stopped a mid-*pagination* failure was an exception unwinding past the flag step —
|
||||
protection by accident of control flow, not by design.
|
||||
|
||||
- **The guard is a single shared policy.** `MediaServerReconciliationGuard.ShouldFlagMissing(logger,
|
||||
libraryName, incomingCount, existingCount)` returns false (and logs a Warning) **only** when
|
||||
`incomingCount == 0 && existingCount > 0`; every other combination sweeps normally. Wired into the three
|
||||
**library-level** sweeps — `MediaServerTelevisionLibraryScanner` (shows), `MediaServerMovieLibraryScanner`,
|
||||
`MediaServerOtherVideoLibraryScanner`. One place owns the invariant so the policy can't drift between
|
||||
scanners.
|
||||
- **An empty incoming set is genuinely ambiguous, so we choose the non-destructive branch.** "User removed
|
||||
every item" and "server returned empty erroneously" are **indistinguishable** at scan time — both report
|
||||
a total of zero (the paginator computes `pages` from `TotalRecordCount`, so a 0 total is a clean empty
|
||||
enumeration, not an error). Given the blast radius, skipping wins: the cost of *not* flagging a
|
||||
legitimately-emptied library (stale rows persist until an item returns or the library is removed by hand)
|
||||
is far smaller than a one-scan permanent wipe of a live library.
|
||||
- **This partially overrides #476 for the degenerate case, on purpose.** #476 cascades a removed show's
|
||||
flag to its seasons/episodes. Its common path — some items removed while **survivors are present**
|
||||
(incoming non-empty) — still flags and cascades exactly as before. Only the degenerate "the last item was
|
||||
removed, so incoming is empty" case now skips instead of flagging. The #476 characterization test was
|
||||
rewritten from an empty incoming to a survivor-plus-removed partial deletion so it exercises the cascade
|
||||
without tripping the guard.
|
||||
- **Scope: library-level sweeps only; the nested TV season/episode sweeps are deliberately left unguarded.**
|
||||
Their blast radius is one show's seasons / one season's episodes (not the whole library), a per-parent
|
||||
empty is a more plausible legitimate state there, and the #476 descendant cascade already handles a fully
|
||||
removed parent. Guarding them would alter #476's per-parent behaviour for little safety gain.
|
||||
- **Deferred — ratio threshold and projection-failure detection.** The issue also floated "skip if the
|
||||
missing fraction exceeds a threshold" and "distinguish a silently-dropped projection failure from a real
|
||||
deletion." A ratio threshold risks suppressing a legitimate bulk deletion and needs a tunable, telemetry-
|
||||
backed policy; projection-failure detection needs a dropped-count threaded out of `JellyfinApiClient`
|
||||
through to the scanner (a cross-layer change). The deterministic zero-count guard has **no** false
|
||||
positives and covers the reported catastrophic case, so both are deferred to a follow-up rather than
|
||||
guessed at here.
|
||||
- **Tests.** `MediaServerReconciliationGuardTests` pins the policy table (only `(0, N>0)` skips-and-warns;
|
||||
`(0,0)`, `(3,5)`, `(3,0)` all sweep). Per-scanner integration tests
|
||||
(`MediaServer{Television,Movie,OtherVideo}LibraryScannerTests`) prove the wiring: empty incoming +
|
||||
non-empty existing flags nothing and reindexes nothing. Proven non-vacuous by neutralizing the guard and
|
||||
watching all four anti-nuke assertions fail while the `(0,0)` no-op case stays green.
|
||||
|
||||
Reference in New Issue
Block a user