From e02a7ef1f13fb7a1109f9cc1a7fb2d6e18e0c4b3 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 17 Oct 2026 15:27:23 +0300 Subject: [PATCH] changed seeker to pick candidates from wanted lists --- .../Features/Jobs/SeekerTests.cs | 1280 ++++++++++++++--- .../Features/Jobs/Seeker.cs | 377 +++-- docs/docs/configuration/seeker/index.mdx | 6 +- 3 files changed, 1231 insertions(+), 432 deletions(-) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index fb4c28f3..fc85a151 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -1,4 +1,4 @@ -using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events.Interfaces; @@ -68,6 +68,20 @@ public class SeekerTests : IDisposable .GetRecentlyGrabbedSeasonsAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns([]); + // Default: no wanted items, overridden per test via StubRadarrWanted/StubSonarrWanted + _radarrClient + .GetMissingMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + _radarrClient + .GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + _sonarrClient + .GetMissingEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + _sonarrClient + .GetCutoffUnmetEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + // Default: PublishSearchTriggered returns a Guid _fixture.EventPublisher .PublishSearchTriggered( @@ -119,6 +133,51 @@ public class SeekerTests : IDisposable throw new OperationCanceledException(); } + /// + /// Stubs the Radarr wanted-list reads from a movie list the way /movie used to: missing = !HasFile, cutoff unmet = MovieFile.QualityCutoffNotMet. + /// + private void StubRadarrWanted(ArrInstance instance, List movies) + { + List missing = movies.Where(m => !m.HasFile).ToList(); + List cutoffUnmet = movies.Where(m => m.MovieFile?.QualityCutoffNotMet ?? false).ToList(); + + _radarrClient + .GetMissingMoviesAsync(Arg.Is(a => a.Id == instance.Id), true, Arg.Any()) + .Returns(missing.Where(m => m.Monitored).ToList()); + _radarrClient + .GetMissingMoviesAsync(Arg.Is(a => a.Id == instance.Id), false, Arg.Any()) + .Returns(missing.Where(m => !m.Monitored).ToList()); + + _radarrClient + .GetCutoffUnmetMoviesAsync(Arg.Is(a => a.Id == instance.Id), true, Arg.Any()) + .Returns(cutoffUnmet.Where(m => m.Monitored).ToList()); + _radarrClient + .GetCutoffUnmetMoviesAsync(Arg.Is(a => a.Id == instance.Id), false, Arg.Any()) + .Returns(cutoffUnmet.Where(m => !m.Monitored).ToList()); + } + + /// + /// Stubs the Sonarr missing/cutoff-unmet episode reads from explicit candidate lists. + /// + private void StubSonarrWanted(ArrInstance instance, List missingCandidates, List? cutoffUnmetCandidates = null) + { + cutoffUnmetCandidates ??= []; + + _sonarrClient + .GetMissingEpisodesAsync(Arg.Is(a => a.Id == instance.Id), true, Arg.Any()) + .Returns(missingCandidates.Where(e => e.Monitored).ToList()); + _sonarrClient + .GetMissingEpisodesAsync(Arg.Is(a => a.Id == instance.Id), false, Arg.Any()) + .Returns(missingCandidates.Where(e => !e.Monitored).ToList()); + + _sonarrClient + .GetCutoffUnmetEpisodesAsync(Arg.Is(a => a.Id == instance.Id), true, Arg.Any()) + .Returns(cutoffUnmetCandidates.Where(e => e.Monitored).ToList()); + _sonarrClient + .GetCutoffUnmetEpisodesAsync(Arg.Is(a => a.Id == instance.Id), false, Arg.Any()) + .Returns(cutoffUnmetCandidates.Where(e => !e.Monitored).ToList()); + } + #region ExecuteAsync Tests [Fact] @@ -407,6 +466,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -469,6 +531,11 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, Tags = [] } + ]); HashSet? capturedSearchItems = null; mockArrClient @@ -536,7 +603,7 @@ public class SeekerTests : IDisposable } [Fact] - public async Task ExecuteAsync_Sonarr_PropagatesCancellationFromEpisodeFetch() + public async Task ExecuteAsync_Sonarr_PropagatesCancellationFromMissingEpisodesFetch() { var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); config.SearchEnabled = true; @@ -568,7 +635,7 @@ public class SeekerTests : IDisposable ])); _sonarrClient - .GetEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .GetMissingEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException>(new OperationCanceledException())); _fixture.ArrClientFactory @@ -618,6 +685,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -678,13 +748,11 @@ public class SeekerTests : IDisposable // Use dates relative to FakeTimeProvider (defaults to Jan 1, 2000) var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, - new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); SeriesSearchItem? capturedSearchItem = null; mockArrClient @@ -750,25 +818,11 @@ public class SeekerTests : IDisposable var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 900, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true } - ]); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 30) - .Returns( - [ - new SearchableEpisode { Id = 901, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true } - ]); - - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 20) - .Returns( - [ - new SearchableEpisode { Id = 902, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } - ]); + // Series 10 and 30 are fully downloaded (no missing episodes); only series 20 has one missing + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 902, SeriesId = 20, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); SeriesSearchItem? capturedSearchItem = null; mockArrClient @@ -829,6 +883,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -895,6 +952,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Monitored Movie", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 2, Title = "Unmonitored Movie", Status = "released", Monitored = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Monitored Movie", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Unmonitored Movie", Status = "released", Monitored = false, Tags = [] } + ]); HashSet? capturedSearchItems = null; mockArrClient @@ -955,6 +1016,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = [1] }, new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = [2, 1] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = [1] }, + new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = [2, 1] } + ]); _radarrClient .GetAllTagsAsync(radarrInstance) @@ -1015,15 +1080,18 @@ public class SeekerTests : IDisposable .ReadAllAsync(mockArrClient, Arg.Any()) .Returns([]); + List taggedMovies = + [ + new SearchableMovie { Id = 1, Title = "Untagged", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Wanted", Status = "released", Monitored = true, Tags = [1] }, + new SearchableMovie { Id = 3, Title = "Keep in other case", Status = "released", Monitored = true, Tags = [2] }, + new SearchableMovie { Id = 4, Title = "Wanted but skipped", Status = "released", Monitored = true, Tags = [1, 3] }, + new SearchableMovie { Id = 5, Title = "Unknown tag id", Status = "released", Monitored = true, Tags = [99] } + ]; _radarrClient .StreamAllMoviesAsync(radarrInstance, Arg.Any()) - .Returns(_ => ToAsyncEnumerable([ - new SearchableMovie { Id = 1, Title = "Untagged", Status = "released", Monitored = true, Tags = [] }, - new SearchableMovie { Id = 2, Title = "Wanted", Status = "released", Monitored = true, Tags = [1] }, - new SearchableMovie { Id = 3, Title = "Keep in other case", Status = "released", Monitored = true, Tags = [2] }, - new SearchableMovie { Id = 4, Title = "Wanted but skipped", Status = "released", Monitored = true, Tags = [1, 3] }, - new SearchableMovie { Id = 5, Title = "Unknown tag id", Status = "released", Monitored = true, Tags = [99] } - ])); + .Returns(_ => ToAsyncEnumerable(taggedMovies)); + StubRadarrWanted(radarrInstance, taggedMovies); _radarrClient .GetAllTagsAsync(radarrInstance) @@ -1088,6 +1156,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Tagged", Status = "released", Monitored = true, Tags = [1] }, new SearchableMovie { Id = 2, Title = "Untagged", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Tagged", Status = "released", Monitored = true, Tags = [1] }, + new SearchableMovie { Id = 2, Title = "Untagged", Status = "released", Monitored = true, Tags = [] } + ]); _radarrClient .GetAllTagsAsync(radarrInstance) @@ -1143,6 +1215,12 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 102, Title = "Has tag 110 (keep)", Status = "released", Monitored = true, Tags = [110] }, new SearchableMovie { Id = 103, Title = "No tags (keep)", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 100, Title = "Has tag 1 (skip)", Status = "released", Monitored = true, Tags = [1] }, + new SearchableMovie { Id = 101, Title = "Has tag 11 (keep)", Status = "released", Monitored = true, Tags = [11] }, + new SearchableMovie { Id = 102, Title = "Has tag 110 (keep)", Status = "released", Monitored = true, Tags = [110] }, + new SearchableMovie { Id = 103, Title = "No tags (keep)", Status = "released", Monitored = true, Tags = [] } + ]); _radarrClient .GetAllTagsAsync(radarrInstance) @@ -1224,6 +1302,13 @@ public class SeekerTests : IDisposable DigitalRelease = null, PhysicalRelease = null, InCinemas = null, } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie + { + Id = 200, Title = "No dates", Status = "released", Monitored = true, + DigitalRelease = null, PhysicalRelease = null, InCinemas = null, + } + ]); _radarrClient.GetAllTagsAsync(radarrInstance).Returns([]); @@ -1288,6 +1373,11 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 2, Title = "Has File", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, new SearchableMovie { Id = 3, Title = "Also Has File", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Has File", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Also Has File", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] } + ]); SearchItem? capturedSearchItem = null; mockArrClient @@ -1349,6 +1439,11 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 2, Title = "Cutoff Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Cutoff Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] } + ]); HashSet? capturedSearchItems = null; mockArrClient @@ -1432,6 +1527,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -1503,6 +1602,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -1573,6 +1675,7 @@ public class SeekerTests : IDisposable _radarrClient .StreamAllMoviesAsync(Arg.Is(a => a.Id == radarrInstance2.Id), Arg.Any()) .Returns(ToAsyncEnumerable([new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }])); + StubRadarrWanted(radarrInstance2, [new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }]); mockArrClient .SearchItemAsync(Arg.Any(), Arg.Any()) @@ -1708,6 +1811,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]); _fixture.ArrClientFactory .GetClient(InstanceType.Radarr, Arg.Any()) @@ -1787,6 +1894,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -1859,6 +1970,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -1931,12 +2045,10 @@ public class SeekerTests : IDisposable ])); var pastDate = now.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) @@ -2009,12 +2121,10 @@ public class SeekerTests : IDisposable ])); var pastDate = now.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); mockArrClient .SearchItemAsync(sonarrInstance, Arg.Any()) @@ -2109,6 +2219,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie A", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(instanceA, [ + new SearchableMovie { Id = 1, Title = "Movie A", Status = "released", Monitored = true, Tags = [] } + ]); _radarrClient .StreamAllMoviesAsync(instanceB, Arg.Any()) @@ -2116,6 +2229,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 10, Title = "Movie B", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(instanceB, [ + new SearchableMovie { Id = 10, Title = "Movie B", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(Arg.Any(), Arg.Any()) @@ -2206,6 +2322,11 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -2292,6 +2413,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -2356,6 +2481,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Recent Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-2) }, new SearchableMovie { Id = 2, Title = "Old Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10) } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Recent Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-2) }, + new SearchableMovie { Id = 2, Title = "Old Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10) } + ]); HashSet? capturedSearchItems = null; mockArrClient @@ -2420,6 +2549,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Just Released", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddMinutes(-5) } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Just Released", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddMinutes(-5) } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -2473,6 +2605,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "No Dates Movie", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "No Dates Movie", Status = "released", Monitored = true, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -2528,13 +2663,11 @@ public class SeekerTests : IDisposable ])); var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-2), HasFile = false }, - new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-10), HasFile = false } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-2), HasFile = false }, + new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-10), HasFile = false } + ]); SeriesSearchItem? capturedSearchItem = null; mockArrClient @@ -2597,9 +2730,15 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ // DigitalRelease is null, PhysicalRelease is 2h ago (within grace) new SearchableMovie { Id = 1, Title = "Physical Only", Status = "released", Monitored = true, Tags = [], PhysicalRelease = now.AddHours(-2) }, - // DigitalRelease is 10h ago (past grace), PhysicalRelease is 2h ago — DigitalRelease takes precedence + // DigitalRelease is 10h ago (past grace), PhysicalRelease is 2h ago: DigitalRelease takes precedence new SearchableMovie { Id = 2, Title = "Digital First", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10), PhysicalRelease = now.AddHours(-2) } ])); + StubRadarrWanted(radarrInstance, [ + // DigitalRelease is null, PhysicalRelease is 2h ago (within grace) + new SearchableMovie { Id = 1, Title = "Physical Only", Status = "released", Monitored = true, Tags = [], PhysicalRelease = now.AddHours(-2) }, + // DigitalRelease is 10h ago (past grace), PhysicalRelease is 2h ago: DigitalRelease takes precedence + new SearchableMovie { Id = 2, Title = "Digital First", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10), PhysicalRelease = now.AddHours(-2) } + ]); HashSet? capturedSearchItems = null; mockArrClient @@ -2691,6 +2830,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, new SearchableMovie { Id = 3, Title = "Above Cutoff", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = false }, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Above Cutoff", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = false }, Tags = [] } + ]); _radarrClient .GetQualityProfilesAsync(radarrInstance) @@ -2788,6 +2931,11 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] }, new SearchableMovie { Id = 4, Title = "Both Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 400, QualityCutoffNotMet = false }, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 2, Title = "CF Below", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] }, + new SearchableMovie { Id = 4, Title = "Both Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 400, QualityCutoffNotMet = false }, Tags = [] } + ]); _radarrClient .GetQualityProfilesAsync(radarrInstance) @@ -2864,6 +3012,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] } + ]); _radarrClient .GetQualityProfilesAsync(radarrInstance) @@ -2930,6 +3081,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, QualityProfileId = 0, Tags = [] }, new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, QualityProfileId = 0, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] } + ]); _radarrClient .GetQualityProfilesAsync(radarrInstance) @@ -2994,6 +3149,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] } + ]); _radarrClient .GetQualityProfilesAsync(radarrInstance) @@ -3058,6 +3216,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -3111,6 +3272,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -3171,6 +3335,11 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 2, Title = "In Cinemas Movie", Status = "inCinemas", Monitored = true, HasFile = false, Tags = [] }, new SearchableMovie { Id = 3, Title = "Released Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Announced Movie", Status = "announced", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "In Cinemas Movie", Status = "inCinemas", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Released Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); SearchItem? capturedSearchItem = null; mockArrClient @@ -3237,13 +3406,10 @@ public class SeekerTests : IDisposable ])); var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, - new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 500 } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); SeriesSearchItem? capturedSearchItem = null; mockArrClient @@ -3306,24 +3472,12 @@ public class SeekerTests : IDisposable ])); var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - // Season 1: has file, cutoff NOT met → should be searched - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 500 }, - // Season 2: has file, cutoff MET → should be excluded - new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 600 } - ]); - - // Episode file 500 has cutoff not met, file 600 has cutoff met - _sonarrClient - .GetEpisodeFilesAsync(Arg.Any(), 10) - .Returns( - [ - new ArrEpisodeFile { Id = 500, QualityCutoffNotMet = true }, - new ArrEpisodeFile { Id = 600, QualityCutoffNotMet = false } - ]); + StubSonarrWanted(sonarrInstance, missingCandidates: [], cutoffUnmetCandidates: + [ + // Season 1: has file, cutoff NOT met → should be searched + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 500 } + // Season 2's episode (cutoff MET) is not in the wanted list at all → excluded + ]); SeriesSearchItem? capturedSearchItem = null; mockArrClient @@ -3377,6 +3531,7 @@ public class SeekerTests : IDisposable ExternalItemId = 10, // SeriesId EpisodeId = 100, ItemType = InstanceType.Sonarr, + SeasonNumber = 1, CurrentScore = 10, CutoffScore = 50, Title = "Below CF Cutoff" @@ -3387,6 +3542,7 @@ public class SeekerTests : IDisposable ExternalItemId = 10, EpisodeId = 101, ItemType = InstanceType.Sonarr, + SeasonNumber = 2, CurrentScore = 60, CutoffScore = 50, Title = "Above CF Cutoff" @@ -3407,15 +3563,6 @@ public class SeekerTests : IDisposable new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } } ])); - var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 500 }, - new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 600 } - ]); - _sonarrClient .GetQualityProfilesAsync(sonarrInstance) .Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]); @@ -3471,6 +3618,7 @@ public class SeekerTests : IDisposable ExternalItemId = 10, EpisodeId = 100, ItemType = InstanceType.Sonarr, + SeasonNumber = 1, CurrentScore = 10, CutoffScore = 50, Title = "Below CF Cutoff" @@ -3491,14 +3639,6 @@ public class SeekerTests : IDisposable new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, QualityProfileId = 0, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 9 } } ])); - DateTime pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 500 } - ]); - _sonarrClient .GetQualityProfilesAsync(sonarrInstance) .Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = false }]); @@ -3512,65 +3652,7 @@ public class SeekerTests : IDisposable // Act await sut.ExecuteAsync(); - // Assert - await _sonarrClient.Received().GetEpisodesAsync(Arg.Any(), 10); - await mockArrClient.DidNotReceive() - .SearchItemAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ExecuteAsync_Sonarr_FullyDownloaded_UpgradesDisabledProfile_SkipsEpisodeFetch() - { - // Arrange - SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); - config.SearchEnabled = true; - config.ProactiveSearchEnabled = true; - await _fixture.DataContext.SaveChangesAsync(); - await _fixture.EventsContext.SaveChangesAsync(); - - ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); - - _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig - { - ArrInstanceId = sonarrInstance.Id, - ArrInstance = sonarrInstance, - Enabled = true, - MonitoredOnly = false, - UseCutoff = false, - UseCustomFormatScore = true - }); - await _fixture.DataContext.SaveChangesAsync(); - await _fixture.EventsContext.SaveChangesAsync(); - - IArrClient mockArrClient = Substitute.For(); - - _fixture.ArrQueueReader - .ReadAllAsync(mockArrClient, Arg.Any()) - .Returns([]); - - _sonarrClient - .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) - .Returns( - ToAsyncEnumerable([ - new SearchableSeries { Id = 10, Title = "Fully Downloaded Series", Status = "continuing", Monitored = true, QualityProfileId = 0, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } } - ])); - - _sonarrClient - .GetQualityProfilesAsync(sonarrInstance) - .Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = false }]); - - _fixture.ArrClientFactory - .GetClient(InstanceType.Sonarr, Arg.Any()) - .Returns(mockArrClient); - - SeekerJob sut = CreateSut(); - - // Act - await sut.ExecuteAsync(); - - // Assert - await _sonarrClient.DidNotReceive() - .GetEpisodesAsync(Arg.Any(), 10); + // Assert: the CF candidate's profile has upgrades disabled, so nothing is searched await mockArrClient.DidNotReceive() .SearchItemAsync(Arg.Any(), Arg.Any()); } @@ -3629,7 +3711,7 @@ public class SeekerTests : IDisposable [Fact] public async Task ExecuteAsync_Sonarr_MonitoredOnlyTrue_ExcludesUnmonitoredSeries() { - // Arrange — MonitoredOnly true: unmonitored series should be excluded + // Arrange: MonitoredOnly true: unmonitored series should be excluded var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); config.SearchEnabled = true; config.ProactiveSearchEnabled = true; @@ -3663,16 +3745,20 @@ public class SeekerTests : IDisposable ])); var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 200, SeriesId = 20, SeasonNumber = 1, EpisodeNumber = 1, Monitored = false, AirDateUtc = pastDate, HasFile = false } + ]); + SeriesSearchItem? capturedSearchItem = null; mockArrClient .SearchItemAsync(Arg.Any(), Arg.Any()) - .Returns(100L); + .Returns(ci => + { + capturedSearchItem = ci.ArgAt(1) as SeriesSearchItem; + return 100L; + }); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) @@ -3683,17 +3769,15 @@ public class SeekerTests : IDisposable // Act await sut.ExecuteAsync(); - // Assert — only monitored series should have episodes fetched - await _sonarrClient.Received(1) - .GetEpisodesAsync(Arg.Any(), 10); - await _sonarrClient.DidNotReceive() - .GetEpisodesAsync(Arg.Any(), 20); + // Assert: only the monitored series (10) was eligible, series 20 dropped out of the library at the streaming filter + capturedSearchItem.ShouldNotBeNull(); + capturedSearchItem.SeriesId.ShouldBe(10); } [Fact] public async Task ExecuteAsync_Sonarr_SkipTags_ExcludesTaggedSeries() { - // Arrange — series with skip tag should be excluded + // Arrange: series with skip tag should be excluded var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); config.SearchEnabled = true; config.ProactiveSearchEnabled = true; @@ -3736,16 +3820,20 @@ public class SeekerTests : IDisposable ]); var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 200, SeriesId = 20, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); + SeriesSearchItem? capturedSearchItem = null; mockArrClient .SearchItemAsync(Arg.Any(), Arg.Any()) - .Returns(100L); + .Returns(ci => + { + capturedSearchItem = ci.ArgAt(1) as SeriesSearchItem; + return 100L; + }); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) @@ -3756,11 +3844,9 @@ public class SeekerTests : IDisposable // Act await sut.ExecuteAsync(); - // Assert — skipped series should not have episodes fetched - await _sonarrClient.Received(1) - .GetEpisodesAsync(Arg.Any(), 10); - await _sonarrClient.DidNotReceive() - .GetEpisodesAsync(Arg.Any(), 20); + // Assert: the skip-tagged series dropped out of the library filter, so only series 10 was searched + capturedSearchItem.ShouldNotBeNull(); + capturedSearchItem.SeriesId.ShouldBe(10); } [Fact] @@ -3801,15 +3887,20 @@ public class SeekerTests : IDisposable .Returns([new Tag { Id = 1, Label = "anime" }]); DateTime pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns([ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 200, SeriesId = 20, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); + SeriesSearchItem? capturedSearchItem = null; mockArrClient .SearchItemAsync(Arg.Any(), Arg.Any()) - .Returns(100L); + .Returns(ci => + { + capturedSearchItem = ci.ArgAt(1) as SeriesSearchItem; + return 100L; + }); _fixture.ArrClientFactory .GetClient(InstanceType.Sonarr, Arg.Any()) @@ -3819,16 +3910,15 @@ public class SeekerTests : IDisposable await sut.ExecuteAsync(); - await _sonarrClient.Received(1) - .GetEpisodesAsync(Arg.Any(), 10); - await _sonarrClient.DidNotReceive() - .GetEpisodesAsync(Arg.Any(), 20); + // Assert: the untagged series dropped out of the library filter, so only series 10 was searched + capturedSearchItem.ShouldNotBeNull(); + capturedSearchItem.SeriesId.ShouldBe(10); } [Fact] public async Task ExecuteAsync_Sonarr_FullyDownloadedSeries_ExcludedWhenMissingOnly() { - // Arrange — series with all episodes downloaded should be excluded when no upgrade filters are on + // Arrange: series with all episodes downloaded should be excluded when no upgrade filters are on var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); config.SearchEnabled = true; config.ProactiveSearchEnabled = true; @@ -3859,19 +3949,82 @@ public class SeekerTests : IDisposable .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) .Returns( ToAsyncEnumerable([ - // Fully downloaded — EpisodeFileCount == EpisodeCount + // Fully downloaded: EpisodeFileCount == EpisodeCount new SearchableSeries { Id = 10, Title = "Complete Series", Status = "ended", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } }, // Has missing episodes new SearchableSeries { Id = 20, Title = "Incomplete Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } } ])); var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); + // Series 10 has no missing episode (fully downloaded); only series 20 does + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 200, SeriesId = 20, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); + + SeriesSearchItem? capturedSearchItem = null; + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + capturedSearchItem = ci.ArgAt(1) as SeriesSearchItem; + return 100L; + }); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the fully downloaded series has no missing episode, so only series 20 was searched + capturedSearchItem.ShouldNotBeNull(); + capturedSearchItem.SeriesId.ShouldBe(20); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_NeverCallsPerSeriesEpisodeEndpoints() + { + // Arrange: proactive search reads the wanted/cutoff lists, never the per-series endpoints + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + UseCutoff = true + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + _sonarrClient - .GetEpisodesAsync(Arg.Any(), 20) + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) .Returns( - [ - new SearchableEpisode { Id = 200, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } - ]); + ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } } + ])); + + var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); + StubSonarrWanted(sonarrInstance, + missingCandidates: [new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }], + cutoffUnmetCandidates: [new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true }]); mockArrClient .SearchItemAsync(Arg.Any(), Arg.Any()) @@ -3886,11 +4039,653 @@ public class SeekerTests : IDisposable // Act await sut.ExecuteAsync(); - // Assert — fully downloaded series should not have episodes fetched + // Assert: the old per-series endpoints are never used for proactive search await _sonarrClient.DidNotReceive() - .GetEpisodesAsync(Arg.Any(), 10); + .GetEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _sonarrClient.DidNotReceive() + .GetEpisodeFilesAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_MonitoredOnlyFalse_ReadsBothMonitoredAndUnmonitoredMissing() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([])); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: both monitored and unmonitored missing reads happen when MonitoredOnly is false await _sonarrClient.Received(1) - .GetEpisodesAsync(Arg.Any(), 20); + .GetMissingEpisodesAsync(sonarrInstance, true, Arg.Any()); + await _sonarrClient.Received(1) + .GetMissingEpisodesAsync(sonarrInstance, false, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_MonitoredOnlyTrue_OnlyReadsMonitoredMissing() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = true + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([])); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: only the monitored read happens when MonitoredOnly is true + await _sonarrClient.Received(1) + .GetMissingEpisodesAsync(sonarrInstance, true, Arg.Any()); + await _sonarrClient.DidNotReceive() + .GetMissingEpisodesAsync(sonarrInstance, false, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_CustomFormatScoreEntryWithoutSeasonNumber_IsNotACandidate() + { + // Arrange: a CF score row synced before the season column existed carries no season + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + UseCutoff = false, + UseCustomFormatScore = true + }); + + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 100, + ItemType = InstanceType.Sonarr, + SeasonNumber = null, + CurrentScore = 10, + CutoffScore = 50, + Title = "No Season Yet" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } } + ])); + + _sonarrClient + .GetQualityProfilesAsync(sonarrInstance) + .Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: no season to search, so nothing was searched + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_CustomFormatScoreEntryWithSeasonNumber_SearchesThatSeasonWithCustomFormatReason() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + UseCutoff = false, + UseCustomFormatScore = true + }); + + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 100, + ItemType = InstanceType.Sonarr, + SeasonNumber = 3, + CurrentScore = 10, + CutoffScore = 50, + Title = "Below CF Cutoff" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } } + ])); + + _sonarrClient + .GetQualityProfilesAsync(sonarrInstance) + .Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]); + + SeriesSearchItem? capturedSearchItem = null; + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + capturedSearchItem = ci.ArgAt(1) as SeriesSearchItem; + return 100L; + }); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: season 3 was searched for a custom-format score upgrade + capturedSearchItem.ShouldNotBeNull(); + capturedSearchItem.Id.ShouldBe(3); + capturedSearchItem.SeriesId.ShouldBe(10); + + await _fixture.EventPublisher.Received(1) + .PublishSearchTriggered( + Arg.Any(), + SeekerSearchType.Proactive, + SeekerSearchReason.CustomFormatScoreBelowCutoff, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_CustomFormatScoreEntry_UpgradesDisabledProfile_IsSkipped() + { + // Arrange: the series' quality profile has upgrades disabled, so the CF candidate is dropped + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + UseCutoff = false, + UseCustomFormatScore = true + }); + + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 100, + ItemType = InstanceType.Sonarr, + SeasonNumber = 1, + CurrentScore = 10, + CutoffScore = 50, + Title = "Below CF Cutoff" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, QualityProfileId = 5, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } } + ])); + + _sonarrClient + .GetQualityProfilesAsync(sonarrInstance) + .Returns([new ArrQualityProfile { Id = 5, UpgradeAllowed = false }]); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: nothing was searched because the profile disallows upgrades + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_MissingEpisodeInsideGracePeriod_IsSkipped() + { + // Arrange: grace period of 24 hours, episode aired 1 hour ago + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.PostReleaseGraceHours = 24; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 9 } } + ])); + + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-1), HasFile = false } + ]); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the episode is still within the grace period, so nothing was searched + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_EveryEligibleSeasonQueued_NoSearchAndCurrentCycleIdUnchanged() + { + // Arrange: the only eligible season is already in the download queue + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + QueueRecord[] queuedRecords = + [ + new() { Id = 1, Title = "Series Episode", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, SeriesId = 10, SeasonNumber = 1, TrackedDownloadState = "downloading" } + ]; + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns(queuedRecords.ToList()); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 9 } } + ])); + + var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the only eligible season was queued, so no search and no new cycle + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id); + instanceConfig.CurrentCycleId.ShouldBe(currentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_TotalEligibleItems_CountsSeriesWithAnEligibleSeason() + { + // Arrange: series 10 has two eligible seasons, series 20 has one; TotalEligibleItems counts series, not seasons + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Series Ten", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 20, EpisodeFileCount = 10 } }, + new SearchableSeries { Id = 20, Title = "Series Twenty", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } } + ])); + + var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 200, SeriesId = 20, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); + + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: two series had an eligible season, even though series 10 contributed two seasons + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id); + instanceConfig.TotalEligibleItems.ShouldBe(2); + } + + [Fact] + public async Task ExecuteAsync_Radarr_Wanted404_SkipsSearchWithoutThrowing_StaleCleanupStillRuns() + { + // Arrange: Radarr older than v5.6 returns 404 for the wanted endpoints + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false + }); + + // Pre-existing history for a movie still present in the library + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 99, + ItemType = InstanceType.Radarr, + CycleId = Guid.NewGuid(), + LastSearchedAt = _fixture.TimeProvider.GetUtcNow().AddDays(-1), + ItemTitle = "Still In Library" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 99, Title = "Still In Library", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + + _radarrClient + .GetMissingMoviesAsync(radarrInstance, Arg.Any(), Arg.Any()) + .Returns(Task.FromException>( + new HttpRequestException("not found", null, System.Net.HttpStatusCode.NotFound))); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act: must not throw + await sut.ExecuteAsync(); + + // Assert: no search triggered + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + + // Assert: stale cleanup still ran, and the movie still in /movie keeps its history row + var historyRows = await _fixture.EventsContext.SeekerHistory + .Where(h => h.ArrInstanceId == radarrInstance.Id) + .ToListAsync(); + historyRows.Count.ShouldBe(1); + historyRows[0].ExternalItemId.ShouldBe(99L); + } + + [Fact] + public async Task ExecuteAsync_Radarr_CutoffUnmetMovie_SearchedOnlyWhenUseCutoffEnabled() + { + // Arrange: a movie with a file whose quality cutoff is not met + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + UseCutoff = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + SearchableMovie cutoffUnmetMovie = new() + { + Id = 1, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, + MovieFile = new MovieFileInfo { Id = 500, QualityCutoffNotMet = true }, Tags = [] + }; + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([cutoffUnmetMovie])); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + // Act 1: UseCutoff off: the cutoff-unmet list is never read, movie is not searched + StubRadarrWanted(radarrInstance, [cutoffUnmetMovie]); + var sut1 = CreateSut(); + await sut1.ExecuteAsync(); + + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + await _radarrClient.DidNotReceive() + .GetCutoffUnmetMoviesAsync(radarrInstance, Arg.Any(), Arg.Any()); + + // Act 2: UseCutoff on: the movie is searched with reason QualityCutoffNotMet + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + instanceConfig.UseCutoff = true; + await _fixture.DataContext.SaveChangesAsync(); + + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(100L); + + var sut2 = CreateSut(); + await sut2.ExecuteAsync(); + + await mockArrClient.Received(1) + .SearchItemAsync(Arg.Any(), Arg.Any()); + + await _fixture.EventPublisher.Received(1) + .PublishSearchTriggered( + "Cutoff Not Met", + SeekerSearchType.Proactive, + SeekerSearchReason.QualityCutoffNotMet, + Arg.Any(), + Arg.Any()); } #endregion @@ -3933,6 +4728,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -3992,6 +4790,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4051,6 +4852,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4122,6 +4926,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4193,6 +5000,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4262,6 +5072,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4319,6 +5132,11 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] }, new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4379,6 +5197,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4483,6 +5304,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4762,6 +5586,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -4875,12 +5702,18 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance1, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); _radarrClient .StreamAllMoviesAsync(radarrInstance2, Arg.Any()) .Returns( ToAsyncEnumerable([ new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance2, [ + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(Arg.Any(), Arg.Any()) @@ -5144,6 +5977,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -5222,6 +6058,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); List searchedMovieIds = []; mockArrClient @@ -5318,6 +6158,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -5405,6 +6248,9 @@ public class SeekerTests : IDisposable ToAsyncEnumerable([ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -5491,13 +6337,11 @@ public class SeekerTests : IDisposable ])); DateTime pastDate = now.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate }, - new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate }, + new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); List searchedSeasons = []; mockArrClient @@ -5591,6 +6435,10 @@ public class SeekerTests : IDisposable new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]); HashSet? capturedSearchItems = null; mockArrClient @@ -5658,13 +6506,11 @@ public class SeekerTests : IDisposable ])); DateTime pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); - _sonarrClient - .GetEpisodesAsync(Arg.Any(), 10) - .Returns( - [ - new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, - new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } - ]); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); SeriesSearchItem? capturedSearchItem = null; mockArrClient @@ -5723,6 +6569,7 @@ public class SeekerTests : IDisposable _radarrClient .StreamAllMoviesAsync(radarrInstance, Arg.Any()) .Returns(ToAsyncEnumerable([])); + StubRadarrWanted(radarrInstance, []); _fixture.ArrClientFactory .GetClient(InstanceType.Radarr, Arg.Any()) @@ -5907,6 +6754,7 @@ public class SeekerTests : IDisposable _radarrClient .StreamAllMoviesAsync(radarrInstance, Arg.Any()) .Returns(ToAsyncEnumerable([])); + StubRadarrWanted(radarrInstance, []); _fixture.ArrClientFactory .GetClient(InstanceType.Radarr, Arg.Any()) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 000a9e8b..c6bb74f4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Hosting; using Cleanuparr.Infrastructure.Realtime; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using System.Net; namespace Cleanuparr.Infrastructure.Features.Jobs; @@ -500,7 +501,7 @@ public sealed class Seeker : IHandler .ToHashSet(); queuedSeasons.UnionWith(recentlyGrabbedSeasons); - result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, effectiveCycleId, isDryRun, cancellationToken, queuedSeasons: queuedSeasons); + result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, effectiveCycleId, isDryRun, queuedSeasons, cancellationToken); } if (result.Candidates.Count == 0) @@ -585,7 +586,7 @@ public sealed class Seeker : IHandler DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours); List allLibraryIds = new(); - List candidates = new(); + List released = new(); await foreach (SearchableMovie movie in _radarrClient.StreamAllMoviesAsync(arrInstance, cancellationToken)) { allLibraryIds.Add(movie.Id); @@ -610,10 +611,39 @@ public sealed class Seeker : IHandler continue; } + released.Add(movie); + } + + HashSet missingIds; + HashSet cutoffUnmetIds = []; + try + { + List missing = await ReadWantedAsync( + monitored => _radarrClient.GetMissingMoviesAsync(arrInstance, monitored, cancellationToken), + instanceConfig.MonitoredOnly); + missingIds = missing.Select(m => m.Id).ToHashSet(); + + if (instanceConfig.UseCutoff) + { + List cutoffUnmet = await ReadWantedAsync( + monitored => _radarrClient.GetCutoffUnmetMoviesAsync(arrInstance, monitored, cancellationToken), + instanceConfig.MonitoredOnly); + cutoffUnmetIds = cutoffUnmet.Select(m => m.Id).ToHashSet(); + } + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.NotFound) + { + _logger.LogWarning("Skipping proactive search for {InstanceName}, it needs Radarr v5.6 or newer", arrInstance.Name); + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + } + + List candidates = new(); + foreach (SearchableMovie movie in released) + { bool belowCustomFormatCutoff = instanceConfig.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(movie.Id, out var entry) && entry.CurrentScore < entry.CutoffScore; bool upgradesDisabled = belowCustomFormatCutoff && upgradesDisabledProfileIds.Contains(movie.QualityProfileId); - bool passesQualityCheck = !movie.HasFile - || (instanceConfig.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false)) + bool passesQualityCheck = missingIds.Contains(movie.Id) + || cutoffUnmetIds.Contains(movie.Id) || (belowCustomFormatCutoff && !upgradesDisabled); if (!passesQualityCheck) { @@ -706,9 +736,9 @@ public sealed class Seeker : IHandler { continue; } - SeekerSearchReason reason = !movie.HasFile + SeekerSearchReason reason = missingIds.Contains(movieId) ? SeekerSearchReason.Missing - : instanceConfig.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false) + : cutoffUnmetIds.Contains(movieId) ? SeekerSearchReason.QualityCutoffNotMet : SeekerSearchReason.CustomFormatScoreBelowCutoff; @@ -735,9 +765,8 @@ public sealed class Seeker : IHandler List currentCycleHistory, Guid cycleId, bool isDryRun, - CancellationToken cancellationToken, - bool isRetry = false, - HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null) + HashSet<(long SeriesId, long SeasonNumber)> queuedSeasons, + CancellationToken cancellationToken) { List tags = await _sonarrClient.GetAllTagsAsync(arrInstance); Dictionary tagsById = tags.ToDictionary(t => t.Id, t => t.Label); @@ -752,9 +781,9 @@ public sealed class Seeker : IHandler } DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours); - + List allLibraryIds = new(); - List candidates = new(); + Dictionary seriesById = new(); await foreach (SearchableSeries series in _sonarrClient.StreamAllSeriesAsync(arrInstance, cancellationToken)) { allLibraryIds.Add(series.Id); @@ -774,101 +803,96 @@ public sealed class Seeker : IHandler continue; } - // Skip fully-downloaded series unless a quality upgrade filter is active. - bool fullyDownloaded = series.Statistics is not null - && series.Statistics.EpisodeCount > 0 - && series.Statistics.EpisodeFileCount >= series.Statistics.EpisodeCount; - bool customFormatUpgradesAllowed = instanceConfig.UseCustomFormatScore - && !upgradesDisabledProfileIds.Contains(series.QualityProfileId); - if (fullyDownloaded && !instanceConfig.UseCutoff && !customFormatUpgradesAllowed) - { - if (instanceConfig.UseCustomFormatScore) - { - _logger.LogDebug("skip | upgrades disabled in profile | {SeriesTitle} | {InstanceName}", series.Title, arrInstance.Name); - } - - continue; - } - - candidates.Add(series); + seriesById[series.Id] = series; } - instanceConfig.TotalEligibleItems = candidates.Count; + List<(long SeriesId, int SeasonNumber, SeekerSearchReason Reason)> qualifying = []; - if (candidates.Count == 0) + bool IsReleasedEpisodeOfCandidate(SearchableEpisode e) => + seriesById.ContainsKey(e.SeriesId) && e.AirDateUtc.HasValue && e.AirDateUtc.Value <= graceCutoff; + + List missing = await ReadWantedAsync( + monitored => _sonarrClient.GetMissingEpisodesAsync(arrInstance, monitored, cancellationToken), + instanceConfig.MonitoredOnly); + qualifying.AddRange(missing + .Where(IsReleasedEpisodeOfCandidate) + .Select(e => (e.SeriesId, e.SeasonNumber, SeekerSearchReason.Missing))); + + if (instanceConfig.UseCutoff) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + List cutoffUnmet = await ReadWantedAsync( + monitored => _sonarrClient.GetCutoffUnmetEpisodesAsync(arrInstance, monitored, cancellationToken), + instanceConfig.MonitoredOnly); + qualifying.AddRange(cutoffUnmet + .Where(IsReleasedEpisodeOfCandidate) + .Select(e => (e.SeriesId, e.SeasonNumber, SeekerSearchReason.QualityCutoffNotMet))); } - // Pass all candidates — BuildSonarrSearchItemAsync handles season-level exclusion - // LastSearched info helps the selector deprioritize recently-searched series - var selectionCandidates = candidates - .Select(s => (s.Id, s.Added, LastSearched: seriesSearchHistory.TryGetValue(s.Id, out var dt) ? (DateTimeOffset?)dt : null)) - .ToList(); - - // Select all candidates in priority order so the loop can find one with unsearched seasons - IItemSelector selector = ItemSelectorFactory.Create(config.SelectionStrategy); - List candidateIds = selector.Select(selectionCandidates, selectionCandidates.Count); - - Dictionary candidatesById = candidates.ToDictionary(s => s.Id); - - // Drill down to find the first series with qualifying unsearched seasons - foreach (long seriesId in candidateIds) + if (instanceConfig.UseCustomFormatScore) { - string seriesTitle = string.Empty; + // Rows synced before the season column existed carry no season until the next sync. + List belowCutoff = await _eventsContext.CustomFormatScoreEntries + .AsNoTracking() + .Where(e => e.ArrInstanceId == arrInstance.Id + && e.ItemType == InstanceType.Sonarr + && e.SeasonNumber != null + && e.CurrentScore < e.CutoffScore + && (!instanceConfig.MonitoredOnly || e.IsMonitored)) + .ToListAsync(cancellationToken); - try + foreach (CustomFormatScoreEntry entry in belowCutoff) { - if (!candidatesById.TryGetValue(seriesId, out SearchableSeries? series)) + if (!seriesById.TryGetValue(entry.ExternalItemId, out SearchableSeries? series)) { continue; } - List seriesHistory = currentCycleHistory - .Where(h => h.ExternalItemId == seriesId) - .ToList(); - - seriesTitle = series.Title; - - (SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode, SeekerSearchReason searchReason) = - await BuildSonarrSearchItemAsync(instanceConfig, arrInstance, seriesId, seriesHistory, seriesTitle, graceCutoff, - !upgradesDisabledProfileIds.Contains(series.QualityProfileId), cancellationToken, queuedSeasons); - - if (searchItem is not null) + if (upgradesDisabledProfileIds.Contains(series.QualityProfileId)) { - string displayName = $"{seriesTitle} S{searchItem.Id:D2}"; - - return new SeekerProcessResult - { - Candidates = - [ - new SeekerSearchCandidate - { - ItemId = seriesId, - Name = displayName, - SeasonNumber = (int)searchItem.Id, - Reason = searchReason, - } - ], - AllLibraryIds = allLibraryIds, - CycleId = cycleId, - }; + _logger.LogDebug("skip | upgrades disabled in profile | {Title} | {InstanceName}", entry.Title, arrInstance.Name); + continue; } - _logger.LogDebug("Skipping '{SeriesTitle}' — no qualifying seasons found", seriesTitle); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to check episodes for '{SeriesTitle}', skipping", seriesTitle); + qualifying.Add((entry.ExternalItemId, entry.SeasonNumber!.Value, SeekerSearchReason.CustomFormatScoreBelowCutoff)); } } - // All candidates were tried and none had qualifying unsearched seasons — cycle complete - if (candidates.Count > 0 && !isRetry) + Dictionary<(long SeriesId, int SeasonNumber), List> seasons = qualifying + .GroupBy(q => (q.SeriesId, q.SeasonNumber)) + .ToDictionary(g => g.Key, g => g.Select(q => q.Reason).ToList()); + + instanceConfig.TotalEligibleItems = seasons.Keys.Select(s => s.SeriesId).Distinct().Count(); + + if (seasons.Count == 0) + { + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + } + + List<(long SeriesId, int SeasonNumber)> available = seasons.Keys + .Where(s => !queuedSeasons.Contains((s.SeriesId, s.SeasonNumber))) + .ToList(); + + int queuedCount = seasons.Count - available.Count; + if (queuedCount > 0) + { + _logger.LogDebug("Excluded {Count} seasons already in queue on {InstanceName}", + queuedCount, arrInstance.Name); + } + + if (available.Count == 0) + { + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + } + + HashSet<(long SeriesId, int SeasonNumber)> searchedSeasons = currentCycleHistory + .Select(h => (h.ExternalItemId, h.SeasonNumber)) + .ToHashSet(); + + List<(long SeriesId, int SeasonNumber)> remaining = available + .Where(s => !searchedSeasons.Contains(s)) + .ToList(); + + if (remaining.Count == 0) { // Respect MinCycleTimeDays even when cycle completes due to queue filtering DateTimeOffset? cycleStartedAt = seriesSearchHistory.Count > 0 ? seriesSearchHistory.Values.Min() : null; @@ -881,148 +905,48 @@ public sealed class Seeker : IHandler } _logger.LogInformation("All {Count} series on {InstanceName} searched in current cycle, starting new cycle", - candidates.Count, arrInstance.Name); + instanceConfig.TotalEligibleItems, arrInstance.Name); - Guid newCycleId; if (isDryRun) { // Live cycle id stays untouched. - newCycleId = Guid.NewGuid(); + cycleId = Guid.NewGuid(); } else { instanceConfig.CurrentCycleId = Guid.NewGuid(); await _dataContext.SaveChangesAsync(); - newCycleId = instanceConfig.CurrentCycleId; + cycleId = instanceConfig.CurrentCycleId; } - // Retry with fresh cycle (only once to prevent infinite recursion) - return await ProcessSonarrAsync(config, arrInstance, instanceConfig, - new Dictionary(), [], newCycleId, isDryRun, cancellationToken, isRetry: true, queuedSeasons: queuedSeasons); + seriesSearchHistory = new Dictionary(); + remaining = available; } - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; - } + Dictionary> remainingSeasonsBySeries = remaining + .GroupBy(s => s.SeriesId) + .ToDictionary(g => g.Key, g => g.Select(s => s.SeasonNumber).ToList()); - /// - /// Fetches episodes for a series and builds a season-level search item. - /// Uses search history to prefer least-recently-searched seasons. - /// - private async Task<(SeriesSearchItem? SearchItem, SearchableEpisode? SelectedEpisode, SeekerSearchReason SearchReason)> BuildSonarrSearchItemAsync( - SeekerInstanceConfig instanceConfig, - ArrInstance arrInstance, - long seriesId, - List seriesHistory, - string seriesTitle, - DateTimeOffset graceCutoff, - bool customFormatUpgradesAllowed, - CancellationToken cancellationToken, - HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null) - { - List episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, seriesId, cancellationToken); - - // Fetch episode file metadata to determine cutoff status from the dedicated episodefile endpoint - HashSet cutoffNotMetFileIds = []; - if (instanceConfig.UseCutoff) - { - List episodeFiles = await _sonarrClient.GetEpisodeFilesAsync(arrInstance, seriesId, cancellationToken); - cutoffNotMetFileIds = episodeFiles - .Where(f => f.QualityCutoffNotMet) - .Select(f => f.Id) - .ToHashSet(); - } - - // Load cached CF scores for this series when custom format score filtering is enabled - Dictionary? cfScores = null; - if (instanceConfig.UseCustomFormatScore) - { - cfScores = await _eventsContext.CustomFormatScoreEntries - .AsNoTracking() - .Where(e => e.ArrInstanceId == arrInstance.Id - && e.ItemType == InstanceType.Sonarr - && e.ExternalItemId == seriesId) - .ToDictionaryAsync(e => e.EpisodeId); - } - - bool BelowCustomFormatCutoff(SearchableEpisode e) => - cfScores != null && cfScores.TryGetValue(e.Id, out CustomFormatScoreEntry? entry) && entry.CurrentScore < entry.CutoffScore; - - // Filter to qualifying episodes — UseCutoff and UseCustomFormatScore are OR-ed. - // Cutoff status comes from the episodefile endpoint; items without a cached CF score are excluded. - List released = episodes - .Where(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value <= graceCutoff) - .Where(e => !instanceConfig.MonitoredOnly || e.Monitored) + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> selectionCandidates = remainingSeasonsBySeries.Keys + .Select(id => (id, seriesById[id].Added, LastSearched: seriesSearchHistory.TryGetValue(id, out DateTimeOffset dt) ? (DateTimeOffset?)dt : null)) .ToList(); - var qualifying = released - .Where(e => !e.HasFile - || (instanceConfig.UseCutoff && cutoffNotMetFileIds.Contains(e.EpisodeFileId)) - || (customFormatUpgradesAllowed && BelowCustomFormatCutoff(e))) - .OrderBy(e => e.SeasonNumber) - .ThenBy(e => e.EpisodeNumber) - .ToList(); + IItemSelector selector = ItemSelectorFactory.Create(config.SelectionStrategy); + List selectedIds = selector.Select(selectionCandidates, 1); - if (!customFormatUpgradesAllowed) + if (selectedIds.Count == 0) { - foreach (SearchableEpisode e in released.Where(e => BelowCustomFormatCutoff(e) && !qualifying.Contains(e))) - { - _logger.LogDebug("skip | upgrades disabled in profile | {SeriesTitle} S{Season:D2}E{Episode:D2} | {InstanceName}", - seriesTitle, e.SeasonNumber, e.EpisodeNumber, arrInstance.Name); - } + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; } - if (qualifying.Count == 0) - { - return (null, null, default); - } + long seriesId = selectedIds[0]; + List seriesSeasons = remainingSeasonsBySeries[seriesId]; + int seasonNumber = seriesSeasons[Random.Shared.Next(seriesSeasons.Count)]; + List seasonReasons = seasons[(seriesId, seasonNumber)]; - // Select least-recently-searched season using history - var seasonGroups = qualifying - .GroupBy(e => e.SeasonNumber) - .Select(g => - { - DateTimeOffset? lastSearched = seriesHistory - .FirstOrDefault(h => h.SeasonNumber == g.Key) - ?.LastSearchedAt; - return (SeasonNumber: g.Key, LastSearched: lastSearched, FirstEpisode: g.First()); - }) - .ToList(); - - // Find unsearched seasons first - var unsearched = seasonGroups.Where(s => s.LastSearched is null).ToList(); - - // Exclude seasons already in the download queue - if (queuedSeasons is { Count: > 0 }) - { - int beforeCount = unsearched.Count; - unsearched = unsearched - .Where(s => !queuedSeasons.Contains((seriesId, (long)s.SeasonNumber))) - .ToList(); - - int skipped = beforeCount - unsearched.Count; - if (skipped > 0) - { - _logger.LogDebug("Excluded {Count} seasons already in queue for '{SeriesTitle}' on {InstanceName}", - skipped, seriesTitle, arrInstance.Name); - } - } - - if (unsearched.Count == 0) - { - // All unsearched seasons are either searched or in the queue - return (null, null, default); - } - - // Pick from unsearched seasons with some randomization - var selected = unsearched - .OrderBy(_ => Random.Shared.Next()) - .First(); - - // Log why this season was selected - var seasonEpisodes = qualifying.Where(e => e.SeasonNumber == selected.SeasonNumber).ToList(); - int missingCount = seasonEpisodes.Count(e => !e.HasFile); - int cutoffCount = seasonEpisodes.Count(e => e.HasFile && cutoffNotMetFileIds.Contains(e.EpisodeFileId)); - int cfCount = seasonEpisodes.Count(e => e.HasFile && customFormatUpgradesAllowed && BelowCustomFormatCutoff(e)); + int missingCount = seasonReasons.Count(r => r == SeekerSearchReason.Missing); + int cutoffCount = seasonReasons.Count(r => r == SeekerSearchReason.QualityCutoffNotMet); + int cfCount = seasonReasons.Count(r => r == SeekerSearchReason.CustomFormatScoreBelowCutoff); List reasons = []; if (missingCount > 0) @@ -1040,24 +964,47 @@ public sealed class Seeker : IHandler reasons.Add($"{cfCount} below CF score cutoff"); } - // Determine the primary search reason SeekerSearchReason searchReason = missingCount > 0 ? SeekerSearchReason.Missing : cutoffCount > 0 ? SeekerSearchReason.QualityCutoffNotMet : SeekerSearchReason.CustomFormatScoreBelowCutoff; + string seriesTitle = seriesById[seriesId].Title; + _logger.LogDebug("Selected '{SeriesTitle}' S{Season:D2} for search on {InstanceName}: {Reasons}", - seriesTitle, selected.SeasonNumber, arrInstance.Name, string.Join(", ", reasons)); + seriesTitle, seasonNumber, arrInstance.Name, string.Join(", ", reasons)); - SeriesSearchItem searchItem = new() + return new SeekerProcessResult { - Id = selected.SeasonNumber, - SeriesId = seriesId, - SearchType = SeriesSearchType.Season + Candidates = + [ + new SeekerSearchCandidate + { + ItemId = seriesId, + Name = $"{seriesTitle} S{seasonNumber:D2}", + SeasonNumber = seasonNumber, + Reason = searchReason, + } + ], + AllLibraryIds = allLibraryIds, + CycleId = cycleId, }; + } - return (searchItem, selected.FirstEpisode, searchReason); + /// + /// Reads a wanted list for monitored items, plus unmonitored ones when the instance searches those too. + /// + private static async Task> ReadWantedAsync(Func>> readWanted, bool monitoredOnly) + { + List records = await readWanted(true); + + if (!monitoredOnly) + { + records.AddRange(await readWanted(false)); + } + + return records; } private async Task UpdateSearchHistoryAsync( diff --git a/docs/docs/configuration/seeker/index.mdx b/docs/docs/configuration/seeker/index.mdx index cb002c4b..1b4e4fdc 100644 --- a/docs/docs/configuration/seeker/index.mdx +++ b/docs/docs/configuration/seeker/index.mdx @@ -67,6 +67,10 @@ Proactive search operates in cycles. Seeker tracks which items have been searche For Sonarr, the cycle is tracked at the season level; each season of each series is treated as a separate entry. Sonarr's searches are currently triggered per season, not per episode. + +Seeker reads the Missing and Cutoff Unmet lists of each *arr instance. Proactive search on Radarr needs Radarr v5.6 or newer, the first version with those lists. + + For an item to be eligible, it has to be released digitally. For example, if the item is currently in cinemas, it will be skipped until it has a proper digital release. @@ -129,7 +133,7 @@ When enabled, items that already have a file but have not yet met the quality pr When enabled, items whose current custom format score is below the quality profile's cutoff format score will be included as upgrade candidates. Seeker uses cached score data that is kept up to date by the Custom Format Score Syncer, which runs automatically when this setting is enabled. -Custom format scores are tracked at the episode level for Sonarr. If a series has episodes with scores below the cutoff, the corresponding season will be searched. +Custom format scores are tracked at the episode level for Sonarr. If a series has episodes with scores below the cutoff, the corresponding season will be searched. After an update to Cleanuparr, these episodes count again once the syncer has run.