added lidarr to seeker proactive search

This commit is contained in:
Flaminel committed 2026-10-26 20:34:44 +02:00
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]
@@ -53,6 +53,7 @@ public class SeekerIntegrationTests : IDisposable
_fixture.EventsContext,
Substitute.For<IRadarrClient>(),
Substitute.For<ISonarrClient>(),
Substitute.For<ILidarrClient>(),
Substitute.For<IWhisparrV3Client>(),
_fixture.TimeProvider));
}
@@ -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));
@@ -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
{