---
key: scan.zero-item-fetch-guard
title: 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)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion.
signals: 'library sweep, zero-item guard, anti-nuke · paths: `MediaServerReconciliationGuard`, `MediaServerTelevisionLibraryScanner`/`MovieLibraryScanner`/`OtherVideoLibraryScanner` · issues: #477, #476'
mechanics: '`MediaServerReconciliationGuardTests` policy table'
---

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.
