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;
}
}