diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs index 7833edb9..8e05239f 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs @@ -131,9 +131,10 @@ public sealed class SeekerPreviewController : ControllerBase } InstanceFunnelStat funnel = ToFunnel(evaluation); + SelectionStrategy strategy = SeekerUnitEvaluator.EffectiveStrategy(config.SelectionStrategy, instanceConfig); List nextUp = waitingUntil is null - ? SeekerUnitEvaluator.SelectNext(evaluation, config.SelectionStrategy, NextUpCount) + ? SeekerUnitEvaluator.SelectNext(evaluation, strategy, NextUpCount) : []; HashSet nextUpIds = nextUp.Select(c => c.ItemId).ToHashSet(); @@ -147,8 +148,8 @@ public sealed class SeekerPreviewController : ControllerBase Funnel = funnel, CycleWaitingUntil = waitingUntil, RandomPick = randomSeason - || config.SelectionStrategy is not (SelectionStrategy.OldestSearchFirst or SelectionStrategy.NewestFirst) - || SeekerUnitEvaluator.HasTiedPicks(evaluation, config.SelectionStrategy, nextUpIds), + || strategy is not (SelectionStrategy.OldestSearchFirst or SelectionStrategy.NewestFirst) + || SeekerUnitEvaluator.HasTiedPicks(evaluation, strategy, nextUpIds), NextUp = nextUp .Select(c => new SeekerPreviewUnitResponse { Name = c.Name, Reason = c.Reason, ContentKind = c.ContentKind }) .ToList(), diff --git a/code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs b/code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs index 39a1c243..c0b063fb 100644 --- a/code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs +++ b/code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs @@ -40,4 +40,12 @@ public enum SelectionStrategy /// Every eligible item has an equal chance of being selected. /// Random, + + /// + /// Weighted random selection combining the custom format gap and search recency. + /// Half the weight is how far the item sits below its custom format cutoff, with missing items furthest below. + /// The other half is search recency, so items searched long ago still come up. + /// Instances that don't use custom format scores fall back to . + /// + CustomFormatGapWeighted, } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs index b64f7572..7d98fe35 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs @@ -8,13 +8,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Seeker; public sealed class ItemSelectorTests { - private static readonly List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> SampleCandidates = + private static readonly List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> SampleCandidates = [ - (1, new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero)), - (2, new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero)), - (3, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), null), - (4, new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero)), - (5, null, new DateTimeOffset(2024, 4, 1, 0, 0, 0, TimeSpan.Zero)), + (1, new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero), 0), + (2, new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), 0), + (3, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), null, 0), + (4, new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero), 0), + (5, null, new DateTimeOffset(2024, 4, 1, 0, 0, 0, TimeSpan.Zero), 0), ]; #region ItemSelectorFactory Tests @@ -26,6 +26,7 @@ public sealed class ItemSelectorTests [InlineData(SelectionStrategy.NewestWeighted, typeof(NewestWeightedSelector))] [InlineData(SelectionStrategy.BalancedWeighted, typeof(BalancedWeightedSelector))] [InlineData(SelectionStrategy.Random, typeof(RandomSelector))] + [InlineData(SelectionStrategy.CustomFormatGapWeighted, typeof(CustomFormatGapWeightedSelector))] public void Factory_Create_ReturnsCorrectSelectorType(SelectionStrategy strategy, Type expectedType) { var selector = ItemSelectorFactory.Create(strategy); @@ -244,7 +245,7 @@ public sealed class ItemSelectorTests public void NewestWeighted_Select_SingleCandidate_ReturnsThatCandidate() { var selector = new NewestWeightedSelector(); - List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> single = [(42, DateTimeOffset.UtcNow, null)]; + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> single = [(42, DateTimeOffset.UtcNow, null, 0)]; var result = selector.Select(single, 1); @@ -300,7 +301,7 @@ public sealed class ItemSelectorTests public void OldestSearchWeighted_Select_SingleCandidate_ReturnsThatCandidate() { var selector = new OldestSearchWeightedSelector(); - List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> single = [(42, DateTimeOffset.UtcNow, null)]; + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> single = [(42, DateTimeOffset.UtcNow, null, 0)]; var result = selector.Select(single, 1); @@ -356,7 +357,7 @@ public sealed class ItemSelectorTests public void BalancedWeighted_Select_SingleCandidate_ReturnsThatCandidate() { var selector = new BalancedWeightedSelector(); - List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> single = [(42, DateTimeOffset.UtcNow, null)]; + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> single = [(42, DateTimeOffset.UtcNow, null, 0)]; var result = selector.Select(single, 1); @@ -377,6 +378,72 @@ public sealed class ItemSelectorTests #endregion + #region CustomFormatGapWeightedSelector Tests + + [Fact] + public void CustomFormatGapWeighted_Select_EmptyInput_ReturnsEmptyList() + { + CustomFormatGapWeightedSelector selector = new(); + + List result = selector.Select([], 5); + + result.ShouldBeEmpty(); + } + + [Fact] + public void CustomFormatGapWeighted_Select_CountExceedsCandidates_ReturnsAll() + { + CustomFormatGapWeightedSelector selector = new(); + + List result = selector.Select(SampleCandidates, 100); + + result.Count.ShouldBe(5); + } + + [Fact] + public void CustomFormatGapWeighted_Select_NoDuplicateIds() + { + CustomFormatGapWeightedSelector selector = new(); + + List result = selector.Select(SampleCandidates, 5); + + result.Distinct().Count().ShouldBe(result.Count); + } + + [Fact] + public void CustomFormatGapWeighted_Select_LargeGap_WinsMoreOftenThanZeroGap() + { + CustomFormatGapWeightedSelector selector = new(); + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates = + [ + (1, null, DateTimeOffset.UtcNow, 50), + (2, null, null, 0), + ]; + + int wins = Enumerable.Range(0, 1000).Count(_ => selector.Select(candidates, 1)[0] == 1); + + // Weights 0.75 vs 0.5: expect about 600 wins + wins.ShouldBeGreaterThan(530); + } + + [Fact] + public void CustomFormatGapWeighted_Select_NoGaps_FavoursTheOldestSearch() + { + CustomFormatGapWeightedSelector selector = new(); + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates = + [ + (1, null, DateTimeOffset.UtcNow, 0), + (2, null, DateTimeOffset.UtcNow.AddDays(-30), 0), + ]; + + int wins = Enumerable.Range(0, 1000).Count(_ => selector.Select(candidates, 1)[0] == 2); + + // Weights 0.5 vs 0.25: expect about 667 wins + wins.ShouldBeGreaterThan(590); + } + + #endregion + #region WeightedRandomByRank Tests [Fact] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs index 2a734508..c723c6ad 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs @@ -1192,6 +1192,42 @@ public class SeekerUnitEvaluatorTests : IDisposable picked.ShouldBe([1L, 2L], ignoreOrder: true); } + [Fact] + public async Task SelectNext_CustomFormatGapWeighted_FavoursTheMissingMovie() + { + // Arrange + SearchableMovie upgradable = Movie(2, hasFile: true); + SetupRadarr([Movie(1), upgradable]); + _radarrClient.GetCutoffUnmetMoviesAsync(Arg.Any(), true, Arg.Any()) + .Returns([upgradable]); + SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.UseCutoff = true)); + + // Act + int missingPicks = Enumerable.Range(0, 1000) + .Count(_ => SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.CustomFormatGapWeighted, 1)[0].ItemId == 1); + + // Assert + // The missing movie takes 60% or 80% of the weight, depending on the shuffled recency rank + missingPicks.ShouldBeGreaterThan(600); + } + + [Theory] + [InlineData(SelectionStrategy.CustomFormatGapWeighted, false, SelectionStrategy.BalancedWeighted)] + [InlineData(SelectionStrategy.CustomFormatGapWeighted, true, SelectionStrategy.CustomFormatGapWeighted)] + [InlineData(SelectionStrategy.NewestFirst, false, SelectionStrategy.NewestFirst)] + public void EffectiveStrategy_FallsBackToBalancedOnlyForTheGapStrategyWithoutCustomFormatScores( + SelectionStrategy strategy, bool useCustomFormatScore, SelectionStrategy expected) + { + // Arrange + SeekerInstanceConfig instanceConfig = InstanceConfig(_radarr, c => c.UseCustomFormatScore = useCustomFormatScore); + + // Act + SelectionStrategy effective = SeekerUnitEvaluator.EffectiveStrategy(strategy, instanceConfig); + + // Assert + effective.ShouldBe(expected); + } + [Fact] public async Task HasTiedPicks_OldestSearchFirst_NeverSearchedMovies_ReturnsTrue() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 1724520b..8794eed9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -571,7 +571,8 @@ public sealed class Seeker : IHandler } } - List candidates = SeekerUnitEvaluator.SelectNext(evaluation, config.SelectionStrategy, 1); + List candidates = SeekerUnitEvaluator.SelectNext( + evaluation, SeekerUnitEvaluator.EffectiveStrategy(config.SelectionStrategy, instanceConfig), 1); foreach (SeekerSearchCandidate candidate in candidates) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/IItemSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/IItemSelector.cs index b196557d..9c4b4964 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/IItemSelector.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/IItemSelector.cs @@ -8,8 +8,8 @@ public interface IItemSelector /// /// Selects up to item IDs from the candidates /// - /// List of (id, dateAdded, lastSearched) tuples + /// List of (id, dateAdded, lastSearched, customFormatGap) tuples /// Maximum number of items to select /// Selected item IDs - List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count); + List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/ItemSelectorFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/ItemSelectorFactory.cs index c818c0a2..642f2ea0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/ItemSelectorFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/ItemSelectorFactory.cs @@ -18,6 +18,7 @@ public static class ItemSelectorFactory SelectionStrategy.NewestWeighted => new NewestWeightedSelector(), SelectionStrategy.BalancedWeighted => new BalancedWeightedSelector(), SelectionStrategy.Random => new RandomSelector(), + SelectionStrategy.CustomFormatGapWeighted => new CustomFormatGapWeightedSelector(), _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown selection strategy") }; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs index 29dcfddb..69be4b07 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs @@ -109,6 +109,16 @@ public sealed class SeekerUnitEvaluator recentGrabs.Episodes, currentCycleHistory, lastSearchedByItem, cancellationToken); } + /// + /// The strategy the instance runs: needs custom format scores. + /// + public static SelectionStrategy EffectiveStrategy(SelectionStrategy strategy, SeekerInstanceConfig instanceConfig) + { + return strategy is SelectionStrategy.CustomFormatGapWeighted && !instanceConfig.UseCustomFormatScore + ? SelectionStrategy.BalancedWeighted + : strategy; + } + /// /// Picks up to unsearched units, at most one per movie or series. /// @@ -118,7 +128,7 @@ public sealed class SeekerUnitEvaluator .GroupBy(u => u.ItemId) .ToDictionary(g => g.Key, g => g.ToList()); - (long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)[] selectionCandidates = BuildSelectionCandidates(evaluation, remainingByItem); + (long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)[] selectionCandidates = BuildSelectionCandidates(evaluation, remainingByItem); // The selectors sort stably, so unshuffled ties would favour the arr's lowest ids Random.Shared.Shuffle(selectionCandidates); @@ -143,7 +153,7 @@ public sealed class SeekerUnitEvaluator .Any(g => g.Count() > 1 && g.Any(c => pickedIds.Contains(c.Id))); } - private static (long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)[] BuildSelectionCandidates( + private static (long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)[] BuildSelectionCandidates( SeekerEvaluation evaluation, Dictionary> remainingByItem) { @@ -151,12 +161,25 @@ public sealed class SeekerUnitEvaluator .GroupBy(u => u.ItemId) .ToDictionary(g => g.Key, g => g.Max(u => u.LastSearchTime)); + HashSet missingItemIds = remainingByItem + .Where(p => p.Value.Any(u => u.Reasons.Contains(SeekerSearchReason.Missing))) + .Select(p => p.Key) + .ToHashSet(); + + // Missing items rank as furthest below the cutoff + int missingGap = Math.Max(1, remainingByItem + .Where(p => !missingItemIds.Contains(p.Key)) + .Select(p => p.Value.Max(u => u.CustomFormatGap)) + .DefaultIfEmpty(0) + .Max()); + return remainingByItem .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); + int gap = missingItemIds.Contains(p.Key) ? missingGap : p.Value.Max(u => u.CustomFormatGap); + return (p.Key, p.Value[0].Added, lastSearched, (double)gap); }) .ToArray(); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/BalancedWeightedSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/BalancedWeightedSelector.cs index ae487b70..466fff06 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/BalancedWeightedSelector.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/BalancedWeightedSelector.cs @@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; /// public sealed class BalancedWeightedSelector : IItemSelector { - public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count) + public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count) { if (candidates.Count == 0) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/CustomFormatGapWeightedSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/CustomFormatGapWeightedSelector.cs new file mode 100644 index 00000000..8f85fa39 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/CustomFormatGapWeightedSelector.cs @@ -0,0 +1,61 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +/// +/// Selects items using weighted random sampling that combines the custom format gap and search recency. +/// Half the weight is the item's gap relative to the largest gap in the pool, half is its search recency rank. +/// Items far below their custom format cutoff that haven't been searched recently get the highest combined weight. +/// +public sealed class CustomFormatGapWeightedSelector : IItemSelector +{ + public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count) + { + if (candidates.Count == 0) + { + return []; + } + + count = Math.Min(count, candidates.Count); + int n = candidates.Count; + + Dictionary searchRanks = candidates + .OrderBy(c => c.LastSearched ?? DateTimeOffset.MinValue) + .Select((c, index) => (c.Id, Rank: n - index)) + .ToDictionary(x => x.Id, x => x.Rank); + + double maxGap = candidates.Max(c => c.Gap); + + // The recency half is at least 1/n, so every weight stays above zero + List selected = new(count); + List<(long Id, double Weight)> pool = candidates + .Select(c => (c.Id, Weight: 0.5 * (maxGap > 0 ? c.Gap / maxGap : 0) + 0.5 * searchRanks[c.Id] / n)) + .ToList(); + + for (int i = 0; i < count && pool.Count > 0; i++) + { + double totalWeight = 0; + for (int j = 0; j < pool.Count; j++) + { + totalWeight += pool[j].Weight; + } + + double target = Random.Shared.NextDouble() * totalWeight; + double cumulative = 0; + int selectedIndex = pool.Count - 1; + + for (int j = 0; j < pool.Count; j++) + { + cumulative += pool[j].Weight; + if (cumulative >= target) + { + selectedIndex = j; + break; + } + } + + selected.Add(pool[selectedIndex].Id); + pool.RemoveAt(selectedIndex); + } + + return selected; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestFirstSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestFirstSelector.cs index f73472fe..5c24718a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestFirstSelector.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestFirstSelector.cs @@ -6,7 +6,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; /// public sealed class NewestFirstSelector : IItemSelector { - public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count) + public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count) { return candidates .OrderByDescending(c => c.Added ?? DateTimeOffset.MinValue) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestWeightedSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestWeightedSelector.cs index 3c0cd5a9..e8805251 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestWeightedSelector.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestWeightedSelector.cs @@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; /// public sealed class NewestWeightedSelector : IItemSelector { - public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count) + public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count) { if (candidates.Count == 0) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchFirstSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchFirstSelector.cs index f79fa548..dbf7b11f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchFirstSelector.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchFirstSelector.cs @@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; /// public sealed class OldestSearchFirstSelector : IItemSelector { - public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count) + public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count) { return candidates .OrderBy(c => c.LastSearched ?? DateTimeOffset.MinValue) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchWeightedSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchWeightedSelector.cs index baffd59d..f7a1934b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchWeightedSelector.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchWeightedSelector.cs @@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; /// public sealed class OldestSearchWeightedSelector : IItemSelector { - public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count) + public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count) { if (candidates.Count == 0) { @@ -29,7 +29,7 @@ public sealed class OldestSearchWeightedSelector : IItemSelector /// The item at rank 0 gets the highest weight (N), rank 1 gets (N-1), etc. /// internal static List WeightedRandomByRank( - List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> ranked, + List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> ranked, int count) { int n = ranked.Count; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/RandomSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/RandomSelector.cs index 3cb26aa3..2cdb5f20 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/RandomSelector.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/RandomSelector.cs @@ -6,10 +6,10 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; /// public sealed class RandomSelector : IItemSelector { - public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count) + public List Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count) { count = Math.Min(count, candidates.Count); - var shuffled = new List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)>(candidates); + var shuffled = new List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)>(candidates); // Fisher-Yates shuffle for (int i = 0; i < count; i++)