diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs index 18b63498..dc4fa734 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs @@ -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(); var response = okResult.Value.ShouldBeOfType(); - 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 diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs index 03aa4817..c99f8506 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs @@ -30,6 +30,7 @@ public class SeekerPreviewControllerTests : IDisposable private readonly EventsContext _eventsContext; private readonly IRadarrClient _radarrClient; private readonly ISonarrClient _sonarrClient; + private readonly 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(); _sonarrClient = Substitute.For(); + _lidarrClient = Substitute.For(); _whisparrV3Client = Substitute.For(); _arrClientFactory = Substitute.For(); _arrQueueReader = Substitute.For(); @@ -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 artists = [new SearchableArtist { Id = 1, ArtistName = "Artist 1", Monitored = true }]; + List albums = + [ + new SearchableAlbum { Id = 10, Title = "Album 10", ArtistId = 1, Monitored = true, ReleaseDate = Now.AddDays(-30) }, + ]; + _lidarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); + _lidarrClient + .GetRecentlyGrabbedAlbumIdsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new HashSet()); + _lidarrClient.StreamAllArtistsAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(artists)); + _lidarrClient.GetMissingAlbumsAsync(Arg.Any(), true, Arg.Any()) + .Returns(albums); + _lidarrClient.GetCutoffUnmetAlbumsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs index a523d75f..d5f90e3d 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs @@ -53,6 +53,7 @@ public class SeekerIntegrationTests : IDisposable _fixture.EventsContext, Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), _fixture.TimeProvider)); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs index 6a699411..5855160e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs @@ -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? 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? 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() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index e0114849..9469c8cc 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -30,6 +30,7 @@ public class SeekerTests : IDisposable private readonly ILogger _logger; private readonly IRadarrClient _radarrClient; private readonly ISonarrClient _sonarrClient; + private readonly 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>(); _radarrClient = Substitute.For(); _sonarrClient = Substitute.For(); + _lidarrClient = Substitute.For(); _whisparrV3Client = Substitute.For(); _dryRunInterceptor = Substitute.For(); _hostingEnvironment = Substitute.For(); @@ -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(); + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + List artists = [new SearchableArtist { Id = 5, ArtistName = "Artist 5", Monitored = true }]; + List albums = + [ + new SearchableAlbum { Id = 77, Title = "Album 77", ArtistId = 5, Monitored = true }, + ]; + _lidarrClient + .StreamAllArtistsAsync(lidarrInstance, Arg.Any()) + .Returns(_ => ToAsyncEnumerable(artists)); + _lidarrClient + .GetMissingAlbumsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(albums); + _lidarrClient + .GetCutoffUnmetAlbumsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + _lidarrClient + .GetAllTagsAsync(Arg.Any()) + .Returns([]); + _lidarrClient + .GetRecentlyGrabbedAlbumIdsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Lidarr, Arg.Any()) + .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(item => capturedSearchItem = item)) + .Returns(42L); + + SeekerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + capturedSearchItem.ShouldNotBeNull(); + capturedSearchItem.ShouldNotBeOfType(); + 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()) + .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() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs index 613ea832..04111c14 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerAppsTests.cs @@ -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)); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs index 90ceaa2f..e44d7b55 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs @@ -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(); _sonarrClient = Substitute.For(); + _lidarrClient = Substitute.For(); _whisparrV3Client = Substitute.For(); _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero)); _radarr = TestDataContextFactory.AddRadarrInstance(_dataContext); _sonarr = TestDataContextFactory.AddSonarrInstance(_dataContext); _whisparr = TestDataContextFactory.AddWhisparrInstance(_dataContext, version: 3); + _lidarr = TestDataContextFactory.AddLidarrInstance(_dataContext); _radarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); _sonarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); @@ -50,6 +54,9 @@ public class SeekerUnitEvaluatorTests : IDisposable _sonarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]); _radarrClient.GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); _sonarrClient.GetCutoffUnmetEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); + _lidarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); + _lidarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]); + _lidarrClient.GetCutoffUnmetAlbumsAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); _whisparrV3Client.GetAllTagsAsync(Arg.Any()).Returns([]); _whisparrV3Client.GetQualityProfilesAsync(Arg.Any()).Returns([]); _whisparrV3Client.GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); @@ -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? 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 library, List missing, List? cutoffUnmet = null) + { + _lidarrClient.StreamAllArtistsAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(library)); + _lidarrClient.GetMissingAlbumsAsync(Arg.Any(), true, Arg.Any()) + .Returns(missing.Where(a => a.Monitored).ToList()); + _lidarrClient.GetMissingAlbumsAsync(Arg.Any(), false, Arg.Any()) + .Returns(missing.Where(a => !a.Monitored).ToList()); + + if (cutoffUnmet is not null) + { + _lidarrClient.GetCutoffUnmetAlbumsAsync(Arg.Any(), true, Arg.Any()) + .Returns(cutoffUnmet.Where(a => a.Monitored).ToList()); + _lidarrClient.GetCutoffUnmetAlbumsAsync(Arg.Any(), false, Arg.Any()) + .Returns(cutoffUnmet.Where(a => !a.Monitored).ToList()); + } + } + + private Task EvaluateLidarrAsync( + SeekerInstanceConfig? instanceConfig = null, + List? queue = null, + SeekerRecentGrabs? recentGrabs = null, + List? 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(), Arg.Any(), Arg.Any()); + } + + [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(), true, Arg.Any()); + await _lidarrClient.Received(1).GetMissingAlbumsAsync(Arg.Any(), false, Arg.Any()); + } + + [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(), false, Arg.Any()); + } + + [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()).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()).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()).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()) + .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()) + .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()) + .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 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(), Arg.Any(), Arg.Any()) + .Returns(new HashSet { 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(), Arg.Any(), Arg.Any()); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 03f6632d..41aeaf5b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -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, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs index 541f6149..03a96f20 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs @@ -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 diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs index ecd769a6..252da4f7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerApps.cs @@ -6,11 +6,11 @@ namespace Cleanuparr.Infrastructure.Features.Seeker; public static class SeekerApps { /// - /// 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. /// 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, }; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerRecentGrabs.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerRecentGrabs.cs index e3017b17..596aa791 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerRecentGrabs.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerRecentGrabs.cs @@ -7,5 +7,7 @@ public sealed record SeekerRecentGrabs { public HashSet MovieIds { get; init; } = []; + public HashSet AlbumIds { get; init; } = []; + public HashSet<(long SeriesId, long SeasonNumber, long EpisodeId)> Episodes { get; init; } = []; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs index b56e40db..64479317 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs @@ -3,29 +3,29 @@ using Cleanuparr.Domain.Enums; namespace Cleanuparr.Infrastructure.Features.Seeker; /// -/// One search: a movie, a whole Sonarr season, or a single episode when is set. +/// One search: a movie, a whole Sonarr season, a single episode when is set, or a Lidarr album. /// public sealed record SeekerSearchUnit { /// - /// MovieId (Radarr) or SeriesId (Sonarr) + /// MovieId (Radarr), SeriesId (Sonarr) or ArtistId (Lidarr) /// public required long ItemId { get; init; } /// - /// 0 for a movie. + /// 0 for a movie or a Lidarr album. /// public required int SeasonNumber { get; init; } /// - /// 0 for a movie or a season unit. + /// 0 for a movie or a season unit, the AlbumId for a Lidarr album. /// public required long EpisodeId { get; init; } public required string Name { get; init; } /// - /// Null for a movie. + /// Null for a movie or a Lidarr album. /// public SeekerContentKind? ContentKind { get; init; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs index 7245dd49..aad74a59 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs @@ -38,6 +38,7 @@ public sealed class SeekerUnitEvaluator private readonly EventsContext _eventsContext; private readonly IRadarrClient _radarrClient; private readonly ISonarrClient _sonarrClient; + private readonly 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 } /// - /// Picks up to unsearched units, at most one per movie or series. + /// Picks up to unsearched units, at most one per movie, series or artist. /// public static List SelectNext(SeekerEvaluation evaluation, SelectionStrategy strategy, int count) { @@ -721,6 +738,237 @@ public sealed class SeekerUnitEvaluator return Build(currentCycleHistory, lastSearchedByItem); } + private async Task EvaluateLidarrAsync( + SeekerConfig config, + SeekerInstanceConfig instanceConfig, + ArrInstance arrInstance, + List activeQueueRecords, + HashSet recentlyGrabbedAlbumIds, + Dictionary<(long ItemId, int SeasonNumber, long EpisodeId), SeekerUnitRest> rests, + List currentCycleHistory, + Dictionary lastSearchedByItem, + CancellationToken cancellationToken) + { + List tags = await _lidarrClient.GetAllTagsAsync(arrInstance); + Dictionary tagsById = tags.ToDictionary(t => t.Id, t => t.Label); + HashSet skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase); + HashSet includeTagSet = new(instanceConfig.IncludeTags, StringComparer.InvariantCultureIgnoreCase); + + HashSet upgradesDisabledProfileIds = []; + if (instanceConfig.UseCustomFormatScore) + { + List profiles = await _lidarrClient.GetQualityProfilesAsync(arrInstance); + upgradesDisabledProfileIds = profiles.Where(p => !p.UpgradeAllowed).Select(p => p.Id).ToHashSet(); + } + + DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours); + + List allLibraryIds = new(); + List exclusions = []; + int releasedCount = 0; + int monitoredCount = 0; + Dictionary 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 wanted = []; + DateTimeOffset now = _timeProvider.GetUtcNow(); + HashSet excludedAlbumIds = []; + HashSet 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 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 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 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 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 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 queuedAlbumIds = activeQueueRecords + .Where(r => r.AlbumId > 0) + .Select(r => r.AlbumId) + .ToHashSet(); + + SeekerEvaluation Build(List history, Dictionary searched) + { + HashSet searchedAlbumIds = history + .Where(h => h.EpisodeId > 0) + .Select(h => h.EpisodeId) + .ToHashSet(); + + List units = []; + foreach (IGrouping 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()), + }; + } + + return Build(currentCycleHistory, lastSearchedByItem); + } + /// /// 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. diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs index ad0db185..f991ac4b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs @@ -3,7 +3,7 @@ using Cleanuparr.Domain.Enums; namespace Cleanuparr.Infrastructure.Features.Seeker; /// -/// 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. /// internal sealed record SonarrWantedEpisode {