Extends MediaServerReconciliationGuard (#477) with a second deterministic refusal: when the enumeration that produced the incoming set silently dropped items whose projection THREW, the file-not-found sweep is refused. A dropped item the server did return is indistinguishable from a deletion at the reconcile step, so a projection regression could otherwise mass-flag a healthy library FileNotFound (which EmptyTrash then deletes permanently). Deliberate guard-clause skips (STRM files, virtual items, unsupported types) are explicitly NOT failures and never suppress a sweep — counting them would permanently disable reconciliation for any library holding a single STRM file. The ratio / missing-fraction threshold is REJECTED, not deferred: it is a two-sided heuristic with no tunable default and no telemetry, and the failure it approximates is exactly observable via the projection-failure count (a genuine bulk deletion produces zero failures). Seam is deliberately narrow — the private ProjectTo* contract inside each api client changed from Option<T> to MediaServerProjectionResult<T> (projected/skipped/failed), the paged helper counts IsFailure in one place, and the scanner reads it through an optional trailing MediaServerProjectionFailureCounter on only the five library-level methods that feed a sweep. The counter is per-enumeration state created by the scanner, never a field on an api client. fixes #484
50 lines
2.4 KiB
C#
50 lines
2.4 KiB
C#
namespace ErsatzTV.Core.Metadata;
|
|
|
|
// #484: a media-server API client maps each item the server returned through a private projection. That
|
|
// projection has TWO reasons to produce nothing, and conflating them is dangerous:
|
|
//
|
|
// - Skipped — a deliberate guard clause (a virtual/non-FileSystem item, a STRM file, an unsupported
|
|
// item type). The item is permanently and expectedly absent from the incoming set; a
|
|
// library containing one STRM file produces a Skipped on every single scan, forever.
|
|
// - Failed — the projection threw and was swallowed by a `catch { LogWarning; }`. The server DID
|
|
// return the item; we simply could not build it. At the reconcile step that is
|
|
// indistinguishable from a deletion, so a projection regression can mass-flag healthy
|
|
// items FileNotFound (which EmptyTrash then deletes permanently).
|
|
//
|
|
// Only Failed may suppress the reconciliation sweep (see MediaServerReconciliationGuard). Treating
|
|
// Skipped as a failure would permanently disable reconciliation for any library holding a single STRM
|
|
// file, so stale rows would accumulate forever — a regression, not a safe default.
|
|
public readonly struct MediaServerProjectionResult<T>
|
|
{
|
|
private MediaServerProjectionResult(Option<T> item, bool isFailure)
|
|
{
|
|
Item = item;
|
|
IsFailure = isFailure;
|
|
}
|
|
|
|
public Option<T> Item { get; }
|
|
|
|
/// <summary>
|
|
/// True only when the projection threw. A deliberate guard-clause skip is NOT a failure.
|
|
/// </summary>
|
|
public bool IsFailure { get; }
|
|
|
|
public static MediaServerProjectionResult<T> Projected(T item) => new(item, false);
|
|
|
|
/// <summary>
|
|
/// The server returned an item we deliberately and permanently do not import (virtual item, STRM
|
|
/// file, unsupported type). Expected on every scan; never suppresses a sweep.
|
|
/// </summary>
|
|
public static MediaServerProjectionResult<T> Skipped() => new(Option<T>.None, false);
|
|
|
|
/// <summary>
|
|
/// The projection threw and the exception was swallowed. The item exists upstream but is missing
|
|
/// from the incoming set, so the sweep must not run.
|
|
/// </summary>
|
|
public static MediaServerProjectionResult<T> Failed() => new(Option<T>.None, true);
|
|
|
|
public static implicit operator MediaServerProjectionResult<T>(T item) => Projected(item);
|
|
|
|
public Option<T> ToOption() => Item;
|
|
}
|