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. // // #484 extended the policy with a SECOND deterministic refusal and rejected the ratio threshold: // // - projection failures (implemented). A media-server API client maps every item the server returned // through a private projection whose `catch` swallows the exception and drops the item. A dropped // item the server DID return is indistinguishable from a deletion here, so one projection // regression could mass-flag a healthy library. When the enumeration that produced `incomingCount` // reports any such failure, the sweep is refused. Deliberate guard-clause skips (STRM files, // virtual items, unsupported types) are NOT failures and never suppress the sweep — see // MediaServerProjectionResult; counting them would permanently disable reconciliation for any // library holding a single STRM file. // - ratio / missing-fraction threshold (REJECTED). It is a two-sided heuristic: set low it silently // suppresses legitimate bulk deletions, set high it misses the partial fetch it exists for, and // there is no telemetry to tune it with. The failure it approximates is exactly observable via the // projection-failure count above, and a genuine bulk deletion produces zero projection failures, so // the deterministic signal has no false positives where the heuristic has unbounded ones. // // #484 also applies the projection-failure refusal to the NESTED per-show season and per-season episode // sweeps, which #477 deliberately left unguarded. #477's reasoning ("blast radius is one show's seasons / // one season's episodes") holds for a per-parent EMPTY fetch — a plausible legitimate state — but not for // a projection failure, which is systematic by construction: one bad code path fires on every parent, so // every season enumerates zero episodes and the whole episode library is swept in a single scan. The // nested sweeps therefore get ShouldFlagMissingDescendants: the SAME failure predicate from the SAME // class, deliberately WITHOUT #477's empty-fetch branch, so the per-parent empty behaviour (and #476's // cascade, which depends on it) is unchanged. // // See docs/decisions.md `scan.projection-failure-sweep-guard`. internal static class MediaServerReconciliationGuard { public static bool ShouldFlagMissing( ILogger logger, string libraryName, int incomingCount, int existingCount, int projectionFailureCount = 0) { if (RefuseForProjectionFailures( logger, $"library {libraryName}", incomingCount, existingCount, projectionFailureCount)) { return false; } 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; } /// /// The nested per-show season / per-season episode sweeps. Applies ONLY the #484 projection-failure /// refusal — a per-parent empty fetch is a plausible legitimate state at this level and #477 /// deliberately left it unguarded, so importing that branch here would silently change #476's /// per-parent cascade behaviour. /// /// /// Names the parent whose descendants are being swept, e.g. show "Sesame Street" seasons. /// public static bool ShouldFlagMissingDescendants( ILogger logger, string scope, int incomingCount, int existingCount, int projectionFailureCount) => !RefuseForProjectionFailures(logger, scope, incomingCount, existingCount, projectionFailureCount); // the one place the projection-failure predicate lives, so the library-level and descendant-level // sweeps can never drift apart on what counts as a failure or when it matters. private static bool RefuseForProjectionFailures( ILogger logger, string scope, int incomingCount, int existingCount, int projectionFailureCount) { if (projectionFailureCount <= 0 || existingCount <= 0) { return false; } logger.LogWarning( "Media server {Scope} silently dropped {FailureCount} item(s) that failed to project during " + "this scan ({IncomingCount} usable, {ExistingCount} exist locally); skipping the " + "file-not-found sweep because a dropped item is indistinguishable from a deletion and " + "would be flagged as missing", scope, projectionFailureCount, incomingCount, existingCount); return true; } }