mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added lidarr to seeker proactive search
This commit is contained in:
1 parent
aacb2c9ebc
commit
f0cdd3ed3d
14 files changed
+967
-19
No files matched your search
@@ -214,7 +214,7 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
public async Task GetSeekerConfig_ListsSonarrRadarrAndLidarrInstances()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
@@ -224,10 +224,10 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.Count.ShouldBe(2);
|
||||
response.Instances.Count.ShouldBe(3);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
|
||||
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == lidarr.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -30,6 +30,7 @@ public class SeekerPreviewControllerTests : IDisposable
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IWhisparrV3Client _whisparrV3Client;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IArrQueueReader _arrQueueReader;
|
||||
@@ -41,6 +42,7 @@ public class SeekerPreviewControllerTests : IDisposable
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_radarrClient = Substitute.For<IRadarrClient>();
|
||||
_sonarrClient = Substitute.For<ISonarrClient>();
|
||||
_lidarrClient = Substitute.For<ILidarrClient>();
|
||||
_whisparrV3Client = Substitute.For<IWhisparrV3Client>();
|
||||
_arrClientFactory = Substitute.For<IArrClientFactory>();
|
||||
_arrQueueReader = Substitute.For<IArrQueueReader>();
|
||||
@@ -62,6 +64,7 @@ public class SeekerPreviewControllerTests : IDisposable
|
||||
_eventsContext,
|
||||
_radarrClient,
|
||||
_sonarrClient,
|
||||
_lidarrClient,
|
||||
_whisparrV3Client,
|
||||
timeProvider);
|
||||
|
||||
@@ -218,15 +221,39 @@ public class SeekerPreviewControllerTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPreview_UnsupportedInstanceType_ReturnsBadRequest()
|
||||
public async Task GetPreview_Lidarr_ReadsTheLibraryThroughTheLidarrClient()
|
||||
{
|
||||
ArrInstance lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig { ArrInstanceId = lidarr.Id, Enabled = true });
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = lidarr.Id,
|
||||
Enabled = true,
|
||||
CurrentCycleId = Guid.NewGuid(),
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
List<SearchableArtist> artists = [new SearchableArtist { Id = 1, ArtistName = "Artist 1", Monitored = true }];
|
||||
List<SearchableAlbum> albums =
|
||||
[
|
||||
new SearchableAlbum { Id = 10, Title = "Album 10", ArtistId = 1, Monitored = true, ReleaseDate = Now.AddDays(-30) },
|
||||
];
|
||||
_lidarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
|
||||
_lidarrClient
|
||||
.GetRecentlyGrabbedAlbumIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new HashSet<long>());
|
||||
_lidarrClient.StreamAllArtistsAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ToAsyncEnumerable(artists));
|
||||
_lidarrClient.GetMissingAlbumsAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
|
||||
.Returns(albums);
|
||||
_lidarrClient.GetCutoffUnmetAlbumsAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
IActionResult result = await _controller.GetPreview(lidarr.Id, CancellationToken.None);
|
||||
SeekerPreviewResponse response = Preview(await _controller.GetPreview(lidarr.Id, CancellationToken.None));
|
||||
|
||||
StatusOf(result).ShouldBe(StatusCodes.Status400BadRequest);
|
||||
SeekerPreviewUnitResponse unit = response.NextUp.ShouldHaveSingleItem();
|
||||
unit.Name.ShouldBe("Artist 1 - Album 10");
|
||||
unit.Reason.ShouldBe(SeekerSearchReason.Missing);
|
||||
response.Funnel.LibraryItems.ShouldBe(1);
|
||||
_sonarrClient.ReceivedCalls().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ public class SeekerIntegrationTests : IDisposable
|
||||
_fixture.EventsContext,
|
||||
Substitute.For<IRadarrClient>(),
|
||||
Substitute.For<ISonarrClient>(),
|
||||
Substitute.For<ILidarrClient>(),
|
||||
Substitute.For<IWhisparrV3Client>(),
|
||||
_fixture.TimeProvider));
|
||||
}
|
||||
|
||||
+31
@@ -923,6 +923,37 @@ public class SeekerCommandMonitorTests : IAsyncDisposable
|
||||
grabbedItems!.ShouldBe(["Example.Album.FLAC"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reports_the_grab_of_a_proactive_lidarr_album_search()
|
||||
{
|
||||
// Arrange
|
||||
ArrInstance lidarrInstance = TestDataContextFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
// Act
|
||||
List<string>? grabbedItems = await CaptureGrabbedItemsAsync(
|
||||
Tracker(lidarrInstance.Id, externalItemId: 5, episodeId: 77, itemTitle: "Example Artist - Example Album"),
|
||||
new QueueRecord { Id = 1, ArtistId = 5, AlbumId = 77, Title = "Example.Album.FLAC", DownloadId = "AL77", Protocol = "torrent", Status = "downloading" });
|
||||
|
||||
// Assert
|
||||
grabbedItems.ShouldNotBeNull();
|
||||
grabbedItems!.ShouldBe(["Example.Album.FLAC"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ignores_another_album_of_a_proactive_lidarr_search()
|
||||
{
|
||||
// Arrange
|
||||
ArrInstance lidarrInstance = TestDataContextFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
// Act
|
||||
List<string>? grabbedItems = await CaptureGrabbedItemsAsync(
|
||||
Tracker(lidarrInstance.Id, externalItemId: 5, episodeId: 77, itemTitle: "Example Artist - Example Album"),
|
||||
new QueueRecord { Id = 1, ArtistId = 5, AlbumId = 78, Title = "Example.Other.FLAC", DownloadId = "AL78", Protocol = "torrent", Status = "downloading" });
|
||||
|
||||
// Assert
|
||||
grabbedItems.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reports_the_grab_of_a_readarr_search()
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ public class SeekerTests : IDisposable
|
||||
private readonly ILogger<SeekerJob> _logger;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IWhisparrV3Client _whisparrV3Client;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
private readonly IHostEnvironment _hostingEnvironment;
|
||||
@@ -44,6 +45,7 @@ public class SeekerTests : IDisposable
|
||||
_logger = Substitute.For<ILogger<SeekerJob>>();
|
||||
_radarrClient = Substitute.For<IRadarrClient>();
|
||||
_sonarrClient = Substitute.For<ISonarrClient>();
|
||||
_lidarrClient = Substitute.For<ILidarrClient>();
|
||||
_whisparrV3Client = Substitute.For<IWhisparrV3Client>();
|
||||
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
_hostingEnvironment = Substitute.For<IHostEnvironment>();
|
||||
@@ -123,6 +125,7 @@ public class SeekerTests : IDisposable
|
||||
_fixture.EventsContext,
|
||||
_radarrClient,
|
||||
_sonarrClient,
|
||||
_lidarrClient,
|
||||
_whisparrV3Client,
|
||||
_fixture.TimeProvider)
|
||||
);
|
||||
@@ -10521,6 +10524,126 @@ public class SeekerTests : IDisposable
|
||||
tracker.ExternalItemId.ShouldBe(7);
|
||||
}
|
||||
|
||||
private async Task<(ArrInstance Instance, IArrClient ArrClient)> SetUpLidarrProactiveAsync()
|
||||
{
|
||||
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
ArrInstance lidarrInstance = TestDataContextFactory.AddLidarrInstance(_fixture.DataContext);
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = lidarrInstance.Id,
|
||||
ArrInstance = lidarrInstance,
|
||||
Enabled = true,
|
||||
MonitoredOnly = false,
|
||||
CurrentCycleId = Guid.NewGuid(),
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
IArrClient mockArrClient = Substitute.For<IArrClient>();
|
||||
_fixture.ArrQueueReader
|
||||
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
|
||||
List<SearchableArtist> artists = [new SearchableArtist { Id = 5, ArtistName = "Artist 5", Monitored = true }];
|
||||
List<SearchableAlbum> albums =
|
||||
[
|
||||
new SearchableAlbum { Id = 77, Title = "Album 77", ArtistId = 5, Monitored = true },
|
||||
];
|
||||
_lidarrClient
|
||||
.StreamAllArtistsAsync(lidarrInstance, Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ToAsyncEnumerable<SearchableArtist>(artists));
|
||||
_lidarrClient
|
||||
.GetMissingAlbumsAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(albums);
|
||||
_lidarrClient
|
||||
.GetCutoffUnmetAlbumsAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
_lidarrClient
|
||||
.GetAllTagsAsync(Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
_lidarrClient
|
||||
.GetRecentlyGrabbedAlbumIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Lidarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
return (lidarrInstance, mockArrClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Lidarr_ProactiveSearch_SearchesTheAlbumAndTracksArtistAndAlbum()
|
||||
{
|
||||
// Arrange
|
||||
(ArrInstance lidarrInstance, IArrClient mockArrClient) = await SetUpLidarrProactiveAsync();
|
||||
SearchItem? capturedSearchItem = null;
|
||||
mockArrClient
|
||||
.SearchItemAsync(lidarrInstance, Arg.Do<SearchItem>(item => capturedSearchItem = item))
|
||||
.Returns(42L);
|
||||
|
||||
SeekerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
capturedSearchItem.ShouldNotBeNull();
|
||||
capturedSearchItem.ShouldNotBeOfType<SeriesSearchItem>();
|
||||
capturedSearchItem.Id.ShouldBe(77);
|
||||
|
||||
SeekerHistory history = await _fixture.EventsContext.SeekerHistory
|
||||
.SingleAsync(h => h.ArrInstanceId == lidarrInstance.Id);
|
||||
history.ItemType.ShouldBe(InstanceType.Lidarr);
|
||||
history.ExternalItemId.ShouldBe(5);
|
||||
history.SeasonNumber.ShouldBe(0);
|
||||
history.EpisodeId.ShouldBe(77);
|
||||
history.ItemTitle.ShouldBe("Artist 5 - Album 77");
|
||||
|
||||
SeekerCommandTracker tracker = await _fixture.EventsContext.SeekerCommandTrackers
|
||||
.SingleAsync(t => t.ArrInstanceId == lidarrInstance.Id);
|
||||
tracker.CommandId.ShouldBe(42L);
|
||||
tracker.ExternalItemId.ShouldBe(5);
|
||||
tracker.SeasonNumber.ShouldBe(0);
|
||||
tracker.EpisodeId.ShouldBe(77);
|
||||
_sonarrClient.ReceivedCalls().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Lidarr_CleansUpHistoryOfArtistsOutsideTheLibrary()
|
||||
{
|
||||
// Arrange
|
||||
(ArrInstance lidarrInstance, IArrClient mockArrClient) = await SetUpLidarrProactiveAsync();
|
||||
Guid cycleId = (await _fixture.DataContext.SeekerInstanceConfigs
|
||||
.SingleAsync(c => c.ArrInstanceId == lidarrInstance.Id)).CurrentCycleId;
|
||||
_fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = lidarrInstance.Id,
|
||||
ExternalItemId = 99,
|
||||
ItemType = InstanceType.Lidarr,
|
||||
EpisodeId = 990,
|
||||
CycleId = cycleId,
|
||||
LastSearchedAt = DateTime.UtcNow.AddDays(-5),
|
||||
ItemTitle = "Deleted Artist - Album",
|
||||
});
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
mockArrClient
|
||||
.SearchItemAsync(lidarrInstance, Arg.Any<SearchItem>())
|
||||
.Returns(42L);
|
||||
|
||||
SeekerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
(await _fixture.EventsContext.SeekerHistory.AnyAsync(h => h.ExternalItemId == 99)).ShouldBeFalse();
|
||||
(await _fixture.EventsContext.SeekerHistory.AnyAsync(h => h.ExternalItemId == 5)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhisparrV2_ProactiveSearch_SkipsTheInstance()
|
||||
{
|
||||
|
||||
@@ -25,9 +25,10 @@ public class SeekerAppsTests
|
||||
[InlineData(InstanceType.Radarr, 0, true)]
|
||||
[InlineData(InstanceType.Whisparr, 2, false)]
|
||||
[InlineData(InstanceType.Whisparr, 3, true)]
|
||||
[InlineData(InstanceType.Lidarr, 0, false)]
|
||||
[InlineData(InstanceType.Lidarr, 0, true)]
|
||||
[InlineData(InstanceType.Lidarr, 2, true)]
|
||||
[InlineData(InstanceType.Readarr, 0, false)]
|
||||
public void IsSupported_ReturnsTrueOnlyForSonarrRadarrAndWhisparrV3(InstanceType type, float version, bool expected)
|
||||
public void IsSupported_ReturnsTrueOnlyForSonarrRadarrLidarrAndWhisparrV3(InstanceType type, float version, bool expected)
|
||||
{
|
||||
// Act
|
||||
bool supported = SeekerApps.IsSupported(Instance(type, version));
|
||||
|
||||
+512
@@ -24,6 +24,7 @@ public class SeekerUnitEvaluatorTests : IDisposable
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IWhisparrV3Client _whisparrV3Client;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SeekerUnitEvaluator _sut;
|
||||
@@ -31,6 +32,7 @@ public class SeekerUnitEvaluatorTests : IDisposable
|
||||
private readonly ArrInstance _radarr;
|
||||
private readonly ArrInstance _sonarr;
|
||||
private readonly ArrInstance _whisparr;
|
||||
private readonly ArrInstance _lidarr;
|
||||
|
||||
public SeekerUnitEvaluatorTests()
|
||||
{
|
||||
@@ -38,11 +40,13 @@ public class SeekerUnitEvaluatorTests : IDisposable
|
||||
_eventsContext = TestDataContextFactory.CreateEvents();
|
||||
_radarrClient = Substitute.For<IRadarrClient>();
|
||||
_sonarrClient = Substitute.For<ISonarrClient>();
|
||||
_lidarrClient = Substitute.For<ILidarrClient>();
|
||||
_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);
|
||||
_lidarr = TestDataContextFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
|
||||
_sonarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
|
||||
@@ -50,6 +54,9 @@ 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([]);
|
||||
_lidarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
|
||||
_lidarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>()).Returns([]);
|
||||
_lidarrClient.GetCutoffUnmetAlbumsAsync(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([]);
|
||||
@@ -59,6 +66,7 @@ public class SeekerUnitEvaluatorTests : IDisposable
|
||||
_eventsContext,
|
||||
_radarrClient,
|
||||
_sonarrClient,
|
||||
_lidarrClient,
|
||||
_whisparrV3Client,
|
||||
_timeProvider);
|
||||
}
|
||||
@@ -2197,4 +2205,508 @@ public class SeekerUnitEvaluatorTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lidarr
|
||||
|
||||
private static SearchableArtist Artist(
|
||||
long id,
|
||||
bool monitored = true,
|
||||
List<long>? tags = null,
|
||||
int qualityProfileId = 0,
|
||||
DateTimeOffset? added = null)
|
||||
{
|
||||
return new SearchableArtist
|
||||
{
|
||||
Id = id,
|
||||
ArtistName = $"Artist {id}",
|
||||
Monitored = monitored,
|
||||
Tags = tags ?? [],
|
||||
QualityProfileId = qualityProfileId,
|
||||
Added = added,
|
||||
};
|
||||
}
|
||||
|
||||
private SearchableAlbum Album(
|
||||
long id,
|
||||
long artistId,
|
||||
bool monitored = true,
|
||||
DateTimeOffset? releaseDate = null,
|
||||
bool noReleaseDate = false,
|
||||
DateTimeOffset? lastSearchTime = null)
|
||||
{
|
||||
return new SearchableAlbum
|
||||
{
|
||||
Id = id,
|
||||
Title = $"Album {id}",
|
||||
ArtistId = artistId,
|
||||
Monitored = monitored,
|
||||
ReleaseDate = noReleaseDate ? null : releaseDate ?? _timeProvider.GetUtcNow().AddDays(-30),
|
||||
LastSearchTime = lastSearchTime,
|
||||
};
|
||||
}
|
||||
|
||||
private void SetupLidarr(List<SearchableArtist> library, List<SearchableAlbum> missing, List<SearchableAlbum>? cutoffUnmet = null)
|
||||
{
|
||||
_lidarrClient.StreamAllArtistsAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ToAsyncEnumerable(library));
|
||||
_lidarrClient.GetMissingAlbumsAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
|
||||
.Returns(missing.Where(a => a.Monitored).ToList());
|
||||
_lidarrClient.GetMissingAlbumsAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>())
|
||||
.Returns(missing.Where(a => !a.Monitored).ToList());
|
||||
|
||||
if (cutoffUnmet is not null)
|
||||
{
|
||||
_lidarrClient.GetCutoffUnmetAlbumsAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
|
||||
.Returns(cutoffUnmet.Where(a => a.Monitored).ToList());
|
||||
_lidarrClient.GetCutoffUnmetAlbumsAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>())
|
||||
.Returns(cutoffUnmet.Where(a => !a.Monitored).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private Task<SeekerEvaluation> EvaluateLidarrAsync(
|
||||
SeekerInstanceConfig? instanceConfig = null,
|
||||
List<QueueRecord>? queue = null,
|
||||
SeekerRecentGrabs? recentGrabs = null,
|
||||
List<SeekerHistory>? history = null)
|
||||
{
|
||||
return _sut.EvaluateAsync(
|
||||
_config,
|
||||
instanceConfig ?? InstanceConfig(_lidarr),
|
||||
_lidarr,
|
||||
queue ?? [],
|
||||
recentGrabs ?? new SeekerRecentGrabs(),
|
||||
history ?? [],
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AddLidarrCustomFormatEntryAsync(
|
||||
long artistId,
|
||||
long albumId,
|
||||
int currentScore,
|
||||
int cutoffScore,
|
||||
bool monitored = true,
|
||||
DateTimeOffset? airDate = null)
|
||||
{
|
||||
await _eventsContext.CustomFormatScoreEntries.AddAsync(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = _lidarr.Id,
|
||||
ExternalItemId = artistId,
|
||||
EpisodeId = albumId,
|
||||
Title = $"Artist {artistId} - Album {albumId}",
|
||||
ItemType = InstanceType.Lidarr,
|
||||
SeasonNumber = 0,
|
||||
AirDateUtc = airDate ?? _timeProvider.GetUtcNow().AddDays(-30),
|
||||
IsMonitored = monitored,
|
||||
CurrentScore = currentScore,
|
||||
CutoffScore = cutoffScore,
|
||||
});
|
||||
await _eventsContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_MissingAlbum_BuildsUnitKeyedByArtistAndAlbum()
|
||||
{
|
||||
// Arrange
|
||||
DateTimeOffset added = _timeProvider.GetUtcNow().AddDays(-100);
|
||||
DateTimeOffset releaseDate = _timeProvider.GetUtcNow().AddDays(-40);
|
||||
SetupLidarr([Artist(1, added: added)], [Album(10, 1, releaseDate: releaseDate)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
SeekerSearchUnit unit = evaluation.Units.ShouldHaveSingleItem();
|
||||
unit.ItemId.ShouldBe(1);
|
||||
unit.SeasonNumber.ShouldBe(0);
|
||||
unit.EpisodeId.ShouldBe(10);
|
||||
unit.Name.ShouldBe("Artist 1 - Album 10");
|
||||
unit.ContentKind.ShouldBeNull();
|
||||
unit.Reasons.ShouldBe([SeekerSearchReason.Missing]);
|
||||
unit.AirDateUtc.ShouldBe(releaseDate);
|
||||
unit.Added.ShouldBe(added);
|
||||
evaluation.AllLibraryIds.ShouldBe([1L]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_AlbumMissingAndCutoffUnmet_CarriesBothReasons()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1)], [Album(10, 1), Album(11, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.UseCutoff = true));
|
||||
|
||||
// Assert
|
||||
evaluation.Units.Count.ShouldBe(2);
|
||||
evaluation.Units.Single(u => u.EpisodeId == 10).Reasons
|
||||
.ShouldBe([SeekerSearchReason.Missing, SeekerSearchReason.QualityCutoffNotMet]);
|
||||
evaluation.Units.Single(u => u.EpisodeId == 11).Reasons
|
||||
.ShouldBe([SeekerSearchReason.QualityCutoffNotMet]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_UseCutoffOff_SkipsTheCutoffRead()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1)], [Album(11, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.UseCutoff = false));
|
||||
|
||||
// Assert
|
||||
evaluation.Units.ShouldHaveSingleItem().EpisodeId.ShouldBe(10);
|
||||
await _lidarrClient.DidNotReceive()
|
||||
.GetCutoffUnmetAlbumsAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_MonitoredOnlyOff_ReadsBothMonitoredValues()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1, monitored: false)], [Album(10, 1, monitored: false), Album(11, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.MonitoredOnly = false));
|
||||
|
||||
// Assert
|
||||
evaluation.Units.Select(u => u.EpisodeId).OrderBy(id => id).ShouldBe([10L, 11L]);
|
||||
await _lidarrClient.Received(1).GetMissingAlbumsAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>());
|
||||
await _lidarrClient.Received(1).GetMissingAlbumsAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_MonitoredOnly_ReadsOnlyMonitoredAlbums()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1)]);
|
||||
|
||||
// Act
|
||||
await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
await _lidarrClient.DidNotReceive().GetMissingAlbumsAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_AlbumDatedInTheFuture_ExcludesAsNotReleased()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1, releaseDate: _timeProvider.GetUtcNow().AddDays(5))]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
ReasonOf(evaluation, "Artist 1 - Album 10").ShouldBe(SeekerExclusionReason.NotReleased);
|
||||
evaluation.Units.ShouldBeEmpty();
|
||||
evaluation.Excluded.ShouldNotContain(e => e.Name == "Artist 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_AlbumReleasedInsideGraceWindow_ExcludesAsInGracePeriod()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1, releaseDate: _timeProvider.GetUtcNow().AddHours(-1))]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
ReasonOf(evaluation, "Artist 1 - Album 10").ShouldBe(SeekerExclusionReason.InGracePeriod);
|
||||
evaluation.Units.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_AlbumReleasedPastGraceWindow_BuildsUnit()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1, releaseDate: _timeProvider.GetUtcNow().AddHours(-7))]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
evaluation.Units.ShouldHaveSingleItem().EpisodeId.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_AlbumWithoutReleaseDate_CountsAsReleased()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1, noReleaseDate: true)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
SeekerSearchUnit unit = evaluation.Units.ShouldHaveSingleItem();
|
||||
unit.AirDateUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_SkipTag_ExcludesArtistAsSkipTag()
|
||||
{
|
||||
// Arrange
|
||||
_lidarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 3, Label = "skip" }]);
|
||||
SetupLidarr([Artist(1, tags: [3])], [Album(10, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.SkipTags = ["skip"]));
|
||||
|
||||
// Assert
|
||||
ReasonOf(evaluation, "Artist 1").ShouldBe(SeekerExclusionReason.SkipTag);
|
||||
evaluation.Units.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_IncludeTagMissing_ExcludesArtistAsMissingIncludeTag()
|
||||
{
|
||||
// Arrange
|
||||
_lidarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 3, Label = "keep" }]);
|
||||
SetupLidarr([Artist(1), Artist(2, tags: [3])], [Album(10, 1), Album(20, 2)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.IncludeTags = ["keep"]));
|
||||
|
||||
// Assert
|
||||
ReasonOf(evaluation, "Artist 1").ShouldBe(SeekerExclusionReason.MissingIncludeTag);
|
||||
evaluation.Units.ShouldHaveSingleItem().ItemId.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_UnmonitoredArtist_ExcludesAsNotMonitored()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1, monitored: false)], [Album(10, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
ReasonOf(evaluation, "Artist 1").ShouldBe(SeekerExclusionReason.NotMonitored);
|
||||
evaluation.Units.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_ArtistWithoutWantedAlbums_ExcludesAsNothingToSearch()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], []);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
ReasonOf(evaluation, "Artist 1").ShouldBe(SeekerExclusionReason.NothingToSearch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_StageCounts_CountArtists()
|
||||
{
|
||||
// Arrange
|
||||
_lidarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 3, Label = "skip" }]);
|
||||
SetupLidarr([Artist(1), Artist(2, monitored: false), Artist(3, tags: [3])], [Album(10, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.SkipTags = ["skip"]));
|
||||
|
||||
// Assert
|
||||
evaluation.ReleasedItems.ShouldBe(3);
|
||||
evaluation.MonitoredItems.ShouldBe(2);
|
||||
evaluation.TagFilteredItems.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_QueuedAlbum_BlocksOnlyThatAlbum()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1), Album(11, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(
|
||||
queue: [new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Download",
|
||||
DownloadId = "hash",
|
||||
Protocol = "torrent",
|
||||
SizeLeft = 1000,
|
||||
ArtistId = 1,
|
||||
AlbumId = 10,
|
||||
TrackedDownloadState = "downloading",
|
||||
}]);
|
||||
|
||||
// Assert
|
||||
evaluation.Units.Single(u => u.EpisodeId == 10).BlockedBy.ShouldBe(SeekerExclusionReason.InQueue);
|
||||
evaluation.Remaining.ShouldHaveSingleItem().EpisodeId.ShouldBe(11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_RecentlyGrabbedAlbum_BlocksOnlyThatAlbum()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1), Album(11, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(recentGrabs: new SeekerRecentGrabs { AlbumIds = [10] });
|
||||
|
||||
// Assert
|
||||
evaluation.Units.Single(u => u.EpisodeId == 10).BlockedBy.ShouldBe(SeekerExclusionReason.RecentlyGrabbed);
|
||||
evaluation.Remaining.ShouldHaveSingleItem().EpisodeId.ShouldBe(11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_RestingAlbum_ExcludesAsResting()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1), Album(11, 1)]);
|
||||
await SeedMissesAsync(_lidarr, 1, 3, _timeProvider.GetUtcNow().AddDays(-2), episodeId: 10);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.RestUnitsEnabled = true));
|
||||
|
||||
// Assert
|
||||
SeekerSearchUnit resting = evaluation.Units.Single(u => u.EpisodeId == 10);
|
||||
resting.BlockedBy.ShouldBe(SeekerExclusionReason.Resting);
|
||||
resting.Rest.ShouldNotBeNull().ConsecutiveMisses.ShouldBe(3);
|
||||
evaluation.Remaining.ShouldHaveSingleItem().EpisodeId.ShouldBe(11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_AlbumInCycleHistory_IsSearchedThisCycle()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], [Album(10, 1), Album(11, 1)]);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(
|
||||
history: [History(_lidarr, 1, _timeProvider.GetUtcNow().AddDays(-1), episodeId: 10)]);
|
||||
|
||||
// Assert
|
||||
evaluation.Units.Single(u => u.EpisodeId == 10).Searched.ShouldBeTrue();
|
||||
evaluation.Units.Single(u => u.EpisodeId == 11).Searched.ShouldBeFalse();
|
||||
evaluation.WithoutCycleHistory().Units.ShouldAllBe(u => !u.Searched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_BelowCustomFormatCutoff_AddsReasonAndGap()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1, qualityProfileId: 5)], []);
|
||||
_lidarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>())
|
||||
.Returns([new ArrQualityProfile { Id = 5, UpgradeAllowed = true }]);
|
||||
await AddLidarrCustomFormatEntryAsync(1, 10, currentScore: 10, cutoffScore: 35);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.UseCustomFormatScore = true));
|
||||
|
||||
// Assert
|
||||
SeekerSearchUnit unit = evaluation.Units.ShouldHaveSingleItem();
|
||||
unit.ItemId.ShouldBe(1);
|
||||
unit.EpisodeId.ShouldBe(10);
|
||||
unit.Name.ShouldBe("Artist 1 - Album 10");
|
||||
unit.Reasons.ShouldBe([SeekerSearchReason.CustomFormatScoreBelowCutoff]);
|
||||
unit.CustomFormatGap.ShouldBe(25);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_CustomFormatEntryOffByDefault_IsIgnored()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1)], []);
|
||||
await AddLidarrCustomFormatEntryAsync(1, 10, currentScore: 10, cutoffScore: 35);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Assert
|
||||
evaluation.Units.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_BelowCustomFormatCutoffWithUpgradesDisabled_ExcludesAsUpgradesDisabled()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1, qualityProfileId: 5)], []);
|
||||
_lidarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>())
|
||||
.Returns([new ArrQualityProfile { Id = 5, UpgradeAllowed = false }]);
|
||||
await AddLidarrCustomFormatEntryAsync(1, 10, currentScore: 10, cutoffScore: 35);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.UseCustomFormatScore = true));
|
||||
|
||||
// Assert
|
||||
ReasonOf(evaluation, "Artist 1 - Album 10").ShouldBe(SeekerExclusionReason.UpgradesDisabled);
|
||||
evaluation.Units.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_MissingAlbumWithUpgradesDisabled_StaysAUnit()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1, qualityProfileId: 5)], [Album(10, 1)]);
|
||||
_lidarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>())
|
||||
.Returns([new ArrQualityProfile { Id = 5, UpgradeAllowed = false }]);
|
||||
await AddLidarrCustomFormatEntryAsync(1, 10, currentScore: 10, cutoffScore: 35);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.UseCustomFormatScore = true));
|
||||
|
||||
// Assert
|
||||
evaluation.Units.ShouldHaveSingleItem().Reasons.ShouldBe([SeekerSearchReason.Missing]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Lidarr_CustomFormatEntryOfFilteredArtist_IsIgnored()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr([Artist(1, monitored: false)], []);
|
||||
await AddLidarrCustomFormatEntryAsync(1, 10, currentScore: 10, cutoffScore: 35);
|
||||
|
||||
// Act
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync(InstanceConfig(_lidarr, c => c.UseCustomFormatScore = true));
|
||||
|
||||
// Assert
|
||||
evaluation.Units.ShouldBeEmpty();
|
||||
ReasonOf(evaluation, "Artist 1").ShouldBe(SeekerExclusionReason.NotMonitored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectNext_Lidarr_ReturnsOneAlbumPerArtist()
|
||||
{
|
||||
// Arrange
|
||||
SetupLidarr(
|
||||
[Artist(1), Artist(2)],
|
||||
[Album(10, 1), Album(11, 1), Album(12, 1), Album(13, 1), Album(14, 1), Album(20, 2)]);
|
||||
SeekerEvaluation evaluation = await EvaluateLidarrAsync();
|
||||
|
||||
// Act
|
||||
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.Random, 2);
|
||||
|
||||
// Assert
|
||||
evaluation.Remaining.Count().ShouldBe(6);
|
||||
candidates.Select(c => c.ItemId).OrderBy(id => id).ShouldBe([1L, 2L]);
|
||||
candidates.Single(c => c.ItemId == 2).EpisodeId.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRecentGrabsAsync_Lidarr_ReadsGrabbedAlbumIds()
|
||||
{
|
||||
// Arrange
|
||||
_lidarrClient
|
||||
.GetRecentlyGrabbedAlbumIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new HashSet<long> { 10 });
|
||||
|
||||
// Act
|
||||
SeekerRecentGrabs grabs = await _sut.ReadRecentGrabsAsync(
|
||||
InstanceConfig(_lidarr, c => c.RecentGrabWindowHours = 6), _lidarr, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
grabs.AlbumIds.ShouldBe([10L]);
|
||||
grabs.Episodes.ShouldBeEmpty();
|
||||
await _sonarrClient.DidNotReceive()
|
||||
.GetRecentlyGrabbedEpisodesAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -497,6 +497,7 @@ public sealed class Seeker : IHandler
|
||||
SearchItem searchItem = instanceType switch
|
||||
{
|
||||
_ when SeekerApps.SearchesMovies(arrInstance) => new SearchItem { Id = candidate.ItemId },
|
||||
InstanceType.Lidarr => new SearchItem { Id = candidate.EpisodeId },
|
||||
_ when candidate.EpisodeId > 0 => new SeriesSearchItem
|
||||
{
|
||||
Id = candidate.EpisodeId,
|
||||
|
||||
@@ -497,7 +497,9 @@ public class SeekerCommandMonitor : BackgroundService
|
||||
{
|
||||
InstanceType.Radarr => record.MovieId == tracker.ExternalItemId,
|
||||
InstanceType.Whisparr when arrInstance.Version is 3 => record.MovieId == tracker.ExternalItemId,
|
||||
InstanceType.Lidarr => record.AlbumId == tracker.ExternalItemId,
|
||||
InstanceType.Lidarr => tracker.EpisodeId > 0
|
||||
? record.AlbumId == tracker.EpisodeId
|
||||
: record.AlbumId == tracker.ExternalItemId,
|
||||
InstanceType.Readarr => record.BookId == tracker.ExternalItemId,
|
||||
_ => tracker.EpisodeId > 0
|
||||
? record.EpisodeId == tracker.EpisodeId
|
||||
|
||||
@@ -6,11 +6,11 @@ namespace Cleanuparr.Infrastructure.Features.Seeker;
|
||||
public static class SeekerApps
|
||||
{
|
||||
/// <summary>
|
||||
/// True for the instances Seeker searches proactively: Sonarr, Radarr and Whisparr v3.
|
||||
/// True for the instances Seeker searches proactively: Sonarr, Radarr, Lidarr and Whisparr v3.
|
||||
/// </summary>
|
||||
public static bool IsSupported(ArrInstance arrInstance) => arrInstance.ArrConfig.Type switch
|
||||
{
|
||||
InstanceType.Sonarr or InstanceType.Radarr => true,
|
||||
InstanceType.Sonarr or InstanceType.Radarr or InstanceType.Lidarr => true,
|
||||
InstanceType.Whisparr => arrInstance.Version is 3,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -7,5 +7,7 @@ public sealed record SeekerRecentGrabs
|
||||
{
|
||||
public HashSet<long> MovieIds { get; init; } = [];
|
||||
|
||||
public HashSet<long> AlbumIds { get; init; } = [];
|
||||
|
||||
public HashSet<(long SeriesId, long SeasonNumber, long EpisodeId)> Episodes { get; init; } = [];
|
||||
}
|
||||
@@ -3,29 +3,29 @@ using Cleanuparr.Domain.Enums;
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker;
|
||||
|
||||
/// <summary>
|
||||
/// One search: a movie, a whole Sonarr season, or a single episode when <see cref="EpisodeId"/> is set.
|
||||
/// One search: a movie, a whole Sonarr season, a single episode when <see cref="EpisodeId"/> is set, or a Lidarr album.
|
||||
/// </summary>
|
||||
public sealed record SeekerSearchUnit
|
||||
{
|
||||
/// <summary>
|
||||
/// MovieId (Radarr) or SeriesId (Sonarr)
|
||||
/// MovieId (Radarr), SeriesId (Sonarr) or ArtistId (Lidarr)
|
||||
/// </summary>
|
||||
public required long ItemId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 0 for a movie.
|
||||
/// 0 for a movie or a Lidarr album.
|
||||
/// </summary>
|
||||
public required int SeasonNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 0 for a movie or a season unit.
|
||||
/// 0 for a movie or a season unit, the AlbumId for a Lidarr album.
|
||||
/// </summary>
|
||||
public required long EpisodeId { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Null for a movie.
|
||||
/// Null for a movie or a Lidarr album.
|
||||
/// </summary>
|
||||
public SeekerContentKind? ContentKind { get; init; }
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class SeekerUnitEvaluator
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly ILidarrClient _lidarrClient;
|
||||
private readonly IWhisparrV3Client _whisparrV3Client;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -46,6 +47,7 @@ public sealed class SeekerUnitEvaluator
|
||||
EventsContext eventsContext,
|
||||
IRadarrClient radarrClient,
|
||||
ISonarrClient sonarrClient,
|
||||
ILidarrClient lidarrClient,
|
||||
IWhisparrV3Client whisparrV3Client,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
@@ -53,6 +55,7 @@ public sealed class SeekerUnitEvaluator
|
||||
_eventsContext = eventsContext;
|
||||
_radarrClient = radarrClient;
|
||||
_sonarrClient = sonarrClient;
|
||||
_lidarrClient = lidarrClient;
|
||||
_whisparrV3Client = whisparrV3Client;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
@@ -80,6 +83,14 @@ public sealed class SeekerUnitEvaluator
|
||||
};
|
||||
}
|
||||
|
||||
if (arrInstance.ArrConfig.Type is InstanceType.Lidarr)
|
||||
{
|
||||
return new SeekerRecentGrabs
|
||||
{
|
||||
AlbumIds = await _lidarrClient.GetRecentlyGrabbedAlbumIdsAsync(arrInstance, since, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
return new SeekerRecentGrabs
|
||||
{
|
||||
Episodes = await _sonarrClient.GetRecentlyGrabbedEpisodesAsync(arrInstance, since, cancellationToken)
|
||||
@@ -115,6 +126,12 @@ public sealed class SeekerUnitEvaluator
|
||||
recentGrabs.MovieIds, rests, lastSearchedByItem, cancellationToken);
|
||||
}
|
||||
|
||||
if (arrInstance.ArrConfig.Type is InstanceType.Lidarr)
|
||||
{
|
||||
return await EvaluateLidarrAsync(config, instanceConfig, arrInstance, activeQueueRecords,
|
||||
recentGrabs.AlbumIds, rests, currentCycleHistory, lastSearchedByItem, cancellationToken);
|
||||
}
|
||||
|
||||
return await EvaluateSonarrAsync(config, instanceConfig, arrInstance, activeQueueRecords,
|
||||
recentGrabs.Episodes, rests, currentCycleHistory, lastSearchedByItem, cancellationToken);
|
||||
}
|
||||
@@ -208,7 +225,7 @@ public sealed class SeekerUnitEvaluator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks up to <paramref name="count"/> unsearched units, at most one per movie or series.
|
||||
/// Picks up to <paramref name="count"/> unsearched units, at most one per movie, series or artist.
|
||||
/// </summary>
|
||||
public static List<SeekerSearchCandidate> SelectNext(SeekerEvaluation evaluation, SelectionStrategy strategy, int count)
|
||||
{
|
||||
@@ -721,6 +738,237 @@ public sealed class SeekerUnitEvaluator
|
||||
return Build(currentCycleHistory, lastSearchedByItem);
|
||||
}
|
||||
|
||||
private async Task<SeekerEvaluation> EvaluateLidarrAsync(
|
||||
SeekerConfig config,
|
||||
SeekerInstanceConfig instanceConfig,
|
||||
ArrInstance arrInstance,
|
||||
List<QueueRecord> activeQueueRecords,
|
||||
HashSet<long> recentlyGrabbedAlbumIds,
|
||||
Dictionary<(long ItemId, int SeasonNumber, long EpisodeId), SeekerUnitRest> rests,
|
||||
List<SeekerHistory> currentCycleHistory,
|
||||
Dictionary<long, DateTimeOffset> lastSearchedByItem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<Tag> tags = await _lidarrClient.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);
|
||||
|
||||
HashSet<int> upgradesDisabledProfileIds = [];
|
||||
if (instanceConfig.UseCustomFormatScore)
|
||||
{
|
||||
List<ArrQualityProfile> profiles = await _lidarrClient.GetQualityProfilesAsync(arrInstance);
|
||||
upgradesDisabledProfileIds = profiles.Where(p => !p.UpgradeAllowed).Select(p => p.Id).ToHashSet();
|
||||
}
|
||||
|
||||
DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours);
|
||||
|
||||
List<long> allLibraryIds = new();
|
||||
List<SeekerExclusion> exclusions = [];
|
||||
int releasedCount = 0;
|
||||
int monitoredCount = 0;
|
||||
Dictionary<long, SearchableArtist> artistsById = new();
|
||||
await foreach (SearchableArtist artist in _lidarrClient.StreamAllArtistsAsync(arrInstance, cancellationToken))
|
||||
{
|
||||
allLibraryIds.Add(artist.Id);
|
||||
releasedCount++;
|
||||
|
||||
if (instanceConfig.MonitoredOnly && !artist.Monitored)
|
||||
{
|
||||
exclusions.Add(new SeekerExclusion(artist.ArtistName, SeekerExclusionReason.NotMonitored));
|
||||
continue;
|
||||
}
|
||||
|
||||
monitoredCount++;
|
||||
|
||||
SeekerExclusionReason? tagExclusion = GetTagExclusion(artist.Tags, tagsById, skipTagSet, includeTagSet);
|
||||
if (tagExclusion is not null)
|
||||
{
|
||||
exclusions.Add(new SeekerExclusion(artist.ArtistName, tagExclusion.Value));
|
||||
continue;
|
||||
}
|
||||
|
||||
artistsById[artist.Id] = artist;
|
||||
}
|
||||
|
||||
List<SonarrWantedEpisode> wanted = [];
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
HashSet<long> excludedAlbumIds = [];
|
||||
HashSet<long> artistsWithExcludedAlbums = [];
|
||||
|
||||
void ExcludeAlbum(long artistId, long albumId, string name, SeekerExclusionReason reason)
|
||||
{
|
||||
artistsWithExcludedAlbums.Add(artistId);
|
||||
if (excludedAlbumIds.Add(albumId))
|
||||
{
|
||||
exclusions.Add(new SeekerExclusion(name, reason));
|
||||
}
|
||||
}
|
||||
|
||||
bool IsReleasedAlbumOfCandidate(SearchableAlbum a)
|
||||
{
|
||||
if (!artistsById.TryGetValue(a.ArtistId, out SearchableArtist? artist))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.ReleaseDate is null || a.ReleaseDate.Value <= graceCutoff)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ExcludeAlbum(a.ArtistId, a.Id, $"{artist.ArtistName} - {a.Title}",
|
||||
a.ReleaseDate.Value > now
|
||||
? SeekerExclusionReason.NotReleased
|
||||
: SeekerExclusionReason.InGracePeriod);
|
||||
return false;
|
||||
}
|
||||
|
||||
SonarrWantedEpisode ToWanted(SearchableAlbum a, SeekerSearchReason reason) => new()
|
||||
{
|
||||
SeriesId = a.ArtistId,
|
||||
SeasonNumber = 0,
|
||||
EpisodeId = a.Id,
|
||||
Title = $"{artistsById[a.ArtistId].ArtistName} - {a.Title}",
|
||||
AirDateUtc = a.ReleaseDate,
|
||||
LastSearchTime = a.LastSearchTime,
|
||||
Monitored = a.Monitored,
|
||||
Reason = reason,
|
||||
};
|
||||
|
||||
List<SearchableAlbum> missing = await ReadWantedAsync(
|
||||
monitored => _lidarrClient.GetMissingAlbumsAsync(arrInstance, monitored, cancellationToken),
|
||||
instanceConfig.MonitoredOnly);
|
||||
wanted.AddRange(missing
|
||||
.Where(IsReleasedAlbumOfCandidate)
|
||||
.Select(a => ToWanted(a, SeekerSearchReason.Missing)));
|
||||
|
||||
if (instanceConfig.UseCutoff)
|
||||
{
|
||||
List<SearchableAlbum> cutoffUnmet = await ReadWantedAsync(
|
||||
monitored => _lidarrClient.GetCutoffUnmetAlbumsAsync(arrInstance, monitored, cancellationToken),
|
||||
instanceConfig.MonitoredOnly);
|
||||
wanted.AddRange(cutoffUnmet
|
||||
.Where(IsReleasedAlbumOfCandidate)
|
||||
.Select(a => ToWanted(a, SeekerSearchReason.QualityCutoffNotMet)));
|
||||
}
|
||||
|
||||
if (instanceConfig.UseCustomFormatScore)
|
||||
{
|
||||
List<CustomFormatScoreEntry> belowCutoff = await _eventsContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ArrInstanceId == arrInstance.Id
|
||||
&& e.ItemType == InstanceType.Lidarr
|
||||
&& (e.AirDateUtc == null || e.AirDateUtc <= graceCutoff)
|
||||
&& e.CurrentScore < e.CutoffScore
|
||||
&& (!instanceConfig.MonitoredOnly || e.IsMonitored))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
HashSet<long> wantedAlbumIds = wanted.Select(w => w.EpisodeId).ToHashSet();
|
||||
|
||||
foreach (CustomFormatScoreEntry entry in belowCutoff)
|
||||
{
|
||||
if (!artistsById.TryGetValue(entry.ExternalItemId, out SearchableArtist? artist))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (upgradesDisabledProfileIds.Contains(artist.QualityProfileId))
|
||||
{
|
||||
if (!wantedAlbumIds.Contains(entry.EpisodeId))
|
||||
{
|
||||
_logger.LogDebug("skip | upgrades disabled in profile | {Title} | {InstanceName}", entry.Title, arrInstance.Name);
|
||||
ExcludeAlbum(entry.ExternalItemId, entry.EpisodeId, entry.Title, SeekerExclusionReason.UpgradesDisabled);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
wanted.Add(new SonarrWantedEpisode
|
||||
{
|
||||
SeriesId = entry.ExternalItemId,
|
||||
SeasonNumber = 0,
|
||||
EpisodeId = entry.EpisodeId,
|
||||
Title = entry.Title,
|
||||
AirDateUtc = entry.AirDateUtc,
|
||||
LastSearchTime = entry.LastSearchTime,
|
||||
Monitored = entry.IsMonitored,
|
||||
CustomFormatGap = entry.CutoffScore - entry.CurrentScore,
|
||||
Reason = SeekerSearchReason.CustomFormatScoreBelowCutoff,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<long> wantedArtistIds = wanted.Select(w => w.SeriesId).ToHashSet();
|
||||
exclusions.AddRange(artistsById.Values
|
||||
.Where(a => !wantedArtistIds.Contains(a.Id) && !artistsWithExcludedAlbums.Contains(a.Id))
|
||||
.Select(a => new SeekerExclusion(a.ArtistName, SeekerExclusionReason.NothingToSearch)));
|
||||
|
||||
HashSet<long> queuedAlbumIds = activeQueueRecords
|
||||
.Where(r => r.AlbumId > 0)
|
||||
.Select(r => r.AlbumId)
|
||||
.ToHashSet();
|
||||
|
||||
SeekerEvaluation Build(List<SeekerHistory> history, Dictionary<long, DateTimeOffset> searched)
|
||||
{
|
||||
HashSet<long> searchedAlbumIds = history
|
||||
.Where(h => h.EpisodeId > 0)
|
||||
.Select(h => h.EpisodeId)
|
||||
.ToHashSet();
|
||||
|
||||
List<SeekerSearchUnit> units = [];
|
||||
foreach (IGrouping<long, SonarrWantedEpisode> album in wanted.GroupBy(w => w.EpisodeId))
|
||||
{
|
||||
SonarrWantedEpisode first = album.First();
|
||||
SeekerExclusionReason? blockedBy = null;
|
||||
SeekerUnitRest? rest = null;
|
||||
if (queuedAlbumIds.Contains(album.Key))
|
||||
{
|
||||
blockedBy = SeekerExclusionReason.InQueue;
|
||||
}
|
||||
else if (recentlyGrabbedAlbumIds.Contains(album.Key))
|
||||
{
|
||||
blockedBy = SeekerExclusionReason.RecentlyGrabbed;
|
||||
}
|
||||
else if (rests.TryGetValue((first.SeriesId, 0, album.Key), out rest))
|
||||
{
|
||||
blockedBy = SeekerExclusionReason.Resting;
|
||||
}
|
||||
|
||||
units.Add(new SeekerSearchUnit
|
||||
{
|
||||
ItemId = first.SeriesId,
|
||||
SeasonNumber = 0,
|
||||
EpisodeId = album.Key,
|
||||
Name = first.Title!,
|
||||
Reasons = album.Select(w => w.Reason).ToList(),
|
||||
AirDateUtc = first.AirDateUtc,
|
||||
Added = artistsById[first.SeriesId].Added,
|
||||
LastSearchTime = album.Max(w => w.LastSearchTime),
|
||||
CustomFormatGap = album.Max(w => w.CustomFormatGap),
|
||||
Searched = searchedAlbumIds.Contains(album.Key),
|
||||
BlockedBy = blockedBy,
|
||||
Rest = rest,
|
||||
});
|
||||
}
|
||||
|
||||
return new SeekerEvaluation
|
||||
{
|
||||
AllLibraryIds = allLibraryIds,
|
||||
ReleasedItems = releasedCount,
|
||||
MonitoredItems = monitoredCount,
|
||||
TagFilteredItems = artistsById.Count,
|
||||
ItemExclusions = exclusions,
|
||||
Units = units,
|
||||
LastSearchedByItem = searched,
|
||||
// Keeps this run's rests, so a rest that ends with the rollover ends one run late
|
||||
WithoutCycleHistory = () => Build([], new Dictionary<long, DateTimeOffset>()),
|
||||
};
|
||||
}
|
||||
|
||||
return Build(currentCycleHistory, lastSearchedByItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups wanted episodes into season and episode units.
|
||||
/// Sonarr runs one query per episode for a specials or airing anime season search, so those get episode units.
|
||||
|
||||
@@ -3,7 +3,7 @@ using Cleanuparr.Domain.Enums;
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker;
|
||||
|
||||
/// <summary>
|
||||
/// An episode that needs a search, with the reason it qualified.
|
||||
/// An episode, or a Lidarr album, that needs a search, with the reason it qualified.
|
||||
/// </summary>
|
||||
internal sealed record SonarrWantedEpisode
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user