Files
ersatztv/ErsatzTV.Core/Metadata/MediaServerProjectionResult.cs
T
timothy e3645a2840 feat(484): suppress the media-server sweep on projection failures; reject the ratio threshold
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
2026-07-25 16:36:35 +02:00

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