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()); } 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"), so none of the per-item metadata members below // are ever invoked — they exist only to satisfy the abstract contract. 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()) { } public Task> Scan( IMediaServerTelevisionRepository televisionRepository, JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, IAsyncEnumerable> showEntries) => ScanLibrary( televisionRepository, connectionParameters, library, _ => string.Empty, showEntries, false, CancellationToken.None); protected override IAsyncEnumerable> GetShowLibraryItems( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) => throw new NotSupportedException(); 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) => throw new NotSupportedException(); protected override IAsyncEnumerable> GetEpisodeLibraryItems( JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show, JellyfinSeason season, bool isNewSeason) => throw new NotSupportedException(); protected override Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinShow incoming, bool deepScan) => throw new NotSupportedException(); protected override Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinSeason incoming, bool deepScan) => throw new NotSupportedException(); 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(); } }