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,
|
EmbyLibrary library,
|
||||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
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(
|
IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
|
||||||
string address,
|
string address,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
EmbyLibrary library,
|
EmbyLibrary library,
|
||||||
string showId);
|
string showId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||||
|
|
||||||
IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||||
string address,
|
string address,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
EmbyLibrary library,
|
EmbyLibrary library,
|
||||||
string showId,
|
string showId,
|
||||||
string seasonId);
|
string seasonId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||||
|
|
||||||
IAsyncEnumerable<Tuple<EmbyCollection, int>> GetCollectionLibraryItems(string address, string apiKey);
|
IAsyncEnumerable<Tuple<EmbyCollection, int>> GetCollectionLibraryItems(string address, string apiKey);
|
||||||
|
|
||||||
|
|||||||
@@ -30,23 +30,28 @@ public interface IJellyfinApiClient
|
|||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
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(
|
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||||
string address,
|
string address,
|
||||||
string authorizationHeader,
|
string authorizationHeader,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
string showId);
|
string showId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||||
|
|
||||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||||
string address,
|
string address,
|
||||||
string authorizationHeader,
|
string authorizationHeader,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
string seasonId);
|
string seasonId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||||
|
|
||||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
||||||
string address,
|
string address,
|
||||||
string authorizationHeader,
|
string authorizationHeader,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
string seasonId);
|
string seasonId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||||
|
|
||||||
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
||||||
string address,
|
string address,
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ public class EmbyApiClient : IEmbyApiClient
|
|||||||
string address,
|
string address,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
EmbyLibrary library,
|
EmbyLibrary library,
|
||||||
string showId) => GetPagedLibraryContents(
|
string showId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null) => GetPagedLibraryContents(
|
||||||
address,
|
address,
|
||||||
library,
|
library,
|
||||||
showId,
|
showId,
|
||||||
@@ -116,14 +117,16 @@ public class EmbyApiClient : IEmbyApiClient
|
|||||||
itemId,
|
itemId,
|
||||||
startIndex: skip,
|
startIndex: skip,
|
||||||
limit: pageSize),
|
limit: pageSize),
|
||||||
(_, item) => ProjectToSeason(item));
|
(_, item) => ProjectToSeason(item),
|
||||||
|
projectionFailures);
|
||||||
|
|
||||||
public IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
public IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||||
string address,
|
string address,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
EmbyLibrary library,
|
EmbyLibrary library,
|
||||||
string showId,
|
string showId,
|
||||||
string seasonId) => GetPagedLibraryContents(
|
string seasonId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null) => GetPagedLibraryContents(
|
||||||
address,
|
address,
|
||||||
library,
|
library,
|
||||||
seasonId,
|
seasonId,
|
||||||
@@ -133,7 +136,8 @@ public class EmbyApiClient : IEmbyApiClient
|
|||||||
seasonId,
|
seasonId,
|
||||||
startIndex: skip,
|
startIndex: skip,
|
||||||
limit: pageSize),
|
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)
|
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 —
|
// #484: defensive, and currently UNREACHABLE — every counted path passes a concrete library, and the
|
||||||
// it must never look like a clean "this item was deleted upstream" to a reconciliation sweep.
|
// 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>(
|
private static MediaServerProjectionResult<TItem> WithLibrary<TItem>(
|
||||||
Option<EmbyLibrary> maybeLibrary,
|
Option<EmbyLibrary> maybeLibrary,
|
||||||
Func<EmbyLibrary, MediaServerProjectionResult<TItem>> project) =>
|
Func<EmbyLibrary, MediaServerProjectionResult<TItem>> project) =>
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ public class JellyfinApiClient : IJellyfinApiClient
|
|||||||
string address,
|
string address,
|
||||||
string authorizationHeader,
|
string authorizationHeader,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
string showId) =>
|
string showId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null) =>
|
||||||
GetPagedLibraryItems(
|
GetPagedLibraryItems(
|
||||||
"JF Seasons",
|
"JF Seasons",
|
||||||
address,
|
address,
|
||||||
@@ -149,13 +150,15 @@ public class JellyfinApiClient : IJellyfinApiClient
|
|||||||
showId,
|
showId,
|
||||||
startIndex: skip,
|
startIndex: skip,
|
||||||
limit: pageSize),
|
limit: pageSize),
|
||||||
(_, item) => ProjectToSeason(item));
|
(_, item) => ProjectToSeason(item),
|
||||||
|
projectionFailures);
|
||||||
|
|
||||||
public IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
public IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||||
string address,
|
string address,
|
||||||
string authorizationHeader,
|
string authorizationHeader,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
string seasonId) =>
|
string seasonId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null) =>
|
||||||
GetPagedLibraryItems(
|
GetPagedLibraryItems(
|
||||||
"JF Episodes*",
|
"JF Episodes*",
|
||||||
address,
|
address,
|
||||||
@@ -167,13 +170,15 @@ public class JellyfinApiClient : IJellyfinApiClient
|
|||||||
seasonId,
|
seasonId,
|
||||||
startIndex: skip,
|
startIndex: skip,
|
||||||
limit: pageSize),
|
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(
|
public IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
||||||
string address,
|
string address,
|
||||||
string authorizationHeader,
|
string authorizationHeader,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
string seasonId) =>
|
string seasonId,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures = null) =>
|
||||||
GetPagedLibraryItems(
|
GetPagedLibraryItems(
|
||||||
"JF Episodes",
|
"JF Episodes",
|
||||||
address,
|
address,
|
||||||
@@ -185,7 +190,8 @@ public class JellyfinApiClient : IJellyfinApiClient
|
|||||||
seasonId,
|
seasonId,
|
||||||
startIndex: skip,
|
startIndex: skip,
|
||||||
limit: pageSize),
|
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(
|
public IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
||||||
string address,
|
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 —
|
// #484: defensive, and currently UNREACHABLE — every counted path passes a concrete library, and the
|
||||||
// it must never look like a clean "this item was deleted upstream" to a reconciliation sweep.
|
// 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>(
|
private static MediaServerProjectionResult<TItem> WithLibrary<TItem>(
|
||||||
Option<JellyfinLibrary> maybeLibrary,
|
Option<JellyfinLibrary> maybeLibrary,
|
||||||
Func<JellyfinLibrary, MediaServerProjectionResult<TItem>> project) =>
|
Func<JellyfinLibrary, MediaServerProjectionResult<TItem>> project) =>
|
||||||
|
|||||||
@@ -51,6 +51,107 @@ public class MediaServerMovieLibraryScannerTests
|
|||||||
await scannerProxy.DidNotReceive().ReindexMediaItems(
|
await scannerProxy.DidNotReceive().ReindexMediaItems(
|
||||||
Arg.Any<int[]>(), Arg.Any<CancellationToken>());
|
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
|
// Minimal concrete subclass exposing the cleanup path. Driven with an empty incoming list, so none of
|
||||||
@@ -80,10 +181,16 @@ public class MediaServerMovieLibraryScannerTests
|
|||||||
false,
|
false,
|
||||||
CancellationToken.None);
|
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(
|
protected override IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
|
||||||
JellyfinConnectionParameters connectionParameters,
|
JellyfinConnectionParameters connectionParameters,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
MediaServerProjectionFailureCounter projectionFailures) => EmptyMovies();
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
|
Movies(MovieEntries, ProjectionFailures, projectionFailures);
|
||||||
|
|
||||||
protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId;
|
protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId;
|
||||||
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
|
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
|
||||||
@@ -103,10 +210,22 @@ public class MediaServerMovieLibraryScannerTests
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
throw new NotSupportedException();
|
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;
|
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>>());
|
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
|
// 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.
|
// sufficient, and the failure branch is reported because it names the actual cause.
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -185,6 +185,220 @@ public class MediaServerTelevisionLibraryScannerTests
|
|||||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
|
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()
|
private static async IAsyncEnumerable<Tuple<JellyfinShow, int>> EmptyShows()
|
||||||
{
|
{
|
||||||
await Task.CompletedTask;
|
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
|
// 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
|
// is supplied directly (empty = "all shows removed"). #484 extended it to also drive the NESTED season
|
||||||
// are ever invoked — they exist only to satisfy the abstract contract.
|
// 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<
|
private sealed class TestTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||||
JellyfinConnectionParameters, JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
JellyfinConnectionParameters, JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||||
JellyfinItemEtag>
|
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(
|
public Task<Either<BaseError, Unit>> Scan(
|
||||||
IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||||
JellyfinItemEtag> televisionRepository,
|
JellyfinItemEtag> televisionRepository,
|
||||||
@@ -235,11 +462,26 @@ public class MediaServerTelevisionLibraryScannerTests
|
|||||||
false,
|
false,
|
||||||
CancellationToken.None);
|
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(
|
protected override IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItems(
|
||||||
JellyfinConnectionParameters connectionParameters,
|
JellyfinConnectionParameters connectionParameters,
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
MediaServerProjectionFailureCounter projectionFailures) =>
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
throw new NotSupportedException();
|
Enumerate(ShowEntries, ShowProjectionFailures, projectionFailures);
|
||||||
|
|
||||||
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
|
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
|
||||||
protected override string MediaServerItemId(JellyfinSeason season) => season.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 string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
|
||||||
|
|
||||||
protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show) =>
|
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
|
||||||
throw new NotSupportedException();
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
|
Enumerate(SeasonEntries, SeasonProjectionFailures, projectionFailures);
|
||||||
|
|
||||||
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
|
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
|
||||||
JellyfinSeason season, bool isNewSeason) =>
|
JellyfinSeason season, bool isNewSeason,
|
||||||
throw new NotSupportedException();
|
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(
|
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
||||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||||
MediaItemScanResult<JellyfinShow> result, JellyfinShow incoming, bool deepScan) =>
|
MediaItemScanResult<JellyfinShow> result, JellyfinShow incoming, bool deepScan) =>
|
||||||
throw new NotSupportedException();
|
Task.FromResult(Option<ShowMetadata>.None);
|
||||||
|
|
||||||
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
||||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||||
MediaItemScanResult<JellyfinSeason> result, JellyfinSeason incoming, bool deepScan) =>
|
MediaItemScanResult<JellyfinSeason> result, JellyfinSeason incoming, bool deepScan) =>
|
||||||
throw new NotSupportedException();
|
Task.FromResult(Option<SeasonMetadata>.None);
|
||||||
|
|
||||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||||
|
|||||||
@@ -152,25 +152,29 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
|||||||
protected override IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
|
protected override IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
|
||||||
EmbyLibrary library,
|
EmbyLibrary library,
|
||||||
EmbyConnectionParameters connectionParameters,
|
EmbyConnectionParameters connectionParameters,
|
||||||
EmbyShow show) =>
|
EmbyShow show,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
_embyApiClient.GetSeasonLibraryItems(
|
_embyApiClient.GetSeasonLibraryItems(
|
||||||
connectionParameters.Address,
|
connectionParameters.Address,
|
||||||
connectionParameters.ApiKey,
|
connectionParameters.ApiKey,
|
||||||
library,
|
library,
|
||||||
show.ItemId);
|
show.ItemId,
|
||||||
|
projectionFailures);
|
||||||
|
|
||||||
protected override IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
protected override IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||||
EmbyLibrary library,
|
EmbyLibrary library,
|
||||||
EmbyConnectionParameters connectionParameters,
|
EmbyConnectionParameters connectionParameters,
|
||||||
EmbyShow show,
|
EmbyShow show,
|
||||||
EmbySeason season,
|
EmbySeason season,
|
||||||
bool isNewSeason) =>
|
bool isNewSeason,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
_embyApiClient.GetEpisodeLibraryItems(
|
_embyApiClient.GetEpisodeLibraryItems(
|
||||||
connectionParameters.Address,
|
connectionParameters.Address,
|
||||||
connectionParameters.ApiKey,
|
connectionParameters.ApiKey,
|
||||||
library,
|
library,
|
||||||
show.ItemId,
|
show.ItemId,
|
||||||
season.ItemId);
|
season.ItemId,
|
||||||
|
projectionFailures);
|
||||||
|
|
||||||
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
||||||
EmbyConnectionParameters connectionParameters,
|
EmbyConnectionParameters connectionParameters,
|
||||||
|
|||||||
@@ -156,34 +156,40 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
|||||||
protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
JellyfinConnectionParameters connectionParameters,
|
JellyfinConnectionParameters connectionParameters,
|
||||||
JellyfinShow show) =>
|
JellyfinShow show,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
_jellyfinApiClient.GetSeasonLibraryItems(
|
_jellyfinApiClient.GetSeasonLibraryItems(
|
||||||
connectionParameters.Address,
|
connectionParameters.Address,
|
||||||
connectionParameters.AuthorizationHeader,
|
connectionParameters.AuthorizationHeader,
|
||||||
library,
|
library,
|
||||||
show.ItemId);
|
show.ItemId,
|
||||||
|
projectionFailures);
|
||||||
|
|
||||||
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||||
JellyfinLibrary library,
|
JellyfinLibrary library,
|
||||||
JellyfinConnectionParameters connectionParameters,
|
JellyfinConnectionParameters connectionParameters,
|
||||||
JellyfinShow show,
|
JellyfinShow show,
|
||||||
JellyfinSeason season,
|
JellyfinSeason season,
|
||||||
bool isNewSeason)
|
bool isNewSeason,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures)
|
||||||
{
|
{
|
||||||
|
// both variants must report into the SAME counter — they feed one episode sweep
|
||||||
if (isNewSeason)
|
if (isNewSeason)
|
||||||
{
|
{
|
||||||
return _jellyfinApiClient.GetEpisodeLibraryItems(
|
return _jellyfinApiClient.GetEpisodeLibraryItems(
|
||||||
connectionParameters.Address,
|
connectionParameters.Address,
|
||||||
connectionParameters.AuthorizationHeader,
|
connectionParameters.AuthorizationHeader,
|
||||||
library,
|
library,
|
||||||
season.ItemId);
|
season.ItemId,
|
||||||
|
projectionFailures);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _jellyfinApiClient.GetEpisodeLibraryItemsWithoutPeople(
|
return _jellyfinApiClient.GetEpisodeLibraryItemsWithoutPeople(
|
||||||
connectionParameters.Address,
|
connectionParameters.Address,
|
||||||
connectionParameters.AuthorizationHeader,
|
connectionParameters.AuthorizationHeader,
|
||||||
library,
|
library,
|
||||||
season.ItemId);
|
season.ItemId,
|
||||||
|
projectionFailures);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<Option<ShowMetadata>> GetFullMetadata(
|
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
|
// 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.
|
// 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`.
|
// See docs/decisions.md `scan.projection-failure-sweep-guard`.
|
||||||
internal static class MediaServerReconciliationGuard
|
internal static class MediaServerReconciliationGuard
|
||||||
{
|
{
|
||||||
@@ -42,17 +51,13 @@ internal static class MediaServerReconciliationGuard
|
|||||||
int existingCount,
|
int existingCount,
|
||||||
int projectionFailureCount = 0)
|
int projectionFailureCount = 0)
|
||||||
{
|
{
|
||||||
if (projectionFailureCount > 0 && existingCount > 0)
|
if (RefuseForProjectionFailures(
|
||||||
{
|
logger,
|
||||||
logger.LogWarning(
|
$"library {libraryName}",
|
||||||
"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,
|
|
||||||
incomingCount,
|
incomingCount,
|
||||||
existingCount);
|
existingCount,
|
||||||
|
projectionFailureCount))
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,4 +74,47 @@ internal static class MediaServerReconciliationGuard
|
|||||||
|
|
||||||
return true;
|
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())
|
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(
|
Either<BaseError, Unit> scanResult = await ScanSeasons(
|
||||||
televisionRepository,
|
televisionRepository,
|
||||||
library,
|
library,
|
||||||
@@ -148,7 +151,12 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
result.Item,
|
result.Item,
|
||||||
result.IsUpdated,
|
result.IsUpdated,
|
||||||
connectionParameters,
|
connectionParameters,
|
||||||
GetSeasonLibraryItems(library, connectionParameters, result.Item),
|
GetSeasonLibraryItems(
|
||||||
|
library,
|
||||||
|
connectionParameters,
|
||||||
|
result.Item,
|
||||||
|
seasonProjectionFailures),
|
||||||
|
seasonProjectionFailures,
|
||||||
deepScan,
|
deepScan,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
@@ -251,17 +259,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
false,
|
false,
|
||||||
cancellationToken);
|
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(
|
protected abstract IAsyncEnumerable<Tuple<TSeason, int>> GetSeasonLibraryItems(
|
||||||
TLibrary library,
|
TLibrary library,
|
||||||
TConnectionParameters connectionParameters,
|
TConnectionParameters connectionParameters,
|
||||||
TShow show);
|
TShow show,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures);
|
||||||
|
|
||||||
protected abstract IAsyncEnumerable<Tuple<TEpisode, int>> GetEpisodeLibraryItems(
|
protected abstract IAsyncEnumerable<Tuple<TEpisode, int>> GetEpisodeLibraryItems(
|
||||||
TLibrary library,
|
TLibrary library,
|
||||||
TConnectionParameters connectionParameters,
|
TConnectionParameters connectionParameters,
|
||||||
TShow show,
|
TShow show,
|
||||||
TSeason season,
|
TSeason season,
|
||||||
bool isNewSeason);
|
bool isNewSeason,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures);
|
||||||
|
|
||||||
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
|
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
|
||||||
TConnectionParameters connectionParameters,
|
TConnectionParameters connectionParameters,
|
||||||
@@ -309,6 +322,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
EpisodeMetadata fullMetadata,
|
EpisodeMetadata fullMetadata,
|
||||||
CancellationToken cancellationToken);
|
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(
|
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
||||||
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
|
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
|
||||||
TLibrary library,
|
TLibrary library,
|
||||||
@@ -317,6 +338,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
bool showIsUpdated,
|
bool showIsUpdated,
|
||||||
TConnectionParameters connectionParameters,
|
TConnectionParameters connectionParameters,
|
||||||
IAsyncEnumerable<Tuple<TSeason, int>> seasonEntries,
|
IAsyncEnumerable<Tuple<TSeason, int>> seasonEntries,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures,
|
||||||
bool deepScan,
|
bool deepScan,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -356,6 +378,9 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
|
|
||||||
foreach (MediaItemScanResult<TSeason> result in maybeSeason.RightToSeq())
|
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(
|
Either<BaseError, Unit> scanResult = await ScanEpisodes(
|
||||||
televisionRepository,
|
televisionRepository,
|
||||||
library,
|
library,
|
||||||
@@ -364,7 +389,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
showIsUpdated,
|
showIsUpdated,
|
||||||
result.Item,
|
result.Item,
|
||||||
connectionParameters,
|
connectionParameters,
|
||||||
GetEpisodeLibraryItems(library, connectionParameters, show, result.Item, result.IsAdded),
|
GetEpisodeLibraryItems(
|
||||||
|
library,
|
||||||
|
connectionParameters,
|
||||||
|
show,
|
||||||
|
result.Item,
|
||||||
|
result.IsAdded,
|
||||||
|
episodeProjectionFailures),
|
||||||
|
episodeProjectionFailures,
|
||||||
deepScan,
|
deepScan,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
@@ -397,18 +429,29 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// trash seasons that are no longer present on the media server
|
// #484: a projection failure is systematic, not per-parent — one bad code path drops seasons for
|
||||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
// EVERY show, so this sweep would flag the library's entire season tree (and, via the #476
|
||||||
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds, cancellationToken);
|
// cascade below, its episodes) in one scan. A per-parent EMPTY fetch is still swept, unchanged.
|
||||||
|
if (MediaServerReconciliationGuard.ShouldFlagMissingDescendants(
|
||||||
// #476: a season gone from the media server (while its show remains) is absent from the incoming
|
_logger,
|
||||||
// list, so the per-season loop never visits it and the episode sweep in ScanEpisodes never runs
|
$"show {ShowScope(show)} seasons",
|
||||||
// for it. Cascade the flag to its episodes.
|
incomingItemIds.Count,
|
||||||
List<int> episodeIds = await televisionRepository.FlagFileNotFoundEpisodesForSeasons(ids, cancellationToken);
|
existingSeasons.Count,
|
||||||
|
projectionFailures.Count))
|
||||||
if (!await _scannerProxy.ReindexMediaItems(ids.Concat(episodeIds).ToArray(), cancellationToken))
|
|
||||||
{
|
{
|
||||||
_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;
|
return Unit.Default;
|
||||||
@@ -423,6 +466,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
TSeason season,
|
TSeason season,
|
||||||
TConnectionParameters connectionParameters,
|
TConnectionParameters connectionParameters,
|
||||||
IAsyncEnumerable<Tuple<TEpisode, int>> episodeEntries,
|
IAsyncEnumerable<Tuple<TEpisode, int>> episodeEntries,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures,
|
||||||
bool deepScan,
|
bool deepScan,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -570,12 +614,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// trash episodes that are no longer present on the media server
|
// #484: the highest-stakes sweep in the scanner. A ProjectToEpisode regression fires for every
|
||||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
// episode of every season, so each season enumerates zero and existing.Except([]) is the entire
|
||||||
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds, cancellationToken);
|
// episode library — permanently deleted by EmptyTrashHandler. A per-parent EMPTY fetch with no
|
||||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
// 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;
|
return Unit.Default;
|
||||||
|
|||||||
@@ -244,10 +244,13 @@ public partial class PlexTelevisionLibraryScanner :
|
|||||||
connectionParameters.Connection,
|
connectionParameters.Connection,
|
||||||
connectionParameters.Token);
|
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(
|
protected override IAsyncEnumerable<Tuple<PlexSeason, int>> GetSeasonLibraryItems(
|
||||||
PlexLibrary library,
|
PlexLibrary library,
|
||||||
PlexConnectionParameters connectionParameters,
|
PlexConnectionParameters connectionParameters,
|
||||||
PlexShow show) =>
|
PlexShow show,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
_plexServerApiClient.GetShowSeasons(
|
_plexServerApiClient.GetShowSeasons(
|
||||||
library,
|
library,
|
||||||
show,
|
show,
|
||||||
@@ -259,7 +262,8 @@ public partial class PlexTelevisionLibraryScanner :
|
|||||||
PlexConnectionParameters connectionParameters,
|
PlexConnectionParameters connectionParameters,
|
||||||
PlexShow show,
|
PlexShow show,
|
||||||
PlexSeason season,
|
PlexSeason season,
|
||||||
bool isNewSeason) =>
|
bool isNewSeason,
|
||||||
|
MediaServerProjectionFailureCounter projectionFailures) =>
|
||||||
_plexServerApiClient.GetSeasonEpisodes(
|
_plexServerApiClient.GetSeasonEpisodes(
|
||||||
library,
|
library,
|
||||||
season,
|
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
|
// 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
|
// 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.
|
// 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]
|
[Test]
|
||||||
public async Task ScanLibrary_Should_Update_Album_And_Track_On_Rescan_Of_Existing_Item()
|
public async Task ScanLibrary_Should_Update_Album_And_Track_On_Rescan_Of_Existing_Item()
|
||||||
{
|
{
|
||||||
@@ -671,6 +749,44 @@ public class JellyfinMusicVideoLibraryScannerTests
|
|||||||
return (scanner, scannerProxy);
|
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
|
// 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).
|
// (an IAsyncEnumerable iterator is single-use, and the scanner mutates the incoming item).
|
||||||
private static IJellyfinApiClient FakeApi(params Func<MusicVideo>[] items)
|
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.
|
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)
|
## 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`
|
`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.
|
**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 · paths: `MediaServerProjectionResult`, `MediaServerProjectionFailureCounter`, `MediaServerReconciliationGuard`, `JellyfinApiClient.GetPagedLibraryItems`, `EmbyApiClient.GetPagedLibraryContents` · issues: #484, #477, #476
|
**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`
|
**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
|
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
|
scanner reads the count through an **optional trailing parameter** on only the library-level methods
|
||||||
that actually feed a sweep (`IJellyfinApiClient.GetMovieLibraryItems` /
|
that actually feed a sweep (`IJellyfinApiClient.GetMovieLibraryItems` /
|
||||||
`GetMusicVideoLibraryItems` / `GetShowLibraryItemsWithoutPeople`, `IEmbyApiClient.GetMovieLibraryItems`
|
`GetMusicVideoLibraryItems` / `GetShowLibraryItemsWithoutPeople`, `IEmbyApiClient.GetMovieLibraryItems`
|
||||||
/ `GetShowLibraryItems`), so every other call site is untouched. A library that cannot be resolved
|
/ `GetShowLibraryItems`) **and** the nested season/episode ones (`GetSeasonLibraryItems`,
|
||||||
(`Option<TLibrary>.None` inside a mapper) is treated as a **failure**, not a skip — fail closed.
|
`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
|
- **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
|
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
|
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
|
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
|
failures into each other's sweep decision; increments are interlocked so a paginator that ever fans
|
||||||
out stays correct.
|
out stays correct.
|
||||||
- **Wired into the four real sweep call sites**, matching #477's scope exactly: the three library-level
|
- **Wired into all six sweeps, not #477's four.** The four library-level ones — the three base scanners
|
||||||
base scanners (`MediaServerMovieLibraryScanner`, `MediaServerTelevisionLibraryScanner`,
|
(`MediaServerMovieLibraryScanner`, `MediaServerTelevisionLibraryScanner`,
|
||||||
`MediaServerOtherVideoLibraryScanner`) and `JellyfinMusicVideoLibraryScanner`. The nested TV
|
`MediaServerOtherVideoLibraryScanner`) plus `JellyfinMusicVideoLibraryScanner` — **and** the two nested
|
||||||
season/episode sweeps stay unguarded for the reasons in `scan.zero-item-fetch-guard`, and
|
TV ones, `ScanSeasons`' `FlagFileNotFoundSeasons` and `ScanEpisodes`' `FlagFileNotFoundEpisodes`.
|
||||||
`ScanLibraryWithoutCleanup` (single-show rescan) passes no counter because it runs no sweep.
|
`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
|
- **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
|
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
|
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
|
instance keep separate counts. `MediaServerTelevisionLibraryScannerTests
|
||||||
.Projection_Failure_Suppresses_The_Partial_Deletion_Sweep` is the integration pair to #476's cascade
|
.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
|
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.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.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.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) |
|
| `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.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) |
|
| `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