Every media-server scanner reconciles "gone upstream" as existing.Except(incoming) → flag FileNotFound. A successful but empty fetch (server up but mid-restore / mid-rebuild, or library emptied upstream) makes incomingItemIds == [], so existing.Except([]) is every item — the whole library is flagged FileNotFound in one scan. That is data-loss-adjacent: EmptyTrashHandler permanently deletes state:FileNotFound rows, and PlayoutSkipMissingItems empties every affected collection.
Fix
A shared MediaServerReconciliationGuard.ShouldFlagMissing(logger, library, incoming, existing) skips the sweep (logging a Warning) only when incoming == 0 && existing > 0; every other combination reconciles normally. Wired into the three library-level sweeps: Television (shows), Movie, OtherVideo.
An empty incoming set is indistinguishable at scan time from a transient error (both report a zero total), so this overrides #476's degenerate "last item removed ⇒ empty incoming ⇒ flag" case. #476's cascade still fires for the common partial-deletion path (survivors present, incoming non-empty); its characterization test was moved from an empty incoming to a survivor+removed partial deletion so it still exercises the cascade without tripping the guard.
Scope / deferrals
Library-level sweeps only. The nested TV season/episode sweeps are left unguarded: bounded blast radius (one parent's children), a per-parent empty is a more plausible legitimate state, and #476's descendant cascade already handles a fully-removed parent.
Deferred to a follow-up: ratio-threshold guard + projection-failure detection (needs a dropped-count threaded out of JellyfinApiClient; a ratio risks suppressing legitimate bulk deletions). The deterministic zero-count guard has no false positives and covers the reported catastrophic case.
MediaServer{Television,Movie,OtherVideo}LibraryScannerTests — per-scanner integration: empty incoming + non-empty existing flags/reindexes nothing; TV also keeps the #476 partial-deletion cascade.
Proven non-vacuous by neutralizing the guard (all four anti-nuke assertions fail; the (0,0) no-op stays green). Full ErsatzTV.Scanner.Tests: 1481 passed. Full solution build: 0 errors.
Docs
docs/decisions.md entry added (policy, the #476 override, scope, deferrals).
Note (out of scope)
While committing I hit a latent trap: .gitignore line 49 (core) matches the Core/ directory case-insensitively on macOS, so new files under any */Core/ path are silently ignored (already-tracked files are unaffected). Force-added here; filing a follow-up.
Fixes #477.
## Problem
Every media-server scanner reconciles "gone upstream" as `existing.Except(incoming)` → flag `FileNotFound`. A **successful but empty** fetch (server up but mid-restore / mid-rebuild, or library emptied upstream) makes `incomingItemIds == []`, so `existing.Except([])` is **every** item — the whole library is flagged `FileNotFound` in one scan. That is data-loss-adjacent: `EmptyTrashHandler` permanently deletes `state:FileNotFound` rows, and `PlayoutSkipMissingItems` empties every affected collection.
## Fix
A shared `MediaServerReconciliationGuard.ShouldFlagMissing(logger, library, incoming, existing)` skips the sweep (logging a Warning) **only** when `incoming == 0 && existing > 0`; every other combination reconciles normally. Wired into the three **library-level** sweeps: Television (shows), Movie, OtherVideo.
## Interaction with #476 (deliberate, documented)
An empty incoming set is indistinguishable at scan time from a transient error (both report a zero total), so this **overrides #476's degenerate "last item removed ⇒ empty incoming ⇒ flag" case**. #476's cascade still fires for the common partial-deletion path (survivors present, incoming non-empty); its characterization test was moved from an empty incoming to a survivor+removed partial deletion so it still exercises the cascade without tripping the guard.
## Scope / deferrals
- **Library-level sweeps only.** The nested TV season/episode sweeps are left unguarded: bounded blast radius (one parent's children), a per-parent empty is a more plausible legitimate state, and #476's descendant cascade already handles a fully-removed parent.
- **Deferred to a follow-up:** ratio-threshold guard + projection-failure detection (needs a dropped-count threaded out of `JellyfinApiClient`; a ratio risks suppressing legitimate bulk deletions). The deterministic zero-count guard has no false positives and covers the reported catastrophic case.
## Tests
- `MediaServerReconciliationGuardTests` — policy table (only `(0, N>0)` skips-and-warns; `(0,0)`, `(3,5)`, `(3,0)` sweep).
- `MediaServer{Television,Movie,OtherVideo}LibraryScannerTests` — per-scanner integration: empty incoming + non-empty existing flags/reindexes nothing; TV also keeps the #476 partial-deletion cascade.
- Proven **non-vacuous** by neutralizing the guard (all four anti-nuke assertions fail; the `(0,0)` no-op stays green). Full `ErsatzTV.Scanner.Tests`: 1481 passed. Full solution build: 0 errors.
## Docs
`docs/decisions.md` entry added (policy, the #476 override, scope, deferrals).
## Note (out of scope)
While committing I hit a latent trap: `.gitignore` line 49 (`core`) matches the `Core/` directory case-insensitively on macOS, so new files under any `*/Core/` path are silently ignored (already-tracked files are unaffected). Force-added here; filing a follow-up.
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>
Independent cold-context adversarial review (Claude, review-only) — read all three scanner base classes in full, the guard, all four test files, grepped every FlagFileNotFound/.Except site in ErsatzTV.Scanner, and ran the 8 new tests (all pass).
Confirmed: guard wiring correct in all three library-level sweeps (no half-done work / wrong return on skip); the TV guard correctly gates the #476 descendant cascade; the #476 survivor short-circuit is real (incomingItemIds.Add runs before GetOrAdd→Left) so the rewritten test exercises the real cascade, not a stub; no missed whole-library sweep site (local folder scanners use a per-file pattern not exposed to this failure mode); tests non-vacuous (negative control real, UpdateProgress correctly stubbed so no ScanCanceled masking). One non-blocking, already-deferred scope note: nested TV season/episode sweeps remain unguarded (bounded blast radius; tracked for #477 follow-up).
Independent cold-context adversarial review (Claude, review-only) — read all three scanner base classes in full, the guard, all four test files, grepped every FlagFileNotFound/.Except site in ErsatzTV.Scanner, and ran the 8 new tests (all pass).
Confirmed: guard wiring correct in all three library-level sweeps (no half-done work / wrong return on skip); the TV guard correctly gates the #476 descendant cascade; the #476 survivor short-circuit is real (incomingItemIds.Add runs before GetOrAdd→Left) so the rewritten test exercises the real cascade, not a stub; no missed whole-library sweep site (local folder scanners use a per-file pattern not exposed to this failure mode); tests non-vacuous (negative control real, UpdateProgress correctly stubbed so no ScanCanceled masking). One non-blocking, already-deferred scope note: nested TV season/episode sweeps remain unguarded (bounded blast radius; tracked for #477 follow-up).
Review-verdict: MERGEABLE @ aa32fd78dd68bf476df6d0ff464e15fa2ce85e5d
timothy
merged commit 55be64bd77 into main2026-07-19 23:53: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 #477.
Problem
Every media-server scanner reconciles "gone upstream" as
existing.Except(incoming)→ flagFileNotFound. A successful but empty fetch (server up but mid-restore / mid-rebuild, or library emptied upstream) makesincomingItemIds == [], soexisting.Except([])is every item — the whole library is flaggedFileNotFoundin one scan. That is data-loss-adjacent:EmptyTrashHandlerpermanently deletesstate:FileNotFoundrows, andPlayoutSkipMissingItemsempties every affected collection.Fix
A shared
MediaServerReconciliationGuard.ShouldFlagMissing(logger, library, incoming, existing)skips the sweep (logging a Warning) only whenincoming == 0 && existing > 0; every other combination reconciles normally. Wired into the three library-level sweeps: Television (shows), Movie, OtherVideo.Interaction with #476 (deliberate, documented)
An empty incoming set is indistinguishable at scan time from a transient error (both report a zero total), so this overrides #476's degenerate "last item removed ⇒ empty incoming ⇒ flag" case. #476's cascade still fires for the common partial-deletion path (survivors present, incoming non-empty); its characterization test was moved from an empty incoming to a survivor+removed partial deletion so it still exercises the cascade without tripping the guard.
Scope / deferrals
JellyfinApiClient; a ratio risks suppressing legitimate bulk deletions). The deterministic zero-count guard has no false positives and covers the reported catastrophic case.Tests
MediaServerReconciliationGuardTests— policy table (only(0, N>0)skips-and-warns;(0,0),(3,5),(3,0)sweep).MediaServer{Television,Movie,OtherVideo}LibraryScannerTests— per-scanner integration: empty incoming + non-empty existing flags/reindexes nothing; TV also keeps the #476 partial-deletion cascade.(0,0)no-op stays green). FullErsatzTV.Scanner.Tests: 1481 passed. Full solution build: 0 errors.Docs
docs/decisions.mdentry added (policy, the #476 override, scope, deferrals).Note (out of scope)
While committing I hit a latent trap:
.gitignoreline 49 (core) matches theCore/directory case-insensitively on macOS, so new files under any*/Core/path are silently ignored (already-tracked files are unaffected). Force-added here; filing a follow-up.Independent cold-context adversarial review (Claude, review-only) — read all three scanner base classes in full, the guard, all four test files, grepped every FlagFileNotFound/.Except site in ErsatzTV.Scanner, and ran the 8 new tests (all pass).
Confirmed: guard wiring correct in all three library-level sweeps (no half-done work / wrong return on skip); the TV guard correctly gates the #476 descendant cascade; the #476 survivor short-circuit is real (incomingItemIds.Add runs before GetOrAdd→Left) so the rewritten test exercises the real cascade, not a stub; no missed whole-library sweep site (local folder scanners use a per-file pattern not exposed to this failure mode); tests non-vacuous (negative control real, UpdateProgress correctly stubbed so no ScanCanceled masking). One non-blocking, already-deferred scope note: nested TV season/episode sweeps remain unguarded (bounded blast radius; tracked for #477 follow-up).
Review-verdict: MERGEABLE @
aa32fd78dd