using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; 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; // #477: a successful-but-empty media-server fetch must not flag a non-empty movie library FileNotFound // (which EmptyTrash could then permanently delete). Assert the sweep is skipped when nothing came in. public class MediaServerMovieLibraryScannerTests { [TestFixture] public class CleanupFileNotFoundItems { [Test] public async Task Empty_Incoming_With_Existing_Movies_Does_Not_Flag() { var movieRepository = Substitute.For(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 3, Name = "Movies" }; movieRepository.GetExistingMovies(library) .Returns(new List { new() { ItemId = "movie-1", State = MediaItemState.Normal }, new() { ItemId = "movie-2", State = MediaItemState.Normal } }); var scanner = new TestMovieLibraryScanner(scannerProxy); Either result = await scanner.Scan( movieRepository, new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), library); result.IsRight.ShouldBeTrue(); await movieRepository.DidNotReceive().FlagFileNotFound( Arg.Any(), Arg.Any>()); await scannerProxy.DidNotReceive().ReindexMediaItems( Arg.Any(), Arg.Any()); } // #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(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 3, Name = "Movies" }; movieRepository.GetExistingMovies(library) .Returns(new List { 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(), Arg.Any(), Arg.Any()) .Returns(Left>(BaseError.New("skip metadata in test"))); scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); var scanner = new TestMovieLibraryScanner(scannerProxy) { MovieEntries = [ new JellyfinMovie { ItemId = "movie-1", Etag = "e1", MovieMetadata = new List { new() { Title = "Keeper" } } } ], ProjectionFailures = 1 }; Either result = await scanner.Scan( movieRepository, new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), library); result.IsRight.ShouldBeTrue(); await movieRepository.DidNotReceive().FlagFileNotFound( Arg.Any(), Arg.Any>()); } // 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(); var scannerProxy = Substitute.For(); var library = new JellyfinLibrary { Id = 3, Name = "Movies" }; movieRepository.GetExistingMovies(library) .Returns(new List { new() { ItemId = "movie-1", Etag = "e1", State = MediaItemState.Normal }, new() { ItemId = "movie-2", Etag = "e2", State = MediaItemState.Normal } }); movieRepository.GetOrAdd(library, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Left>(BaseError.New("skip metadata in test"))); scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); // an unstubbed substitute returns null, which would NRE on ids.ToArray() movieRepository.FlagFileNotFound(Arg.Any(), Arg.Any>()) .Returns(new List { 7 }); scannerProxy.ReindexMediaItems(Arg.Any(), Arg.Any()).Returns(true); var scanner = new TestMovieLibraryScanner(scannerProxy) { MovieEntries = [ new JellyfinMovie { ItemId = "movie-1", Etag = "e1", MovieMetadata = new List { new() { Title = "Keeper" } } } ], ProjectionFailures = 0 }; Either result = await scanner.Scan( movieRepository, new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7), library); result.IsRight.ShouldBeTrue(); await movieRepository.Received(1).FlagFileNotFound( library, Arg.Is>(l => l.Count == 1 && l.Contains("movie-2"))); } } // Minimal concrete subclass exposing the cleanup path. Driven with an empty incoming list, so none of // the per-item metadata members below are ever invoked — they exist only to satisfy the contract. private sealed class TestMovieLibraryScanner : MediaServerMovieLibraryScanner< JellyfinConnectionParameters, JellyfinLibrary, JellyfinMovie, JellyfinItemEtag> { public TestMovieLibraryScanner(IScannerProxy scannerProxy) : base( scannerProxy, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For()) { } public Task> Scan( IJellyfinMovieRepository movieRepository, JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) => ScanLibrary( movieRepository, connectionParameters, library, _ => string.Empty, 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> GetMovieLibraryItems( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaServerProjectionFailureCounter projectionFailures) => Movies(MovieEntries, ProjectionFailures, projectionFailures); protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId; protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag; protected override Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinMovie incoming, bool deepScan) => throw new NotSupportedException(); protected override Task>> GetFullMetadataAndStatistics( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinMovie incoming) => throw new NotSupportedException(); protected override Task>> UpdateMetadata( MediaItemScanResult result, MovieMetadata fullMetadata, CancellationToken cancellationToken) => throw new NotSupportedException(); // mirrors production: failures are recorded DURING enumeration into the caller's counter private static async IAsyncEnumerable> Movies( JellyfinMovie[] movies, int projectionFailureCount, MediaServerProjectionFailureCounter projectionFailures) { await Task.CompletedTask; for (var i = 0; i < projectionFailureCount; i++) { projectionFailures.RecordFailure(); } foreach (JellyfinMovie movie in movies) { yield return new Tuple(movie, movies.Length + projectionFailureCount); } } } }