From 01532bcf1a9484d4f9aecb4eac03ef79b058ec8b Mon Sep 17 00:00:00 2001 From: Flaminel Date: Mon, 26 Oct 2026 18:47:50 +0200 Subject: [PATCH] added whisparr v3 to seeker proactive search --- .../Seeker/SeekerPreviewControllerTests.cs | 3 + .../Entities/Arr/SearchableMovie.cs | 5 + .../Integration/SeekerIntegrationTests.cs | 1 + .../Features/Jobs/SeekerTests.cs | 110 +++++++++ .../Features/Seeker/SeekerAppsTests.cs | 54 +++++ .../Seeker/SeekerUnitEvaluatorTests.cs | 214 ++++++++++++++++++ .../Features/Jobs/Seeker.cs | 4 +- .../Features/Seeker/SeekerApps.cs | 27 +++ .../Features/Seeker/SeekerUnitEvaluator.cs | 40 ++-- 9 files changed, 442 insertions(+), 16 deletions(-) create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs index 33ee6b6e..cae983b6 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs @@ -30,6 +30,7 @@ public class SeekerPreviewControllerTests : IDisposable private readonly EventsContext _eventsContext; private readonly IRadarrClient _radarrClient; private readonly ISonarrClient _sonarrClient; + private readonly IWhisparrV3Client _whisparrV3Client; private readonly IArrClientFactory _arrClientFactory; private readonly IArrQueueReader _arrQueueReader; private readonly SeekerPreviewController _controller; @@ -40,6 +41,7 @@ public class SeekerPreviewControllerTests : IDisposable _eventsContext = SeekerTestDataFactory.CreateEventsContext(); _radarrClient = Substitute.For(); _sonarrClient = Substitute.For(); + _whisparrV3Client = Substitute.For(); _arrClientFactory = Substitute.For(); _arrQueueReader = Substitute.For(); FakeTimeProvider timeProvider = new(Now); @@ -60,6 +62,7 @@ public class SeekerPreviewControllerTests : IDisposable _eventsContext, _radarrClient, _sonarrClient, + _whisparrV3Client, timeProvider); _controller = new SeekerPreviewController( diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs index 919079be..7c560634 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs @@ -26,6 +26,11 @@ public sealed record SearchableMovie public DateTimeOffset? InCinemas { get; init; } + /// + /// Whisparr v3's only release date. + /// + public DateTimeOffset? ReleaseDate { get; init; } + /// /// Radarr's own verdict from the movie's minimum availability and the availability delay. /// diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs index a912566f..a523d75f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs @@ -53,6 +53,7 @@ public class SeekerIntegrationTests : IDisposable _fixture.EventsContext, Substitute.For(), Substitute.For(), + Substitute.For(), _fixture.TimeProvider)); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 79175a98..e0114849 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -30,6 +30,7 @@ public class SeekerTests : IDisposable private readonly ILogger _logger; private readonly IRadarrClient _radarrClient; private readonly ISonarrClient _sonarrClient; + private readonly IWhisparrV3Client _whisparrV3Client; private readonly IDryRunInterceptor _dryRunInterceptor; private readonly IHostEnvironment _hostingEnvironment; private readonly IStatusNotifier _statusNotifier; @@ -43,6 +44,7 @@ public class SeekerTests : IDisposable _logger = Substitute.For>(); _radarrClient = Substitute.For(); _sonarrClient = Substitute.For(); + _whisparrV3Client = Substitute.For(); _dryRunInterceptor = Substitute.For(); _hostingEnvironment = Substitute.For(); _statusNotifier = Substitute.For(); @@ -121,6 +123,7 @@ public class SeekerTests : IDisposable _fixture.EventsContext, _radarrClient, _sonarrClient, + _whisparrV3Client, _fixture.TimeProvider) ); } @@ -10445,5 +10448,112 @@ public class SeekerTests : IDisposable null); } + [Fact] + public async Task ExecuteAsync_WhisparrV3_ProactiveSearch_SearchesWithAPlainMovieSearchItem() + { + // Arrange + SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + ArrInstance whisparrInstance = TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext, version: 3); + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = whisparrInstance.Id, + ArrInstance = whisparrInstance, + Enabled = true, + MonitoredOnly = false + }); + await _fixture.DataContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + List movies = + [ + new SearchableMovie { Id = 7, Title = "Movie 7", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]; + _whisparrV3Client + .StreamAllMoviesAsync(whisparrInstance, Arg.Any()) + .Returns(_ => ToAsyncEnumerable(movies)); + _whisparrV3Client + .GetMissingMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(movies); + _whisparrV3Client + .GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + _whisparrV3Client + .GetAllTagsAsync(Arg.Any()) + .Returns([]); + _whisparrV3Client + .GetRecentlyGrabbedMovieIdsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + SearchItem? capturedSearchItem = null; + mockArrClient + .SearchItemAsync(whisparrInstance, Arg.Do(item => capturedSearchItem = item)) + .Returns(42L); + _fixture.ArrClientFactory + .GetClient(InstanceType.Whisparr, Arg.Any()) + .Returns(mockArrClient); + + SeekerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + capturedSearchItem.ShouldNotBeNull(); + capturedSearchItem.ShouldNotBeOfType(); + capturedSearchItem.Id.ShouldBe(7); + + SeekerHistory history = await _fixture.EventsContext.SeekerHistory + .SingleAsync(h => h.ArrInstanceId == whisparrInstance.Id); + history.ItemType.ShouldBe(InstanceType.Whisparr); + history.ExternalItemId.ShouldBe(7); + + SeekerCommandTracker tracker = await _fixture.EventsContext.SeekerCommandTrackers + .SingleAsync(t => t.ArrInstanceId == whisparrInstance.Id); + tracker.CommandId.ShouldBe(42L); + tracker.ExternalItemId.ShouldBe(7); + } + + [Fact] + public async Task ExecuteAsync_WhisparrV2_ProactiveSearch_SkipsTheInstance() + { + // Arrange + SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + ArrInstance whisparrInstance = TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext, version: 2); + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = whisparrInstance.Id, + ArrInstance = whisparrInstance, + Enabled = true, + MonitoredOnly = false + }); + await _fixture.DataContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + _fixture.ArrClientFactory + .GetClient(InstanceType.Whisparr, Arg.Any()) + .Returns(mockArrClient); + + SeekerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await mockArrClient.DidNotReceive().SearchItemAsync(Arg.Any(), Arg.Any()); + _whisparrV3Client.ReceivedCalls().ShouldBeEmpty(); + } + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs new file mode 100644 index 00000000..613ea832 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs @@ -0,0 +1,54 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Seeker; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Seeker; + +public class SeekerAppsTests +{ + private static ArrInstance Instance(InstanceType type, float version = 0) + { + return new ArrInstance + { + Name = "Test", + Url = new Uri("http://arr:1234"), + ApiKey = "key", + Version = version, + ArrConfig = new ArrConfig { Type = type }, + }; + } + + [Theory] + [InlineData(InstanceType.Sonarr, 0, true)] + [InlineData(InstanceType.Radarr, 0, true)] + [InlineData(InstanceType.Whisparr, 2, false)] + [InlineData(InstanceType.Whisparr, 3, true)] + [InlineData(InstanceType.Lidarr, 0, false)] + [InlineData(InstanceType.Readarr, 0, false)] + public void IsSupported_ReturnsTrueOnlyForSonarrRadarrAndWhisparrV3(InstanceType type, float version, bool expected) + { + // Act + bool supported = SeekerApps.IsSupported(Instance(type, version)); + + // Assert + supported.ShouldBe(expected); + } + + [Theory] + [InlineData(InstanceType.Sonarr, 0, false)] + [InlineData(InstanceType.Radarr, 0, true)] + [InlineData(InstanceType.Whisparr, 2, false)] + [InlineData(InstanceType.Whisparr, 3, true)] + [InlineData(InstanceType.Lidarr, 0, false)] + [InlineData(InstanceType.Readarr, 0, false)] + public void SearchesMovies_ReturnsTrueOnlyForRadarrAndWhisparrV3(InstanceType type, float version, bool expected) + { + // Act + bool searchesMovies = SeekerApps.SearchesMovies(Instance(type, version)); + + // Assert + searchesMovies.ShouldBe(expected); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs index dda4cfa5..90ceaa2f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs @@ -24,11 +24,13 @@ public class SeekerUnitEvaluatorTests : IDisposable private readonly EventsContext _eventsContext; private readonly IRadarrClient _radarrClient; private readonly ISonarrClient _sonarrClient; + private readonly IWhisparrV3Client _whisparrV3Client; private readonly FakeTimeProvider _timeProvider; private readonly SeekerUnitEvaluator _sut; private readonly SeekerConfig _config = new() { PostReleaseGraceHours = 6 }; private readonly ArrInstance _radarr; private readonly ArrInstance _sonarr; + private readonly ArrInstance _whisparr; public SeekerUnitEvaluatorTests() { @@ -36,9 +38,11 @@ public class SeekerUnitEvaluatorTests : IDisposable _eventsContext = TestDataContextFactory.CreateEvents(); _radarrClient = Substitute.For(); _sonarrClient = Substitute.For(); + _whisparrV3Client = Substitute.For(); _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero)); _radarr = TestDataContextFactory.AddRadarrInstance(_dataContext); _sonarr = TestDataContextFactory.AddSonarrInstance(_dataContext); + _whisparr = TestDataContextFactory.AddWhisparrInstance(_dataContext, version: 3); _radarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); _sonarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); @@ -46,12 +50,16 @@ public class SeekerUnitEvaluatorTests : IDisposable _sonarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]); _radarrClient.GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); _sonarrClient.GetCutoffUnmetEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); + _whisparrV3Client.GetAllTagsAsync(Arg.Any()).Returns([]); + _whisparrV3Client.GetQualityProfilesAsync(Arg.Any()).Returns([]); + _whisparrV3Client.GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); _sut = new SeekerUnitEvaluator( Substitute.For>(), _eventsContext, _radarrClient, _sonarrClient, + _whisparrV3Client, _timeProvider); } @@ -80,6 +88,7 @@ public class SeekerUnitEvaluatorTests : IDisposable bool hasFile = false, int qualityProfileId = 1, DateTimeOffset? digitalRelease = null, + DateTimeOffset? releaseDate = null, DateTimeOffset? added = null, DateTimeOffset? lastSearchTime = null, bool isAvailable = false) @@ -94,6 +103,7 @@ public class SeekerUnitEvaluatorTests : IDisposable HasFile = hasFile, QualityProfileId = qualityProfileId, DigitalRelease = digitalRelease, + ReleaseDate = releaseDate, Added = added, LastSearchTime = lastSearchTime, IsAvailable = isAvailable, @@ -193,6 +203,18 @@ public class SeekerUnitEvaluatorTests : IDisposable .Returns(wanted.Where(m => !m.Monitored).ToList()); } + private void SetupWhisparr(List library, List? missing = null) + { + List wanted = missing ?? library.Where(m => !m.HasFile).ToList(); + + _whisparrV3Client.StreamAllMoviesAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(library)); + _whisparrV3Client.GetMissingMoviesAsync(Arg.Any(), true, Arg.Any()) + .Returns(wanted.Where(m => m.Monitored).ToList()); + _whisparrV3Client.GetMissingMoviesAsync(Arg.Any(), false, Arg.Any()) + .Returns(wanted.Where(m => !m.Monitored).ToList()); + } + private void SetupSonarr(List library, List missing) { _sonarrClient.StreamAllSeriesAsync(Arg.Any(), Arg.Any()) @@ -219,6 +241,18 @@ public class SeekerUnitEvaluatorTests : IDisposable CancellationToken.None); } + private Task EvaluateWhisparrAsync(SeekerInstanceConfig? instanceConfig = null) + { + return _sut.EvaluateAsync( + _config, + instanceConfig ?? InstanceConfig(_whisparr), + _whisparr, + [], + new SeekerRecentGrabs(), + [], + CancellationToken.None); + } + private Task EvaluateSonarrAsync( SeekerInstanceConfig? instanceConfig = null, List? queue = null, @@ -1983,4 +2017,184 @@ public class SeekerUnitEvaluatorTests : IDisposable } #endregion + + #region Whisparr v3 + + [Fact] + public async Task EvaluateAsync_WhisparrV3_UsesTheWhisparrClientAndNeverTheRadarrClient() + { + // Arrange + _whisparrV3Client.GetAllTagsAsync(Arg.Any()).Returns([new Tag { Id = 7, Label = "skip" }]); + _whisparrV3Client.GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([Movie(2, hasFile: true)]); + SetupWhisparr([Movie(1, tags: [7]), Movie(2, hasFile: true), Movie(3)]); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync(InstanceConfig(_whisparr, c => + { + c.SkipTags = ["skip"]; + c.UseCutoff = true; + })); + + // Assert + ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.SkipTag); + evaluation.Units.Select(u => u.ItemId).Order().ShouldBe([2L, 3L]); + await _whisparrV3Client.Received().GetMissingMoviesAsync(_whisparr, true, Arg.Any()); + await _whisparrV3Client.Received().GetCutoffUnmetMoviesAsync(_whisparr, true, Arg.Any()); + _radarrClient.ReceivedCalls().ShouldBeEmpty(); + } + + [Fact] + public async Task ReadRecentGrabsAsync_WhisparrV3_ReadsMovieIdsFromTheWhisparrClient() + { + // Arrange + _whisparrV3Client.GetRecentlyGrabbedMovieIdsAsync(_whisparr, Arg.Any(), Arg.Any()) + .Returns(new HashSet { 4, 9 }); + + // Act + SeekerRecentGrabs grabs = await _sut.ReadRecentGrabsAsync( + InstanceConfig(_whisparr, c => c.RecentGrabWindowHours = 12), _whisparr, CancellationToken.None); + + // Assert + grabs.MovieIds.ShouldBe([4L, 9L], ignoreOrder: true); + await _radarrClient.DidNotReceiveWithAnyArgs().GetRecentlyGrabbedMovieIdsAsync(default!, default, default); + } + + [Fact] + public async Task EvaluateAsync_WhisparrV3_BelowCustomFormatCutoff_UsesWhisparrScoreEntries() + { + // Arrange + SetupWhisparr([Movie(1, hasFile: true, qualityProfileId: 4)]); + _whisparrV3Client.GetQualityProfilesAsync(Arg.Any()) + .Returns([new ArrQualityProfile { Id = 4, Name = "Open", UpgradeAllowed = true }]); + _eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = _whisparr.Id, + ExternalItemId = 1, + ItemType = InstanceType.Whisparr, + CurrentScore = 5, + CutoffScore = 10, + }); + await _eventsContext.SaveChangesAsync(); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync(InstanceConfig(_whisparr, c => c.UseCustomFormatScore = true)); + + // Assert + SeekerSearchUnit unit = evaluation.Units.ShouldHaveSingleItem(); + unit.Reasons.ShouldBe([SeekerSearchReason.CustomFormatScoreBelowCutoff]); + } + + [Fact] + public async Task EvaluateAsync_WhisparrV3_RadarrTypedScoreEntries_AreIgnored() + { + // Arrange + SetupWhisparr([Movie(1, hasFile: true, qualityProfileId: 4)]); + _whisparrV3Client.GetQualityProfilesAsync(Arg.Any()) + .Returns([new ArrQualityProfile { Id = 4, Name = "Open", UpgradeAllowed = true }]); + _eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = _whisparr.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CurrentScore = 5, + CutoffScore = 10, + }); + await _eventsContext.SaveChangesAsync(); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync(InstanceConfig(_whisparr, c => c.UseCustomFormatScore = true)); + + // Assert + evaluation.Units.ShouldBeEmpty(); + ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.NothingToSearch); + } + + [Fact] + public async Task EvaluateAsync_WhisparrV3_StatusNotReleased_ExcludesAsNotReleased() + { + // Arrange + SetupWhisparr([Movie(1, status: "announced")]); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync(); + + // Assert + ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.NotReleased); + evaluation.Units.ShouldBeEmpty(); + } + + [Fact] + public async Task EvaluateAsync_WhisparrV3_ReleaseDateInsideGraceWindow_ExcludesAsInGracePeriod() + { + // Arrange + SetupWhisparr([Movie(1, releaseDate: _timeProvider.GetUtcNow().AddHours(-1))]); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync(); + + // Assert + ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.InGracePeriod); + } + + [Fact] + public async Task EvaluateAsync_WhisparrV3_ReleaseDatePastGraceWindow_BuildsUnit() + { + // Arrange + SetupWhisparr([Movie(1, releaseDate: _timeProvider.GetUtcNow().AddDays(-30))]); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync(); + + // Assert + evaluation.Units.ShouldHaveSingleItem().Name.ShouldBe("Movie 1"); + } + + [Fact] + public async Task EvaluateAsync_WhisparrV3_ArrAvailabilityGateConfigured_IsIgnored() + { + // Arrange + SetupWhisparr([Movie(1, releaseDate: _timeProvider.GetUtcNow().AddDays(-30), isAvailable: false)]); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync( + InstanceConfig(_whisparr, c => c.RadarrReleaseGate = RadarrReleaseGate.ArrAvailability)); + + // Assert + evaluation.Units.ShouldHaveSingleItem().Name.ShouldBe("Movie 1"); + } + + [Fact] + public async Task EvaluateAsync_WhisparrV3_WantedEndpointNotFound_FlagsWantedUnsupported() + { + // Arrange + SetupWhisparr([Movie(1), Movie(2)]); + _whisparrV3Client.GetMissingMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound)); + + // Act + SeekerEvaluation evaluation = await EvaluateWhisparrAsync(); + + // Assert + evaluation.WantedUnsupported.ShouldBeTrue(); + evaluation.Units.ShouldBeEmpty(); + } + + [Fact] + public async Task EvaluateAsync_Radarr_ReleaseDateInsideGraceWindowButDigitalReleasePast_BuildsUnit() + { + // Arrange + SetupRadarr([Movie( + 1, + digitalRelease: _timeProvider.GetUtcNow().AddDays(-30), + releaseDate: _timeProvider.GetUtcNow().AddHours(-1))]); + + // Act + SeekerEvaluation evaluation = await EvaluateRadarrAsync(); + + // Assert + evaluation.Units.ShouldHaveSingleItem().Name.ShouldBe("Movie 1"); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index af4b3da0..03f6632d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -231,7 +231,7 @@ public sealed class Seeker : IHandler .ToListAsync(); instanceConfigs = instanceConfigs - .Where(s => s.ArrInstance.ArrConfig.Type is InstanceType.Sonarr or InstanceType.Radarr) + .Where(s => SeekerApps.IsSupported(s.ArrInstance)) .ToList(); if (instanceConfigs.Count == 0) @@ -496,7 +496,7 @@ public sealed class Seeker : IHandler SearchItem searchItem = instanceType switch { - InstanceType.Radarr => new SearchItem { Id = candidate.ItemId }, + _ when SeekerApps.SearchesMovies(arrInstance) => new SearchItem { Id = candidate.ItemId }, _ when candidate.EpisodeId > 0 => new SeriesSearchItem { Id = candidate.EpisodeId, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs new file mode 100644 index 00000000..ecd769a6 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs @@ -0,0 +1,27 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Infrastructure.Features.Seeker; + +public static class SeekerApps +{ + /// + /// True for the instances Seeker searches proactively: Sonarr, Radarr and Whisparr v3. + /// + public static bool IsSupported(ArrInstance arrInstance) => arrInstance.ArrConfig.Type switch + { + InstanceType.Sonarr or InstanceType.Radarr => true, + InstanceType.Whisparr => arrInstance.Version is 3, + _ => false, + }; + + /// + /// True for the instances that search one movie per unit: Radarr and Whisparr v3. + /// + public static bool SearchesMovies(ArrInstance arrInstance) => arrInstance.ArrConfig.Type switch + { + InstanceType.Radarr => true, + InstanceType.Whisparr => arrInstance.Version is 3, + _ => false, + }; +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs index 6a59a03e..7245dd49 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs @@ -38,6 +38,7 @@ public sealed class SeekerUnitEvaluator private readonly EventsContext _eventsContext; private readonly IRadarrClient _radarrClient; private readonly ISonarrClient _sonarrClient; + private readonly IWhisparrV3Client _whisparrV3Client; private readonly TimeProvider _timeProvider; public SeekerUnitEvaluator( @@ -45,12 +46,14 @@ public sealed class SeekerUnitEvaluator EventsContext eventsContext, IRadarrClient radarrClient, ISonarrClient sonarrClient, + IWhisparrV3Client whisparrV3Client, TimeProvider timeProvider) { _logger = logger; _eventsContext = eventsContext; _radarrClient = radarrClient; _sonarrClient = sonarrClient; + _whisparrV3Client = whisparrV3Client; _timeProvider = timeProvider; } @@ -69,11 +72,11 @@ public sealed class SeekerUnitEvaluator DateTimeOffset since = _timeProvider.GetUtcNow().AddHours(-instanceConfig.RecentGrabWindowHours); - if (arrInstance.ArrConfig.Type == InstanceType.Radarr) + if (SeekerApps.SearchesMovies(arrInstance)) { return new SeekerRecentGrabs { - MovieIds = await _radarrClient.GetRecentlyGrabbedMovieIdsAsync(arrInstance, since, cancellationToken) + MovieIds = await MovieClient(arrInstance).GetRecentlyGrabbedMovieIdsAsync(arrInstance, since, cancellationToken) }; } @@ -106,9 +109,9 @@ public sealed class SeekerUnitEvaluator Dictionary<(long ItemId, int SeasonNumber, long EpisodeId), SeekerUnitRest> rests = await ReadRestsAsync(instanceConfig, arrInstance, cancellationToken); - if (arrInstance.ArrConfig.Type == InstanceType.Radarr) + if (SeekerApps.SearchesMovies(arrInstance)) { - return await EvaluateRadarrAsync(config, instanceConfig, arrInstance, activeQueueRecords, + return await EvaluateMoviesAsync(config, instanceConfig, arrInstance, activeQueueRecords, recentGrabs.MovieIds, rests, lastSearchedByItem, cancellationToken); } @@ -269,7 +272,10 @@ public sealed class SeekerUnitEvaluator .ToArray(); } - private async Task EvaluateRadarrAsync( + private IRadarrClient MovieClient(ArrInstance arrInstance) => + arrInstance.ArrConfig.Type is InstanceType.Whisparr ? _whisparrV3Client : _radarrClient; + + private async Task EvaluateMoviesAsync( SeekerConfig config, SeekerInstanceConfig instanceConfig, ArrInstance arrInstance, @@ -279,7 +285,9 @@ public sealed class SeekerUnitEvaluator Dictionary lastSearchedByItem, CancellationToken cancellationToken) { - List tags = await _radarrClient.GetAllTagsAsync(arrInstance); + IRadarrClient movieClient = MovieClient(arrInstance); + InstanceType instanceType = arrInstance.ArrConfig.Type; + List tags = await movieClient.GetAllTagsAsync(arrInstance); Dictionary tagsById = tags.ToDictionary(t => t.Id, t => t.Label); HashSet skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase); HashSet includeTagSet = new(instanceConfig.IncludeTags, StringComparer.InvariantCultureIgnoreCase); @@ -291,25 +299,28 @@ public sealed class SeekerUnitEvaluator { cfScores = await _eventsContext.CustomFormatScoreEntries .AsNoTracking() - .Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == InstanceType.Radarr) + .Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == instanceType) .ToDictionaryAsync(e => e.ExternalItemId, cancellationToken); - List profiles = await _radarrClient.GetQualityProfilesAsync(arrInstance); + List profiles = await movieClient.GetQualityProfilesAsync(arrInstance); upgradesDisabledProfileIds = profiles.Where(p => !p.UpgradeAllowed).Select(p => p.Id).ToHashSet(); } DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours); + RadarrReleaseGate releaseGate = instanceType is InstanceType.Radarr + ? instanceConfig.RadarrReleaseGate + : RadarrReleaseGate.ReleaseDate; List allLibraryIds = new(); List exclusions = []; int releasedCount = 0; int monitoredCount = 0; List released = new(); - await foreach (SearchableMovie movie in _radarrClient.StreamAllMoviesAsync(arrInstance, cancellationToken)) + await foreach (SearchableMovie movie in movieClient.StreamAllMoviesAsync(arrInstance, cancellationToken)) { allLibraryIds.Add(movie.Id); - SeekerExclusionReason? releaseExclusion = GetReleaseExclusion(movie, instanceConfig.RadarrReleaseGate, graceCutoff); + SeekerExclusionReason? releaseExclusion = GetReleaseExclusion(movie, releaseGate, graceCutoff); if (releaseExclusion is not null) { exclusions.Add(new SeekerExclusion(movie.Title, releaseExclusion.Value)); @@ -341,21 +352,22 @@ public sealed class SeekerUnitEvaluator try { List missing = await ReadWantedAsync( - monitored => _radarrClient.GetMissingMoviesAsync(arrInstance, monitored, cancellationToken), + monitored => movieClient.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), + monitored => movieClient.GetCutoffUnmetMoviesAsync(arrInstance, monitored, cancellationToken), instanceConfig.MonitoredOnly); cutoffUnmetIds = cutoffUnmet.Select(m => m.Id).ToHashSet(); } } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.NotFound) { - _logger.LogWarning(ex, "Skipping proactive search for {InstanceName}, it needs Radarr v5.6 or newer", arrInstance.Name); + string minimumVersion = instanceType is InstanceType.Radarr ? "Radarr v5.6" : "Whisparr v3.1"; + _logger.LogWarning(ex, "Skipping proactive search for {InstanceName}, it needs {MinimumVersion} or newer", arrInstance.Name, minimumVersion); SeekerEvaluation Unsupported() => new() { @@ -937,7 +949,7 @@ public sealed class SeekerUnitEvaluator /// private static bool IsMoviePastGracePeriod(SearchableMovie movie, DateTimeOffset graceCutoff) { - DateTimeOffset? releaseDate = movie.DigitalRelease ?? movie.PhysicalRelease ?? movie.InCinemas; + DateTimeOffset? releaseDate = movie.DigitalRelease ?? movie.PhysicalRelease ?? movie.InCinemas ?? movie.ReleaseDate; return releaseDate is null || releaseDate.Value <= graceCutoff; }