using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Scanner.Core.Metadata; using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Scanner.Tests.Core.Metadata; // #476: a show/season gone from the media server is absent from the incoming list, so the per-parent // loop never visits it and the descendant sweeps never run for it. These tests assert the scanner // cascades the FileNotFound flag to descendants via the repository, using a substituted repository. // #477: an empty incoming list is treated as a suspect (mid-restore / emptied) fetch and the sweep is // skipped instead of nuking the whole library — so the #476 cascade is now exercised with a survivor // present (a genuine partial deletion), and the empty case asserts nothing is flagged. public class MediaServerTelevisionLibraryScannerTests { [TestFixture] public class CleanupFileNotFoundItems { [Test] public async Task Removed_Show_Cascades_FileNotFound_To_Seasons_And_Episodes() { var televisionRepository = Substitute.For(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" }; // two shows exist; the survivor is still in the (non-empty) incoming list, so this is a // genuine partial deletion — "show-6366" is gone upstream and must be flagged + cascaded. // (#477: an EMPTY incoming would instead skip the sweep — see the guard test below.) televisionRepository.GetExistingShows(library, Arg.Any()) .Returns(new List { new() { ItemId = "show-keep", State = MediaItemState.Normal }, new() { ItemId = "show-6366", State = MediaItemState.FileNotFound } }); // the survivor short-circuits to Left so the per-item metadata path (unsupported in this // harness) is never entered; it is still recorded as incoming, so it is not swept. televisionRepository.GetOrAdd(library, Arg.Any(), Arg.Any()) .Returns(Left>(BaseError.New("skip metadata in test"))); // the per-item loop reports progress; an unstubbed substitute returns false => ScanCanceled scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); televisionRepository .FlagFileNotFoundShows(library, Arg.Is>(l => l.Contains("show-6366")), Arg.Any()) .Returns(new List { 100 }); televisionRepository .FlagFileNotFoundSeasonsForShows(Arg.Is>(l => l.SequenceEqual(new[] { 100 })), Arg.Any()) .Returns(new List { 200, 201 }); televisionRepository .FlagFileNotFoundEpisodesForSeasons(Arg.Is>(l => l.SequenceEqual(new[] { 200, 201 })), Arg.Any()) .Returns(new List { 300, 301, 302 }); scannerProxy.ReindexMediaItems(Arg.Any(), Arg.Any()).Returns(true); var scanner = new TestTelevisionLibraryScanner(scannerProxy); Either result = await scanner.Scan( televisionRepository, new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), library, Shows(new JellyfinShow { ItemId = "show-keep", ShowMetadata = new List { new() { Title = "Keeper" } } })); result.IsRight.ShouldBeTrue(); await televisionRepository.Received(1).FlagFileNotFoundShows( library, Arg.Is>(l => l.Count == 1 && l.Contains("show-6366")), Arg.Any()); await televisionRepository.Received(1).FlagFileNotFoundSeasonsForShows( Arg.Is>(l => l.SequenceEqual(new[] { 100 })), Arg.Any()); await televisionRepository.Received(1).FlagFileNotFoundEpisodesForSeasons( Arg.Is>(l => l.SequenceEqual(new[] { 200, 201 })), Arg.Any()); // every affected item (show + seasons + episodes) is reindexed so search reflects the new state await scannerProxy.Received(1).ReindexMediaItems( Arg.Is(a => new[] { 100, 200, 201, 300, 301, 302 }.All(id => a.Contains(id))), Arg.Any()); } [Test] public async Task Empty_Incoming_With_Existing_Shows_Does_Not_Flag() { var televisionRepository = Substitute.For(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" }; // shows exist locally, but a successful fetch returned ZERO items (server mid-restore or the // library was emptied upstream). #477: flagging here would nuke the entire library, so the // sweep must be skipped and nothing flagged or reindexed. televisionRepository.GetExistingShows(library, Arg.Any()) .Returns(new List { new() { ItemId = "show-1", State = MediaItemState.Normal }, new() { ItemId = "show-2", State = MediaItemState.Normal } }); var scanner = new TestTelevisionLibraryScanner(scannerProxy); Either result = await scanner.Scan( televisionRepository, new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), library, EmptyShows()); result.IsRight.ShouldBeTrue(); await televisionRepository.DidNotReceive().FlagFileNotFoundShows( Arg.Any(), Arg.Any>(), Arg.Any()); await televisionRepository.DidNotReceive().FlagFileNotFoundSeasonsForShows( Arg.Any>(), Arg.Any()); await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodesForSeasons( Arg.Any>(), Arg.Any()); await scannerProxy.DidNotReceive().ReindexMediaItems( Arg.Any(), Arg.Any()); } // #484: the same partial deletion as Removed_Show_Cascades_..., but the api client silently dropped // an item whose projection threw. "show-6366" is missing from the incoming list only because we // failed to build it, not because the server stopped reporting it — flagging it would trash a // healthy show (and, via the #476 cascade, all of its seasons and episodes). Paired with the // cascade test above as a positive control: identical arrangement, only the counter differs. [Test] public async Task Projection_Failure_Suppresses_The_Partial_Deletion_Sweep() { var televisionRepository = Substitute.For(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" }; televisionRepository.GetExistingShows(library, Arg.Any()) .Returns(new List { new() { ItemId = "show-keep", State = MediaItemState.Normal }, new() { ItemId = "show-6366", State = MediaItemState.Normal } }); televisionRepository.GetOrAdd(library, Arg.Any(), Arg.Any()) .Returns(Left>(BaseError.New("skip metadata in test"))); scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); var projectionFailures = new MediaServerProjectionFailureCounter(); projectionFailures.RecordFailure(); var scanner = new TestTelevisionLibraryScanner(scannerProxy); Either result = await scanner.Scan( televisionRepository, new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), library, Shows(new JellyfinShow { ItemId = "show-keep", ShowMetadata = new List { new() { Title = "Keeper" } } }), projectionFailures); result.IsRight.ShouldBeTrue(); await televisionRepository.DidNotReceive().FlagFileNotFoundShows( Arg.Any(), Arg.Any>(), Arg.Any()); await televisionRepository.DidNotReceive().FlagFileNotFoundSeasonsForShows( Arg.Any>(), Arg.Any()); await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodesForSeasons( Arg.Any>(), Arg.Any()); } // #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(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" }; televisionRepository.GetExistingShows(library, Arg.Any()) .Returns(new List { new() { ItemId = "show-keep", State = MediaItemState.Normal }, new() { ItemId = "show-6366", State = MediaItemState.Normal } }); televisionRepository.GetOrAdd(library, Arg.Any(), Arg.Any()) .Returns(Left>(BaseError.New("skip metadata in test"))); scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); var scanner = new TestTelevisionLibraryScanner(scannerProxy) { ShowEntries = [ new JellyfinShow { ItemId = "show-keep", ShowMetadata = new List { new() { Title = "Keeper" } } } ], ShowProjectionFailures = 1 }; Either result = await scanner.ScanFromApi( televisionRepository, new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), library); result.IsRight.ShouldBeTrue(); await televisionRepository.DidNotReceive().FlagFileNotFoundShows( Arg.Any(), Arg.Any>(), Arg.Any()); } // #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(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" }; var show = new JellyfinShow { ItemId = "show-1", ShowMetadata = new List { new() { Title = "Keeper" } } }; televisionRepository.GetExistingShows(library, Arg.Any()) .Returns(new List { new() { ItemId = "show-1", State = MediaItemState.Normal } }); televisionRepository.GetOrAdd(library, Arg.Any(), Arg.Any()) .Returns(Right>(new MediaItemScanResult(show))); scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); // two seasons exist, only one comes back — a partial deletion at the season level televisionRepository.GetExistingSeasons(library, Arg.Any(), Arg.Any()) .Returns(new List { 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(), Arg.Any()) .Returns(Left>(BaseError.New("skip season in test"))); // an unstubbed substitute returns null, which would NRE in the sweep's Concat televisionRepository.FlagFileNotFoundSeasons( Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new List { 200 }); televisionRepository.FlagFileNotFoundEpisodesForSeasons( Arg.Any>(), Arg.Any()) .Returns(new List { 300 }); televisionRepository.FlagFileNotFoundShows( Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new List()); televisionRepository.FlagFileNotFoundSeasonsForShows( Arg.Any>(), Arg.Any()) .Returns(new List()); scannerProxy.ReindexMediaItems(Arg.Any(), Arg.Any()).Returns(true); var scanner = new TestTelevisionLibraryScanner(scannerProxy) { SeasonEntries = [new JellyfinSeason { ItemId = "season-keep", SeasonNumber = 1 }], SeasonProjectionFailures = failures }; Either 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>(l => l.Count == 1 && l.Contains("season-gone")), Arg.Any()); } else { await televisionRepository.DidNotReceive().FlagFileNotFoundSeasons( Arg.Any(), Arg.Any>(), Arg.Any()); } } // #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(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" }; var show = new JellyfinShow { ItemId = "show-1", ShowMetadata = new List { new() { Title = "Keeper" } } }; var season = new JellyfinSeason { ItemId = "season-1", SeasonNumber = 1 }; televisionRepository.GetExistingShows(library, Arg.Any()) .Returns(new List { new() { ItemId = "show-1", State = MediaItemState.Normal } }); televisionRepository.GetOrAdd(library, Arg.Any(), Arg.Any()) .Returns(Right>(new MediaItemScanResult(show))); scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); // the season comes back, so the season sweep flags nothing and cannot mask the episode assertion televisionRepository.GetExistingSeasons(library, Arg.Any(), Arg.Any()) .Returns(new List { new() { ItemId = "season-1", State = MediaItemState.Normal } }); televisionRepository.GetOrAdd(library, Arg.Any(), Arg.Any()) .Returns(Right>(new MediaItemScanResult(season))); // two episodes exist, only one comes back — a partial deletion at the episode level televisionRepository.GetExistingEpisodes(library, Arg.Any(), Arg.Any()) .Returns(new List { 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(), Arg.Any>(), Arg.Any()) .Returns(new List { 300 }); televisionRepository.FlagFileNotFoundSeasons( Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new List()); televisionRepository.FlagFileNotFoundEpisodesForSeasons( Arg.Any>(), Arg.Any()) .Returns(new List()); televisionRepository.FlagFileNotFoundShows( Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new List()); televisionRepository.FlagFileNotFoundSeasonsForShows( Arg.Any>(), Arg.Any()) .Returns(new List()); scannerProxy.ReindexMediaItems(Arg.Any(), Arg.Any()).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 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>(l => l.Count == 1 && l.Contains("ep-gone")), Arg.Any()); } else { await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodes( Arg.Any(), Arg.Any>(), Arg.Any()); } // the season counter is a separate instance and stays clean either way await televisionRepository.DidNotReceive().FlagFileNotFoundSeasons( Arg.Any(), Arg.Is>(l => l.Count > 0), Arg.Any()); } private static async IAsyncEnumerable> EmptyShows() { await Task.CompletedTask; yield break; } private static async IAsyncEnumerable> Shows(params JellyfinShow[] shows) { await Task.CompletedTask; foreach (JellyfinShow show in shows) { yield return new Tuple(show, shows.Length); } } } // Minimal concrete subclass that exposes the abstract scanner's cleanup path. The incoming show list // 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> { public TestTelevisionLibraryScanner(IScannerProxy scannerProxy) : base( scannerProxy, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For()) { } // #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> Scan( IMediaServerTelevisionRepository televisionRepository, JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, IAsyncEnumerable> showEntries, MediaServerProjectionFailureCounter? projectionFailures = null) => ScanLibrary( televisionRepository, connectionParameters, library, _ => string.Empty, showEntries, projectionFailures ?? new MediaServerProjectionFailureCounter(), 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> ScanFromApi( IMediaServerTelevisionRepository televisionRepository, JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) => ScanLibrary( televisionRepository, connectionParameters, library, _ => string.Empty, false, CancellationToken.None); protected override IAsyncEnumerable> GetShowLibraryItems( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaServerProjectionFailureCounter projectionFailures) => Enumerate(ShowEntries, ShowProjectionFailures, projectionFailures); protected override string MediaServerItemId(JellyfinShow show) => show.ItemId; protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId; protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId; protected override string MediaServerEtag(JellyfinShow show) => show.Etag; protected override string MediaServerEtag(JellyfinSeason season) => season.Etag; protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag; protected override IAsyncEnumerable> GetSeasonLibraryItems( JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show, MediaServerProjectionFailureCounter projectionFailures) => Enumerate(SeasonEntries, SeasonProjectionFailures, projectionFailures); protected override IAsyncEnumerable> GetEpisodeLibraryItems( JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show, 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> Enumerate( 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(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> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinShow incoming, bool deepScan) => Task.FromResult(Option.None); protected override Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinSeason incoming, bool deepScan) => Task.FromResult(Option.None); protected override Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinEpisode incoming, bool deepScan) => throw new NotSupportedException(); protected override Task>> GetFullMetadataAndStatistics( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinEpisode incoming) => throw new NotSupportedException(); protected override Task>> UpdateMetadata( MediaItemScanResult result, ShowMetadata fullMetadata) => throw new NotSupportedException(); protected override Task>> UpdateMetadata( MediaItemScanResult result, SeasonMetadata fullMetadata) => throw new NotSupportedException(); protected override Task>> UpdateMetadata( MediaItemScanResult result, EpisodeMetadata fullMetadata, CancellationToken cancellationToken) => throw new NotSupportedException(); } }