added whisparr v3 to seeker proactive search

This commit is contained in:
Flaminel committed 2026-10-28 22:27:52 +02:00
1 parent 1d125adf05
commit 01532bcf1a
9 files changed
+442 -16

No files matched your search

@@ -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<IRadarrClient>();
_sonarrClient = Substitute.For<ISonarrClient>();
_whisparrV3Client = Substitute.For<IWhisparrV3Client>();
_arrClientFactory = Substitute.For<IArrClientFactory>();
_arrQueueReader = Substitute.For<IArrQueueReader>();
FakeTimeProvider timeProvider = new(Now);
@@ -60,6 +62,7 @@ public class SeekerPreviewControllerTests : IDisposable
_eventsContext,
_radarrClient,
_sonarrClient,
_whisparrV3Client,
timeProvider);
_controller = new SeekerPreviewController(
@@ -26,6 +26,11 @@ public sealed record SearchableMovie
public DateTimeOffset? InCinemas { get; init; }
/// <summary>
/// Whisparr v3's only release date.
/// </summary>
public DateTimeOffset? ReleaseDate { get; init; }
/// <summary>
/// Radarr's own verdict from the movie's minimum availability and the availability delay.
/// </summary>
@@ -53,6 +53,7 @@ public class SeekerIntegrationTests : IDisposable
_fixture.EventsContext,
Substitute.For<IRadarrClient>(),
Substitute.For<ISonarrClient>(),
Substitute.For<IWhisparrV3Client>(),
_fixture.TimeProvider));
}
@@ -30,6 +30,7 @@ public class SeekerTests : IDisposable
private readonly ILogger<SeekerJob> _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<ILogger<SeekerJob>>();
_radarrClient = Substitute.For<IRadarrClient>();
_sonarrClient = Substitute.For<ISonarrClient>();
_whisparrV3Client = Substitute.For<IWhisparrV3Client>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_hostingEnvironment = Substitute.For<IHostEnvironment>();
_statusNotifier = Substitute.For<IStatusNotifier>();
@@ -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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
List<SearchableMovie> movies =
[
new SearchableMovie { Id = 7, Title = "Movie 7", Status = "released", Monitored = true, HasFile = false, Tags = [] }
];
_whisparrV3Client
.StreamAllMoviesAsync(whisparrInstance, Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable<SearchableMovie>(movies));
_whisparrV3Client
.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(movies);
_whisparrV3Client
.GetCutoffUnmetMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns([]);
_whisparrV3Client
.GetAllTagsAsync(Arg.Any<ArrInstance>())
.Returns([]);
_whisparrV3Client
.GetRecentlyGrabbedMovieIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns([]);
SearchItem? capturedSearchItem = null;
mockArrClient
.SearchItemAsync(whisparrInstance, Arg.Do<SearchItem>(item => capturedSearchItem = item))
.Returns(42L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Whisparr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
capturedSearchItem.ShouldNotBeNull();
capturedSearchItem.ShouldNotBeOfType<SeriesSearchItem>();
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<IArrClient>();
_fixture.ArrClientFactory
.GetClient(InstanceType.Whisparr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockArrClient.DidNotReceive().SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
_whisparrV3Client.ReceivedCalls().ShouldBeEmpty();
}
#endregion
}
@@ -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);
}
}
@@ -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<IRadarrClient>();
_sonarrClient = Substitute.For<ISonarrClient>();
_whisparrV3Client = Substitute.For<IWhisparrV3Client>();
_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<ArrInstance>()).Returns([]);
_sonarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
@@ -46,12 +50,16 @@ public class SeekerUnitEvaluatorTests : IDisposable
_sonarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>()).Returns([]);
_radarrClient.GetCutoffUnmetMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns([]);
_sonarrClient.GetCutoffUnmetEpisodesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns([]);
_whisparrV3Client.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
_whisparrV3Client.GetQualityProfilesAsync(Arg.Any<ArrInstance>()).Returns([]);
_whisparrV3Client.GetCutoffUnmetMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns([]);
_sut = new SeekerUnitEvaluator(
Substitute.For<ILogger<SeekerUnitEvaluator>>(),
_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<SearchableMovie> library, List<SearchableMovie>? missing = null)
{
List<SearchableMovie> wanted = missing ?? library.Where(m => !m.HasFile).ToList();
_whisparrV3Client.StreamAllMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable(library));
_whisparrV3Client.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
.Returns(wanted.Where(m => m.Monitored).ToList());
_whisparrV3Client.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>())
.Returns(wanted.Where(m => !m.Monitored).ToList());
}
private void SetupSonarr(List<SearchableSeries> library, List<SearchableEpisode> missing)
{
_sonarrClient.StreamAllSeriesAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
@@ -219,6 +241,18 @@ public class SeekerUnitEvaluatorTests : IDisposable
CancellationToken.None);
}
private Task<SeekerEvaluation> EvaluateWhisparrAsync(SeekerInstanceConfig? instanceConfig = null)
{
return _sut.EvaluateAsync(
_config,
instanceConfig ?? InstanceConfig(_whisparr),
_whisparr,
[],
new SeekerRecentGrabs(),
[],
CancellationToken.None);
}
private Task<SeekerEvaluation> EvaluateSonarrAsync(
SeekerInstanceConfig? instanceConfig = null,
List<QueueRecord>? 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<ArrInstance>()).Returns([new Tag { Id = 7, Label = "skip" }]);
_whisparrV3Client.GetCutoffUnmetMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.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<CancellationToken>());
await _whisparrV3Client.Received().GetCutoffUnmetMoviesAsync(_whisparr, true, Arg.Any<CancellationToken>());
_radarrClient.ReceivedCalls().ShouldBeEmpty();
}
[Fact]
public async Task ReadRecentGrabsAsync_WhisparrV3_ReadsMovieIdsFromTheWhisparrClient()
{
// Arrange
_whisparrV3Client.GetRecentlyGrabbedMovieIdsAsync(_whisparr, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new HashSet<long> { 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<ArrInstance>())
.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<ArrInstance>())
.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<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.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
}
@@ -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,
@@ -0,0 +1,27 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Infrastructure.Features.Seeker;
public static class SeekerApps
{
/// <summary>
/// True for the instances Seeker searches proactively: Sonarr, Radarr and Whisparr v3.
/// </summary>
public static bool IsSupported(ArrInstance arrInstance) => arrInstance.ArrConfig.Type switch
{
InstanceType.Sonarr or InstanceType.Radarr => true,
InstanceType.Whisparr => arrInstance.Version is 3,
_ => false,
};
/// <summary>
/// True for the instances that search one movie per unit: Radarr and Whisparr v3.
/// </summary>
public static bool SearchesMovies(ArrInstance arrInstance) => arrInstance.ArrConfig.Type switch
{
InstanceType.Radarr => true,
InstanceType.Whisparr => arrInstance.Version is 3,
_ => false,
};
}
@@ -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<SeekerEvaluation> EvaluateRadarrAsync(
private IRadarrClient MovieClient(ArrInstance arrInstance) =>
arrInstance.ArrConfig.Type is InstanceType.Whisparr ? _whisparrV3Client : _radarrClient;
private async Task<SeekerEvaluation> EvaluateMoviesAsync(
SeekerConfig config,
SeekerInstanceConfig instanceConfig,
ArrInstance arrInstance,
@@ -279,7 +285,9 @@ public sealed class SeekerUnitEvaluator
Dictionary<long, DateTimeOffset> lastSearchedByItem,
CancellationToken cancellationToken)
{
List<Tag> tags = await _radarrClient.GetAllTagsAsync(arrInstance);
IRadarrClient movieClient = MovieClient(arrInstance);
InstanceType instanceType = arrInstance.ArrConfig.Type;
List<Tag> tags = await movieClient.GetAllTagsAsync(arrInstance);
Dictionary<long, string> tagsById = tags.ToDictionary(t => t.Id, t => t.Label);
HashSet<string> skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase);
HashSet<string> 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<ArrQualityProfile> profiles = await _radarrClient.GetQualityProfilesAsync(arrInstance);
List<ArrQualityProfile> 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<long> allLibraryIds = new();
List<SeekerExclusion> exclusions = [];
int releasedCount = 0;
int monitoredCount = 0;
List<SearchableMovie> 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<SearchableMovie> 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<SearchableMovie> 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
/// </summary>
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;
}