mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
added custom format gap selection strategy
This commit is contained in:
1 parent
57f7c7e611
commit
37a124fc73
15 files changed
+224
-26
No files matched your search
@@ -131,9 +131,10 @@ public sealed class SeekerPreviewController : ControllerBase
|
||||
}
|
||||
|
||||
InstanceFunnelStat funnel = ToFunnel(evaluation);
|
||||
SelectionStrategy strategy = SeekerUnitEvaluator.EffectiveStrategy(config.SelectionStrategy, instanceConfig);
|
||||
|
||||
List<SeekerSearchCandidate> nextUp = waitingUntil is null
|
||||
? SeekerUnitEvaluator.SelectNext(evaluation, config.SelectionStrategy, NextUpCount)
|
||||
? SeekerUnitEvaluator.SelectNext(evaluation, strategy, NextUpCount)
|
||||
: [];
|
||||
|
||||
HashSet<long> 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(),
|
||||
|
||||
@@ -40,4 +40,12 @@ public enum SelectionStrategy
|
||||
/// Every eligible item has an equal chance of being selected.
|
||||
/// </summary>
|
||||
Random,
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="BalancedWeighted"/>.
|
||||
/// </summary>
|
||||
CustomFormatGapWeighted,
|
||||
}
|
||||
@@ -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<long> result = selector.Select([], 5);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomFormatGapWeighted_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
CustomFormatGapWeightedSelector selector = new();
|
||||
|
||||
List<long> result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
result.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomFormatGapWeighted_Select_NoDuplicateIds()
|
||||
{
|
||||
CustomFormatGapWeightedSelector selector = new();
|
||||
|
||||
List<long> 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]
|
||||
|
||||
+36
@@ -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<ArrInstance>(), true, Arg.Any<CancellationToken>())
|
||||
.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()
|
||||
{
|
||||
|
||||
@@ -571,7 +571,8 @@ public sealed class Seeker : IHandler
|
||||
}
|
||||
}
|
||||
|
||||
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, config.SelectionStrategy, 1);
|
||||
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(
|
||||
evaluation, SeekerUnitEvaluator.EffectiveStrategy(config.SelectionStrategy, instanceConfig), 1);
|
||||
|
||||
foreach (SeekerSearchCandidate candidate in candidates)
|
||||
{
|
||||
|
||||
@@ -8,8 +8,8 @@ public interface IItemSelector
|
||||
/// <summary>
|
||||
/// Selects up to <paramref name="count"/> item IDs from the candidates
|
||||
/// </summary>
|
||||
/// <param name="candidates">List of (id, dateAdded, lastSearched) tuples</param>
|
||||
/// <param name="candidates">List of (id, dateAdded, lastSearched, customFormatGap) tuples</param>
|
||||
/// <param name="count">Maximum number of items to select</param>
|
||||
/// <returns>Selected item IDs</returns>
|
||||
List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count);
|
||||
List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count);
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,6 +109,16 @@ public sealed class SeekerUnitEvaluator
|
||||
recentGrabs.Episodes, currentCycleHistory, lastSearchedByItem, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The strategy the instance runs: <see cref="SelectionStrategy.CustomFormatGapWeighted"/> needs custom format scores.
|
||||
/// </summary>
|
||||
public static SelectionStrategy EffectiveStrategy(SelectionStrategy strategy, SeekerInstanceConfig instanceConfig)
|
||||
{
|
||||
return strategy is SelectionStrategy.CustomFormatGapWeighted && !instanceConfig.UseCustomFormatScore
|
||||
? SelectionStrategy.BalancedWeighted
|
||||
: strategy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks up to <paramref name="count"/> unsearched units, at most one per movie or series.
|
||||
/// </summary>
|
||||
@@ -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<long, List<SeekerSearchUnit>> remainingByItem)
|
||||
{
|
||||
@@ -151,12 +161,25 @@ public sealed class SeekerUnitEvaluator
|
||||
.GroupBy(u => u.ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Max(u => u.LastSearchTime));
|
||||
|
||||
HashSet<long> 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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
/// </summary>
|
||||
public sealed class BalancedWeightedSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class CustomFormatGapWeightedSelector : IItemSelector
|
||||
{
|
||||
public List<long> 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<long, int> 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<long> 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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
/// </summary>
|
||||
public sealed class NewestFirstSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
|
||||
{
|
||||
return candidates
|
||||
.OrderByDescending(c => c.Added ?? DateTimeOffset.MinValue)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
/// </summary>
|
||||
public sealed class NewestWeightedSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
/// </summary>
|
||||
public sealed class OldestSearchFirstSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
|
||||
{
|
||||
return candidates
|
||||
.OrderBy(c => c.LastSearched ?? DateTimeOffset.MinValue)
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
/// </summary>
|
||||
public sealed class OldestSearchWeightedSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
|
||||
public List<long> 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.
|
||||
/// </summary>
|
||||
internal static List<long> 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;
|
||||
|
||||
@@ -6,10 +6,10 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
/// </summary>
|
||||
public sealed class RandomSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
|
||||
public List<long> 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++)
|
||||
|
||||
Reference in new issue
Block a user