mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-11-02 20:35:04 -05:00
added whisparr v3 to seeker proactive search
This commit is contained in:
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>
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
}
|
||||
+214
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user