From 6759fe161d83ecff957b808d8e6a151fdde89e73 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 23 Oct 2026 02:11:23 +0300 Subject: [PATCH] changed gap strategy to search its largest gap unit --- .../Seeker/SeekerUnitEvaluatorTests.cs | 22 +++++++++++++++++++ .../Features/Seeker/SeekerUnitEvaluator.cs | 15 +++++++++++-- docs/docs/configuration/seeker/index.mdx | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs index c723c6ad..92c20e34 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs @@ -1211,6 +1211,28 @@ public class SeekerUnitEvaluatorTests : IDisposable missingPicks.ShouldBeGreaterThan(600); } + [Fact] + public async Task SelectNext_CustomFormatGapWeighted_Sonarr_SearchesTheLargestGapUnit() + { + // Arrange + SetupSonarr([Series(1)], []); + await AddSonarrCustomFormatEntryAsync(episodeId: 20, currentScore: 5, cutoffScore: 10); + await AddSonarrCustomFormatEntryAsync(episodeId: 21, currentScore: 0, cutoffScore: 30); + SeekerEvaluation evaluation = await EvaluateSonarrAsync(InstanceConfig(_sonarr, c => + { + c.UseCustomFormatScore = true; + c.SonarrSearchMode = SonarrSearchMode.Episode; + })); + + // Act + List picked = Enumerable.Range(0, 50) + .Select(_ => SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.CustomFormatGapWeighted, 1)[0].EpisodeId) + .ToList(); + + // Assert + picked.ShouldAllBe(id => id == 21); + } + [Theory] [InlineData(SelectionStrategy.CustomFormatGapWeighted, false, SelectionStrategy.BalancedWeighted)] [InlineData(SelectionStrategy.CustomFormatGapWeighted, true, SelectionStrategy.CustomFormatGapWeighted)] diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs index 69be4b07..c79b4a72 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs @@ -135,7 +135,7 @@ public sealed class SeekerUnitEvaluator return ItemSelectorFactory.Create(strategy) .Select([.. selectionCandidates], count) - .Select(id => ToCandidate(PickUnit(remainingByItem[id]))) + .Select(id => ToCandidate(PickUnit(remainingByItem[id], strategy))) .ToList(); } @@ -708,9 +708,20 @@ public sealed class SeekerUnitEvaluator /// /// Picks a random season of the item, then its earliest airing unit. + /// Under it picks the missing unit, else the largest gap unit. /// - private static SeekerSearchUnit PickUnit(List itemUnits) + private static SeekerSearchUnit PickUnit(List itemUnits, SelectionStrategy strategy) { + if (strategy is SelectionStrategy.CustomFormatGapWeighted) + { + return itemUnits + .OrderByDescending(u => u.Reasons.Contains(SeekerSearchReason.Missing)) + .ThenByDescending(u => u.CustomFormatGap) + .ThenBy(u => u.AirDateUtc ?? DateTimeOffset.MaxValue) + .ThenBy(u => u.EpisodeId) + .First(); + } + List seasons = itemUnits.Select(u => u.SeasonNumber).Distinct().ToList(); int seasonNumber = seasons[Random.Shared.Next(seasons.Count)]; diff --git a/docs/docs/configuration/seeker/index.mdx b/docs/docs/configuration/seeker/index.mdx index 16f4a930..8f15d0c4 100644 --- a/docs/docs/configuration/seeker/index.mdx +++ b/docs/docs/configuration/seeker/index.mdx @@ -74,7 +74,7 @@ To get notified when a cycle completes, turn on [Seeker Cycle Completed](../noti The **Eligibility** row on an instance card counts what passed each filter on the latest run. It starts with the movies or series in the library, then those released and past the grace period, monitored, and past the tag filters. The last stage counts the units that need a search, split into in queue, recently grabbed, searched this cycle, and left. -**Preview** on an instance card reads the instance live and lists the next 10 units the selection strategy would pick, plus every excluded item with the first filter that dropped it. You can filter the excluded list by reason or title. Preview searches nothing and saves nothing. With a random or weighted strategy, the next up list is one random draw, so the next run can pick other items. A series with missing episodes in several seasons also gets a random season draw under any strategy. When the cycle is complete and waits for the minimum cycle time, the preview shows when the next cycle starts. +**Preview** on an instance card reads the instance live and lists the next 10 units the selection strategy would pick, plus every excluded item with the first filter that dropped it. You can filter the excluded list by reason or title. Preview searches nothing and saves nothing. With a random or weighted strategy, the next up list is one random draw, so the next run can pick other items. A series with missing episodes in several seasons also gets a random season draw under any strategy, except Custom Format Gap Weighted, which searches the series' missing unit, else its largest gap unit. When the cycle is complete and waits for the minimum cycle time, the preview shows when the next cycle starts. Each Sonarr search event records a content kind: `Standard` (a regular season or episode), `Daily` (a daily series season or episode), `Anime` (an anime season or episode) or `Special` (an episode from season 0). Radarr and replacement searches carry none. The Searches tab can filter events by content kind.