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