Files
ersatztv/ErsatzTV.Scanner/Core/Metadata/MediaServerReconciliationGuard.cs
T
timothyandClaude Opus 4.8 aa32fd78dd
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
fix(477): guard media-server library sweeps against successful-but-empty fetches
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>
2026-07-19 23:28:27 +02:00

41 lines
2.1 KiB
C#

using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Metadata;
// #477: a media-server library sweep computes "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 emptied upstream) then existing.Except([]) is EVERY existing item, so
// the whole library is flagged FileNotFound in one pass. That is data-loss-adjacent: EmptyTrashHandler
// deletes state:FileNotFound rows permanently, and PlayoutSkipMissingItems empties every affected
// collection. An empty incoming set is indistinguishable at scan time from a transient error (both report
// zero), so the safe policy is to refuse the sweep — logged loudly — rather than nuke the library.
//
// This deliberately overrides the degenerate "last item removed => empty incoming => flag" case that a
// partial-deletion sweep would otherwise handle (see #476's cascade, which still fires for the common
// case where survivors are present and only some items are gone). The cost of not flagging a genuinely
// emptied library (stale rows persist until an item returns or the library is removed) is far smaller
// than a one-scan permanent wipe. Ratio-thresholds and projection-failure detection are deferred — see
// docs/decisions.md and the #477 follow-up.
internal static class MediaServerReconciliationGuard
{
public static bool ShouldFlagMissing(
ILogger logger,
string libraryName,
int incomingCount,
int existingCount)
{
if (incomingCount == 0 && existingCount > 0)
{
logger.LogWarning(
"Media server library {Library} returned zero items but {ExistingCount} exist locally; "
+ "skipping the file-not-found sweep to avoid flagging the entire library as missing "
+ "(expected if the server is mid-restore or the library was emptied upstream)",
libraryName,
existingCount);
return false;
}
return true;
}
}