fix(484): guard the nested TV season and episode sweeps against projection failures
Review finding 1 (blocking). ScanSeasons' FlagFileNotFoundSeasons and ScanEpisodes' FlagFileNotFoundEpisodes had no guard at all — neither #477's nor #484's — so ProjectToSeason / ProjectToEpisode returning Failed() was computed and discarded. #477 scoped those out because "the blast radius is one show's seasons / one season's episodes", which holds for a per-parent EMPTY fetch but not for a projection failure: that is systematic by construction. One bad code path fires on every parent, so every season enumerates zero episodes, existing.Except([]) is the whole episode library, and EmptyTrashHandler deletes it permanently. Threads the counter into GetSeasonLibraryItems / GetEpisodeLibraryItems(WithoutPeople) for Jellyfin and Emby using the same optional-trailing-param shape, and guards both sweeps with MediaServerReconciliationGuard.ShouldFlagMissingDescendants — the same class and the same private failure predicate as ShouldFlagMissing, deliberately WITHOUT #477's empty-fetch branch so per-parent empty behaviour (and #476's cascade, which depends on it) is unchanged. Also from the review: - finding 3: tests now pin the same-instance JOIN at every level (movie, show, season, episode, music video) by driving the real ScanLibrary entry point and recording the failure from inside the enumeration, so a refactor handing the api client a fresh counter goes red. - finding 4: the missing-library Failed() branch is documented as defensive and unreachable. - finding 2: the mass-Skip residual (Emby's response-shape-dependent MediaSources guard, Plex's pre-projection filter) is stated as a known limitation in the decision record. - finding 5: the log-contract change (only the #484 message when both refusals apply) is noted. fixes #484
This commit is contained in:
@@ -24,18 +24,22 @@ public interface IEmbyApiClient
|
||||
EmbyLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
// #484: the nested per-show season and per-season episode enumerations feed their own sweeps
|
||||
// (FlagFileNotFoundSeasons / FlagFileNotFoundEpisodes), so they carry the same optional counter.
|
||||
IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId);
|
||||
string showId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId,
|
||||
string seasonId);
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<EmbyCollection, int>> GetCollectionLibraryItems(string address, string apiKey);
|
||||
|
||||
|
||||
@@ -30,23 +30,28 @@ public interface IJellyfinApiClient
|
||||
JellyfinLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
// #484: the nested per-show season and per-season episode enumerations feed their own sweeps
|
||||
// (FlagFileNotFoundSeasons / FlagFileNotFoundEpisodes), so they carry the same optional counter.
|
||||
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
string showId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
||||
string address,
|
||||
|
||||
@@ -107,7 +107,8 @@ public class EmbyApiClient : IEmbyApiClient
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId) => GetPagedLibraryContents(
|
||||
string showId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null) => GetPagedLibraryContents(
|
||||
address,
|
||||
library,
|
||||
showId,
|
||||
@@ -116,14 +117,16 @@ public class EmbyApiClient : IEmbyApiClient
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToSeason(item));
|
||||
(_, item) => ProjectToSeason(item),
|
||||
projectionFailures);
|
||||
|
||||
public IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId,
|
||||
string seasonId) => GetPagedLibraryContents(
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null) => GetPagedLibraryContents(
|
||||
address,
|
||||
library,
|
||||
seasonId,
|
||||
@@ -133,7 +136,8 @@ public class EmbyApiClient : IEmbyApiClient
|
||||
seasonId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)));
|
||||
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)),
|
||||
projectionFailures);
|
||||
|
||||
public IAsyncEnumerable<Tuple<EmbyCollection, int>> GetCollectionLibraryItems(string address, string apiKey)
|
||||
{
|
||||
@@ -273,8 +277,10 @@ public class EmbyApiClient : IEmbyApiClient
|
||||
}
|
||||
}
|
||||
|
||||
// #484: a library whose id could not be resolved is not a deliberate per-item skip, so fail closed —
|
||||
// it must never look like a clean "this item was deleted upstream" to a reconciliation sweep.
|
||||
// #484: defensive, and currently UNREACHABLE — every counted path passes a concrete library, and the
|
||||
// only None caller (collections) does not route through here. Kept because the classification would
|
||||
// otherwise be wrong by default if a future caller does pass None: a library we could not resolve is
|
||||
// not a deliberate per-item skip, so fail closed rather than look like a clean upstream deletion.
|
||||
private static MediaServerProjectionResult<TItem> WithLibrary<TItem>(
|
||||
Option<EmbyLibrary> maybeLibrary,
|
||||
Func<EmbyLibrary, MediaServerProjectionResult<TItem>> project) =>
|
||||
|
||||
@@ -137,7 +137,8 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showId) =>
|
||||
string showId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null) =>
|
||||
GetPagedLibraryItems(
|
||||
"JF Seasons",
|
||||
address,
|
||||
@@ -149,13 +150,15 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
showId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToSeason(item));
|
||||
(_, item) => ProjectToSeason(item),
|
||||
projectionFailures);
|
||||
|
||||
public IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId) =>
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null) =>
|
||||
GetPagedLibraryItems(
|
||||
"JF Episodes*",
|
||||
address,
|
||||
@@ -167,13 +170,15 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
seasonId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)));
|
||||
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)),
|
||||
projectionFailures);
|
||||
|
||||
public IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId) =>
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null) =>
|
||||
GetPagedLibraryItems(
|
||||
"JF Episodes",
|
||||
address,
|
||||
@@ -185,7 +190,8 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
seasonId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)));
|
||||
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)),
|
||||
projectionFailures);
|
||||
|
||||
public IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
||||
string address,
|
||||
@@ -372,8 +378,10 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
}
|
||||
}
|
||||
|
||||
// #484: a library whose id could not be resolved is not a deliberate per-item skip, so fail closed —
|
||||
// it must never look like a clean "this item was deleted upstream" to a reconciliation sweep.
|
||||
// #484: defensive, and currently UNREACHABLE — every counted path passes a concrete library, and the
|
||||
// only None caller (collections) does not route through here. Kept because the classification would
|
||||
// otherwise be wrong by default if a future caller does pass None: a library we could not resolve is
|
||||
// not a deliberate per-item skip, so fail closed rather than look like a clean upstream deletion.
|
||||
private static MediaServerProjectionResult<TItem> WithLibrary<TItem>(
|
||||
Option<JellyfinLibrary> maybeLibrary,
|
||||
Func<JellyfinLibrary, MediaServerProjectionResult<TItem>> project) =>
|
||||
|
||||
@@ -51,6 +51,107 @@ public class MediaServerMovieLibraryScannerTests
|
||||
await scannerProxy.DidNotReceive().ReindexMediaItems(
|
||||
Arg.Any<int[]>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// #484 finding 3: ScanLibrary creates ONE counter and must hand that same instance to both
|
||||
// GetMovieLibraryItems and ShouldFlagMissing. A refactor that handed the api client a fresh
|
||||
// counter would leave the guard reading zero and silently re-enable the sweep — every other test
|
||||
// would stay green, this one goes red. The zero-incoming guard cannot mask it: a survivor is
|
||||
// present, so #477's branch does not fire.
|
||||
[Test]
|
||||
public async Task Projection_Failure_From_The_Api_Suppresses_The_Sweep_End_To_End()
|
||||
{
|
||||
var movieRepository = Substitute.For<IJellyfinMovieRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
|
||||
var library = new JellyfinLibrary { Id = 3, Name = "Movies" };
|
||||
|
||||
movieRepository.GetExistingMovies(library)
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "movie-1", Etag = "e1", State = MediaItemState.Normal },
|
||||
new() { ItemId = "movie-2", Etag = "e2", State = MediaItemState.Normal }
|
||||
});
|
||||
|
||||
// Left short-circuits the per-item metadata path; the id is still recorded as incoming
|
||||
movieRepository.GetOrAdd(library, Arg.Any<JellyfinMovie>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MediaItemScanResult<JellyfinMovie>>(BaseError.New("skip metadata in test")));
|
||||
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var scanner = new TestMovieLibraryScanner(scannerProxy)
|
||||
{
|
||||
MovieEntries =
|
||||
[
|
||||
new JellyfinMovie
|
||||
{
|
||||
ItemId = "movie-1",
|
||||
Etag = "e1",
|
||||
MovieMetadata = new List<MovieMetadata> { new() { Title = "Keeper" } }
|
||||
}
|
||||
],
|
||||
ProjectionFailures = 1
|
||||
};
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(
|
||||
movieRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await movieRepository.DidNotReceive().FlagFileNotFound(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>());
|
||||
}
|
||||
|
||||
// positive control for the test above: identical arrangement, zero failures => movie-2 IS swept.
|
||||
[Test]
|
||||
public async Task Partial_Deletion_With_No_Projection_Failures_Still_Sweeps()
|
||||
{
|
||||
var movieRepository = Substitute.For<IJellyfinMovieRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
|
||||
var library = new JellyfinLibrary { Id = 3, Name = "Movies" };
|
||||
|
||||
movieRepository.GetExistingMovies(library)
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "movie-1", Etag = "e1", State = MediaItemState.Normal },
|
||||
new() { ItemId = "movie-2", Etag = "e2", State = MediaItemState.Normal }
|
||||
});
|
||||
|
||||
movieRepository.GetOrAdd(library, Arg.Any<JellyfinMovie>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MediaItemScanResult<JellyfinMovie>>(BaseError.New("skip metadata in test")));
|
||||
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
// an unstubbed substitute returns null, which would NRE on ids.ToArray()
|
||||
movieRepository.FlagFileNotFound(Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>())
|
||||
.Returns(new List<int> { 7 });
|
||||
scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var scanner = new TestMovieLibraryScanner(scannerProxy)
|
||||
{
|
||||
MovieEntries =
|
||||
[
|
||||
new JellyfinMovie
|
||||
{
|
||||
ItemId = "movie-1",
|
||||
Etag = "e1",
|
||||
MovieMetadata = new List<MovieMetadata> { new() { Title = "Keeper" } }
|
||||
}
|
||||
],
|
||||
ProjectionFailures = 0
|
||||
};
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(
|
||||
movieRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await movieRepository.Received(1).FlagFileNotFound(
|
||||
library,
|
||||
Arg.Is<List<string>>(l => l.Count == 1 && l.Contains("movie-2")));
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal concrete subclass exposing the cleanup path. Driven with an empty incoming list, so none of
|
||||
@@ -80,10 +181,16 @@ public class MediaServerMovieLibraryScannerTests
|
||||
false,
|
||||
CancellationToken.None);
|
||||
|
||||
// #484: incoming movies plus how many projection failures the enumeration reports into the
|
||||
// counter the SCANNER created — this is what pins same-instance wiring end to end.
|
||||
public JellyfinMovie[] MovieEntries { get; init; } = [];
|
||||
public int ProjectionFailures { get; init; }
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures) => EmptyMovies();
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
Movies(MovieEntries, ProjectionFailures, projectionFailures);
|
||||
|
||||
protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId;
|
||||
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
|
||||
@@ -103,10 +210,22 @@ public class MediaServerMovieLibraryScannerTests
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<JellyfinMovie, int>> EmptyMovies()
|
||||
// mirrors production: failures are recorded DURING enumeration into the caller's counter
|
||||
private static async IAsyncEnumerable<Tuple<JellyfinMovie, int>> Movies(
|
||||
JellyfinMovie[] movies,
|
||||
int projectionFailureCount,
|
||||
MediaServerProjectionFailureCounter projectionFailures)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
for (var i = 0; i < projectionFailureCount; i++)
|
||||
{
|
||||
projectionFailures.RecordFailure();
|
||||
}
|
||||
|
||||
foreach (JellyfinMovie movie in movies)
|
||||
{
|
||||
yield return new Tuple<JellyfinMovie, int>(movie, movies.Length + projectionFailureCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,65 @@ public class MediaServerReconciliationGuardTests
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
// #484: the nested per-show season / per-season episode sweeps. They get the SAME projection-failure
|
||||
// predicate...
|
||||
[Test]
|
||||
public void Descendant_Sweep_Skips_And_Warns_On_A_Projection_Failure()
|
||||
{
|
||||
var logger = Substitute.For<ILogger>();
|
||||
|
||||
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 4, 5, 1)
|
||||
.ShouldBeFalse();
|
||||
|
||||
logger.Received(1).Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
// ...but deliberately NOT #477's empty-fetch branch. A per-parent empty is a plausible legitimate
|
||||
// state (every episode of a season really was deleted) with a bounded blast radius, and #476's
|
||||
// descendant cascade depends on it still sweeping. Importing that branch here would be a silent
|
||||
// behaviour change, which is why this is a separate entry point rather than a shared signature.
|
||||
[Test]
|
||||
public void Descendant_Sweep_Still_Runs_On_An_Empty_Per_Parent_Fetch()
|
||||
{
|
||||
var logger = Substitute.For<ILogger>();
|
||||
|
||||
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "season 1 of show Keeper episodes", 0, 5, 0)
|
||||
.ShouldBeTrue();
|
||||
|
||||
logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Descendant_Sweep_Runs_With_No_Failures_And_Is_A_Noop_When_Nothing_Exists()
|
||||
{
|
||||
var logger = Substitute.For<ILogger>();
|
||||
|
||||
// deliberate skips only => still sweeps (the STRM regression guard at the descendant level)
|
||||
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 3, 5, 0)
|
||||
.ShouldBeTrue();
|
||||
|
||||
// failures but nothing exists locally => the sweep flags nothing anyway, so don't warn
|
||||
MediaServerReconciliationGuard.ShouldFlagMissingDescendants(logger, "show Keeper seasons", 2, 0, 3)
|
||||
.ShouldBeTrue();
|
||||
|
||||
logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
// both refusals can be true at once (a fetch that dropped everything it returned); either is
|
||||
// sufficient, and the failure branch is reported because it names the actual cause.
|
||||
[Test]
|
||||
|
||||
@@ -185,6 +185,220 @@ public class MediaServerTelevisionLibraryScannerTests
|
||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// #484 finding 3: the public ScanLibrary creates the counter and must hand the SAME instance to
|
||||
// GetShowLibraryItems and to the sweep. A refactor that passed a fresh counter to the api client
|
||||
// would keep every other test green and silently kill the protection; this one goes red.
|
||||
[Test]
|
||||
public async Task Public_ScanLibrary_Joins_The_Api_Counter_To_The_Sweep()
|
||||
{
|
||||
var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };
|
||||
|
||||
televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "show-keep", State = MediaItemState.Normal },
|
||||
new() { ItemId = "show-6366", State = MediaItemState.Normal }
|
||||
});
|
||||
televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MediaItemScanResult<JellyfinShow>>(BaseError.New("skip metadata in test")));
|
||||
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var scanner = new TestTelevisionLibraryScanner(scannerProxy)
|
||||
{
|
||||
ShowEntries =
|
||||
[
|
||||
new JellyfinShow
|
||||
{
|
||||
ItemId = "show-keep",
|
||||
ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
|
||||
}
|
||||
],
|
||||
ShowProjectionFailures = 1
|
||||
};
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.ScanFromApi(
|
||||
televisionRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await televisionRepository.DidNotReceive().FlagFileNotFoundShows(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// #484: the per-show SEASON sweep. A projection failure is systematic, so this fires for every
|
||||
// show at once — the blast radius is the library's whole season tree plus, via #476, its episodes.
|
||||
[TestCase(1, false, TestName = "Season_Sweep_Is_Skipped_When_The_Season_Projection_Failed")]
|
||||
[TestCase(0, true, TestName = "Season_Sweep_Still_Runs_With_No_Projection_Failures")]
|
||||
public async Task Season_Sweep_Respects_Projection_Failures(int failures, bool expectFlag)
|
||||
{
|
||||
var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };
|
||||
|
||||
var show = new JellyfinShow
|
||||
{
|
||||
ItemId = "show-1",
|
||||
ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
|
||||
};
|
||||
|
||||
televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag> { new() { ItemId = "show-1", State = MediaItemState.Normal } });
|
||||
televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MediaItemScanResult<JellyfinShow>>(new MediaItemScanResult<JellyfinShow>(show)));
|
||||
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
// two seasons exist, only one comes back — a partial deletion at the season level
|
||||
televisionRepository.GetExistingSeasons(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "season-keep", State = MediaItemState.Normal },
|
||||
new() { ItemId = "season-gone", State = MediaItemState.Normal }
|
||||
});
|
||||
|
||||
// Left short-circuits the per-season path so ScanEpisodes is never entered
|
||||
televisionRepository.GetOrAdd(library, Arg.Any<JellyfinSeason>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MediaItemScanResult<JellyfinSeason>>(BaseError.New("skip season in test")));
|
||||
|
||||
// an unstubbed substitute returns null, which would NRE in the sweep's Concat
|
||||
televisionRepository.FlagFileNotFoundSeasons(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 200 });
|
||||
televisionRepository.FlagFileNotFoundEpisodesForSeasons(
|
||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 300 });
|
||||
televisionRepository.FlagFileNotFoundShows(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int>());
|
||||
televisionRepository.FlagFileNotFoundSeasonsForShows(
|
||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int>());
|
||||
scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var scanner = new TestTelevisionLibraryScanner(scannerProxy)
|
||||
{
|
||||
SeasonEntries = [new JellyfinSeason { ItemId = "season-keep", SeasonNumber = 1 }],
|
||||
SeasonProjectionFailures = failures
|
||||
};
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(
|
||||
televisionRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library,
|
||||
Shows(show));
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
if (expectFlag)
|
||||
{
|
||||
await televisionRepository.Received(1).FlagFileNotFoundSeasons(
|
||||
library,
|
||||
Arg.Is<List<string>>(l => l.Count == 1 && l.Contains("season-gone")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
else
|
||||
{
|
||||
await televisionRepository.DidNotReceive().FlagFileNotFoundSeasons(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
// #484: the per-season EPISODE sweep — the highest-stakes one. A ProjectToEpisode regression makes
|
||||
// every season enumerate zero episodes, so existing.Except([]) is the entire episode library.
|
||||
[TestCase(1, false, TestName = "Episode_Sweep_Is_Skipped_When_The_Episode_Projection_Failed")]
|
||||
[TestCase(0, true, TestName = "Episode_Sweep_Still_Runs_With_No_Projection_Failures")]
|
||||
public async Task Episode_Sweep_Respects_Projection_Failures(int failures, bool expectFlag)
|
||||
{
|
||||
var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };
|
||||
|
||||
var show = new JellyfinShow
|
||||
{
|
||||
ItemId = "show-1",
|
||||
ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
|
||||
};
|
||||
var season = new JellyfinSeason { ItemId = "season-1", SeasonNumber = 1 };
|
||||
|
||||
televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag> { new() { ItemId = "show-1", State = MediaItemState.Normal } });
|
||||
televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MediaItemScanResult<JellyfinShow>>(new MediaItemScanResult<JellyfinShow>(show)));
|
||||
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
// the season comes back, so the season sweep flags nothing and cannot mask the episode assertion
|
||||
televisionRepository.GetExistingSeasons(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag> { new() { ItemId = "season-1", State = MediaItemState.Normal } });
|
||||
televisionRepository.GetOrAdd(library, Arg.Any<JellyfinSeason>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MediaItemScanResult<JellyfinSeason>>(new MediaItemScanResult<JellyfinSeason>(season)));
|
||||
|
||||
// two episodes exist, only one comes back — a partial deletion at the episode level
|
||||
televisionRepository.GetExistingEpisodes(library, Arg.Any<JellyfinSeason>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "ep-keep", Etag = "e1", State = MediaItemState.Normal },
|
||||
new() { ItemId = "ep-gone", Etag = "e2", State = MediaItemState.Normal }
|
||||
});
|
||||
|
||||
// an unstubbed substitute returns null, which would NRE in the sweeps
|
||||
televisionRepository.FlagFileNotFoundEpisodes(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 300 });
|
||||
televisionRepository.FlagFileNotFoundSeasons(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int>());
|
||||
televisionRepository.FlagFileNotFoundEpisodesForSeasons(
|
||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int>());
|
||||
televisionRepository.FlagFileNotFoundShows(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int>());
|
||||
televisionRepository.FlagFileNotFoundSeasonsForShows(
|
||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int>());
|
||||
scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var scanner = new TestTelevisionLibraryScanner(scannerProxy)
|
||||
{
|
||||
SeasonEntries = [season],
|
||||
|
||||
// matching etag + missing local file => ShouldScanItem short-circuits before the metadata
|
||||
// path, but the id is already recorded as incoming
|
||||
EpisodeEntries = [new JellyfinEpisode { ItemId = "ep-keep", Etag = "e1" }],
|
||||
EpisodeProjectionFailures = failures
|
||||
};
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(
|
||||
televisionRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library,
|
||||
Shows(show));
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
if (expectFlag)
|
||||
{
|
||||
await televisionRepository.Received(1).FlagFileNotFoundEpisodes(
|
||||
library,
|
||||
Arg.Is<List<string>>(l => l.Count == 1 && l.Contains("ep-gone")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
else
|
||||
{
|
||||
await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodes(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// the season counter is a separate instance and stays clean either way
|
||||
await televisionRepository.DidNotReceive().FlagFileNotFoundSeasons(
|
||||
Arg.Any<JellyfinLibrary>(),
|
||||
Arg.Is<List<string>>(l => l.Count > 0),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<JellyfinShow, int>> EmptyShows()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
@@ -202,8 +416,10 @@ public class MediaServerTelevisionLibraryScannerTests
|
||||
}
|
||||
|
||||
// Minimal concrete subclass that exposes the abstract scanner's cleanup path. The incoming show list
|
||||
// is supplied directly (empty = "all shows removed"), so none of the per-item metadata members below
|
||||
// are ever invoked — they exist only to satisfy the abstract contract.
|
||||
// is supplied directly (empty = "all shows removed"). #484 extended it to also drive the NESTED season
|
||||
// and episode sweeps: SeasonEntries/EpisodeEntries supply those enumerations, and
|
||||
// SeasonProjectionFailures/EpisodeProjectionFailures make the enumeration report failures into the
|
||||
// counter the SCANNER handed it — so these tests also pin same-instance wiring at both nested levels.
|
||||
private sealed class TestTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
JellyfinConnectionParameters, JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||
JellyfinItemEtag>
|
||||
@@ -218,6 +434,17 @@ public class MediaServerTelevisionLibraryScannerTests
|
||||
{
|
||||
}
|
||||
|
||||
// #484: incoming shows for the top-level (public) ScanLibrary entry point, plus how many
|
||||
// projection failures that enumeration should report into the counter the scanner created.
|
||||
public JellyfinShow[] ShowEntries { get; init; } = [];
|
||||
public int ShowProjectionFailures { get; init; }
|
||||
|
||||
public JellyfinSeason[] SeasonEntries { get; init; } = [];
|
||||
public int SeasonProjectionFailures { get; init; }
|
||||
|
||||
public JellyfinEpisode[] EpisodeEntries { get; init; } = [];
|
||||
public int EpisodeProjectionFailures { get; init; }
|
||||
|
||||
public Task<Either<BaseError, Unit>> Scan(
|
||||
IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||
JellyfinItemEtag> televisionRepository,
|
||||
@@ -235,11 +462,26 @@ public class MediaServerTelevisionLibraryScannerTests
|
||||
false,
|
||||
CancellationToken.None);
|
||||
|
||||
// #484 finding 3: the PUBLIC entry point, which creates the counter itself and must hand the SAME
|
||||
// instance to both GetShowLibraryItems and the sweep. Nothing else exercises that join.
|
||||
public Task<Either<BaseError, Unit>> ScanFromApi(
|
||||
IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||
JellyfinItemEtag> televisionRepository,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
ScanLibrary(
|
||||
televisionRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
_ => string.Empty,
|
||||
false,
|
||||
CancellationToken.None);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
throw new NotSupportedException();
|
||||
Enumerate(ShowEntries, ShowProjectionFailures, projectionFailures);
|
||||
|
||||
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
|
||||
@@ -249,23 +491,46 @@ public class MediaServerTelevisionLibraryScannerTests
|
||||
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show) =>
|
||||
throw new NotSupportedException();
|
||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
Enumerate(SeasonEntries, SeasonProjectionFailures, projectionFailures);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
|
||||
JellyfinSeason season, bool isNewSeason) =>
|
||||
throw new NotSupportedException();
|
||||
JellyfinSeason season, bool isNewSeason,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
Enumerate(EpisodeEntries, EpisodeProjectionFailures, projectionFailures);
|
||||
|
||||
// mirrors production: failures are recorded DURING enumeration, into the counter the caller
|
||||
// supplied, and are only read after the enumeration completes.
|
||||
private static async IAsyncEnumerable<Tuple<T, int>> Enumerate<T>(
|
||||
T[] items,
|
||||
int projectionFailureCount,
|
||||
MediaServerProjectionFailureCounter projectionFailures)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
for (var i = 0; i < projectionFailureCount; i++)
|
||||
{
|
||||
projectionFailures.RecordFailure();
|
||||
}
|
||||
|
||||
foreach (T item in items)
|
||||
{
|
||||
yield return new Tuple<T, int>(item, items.Length + projectionFailureCount);
|
||||
}
|
||||
}
|
||||
|
||||
// returning None makes the base's UpdateMetadata a passthrough, so the per-item metadata path is
|
||||
// never entered and the nested sweeps can be driven without a full metadata harness.
|
||||
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinShow> result, JellyfinShow incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
Task.FromResult(Option<ShowMetadata>.None);
|
||||
|
||||
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinSeason> result, JellyfinSeason incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
Task.FromResult(Option<SeasonMetadata>.None);
|
||||
|
||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
|
||||
@@ -152,25 +152,29 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
protected override IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
|
||||
EmbyLibrary library,
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyShow show) =>
|
||||
EmbyShow show,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
_embyApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
show.ItemId);
|
||||
show.ItemId,
|
||||
projectionFailures);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||
EmbyLibrary library,
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyShow show,
|
||||
EmbySeason season,
|
||||
bool isNewSeason) =>
|
||||
bool isNewSeason,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
_embyApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
show.ItemId,
|
||||
season.ItemId);
|
||||
season.ItemId,
|
||||
projectionFailures);
|
||||
|
||||
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
|
||||
@@ -156,34 +156,40 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
JellyfinLibrary library,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinShow show) =>
|
||||
JellyfinShow show,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
_jellyfinApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.AuthorizationHeader,
|
||||
library,
|
||||
show.ItemId);
|
||||
show.ItemId,
|
||||
projectionFailures);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
JellyfinLibrary library,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinShow show,
|
||||
JellyfinSeason season,
|
||||
bool isNewSeason)
|
||||
bool isNewSeason,
|
||||
MediaServerProjectionFailureCounter projectionFailures)
|
||||
{
|
||||
// both variants must report into the SAME counter — they feed one episode sweep
|
||||
if (isNewSeason)
|
||||
{
|
||||
return _jellyfinApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.AuthorizationHeader,
|
||||
library,
|
||||
season.ItemId);
|
||||
season.ItemId,
|
||||
projectionFailures);
|
||||
}
|
||||
|
||||
return _jellyfinApiClient.GetEpisodeLibraryItemsWithoutPeople(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.AuthorizationHeader,
|
||||
library,
|
||||
season.ItemId);
|
||||
season.ItemId,
|
||||
projectionFailures);
|
||||
}
|
||||
|
||||
protected override async Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
|
||||
@@ -32,6 +32,15 @@ namespace ErsatzTV.Scanner.Core.Metadata;
|
||||
// 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
|
||||
{
|
||||
@@ -42,17 +51,13 @@ internal static class MediaServerReconciliationGuard
|
||||
int existingCount,
|
||||
int projectionFailureCount = 0)
|
||||
{
|
||||
if (projectionFailureCount > 0 && existingCount > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Media server library {Library} 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",
|
||||
libraryName,
|
||||
projectionFailureCount,
|
||||
if (RefuseForProjectionFailures(
|
||||
logger,
|
||||
$"library {libraryName}",
|
||||
incomingCount,
|
||||
existingCount);
|
||||
existingCount,
|
||||
projectionFailureCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -69,4 +74,47 @@ internal static class MediaServerReconciliationGuard
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="scope">
|
||||
/// Names the parent whose descendants are being swept, e.g. <c>show "Sesame Street" seasons</c>.
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
foreach (MediaItemScanResult<TShow> result in maybeShow.RightToSeq())
|
||||
{
|
||||
// #484: one counter per season enumeration, i.e. per show
|
||||
var seasonProjectionFailures = new MediaServerProjectionFailureCounter();
|
||||
|
||||
Either<BaseError, Unit> scanResult = await ScanSeasons(
|
||||
televisionRepository,
|
||||
library,
|
||||
@@ -148,7 +151,12 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
result.Item,
|
||||
result.IsUpdated,
|
||||
connectionParameters,
|
||||
GetSeasonLibraryItems(library, connectionParameters, result.Item),
|
||||
GetSeasonLibraryItems(
|
||||
library,
|
||||
connectionParameters,
|
||||
result.Item,
|
||||
seasonProjectionFailures),
|
||||
seasonProjectionFailures,
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -251,17 +259,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
false,
|
||||
cancellationToken);
|
||||
|
||||
// #484: see GetShowLibraryItems. These feed the nested per-show / per-season sweeps, which are
|
||||
// guarded against projection failures (though NOT against a per-parent empty fetch — see
|
||||
// MediaServerReconciliationGuard.ShouldFlagMissingDescendants).
|
||||
protected abstract IAsyncEnumerable<Tuple<TSeason, int>> GetSeasonLibraryItems(
|
||||
TLibrary library,
|
||||
TConnectionParameters connectionParameters,
|
||||
TShow show);
|
||||
TShow show,
|
||||
MediaServerProjectionFailureCounter projectionFailures);
|
||||
|
||||
protected abstract IAsyncEnumerable<Tuple<TEpisode, int>> GetEpisodeLibraryItems(
|
||||
TLibrary library,
|
||||
TConnectionParameters connectionParameters,
|
||||
TShow show,
|
||||
TSeason season,
|
||||
bool isNewSeason);
|
||||
bool isNewSeason,
|
||||
MediaServerProjectionFailureCounter projectionFailures);
|
||||
|
||||
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
TConnectionParameters connectionParameters,
|
||||
@@ -309,6 +322,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
EpisodeMetadata fullMetadata,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// a human-readable name for the guard's warning; falls back to the server item id when a show has no
|
||||
// metadata (possible for a freshly-added row), so this can never throw inside the sweep.
|
||||
private string ShowScope(TShow show) =>
|
||||
Optional(show.ShowMetadata).Flatten().HeadOrNone()
|
||||
.Map(m => m.Title)
|
||||
.Filter(t => !string.IsNullOrWhiteSpace(t))
|
||||
.IfNone(() => MediaServerItemId(show));
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
||||
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
|
||||
TLibrary library,
|
||||
@@ -317,6 +338,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
bool showIsUpdated,
|
||||
TConnectionParameters connectionParameters,
|
||||
IAsyncEnumerable<Tuple<TSeason, int>> seasonEntries,
|
||||
MediaServerProjectionFailureCounter projectionFailures,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -356,6 +378,9 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
foreach (MediaItemScanResult<TSeason> result in maybeSeason.RightToSeq())
|
||||
{
|
||||
// #484: one counter per episode enumeration, i.e. per season
|
||||
var episodeProjectionFailures = new MediaServerProjectionFailureCounter();
|
||||
|
||||
Either<BaseError, Unit> scanResult = await ScanEpisodes(
|
||||
televisionRepository,
|
||||
library,
|
||||
@@ -364,7 +389,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
showIsUpdated,
|
||||
result.Item,
|
||||
connectionParameters,
|
||||
GetEpisodeLibraryItems(library, connectionParameters, show, result.Item, result.IsAdded),
|
||||
GetEpisodeLibraryItems(
|
||||
library,
|
||||
connectionParameters,
|
||||
show,
|
||||
result.Item,
|
||||
result.IsAdded,
|
||||
episodeProjectionFailures),
|
||||
episodeProjectionFailures,
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -397,18 +429,29 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
}
|
||||
|
||||
// trash seasons that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds, cancellationToken);
|
||||
|
||||
// #476: a season gone from the media server (while its show remains) is absent from the incoming
|
||||
// list, so the per-season loop never visits it and the episode sweep in ScanEpisodes never runs
|
||||
// for it. Cascade the flag to its episodes.
|
||||
List<int> episodeIds = await televisionRepository.FlagFileNotFoundEpisodesForSeasons(ids, cancellationToken);
|
||||
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.Concat(episodeIds).ToArray(), cancellationToken))
|
||||
// #484: a projection failure is systematic, not per-parent — one bad code path drops seasons for
|
||||
// EVERY show, so this sweep would flag the library's entire season tree (and, via the #476
|
||||
// cascade below, its episodes) in one scan. A per-parent EMPTY fetch is still swept, unchanged.
|
||||
if (MediaServerReconciliationGuard.ShouldFlagMissingDescendants(
|
||||
_logger,
|
||||
$"show {ShowScope(show)} seasons",
|
||||
incomingItemIds.Count,
|
||||
existingSeasons.Count,
|
||||
projectionFailures.Count))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
// trash seasons that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds, cancellationToken);
|
||||
|
||||
// #476: a season gone from the media server (while its show remains) is absent from the incoming
|
||||
// list, so the per-season loop never visits it and the episode sweep in ScanEpisodes never runs
|
||||
// for it. Cascade the flag to its episodes.
|
||||
List<int> episodeIds = await televisionRepository.FlagFileNotFoundEpisodesForSeasons(ids, cancellationToken);
|
||||
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.Concat(episodeIds).ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
@@ -423,6 +466,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
TSeason season,
|
||||
TConnectionParameters connectionParameters,
|
||||
IAsyncEnumerable<Tuple<TEpisode, int>> episodeEntries,
|
||||
MediaServerProjectionFailureCounter projectionFailures,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -570,12 +614,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
}
|
||||
|
||||
// trash episodes that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds, cancellationToken);
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
// #484: the highest-stakes sweep in the scanner. A ProjectToEpisode regression fires for every
|
||||
// episode of every season, so each season enumerates zero and existing.Except([]) is the entire
|
||||
// episode library — permanently deleted by EmptyTrashHandler. A per-parent EMPTY fetch with no
|
||||
// failures is still swept, unchanged (#477 scoped that case out on purpose).
|
||||
if (MediaServerReconciliationGuard.ShouldFlagMissingDescendants(
|
||||
_logger,
|
||||
$"season {season.SeasonNumber} of show {ShowScope(show)} episodes",
|
||||
incomingItemIds.Count,
|
||||
existingEpisodes.Count,
|
||||
projectionFailures.Count))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
// trash episodes that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds, cancellationToken);
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -244,10 +244,13 @@ public partial class PlexTelevisionLibraryScanner :
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
// #484: as with shows, Plex's season/episode projections return bare entities with no catch, so a
|
||||
// bad item throws and unwinds the scan instead of being silently dropped — nothing to count here.
|
||||
protected override IAsyncEnumerable<Tuple<PlexSeason, int>> GetSeasonLibraryItems(
|
||||
PlexLibrary library,
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexShow show) =>
|
||||
PlexShow show,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
_plexServerApiClient.GetShowSeasons(
|
||||
library,
|
||||
show,
|
||||
@@ -259,7 +262,8 @@ public partial class PlexTelevisionLibraryScanner :
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexShow show,
|
||||
PlexSeason season,
|
||||
bool isNewSeason) =>
|
||||
bool isNewSeason,
|
||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||
_plexServerApiClient.GetSeasonEpisodes(
|
||||
library,
|
||||
season,
|
||||
|
||||
@@ -397,6 +397,84 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
// alone would only ever reach music videos ADDED after it — an EXISTING item whose album/track is set or
|
||||
// corrected in Jellyfin would keep its stale value forever. Same shape as the #497 collection bug, one
|
||||
// layer up. Non-vacuous: reverting the two assignments in UpdateMetadata fails both assertions below.
|
||||
// #484: music videos are HARD-deleted by this sweep (there is no per-item FileNotFound seam), so a
|
||||
// silently dropped projection is immediately destructive here. Parameterised with its own positive
|
||||
// control: identical arrangement, only the failure count differs.
|
||||
[TestCase(1, false, TestName = "MusicVideo_Sweep_Is_Skipped_When_A_Projection_Failed")]
|
||||
[TestCase(0, true, TestName = "MusicVideo_Sweep_Still_Runs_With_No_Projection_Failures")]
|
||||
public async Task MusicVideo_Sweep_Respects_Projection_Failures(int failures, bool expectDelete)
|
||||
{
|
||||
JellyfinLibrary library = BuildLibrary(1, "/data/music");
|
||||
var connectionParameters = new JellyfinConnectionParameters("http://jellyfin", "api-key", 1);
|
||||
const string KeepPath = "/data/music/artist1/song1.mkv";
|
||||
const string GonePath = "/data/music/artist1/song2.mkv";
|
||||
|
||||
var existing = new MusicVideo
|
||||
{
|
||||
Id = 7,
|
||||
ArtistId = 3,
|
||||
MediaVersions =
|
||||
[
|
||||
new MediaVersion
|
||||
{
|
||||
MediaFiles = [new MediaFile { Path = KeepPath }],
|
||||
Streams = []
|
||||
}
|
||||
],
|
||||
MusicVideoMetadata =
|
||||
[
|
||||
new MusicVideoMetadata { Id = 11, Genres = [], Tags = [], Studios = [], Artists = [] }
|
||||
]
|
||||
};
|
||||
|
||||
var artistRepository = Substitute.For<IArtistRepository>();
|
||||
artistRepository.GetArtistByMetadata(Arg.Any<int>(), Arg.Any<ArtistMetadata>())
|
||||
.Returns(Some(new Artist { Id = 3, ArtistMetadata = new List<ArtistMetadata>() }));
|
||||
artistRepository.DeleteEmptyArtists(Arg.Any<LibraryPath>()).Returns(new List<int>());
|
||||
|
||||
var musicVideoRepository = Substitute.For<IMusicVideoRepository>();
|
||||
musicVideoRepository
|
||||
.GetOrAdd(Arg.Any<Artist>(), Arg.Any<LibraryPath>(), Arg.Any<LibraryFolder>(), Arg.Any<string>())
|
||||
.Returns(Right<BaseError, MediaItemScanResult<MusicVideo>>(
|
||||
new MediaItemScanResult<MusicVideo>(existing) { IsAdded = false }));
|
||||
|
||||
// two rows exist locally, only one comes back — a genuine partial deletion, so #477's empty
|
||||
// branch cannot mask the assertion
|
||||
musicVideoRepository.FindMusicVideoPaths(Arg.Any<LibraryPath>())
|
||||
.Returns(new List<string> { KeepPath, GonePath }.AsEnumerable());
|
||||
musicVideoRepository.DeleteByPath(Arg.Any<LibraryPath>(), Arg.Any<string>()).Returns(new List<int> { 7 });
|
||||
|
||||
var metadataRepository = Substitute.For<IMetadataRepository>();
|
||||
metadataRepository.Update(Arg.Any<ErsatzTV.Core.Domain.Metadata>()).Returns(true);
|
||||
metadataRepository.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>())
|
||||
.Returns(false);
|
||||
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
libraryRepository.GetParentFolderId(Arg.Any<LibraryPath>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.None);
|
||||
libraryRepository.GetOrAddFolder(Arg.Any<LibraryPath>(), Arg.Any<Option<int>>(), Arg.Any<string>())
|
||||
.Returns(new LibraryFolder { Id = 5, Path = "/data/music/artist1" });
|
||||
|
||||
JellyfinMusicVideoLibraryScanner scanner = BuildScannerWith(
|
||||
FakeApiWithProjectionFailures(failures, () => BuildIncoming(KeepPath, "Artist 1", "Song 1")),
|
||||
artistRepository,
|
||||
musicVideoRepository,
|
||||
libraryRepository,
|
||||
metadataRepository);
|
||||
|
||||
(await scanner.ScanLibrary(connectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
if (expectDelete)
|
||||
{
|
||||
await musicVideoRepository.Received(1).DeleteByPath(Arg.Any<LibraryPath>(), GonePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
await musicVideoRepository.DidNotReceive().DeleteByPath(Arg.Any<LibraryPath>(), Arg.Any<string>());
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Update_Album_And_Track_On_Rescan_Of_Existing_Item()
|
||||
{
|
||||
@@ -671,6 +749,44 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
return (scanner, scannerProxy);
|
||||
}
|
||||
|
||||
// #484 finding 3: the api client records projection failures into the counter the SCANNER created and
|
||||
// handed it. This fake does the same, so the test exercises the same-instance join that
|
||||
// JellyfinMusicVideoLibraryScanner.ScanLibrary makes between GetMusicVideoLibraryItems and the sweep.
|
||||
private static IJellyfinApiClient FakeApiWithProjectionFailures(
|
||||
int projectionFailureCount,
|
||||
params Func<MusicVideo>[] items)
|
||||
{
|
||||
var apiClient = Substitute.For<IJellyfinApiClient>();
|
||||
apiClient.GetMusicVideoLibraryItems(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<JellyfinLibrary>(),
|
||||
Arg.Any<MediaServerProjectionFailureCounter>())
|
||||
.Returns(ci => ItemsWithFailures(
|
||||
items,
|
||||
projectionFailureCount,
|
||||
ci.ArgAt<MediaServerProjectionFailureCounter>(3)));
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<MusicVideo, int>> ItemsWithFailures(
|
||||
Func<MusicVideo>[] items,
|
||||
int projectionFailureCount,
|
||||
MediaServerProjectionFailureCounter projectionFailures)
|
||||
{
|
||||
for (var i = 0; i < projectionFailureCount; i++)
|
||||
{
|
||||
projectionFailures.RecordFailure();
|
||||
}
|
||||
|
||||
foreach (Func<MusicVideo> item in items)
|
||||
{
|
||||
yield return new Tuple<MusicVideo, int>(item(), items.Length + projectionFailureCount);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Each Func builds a fresh MusicVideo so the async stream can be re-enumerated across ScanLibrary calls
|
||||
// (an IAsyncEnumerable iterator is single-use, and the scanner mutates the incoming item).
|
||||
private static IJellyfinApiClient FakeApi(params Func<MusicVideo>[] items)
|
||||
|
||||
+55
-9
@@ -3779,8 +3779,8 @@ measuring is real signal; it just needs its own actor convention and wasn't wort
|
||||
change.
|
||||
## 2026-07-25 — A media-server sweep also refuses when the api client silently dropped items whose projection threw; the ratio threshold is rejected (#484)
|
||||
`key: scan.projection-failure-sweep-guard` · `status: active` · `since: 2026-07-25` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** `MediaServerReconciliationGuard.ShouldFlagMissing` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set; deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred.
|
||||
**Signals:** projection failure, silently dropped item, deferred ratio threshold rejected, STRM skip vs failure, library sweep anti-nuke follow-up · paths: `MediaServerProjectionResult`, `MediaServerProjectionFailureCounter`, `MediaServerReconciliationGuard`, `JellyfinApiClient.GetPagedLibraryItems`, `EmbyApiClient.GetPagedLibraryContents` · issues: #484, #477, #476
|
||||
**Rule:** `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred.
|
||||
**Signals:** projection failure, silently dropped item, deferred ratio threshold rejected, STRM skip vs failure, library sweep anti-nuke follow-up, nested season/episode sweep guard · paths: `MediaServerProjectionResult`, `MediaServerProjectionFailureCounter`, `MediaServerReconciliationGuard`, `JellyfinApiClient.GetPagedLibraryItems`, `EmbyApiClient.GetPagedLibraryContents` · issues: #484, #477, #476
|
||||
**Mechanics:** `MediaServerReconciliationGuardTests` policy table · `JellyfinApiClientTests.ProjectionFailureCounter` · `MediaServerTelevisionLibraryScannerTests.Projection_Failure_Suppresses_The_Partial_Deletion_Sweep`
|
||||
|
||||
This resolves both items the `scan.zero-item-fetch-guard` record (#477) left deferred. That record's rule
|
||||
@@ -3820,19 +3820,53 @@ This is a **second, independent** refusal on the same guard, plus a decision not
|
||||
scanner reads the count through an **optional trailing parameter** on only the library-level methods
|
||||
that actually feed a sweep (`IJellyfinApiClient.GetMovieLibraryItems` /
|
||||
`GetMusicVideoLibraryItems` / `GetShowLibraryItemsWithoutPeople`, `IEmbyApiClient.GetMovieLibraryItems`
|
||||
/ `GetShowLibraryItems`), so every other call site is untouched. A library that cannot be resolved
|
||||
(`Option<TLibrary>.None` inside a mapper) is treated as a **failure**, not a skip — fail closed.
|
||||
/ `GetShowLibraryItems`) **and** the nested season/episode ones (`GetSeasonLibraryItems`,
|
||||
`GetEpisodeLibraryItems`, `GetEpisodeLibraryItemsWithoutPeople`), so every other call site is untouched.
|
||||
A library that cannot be resolved (`Option<TLibrary>.None` inside a mapper) is classed as a **failure**
|
||||
rather than a skip; that branch is defensive and currently **unreachable** (every counted path passes a
|
||||
concrete library, and the only `None` caller — collections — does not route through it), kept only so
|
||||
the default classification is right if a future caller does pass `None`.
|
||||
- **The counter is per-enumeration state, never ambient.** `MediaServerProjectionFailureCounter` is
|
||||
created by the scanner that owns the sweep, handed to the single enumeration whose result it will
|
||||
diff, and read only after that enumeration completes. It is never a field on an api client (those are
|
||||
long-lived and shared) and never static, so concurrent scans of different libraries cannot leak
|
||||
failures into each other's sweep decision; increments are interlocked so a paginator that ever fans
|
||||
out stays correct.
|
||||
- **Wired into the four real sweep call sites**, matching #477's scope exactly: the three library-level
|
||||
base scanners (`MediaServerMovieLibraryScanner`, `MediaServerTelevisionLibraryScanner`,
|
||||
`MediaServerOtherVideoLibraryScanner`) and `JellyfinMusicVideoLibraryScanner`. The nested TV
|
||||
season/episode sweeps stay unguarded for the reasons in `scan.zero-item-fetch-guard`, and
|
||||
- **Wired into all six sweeps, not #477's four.** The four library-level ones — the three base scanners
|
||||
(`MediaServerMovieLibraryScanner`, `MediaServerTelevisionLibraryScanner`,
|
||||
`MediaServerOtherVideoLibraryScanner`) plus `JellyfinMusicVideoLibraryScanner` — **and** the two nested
|
||||
TV ones, `ScanSeasons`' `FlagFileNotFoundSeasons` and `ScanEpisodes`' `FlagFileNotFoundEpisodes`.
|
||||
`ScanLibraryWithoutCleanup` (single-show rescan) passes no counter because it runs no sweep.
|
||||
- **The nested season/episode sweeps get the failure refusal but NOT #477's empty-fetch branch.**
|
||||
`scan.zero-item-fetch-guard` left them unguarded because "the blast radius is one show's seasons / one
|
||||
season's episodes, not the whole library." That reasoning is sound for a per-parent **empty fetch** — a
|
||||
plausible legitimate state, bounded to one parent — but it does **not** transfer to a projection
|
||||
failure, which is systematic by construction: one bad code path fires on every parent, so a
|
||||
`ProjectToEpisode` regression makes *every* season enumerate zero episodes and
|
||||
`existing.Except([])` sweeps the **entire** episode library in a single scan, which `EmptyTrashHandler`
|
||||
then deletes permanently. Same shape for `ProjectToSeason` plus #476's cascade. So the nested sweeps
|
||||
call `MediaServerReconciliationGuard.ShouldFlagMissingDescendants` — the **same class and the same
|
||||
private failure predicate** as `ShouldFlagMissing`, deliberately without the empty-fetch branch. A
|
||||
separate entry point rather than reusing `ShouldFlagMissing` wholesale, because importing #477's
|
||||
zero-count branch here would silently change per-parent behaviour and break #476's cascade, which
|
||||
depends on an emptied parent still sweeping. One class still owns the invariant; only the empty-fetch
|
||||
policy differs, and the difference is pinned by a test.
|
||||
- **Known residual: a mass *Skip* is not covered, by design, and one skip site is response-shape
|
||||
dependent.** The whole split rests on skips being permanent and item-intrinsic. Emby's
|
||||
`ProjectToMovie` guard (`MediaSources is null || Count == 0`) does not fully satisfy that: it is
|
||||
response-shape dependent, so a Refit/DTO drift or a `fields`-parameter regression would present as a
|
||||
mass **Skip**, which by construction leaves the sweep enabled. A *total* such regression is caught by
|
||||
#477's zero-incoming branch; a *partial* one (say 1,200 incoming vs 2,000 existing, zero failures)
|
||||
would sweep 800 healthy movies into `FileNotFound`. Plex's pre-projection
|
||||
`.Filter(m => m.Media.Count > 0 …)` is the same class. This is stated rather than engineered around:
|
||||
re-classifying shape-dependent skips as failures would reintroduce exactly the STRM-style permanent
|
||||
suppression the split exists to prevent. Treat it as the known next question if a mass-skip incident
|
||||
ever occurs.
|
||||
- **Log-contract note.** When both refusals apply, only the #484 "silently dropped …" warning is emitted,
|
||||
not #477's "returned zero items" line — the failure names the actual cause, and emitting both would
|
||||
imply two independent problems. Operator alerting that greps the #477 string will not fire in that
|
||||
case. The message property is `{Scope}` (e.g. `library Movies`, `show Keeper seasons`,
|
||||
`season 1 of show Keeper episodes`), not `{Library}`, so one alert pattern covers all six sweeps.
|
||||
- **Plex reports zero failures because it has none.** `PlexServerApiClient`'s movie/show/other-video
|
||||
projections return a bare entity with no `Option` and no `catch`, so a bad item throws and unwinds the
|
||||
whole scan — the pre-existing "protection by accident of control flow" that #477 named. The Plex
|
||||
@@ -3848,4 +3882,16 @@ This is a **second, independent** refusal on the same guard, plus a decision not
|
||||
instance keep separate counts. `MediaServerTelevisionLibraryScannerTests
|
||||
.Projection_Failure_Suppresses_The_Partial_Deletion_Sweep` is the integration pair to #476's cascade
|
||||
test: identical arrangement, only the counter differs, so the cascade test is the positive control
|
||||
that keeps the new test from passing vacuously.
|
||||
that keeps the new test from passing vacuously. The nested levels get
|
||||
`Season_Sweep_Respects_Projection_Failures` and `Episode_Sweep_Respects_Projection_Failures`, each
|
||||
parameterised with its own zero-failure positive control, plus guard tests asserting the descendant
|
||||
entry point still sweeps a per-parent **empty** fetch (the #477-scope invariant) and still sweeps when
|
||||
only deliberate skips shortened the incoming set.
|
||||
- **Tests pin the JOIN, not just the two halves.** A client test proving the counter fills, plus a
|
||||
scanner test proving the guard honours a counter it is handed, would both stay green if a refactor
|
||||
handed the api client a *fresh* counter while the sweep read the local one — silently killing the
|
||||
protection. `MediaServerMovieLibraryScannerTests.Projection_Failure_From_The_Api_Suppresses_The_Sweep_
|
||||
End_To_End`, `MediaServerTelevisionLibraryScannerTests.Public_ScanLibrary_Joins_The_Api_Counter_To_The_
|
||||
Sweep`, the two nested TV cases, and `JellyfinMusicVideoLibraryScannerTests
|
||||
.MusicVideo_Sweep_Respects_Projection_Failures` all drive the real `ScanLibrary` entry point and record
|
||||
the failure from *inside* the enumeration, so same-instance wiring is what makes them pass.
|
||||
|
||||
@@ -114,7 +114,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](../decisions.md#2026-07-20--ilibraryrepositorygetoraddfolder-resolves-the-folder-from-the-db-not-the-callers-librarypathlibraryfolders-navigation-488) |
|
||||
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](../decisions.md#2026-07-20-489--jellyfin-mixed-content-libraries-map-to-one-library-holding-many-kinds) |
|
||||
| `scan.musicvideo-reconciliation` | `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity. | 2026-07-20 | [link](../decisions.md#2026-07-20--jellyfinmusicvideolibraryscanner-reconciles-by-library-scoped-path-diff--hard-delete-not-server-itemid-soft-trash-494) |
|
||||
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard.ShouldFlagMissing` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set; deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](../decisions.md#2026-07-25--a-media-server-sweep-also-refuses-when-the-api-client-silently-dropped-items-whose-projection-threw-the-ratio-threshold-is-rejected-484) |
|
||||
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](../decisions.md#2026-07-25--a-media-server-sweep-also-refuses-when-the-api-client-silently-dropped-items-whose-projection-threw-the-ratio-threshold-is-rejected-484) |
|
||||
| `scan.zero-item-fetch-guard` | A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion. | 2026-07-19 | [link](../decisions.md#2026-07-19--a-media-server-library-sweep-refuses-to-flag-when-a-successful-fetch-returns-zero-items-rather-than-nuking-the-whole-library-477) |
|
||||
| `sched.auto-tune-foundation` | Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating. | 2026-07-16 | [link](../decisions.md#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69) |
|
||||
| `sched.autotune-detailpanel-members` | The Auto-Tune DetailPanel's per-channel content-source list is a live `ISearchIndex.Search` roll-up through the server-owned `AutoTuneAxisMap.GenerateQuery`, not an EF distinct+count query, so the preview matches exactly what the built channel's SmartCollection will contain. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384) |
|
||||
|
||||
Reference in New Issue
Block a user