From 41d8c28e93aebc3ed409c54ea353b66653c36a4c Mon Sep 17 00:00:00 2001 From: Flaminel Date: Thu, 22 Oct 2026 12:52:50 +0300 Subject: [PATCH] changed seeker order to arr last search --- .../Entities/Arr/SearchableMovie.cs | 5 + .../Seeker/SeekerUnitEvaluatorTests.cs | 114 +++++++++++++++++- .../Features/Seeker/SeekerSearchUnit.cs | 5 + .../Features/Seeker/SeekerUnitEvaluator.cs | 17 ++- .../Features/Seeker/SonarrWantedEpisode.cs | 2 + 5 files changed, 139 insertions(+), 4 deletions(-) diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs index 0fcc7057..0b6009ec 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs @@ -25,4 +25,9 @@ public sealed record SearchableMovie public DateTimeOffset? PhysicalRelease { get; init; } public DateTimeOffset? InCinemas { get; init; } + + /// + /// Set when a search reached an indexer; RSS grabs leave it alone. + /// + public DateTimeOffset? LastSearchTime { get; init; } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs index f2349dc7..65371519 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs @@ -80,7 +80,8 @@ public class SeekerUnitEvaluatorTests : IDisposable bool hasFile = false, int qualityProfileId = 1, DateTimeOffset? digitalRelease = null, - DateTimeOffset? added = null) + DateTimeOffset? added = null, + DateTimeOffset? lastSearchTime = null) { return new SearchableMovie { @@ -93,6 +94,7 @@ public class SeekerUnitEvaluatorTests : IDisposable QualityProfileId = qualityProfileId, DigitalRelease = digitalRelease, Added = added, + LastSearchTime = lastSearchTime, }; } @@ -133,7 +135,7 @@ public class SeekerUnitEvaluatorTests : IDisposable }; } - private SearchableEpisode Episode(long id, long seriesId, int season = 1, int number = 1, DateTimeOffset? airDate = null, bool noAirDate = false) + private SearchableEpisode Episode(long id, long seriesId, int season = 1, int number = 1, DateTimeOffset? airDate = null, bool noAirDate = false, DateTimeOffset? lastSearchTime = null) { return new SearchableEpisode { @@ -143,6 +145,7 @@ public class SeekerUnitEvaluatorTests : IDisposable EpisodeNumber = number, Monitored = true, AirDateUtc = noAirDate ? null : airDate ?? _timeProvider.GetUtcNow().AddDays(-30), + LastSearchTime = lastSearchTime, }; } @@ -1042,6 +1045,97 @@ public class SeekerUnitEvaluatorTests : IDisposable candidates[1].SeasonNumber.ShouldBe(2); } + [Fact] + public async Task SelectNext_OldestSearchFirst_OrdersMoviesByTheArrLastSearchTime() + { + // Arrange + DateTimeOffset now = _timeProvider.GetUtcNow(); + SetupRadarr( + [ + Movie(1, lastSearchTime: now.AddDays(-1)), + Movie(2), + Movie(3, lastSearchTime: now.AddDays(-5)), + ]); + SeekerEvaluation evaluation = await EvaluateRadarrAsync(); + + // Act + List candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.OldestSearchFirst, 3); + + // Assert + candidates.Select(c => c.ItemId).ShouldBe([2L, 3L, 1L]); + } + + [Fact] + public async Task SelectNext_OldestSearchFirst_RanksSeriesByTheirLatestEpisodeSearch() + { + // Arrange + DateTimeOffset now = _timeProvider.GetUtcNow(); + SetupSonarr([Series(1), Series(2)], + [ + Episode(10, 1, season: 1, lastSearchTime: now.AddDays(-10)), + Episode(11, 1, season: 2, lastSearchTime: now.AddDays(-1)), + Episode(20, 2, season: 1, lastSearchTime: now.AddDays(-5)), + ]); + SeekerEvaluation evaluation = await EvaluateSonarrAsync(); + + // Act + List candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.OldestSearchFirst, 1); + + // Assert + candidates.ShouldHaveSingleItem().ItemId.ShouldBe(2); + } + + [Fact] + public async Task SelectNext_OldestSearchFirst_CycleSearchNewerThanTheArrTimeWins() + { + // Arrange + DateTimeOffset now = _timeProvider.GetUtcNow(); + SetupSonarr([Series(1), Series(2)], + [ + Episode(10, 1, season: 1, lastSearchTime: now.AddDays(-10)), + Episode(11, 1, season: 2, lastSearchTime: now.AddDays(-10)), + Episode(20, 2, season: 1, lastSearchTime: now.AddDays(-5)), + ]); + SeekerEvaluation evaluation = await EvaluateSonarrAsync( + history: [History(_sonarr, 1, now.AddDays(-1), seasonNumber: 1)]); + + // Act + List candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.OldestSearchFirst, 1); + + // Assert + candidates.ShouldHaveSingleItem().ItemId.ShouldBe(2); + } + + [Fact] + public async Task EvaluateAsync_Sonarr_CustomFormatOnlyEpisode_TakesTheCachedLastSearchTime() + { + // Arrange + DateTimeOffset lastSearchTime = _timeProvider.GetUtcNow().AddDays(-3); + SetupSonarr([Series(1)], []); + _sonarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]); + _eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = _sonarr.Id, + ExternalItemId = 1, + EpisodeId = 20, + Title = "Series 1 S01E01", + ItemType = InstanceType.Sonarr, + SeasonNumber = 1, + AirDateUtc = _timeProvider.GetUtcNow().AddDays(-30), + LastSearchTime = lastSearchTime, + IsMonitored = true, + CurrentScore = 5, + CutoffScore = 10, + }); + await _eventsContext.SaveChangesAsync(); + + // Act + SeekerEvaluation evaluation = await EvaluateSonarrAsync(InstanceConfig(_sonarr, c => c.UseCustomFormatScore = true)); + + // Assert + evaluation.Units.ShouldHaveSingleItem().LastSearchTime.ShouldBe(lastSearchTime); + } + #endregion #region WithoutCycleHistory @@ -1087,6 +1181,22 @@ public class SeekerUnitEvaluatorTests : IDisposable fresh.LastSearchedByItem.ShouldBeEmpty(); } + [Fact] + public async Task WithoutCycleHistory_KeepsTheArrLastSearchTime() + { + // Arrange + DateTimeOffset lastSearchTime = _timeProvider.GetUtcNow().AddDays(-4); + SetupRadarr([Movie(1, lastSearchTime: lastSearchTime)]); + SeekerEvaluation evaluation = await EvaluateRadarrAsync( + history: [History(_radarr, 1, _timeProvider.GetUtcNow().AddDays(-1))]); + + // Act + SeekerEvaluation fresh = evaluation.WithoutCycleHistory(); + + // Assert + fresh.Units.ShouldHaveSingleItem().LastSearchTime.ShouldBe(lastSearchTime); + } + [Fact] public async Task WithoutCycleHistory_KeepsQueueBlocks() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs index ae22847b..adad89b1 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs @@ -41,6 +41,11 @@ public sealed record SeekerSearchUnit /// public DateTimeOffset? Added { get; init; } + /// + /// Latest search the arr recorded for the movie or any episode the unit covers. + /// + public DateTimeOffset? LastSearchTime { get; init; } + /// /// Whether the current cycle already searched this unit. /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs index 1163e8d9..d2b63c54 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs @@ -118,9 +118,17 @@ public sealed class SeekerUnitEvaluator .GroupBy(u => u.ItemId) .ToDictionary(g => g.Key, g => g.ToList()); + Dictionary arrSearchedByItem = evaluation.Units + .GroupBy(u => u.ItemId) + .ToDictionary(g => g.Key, g => g.Max(u => u.LastSearchTime)); + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> selectionCandidates = remainingByItem - .Select(p => (p.Key, p.Value[0].Added, - LastSearched: evaluation.LastSearchedByItem.TryGetValue(p.Key, out DateTimeOffset at) ? (DateTimeOffset?)at : null)) + .Select(p => + { + DateTimeOffset? cycleSearched = evaluation.LastSearchedByItem.TryGetValue(p.Key, out DateTimeOffset at) ? at : null; + DateTimeOffset? lastSearched = new[] { arrSearchedByItem[p.Key], cycleSearched }.Max(); + return (p.Key, p.Value[0].Added, lastSearched); + }) .ToList(); return ItemSelectorFactory.Create(strategy) @@ -294,6 +302,7 @@ public sealed class SeekerUnitEvaluator Name = movie.Title, Reasons = reasons, Added = movie.Added, + LastSearchTime = movie.LastSearchTime, Searched = false, BlockedBy = blockedBy, }); @@ -413,6 +422,7 @@ public sealed class SeekerUnitEvaluator EpisodeId = e.Id, EpisodeNumber = e.EpisodeNumber, AirDateUtc = e.AirDateUtc, + LastSearchTime = e.LastSearchTime, Monitored = e.Monitored, Reason = reason, }; @@ -475,6 +485,7 @@ public sealed class SeekerUnitEvaluator EpisodeId = entry.EpisodeId, Title = entry.Title, AirDateUtc = entry.AirDateUtc, + LastSearchTime = entry.LastSearchTime, Monitored = entry.IsMonitored, Reason = SeekerSearchReason.CustomFormatScoreBelowCutoff, }); @@ -593,6 +604,7 @@ public sealed class SeekerUnitEvaluator ContentKind = kind, Reasons = season.Select(w => w.Reason).ToList(), Added = series.Added, + LastSearchTime = season.Max(w => w.LastSearchTime), Searched = seasonSearched, }); continue; @@ -615,6 +627,7 @@ public sealed class SeekerUnitEvaluator Reasons = episode.Select(w => w.Reason).ToList(), AirDateUtc = first.AirDateUtc, Added = series.Added, + LastSearchTime = episode.Max(w => w.LastSearchTime), Searched = seasonSearched || searchedEpisodeIds.Contains(episode.Key), }); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs index 130e24b5..9dfa70e5 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs @@ -25,6 +25,8 @@ internal sealed record SonarrWantedEpisode public DateTimeOffset? AirDateUtc { get; init; } + public DateTimeOffset? LastSearchTime { get; init; } + public required bool Monitored { get; init; } public required SeekerSearchReason Reason { get; init; }