mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-26 18:13:32 -04:00
fixed instances not retroactively checking total items for changes
This commit is contained in:
@@ -1297,7 +1297,9 @@ public class SeekerTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RoundRobin_SkipsInstanceWaitingForMinCycleTime()
|
||||
{
|
||||
// Arrange — two Radarr instances: one waiting for MinCycleTimeDays, the other has work to do
|
||||
// Arrange — two Radarr instances: one waiting for MinCycleTimeDays, the other has work to do.
|
||||
// Round-robin tries instances in order of oldest LastProcessedAt.
|
||||
// Instance A (oldest) is cycle-complete and waiting — no search triggered, moves to instance B.
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
@@ -1307,7 +1309,7 @@ public class SeekerTests : IDisposable
|
||||
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
// Instance A: cycle complete, waiting for MinCycleTimeDays (oldest LastProcessedAt — would be picked first)
|
||||
// Instance A: cycle complete, waiting for MinCycleTimeDays (oldest LastProcessedAt — tried first)
|
||||
var instanceA = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-a:7878");
|
||||
var cycleIdA = Guid.NewGuid();
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
@@ -1318,7 +1320,7 @@ public class SeekerTests : IDisposable
|
||||
CurrentCycleId = cycleIdA,
|
||||
MinCycleTimeDays = 30,
|
||||
TotalEligibleItems = 1,
|
||||
LastProcessedAt = now.AddDays(-5) // Oldest — round-robin would pick this first
|
||||
LastProcessedAt = now.AddDays(-5) // Oldest — round-robin tries this first
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
@@ -1353,6 +1355,14 @@ public class SeekerTests : IDisposable
|
||||
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Instance A: return the movie that was already searched in its cycle
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(instanceA))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableMovie { Id = 1, Title = "Movie A", Status = "released", Monitored = true, Tags = [] }
|
||||
]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(instanceB))
|
||||
.ReturnsAsync(
|
||||
@@ -1361,7 +1371,7 @@ public class SeekerTests : IDisposable
|
||||
]);
|
||||
|
||||
mockArrClient
|
||||
.Setup(x => x.SearchItemsAsync(instanceB, It.IsAny<HashSet<SearchItem>>()))
|
||||
.Setup(x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()))
|
||||
.ReturnsAsync([100L]);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
@@ -1373,13 +1383,192 @@ public class SeekerTests : IDisposable
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — Instance B was processed (not A which was waiting)
|
||||
// Assert — Instance A was checked (library fetched) but no search triggered
|
||||
_radarrClient.Verify(
|
||||
x => x.GetAllMoviesAsync(instanceA),
|
||||
Times.Once);
|
||||
// Instance B was processed and searched
|
||||
_radarrClient.Verify(
|
||||
x => x.GetAllMoviesAsync(instanceB),
|
||||
Times.Once);
|
||||
_radarrClient.Verify(
|
||||
x => x.GetAllMoviesAsync(instanceA),
|
||||
// Search was only triggered for instance B, not instance A
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(instanceA, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Never);
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(instanceB, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Radarr_NewItemAdded_SearchedDespiteCycleComplete()
|
||||
{
|
||||
// Arrange — cycle was complete (2 items searched), but a new item was added to the library.
|
||||
// The new item should be searched immediately without waiting for MinCycleTimeDays.
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
config.MonitoredOnly = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
var currentCycleId = Guid.NewGuid();
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
CurrentCycleId = currentCycleId,
|
||||
MinCycleTimeDays = 30,
|
||||
TotalEligibleItems = 2 // Stale value from previous run
|
||||
});
|
||||
|
||||
// History: 2 items searched in current cycle
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
CycleId = currentCycleId,
|
||||
LastSearchedAt = now.AddDays(-2),
|
||||
ItemTitle = "Movie 1"
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 2,
|
||||
ItemType = InstanceType.Radarr,
|
||||
CycleId = currentCycleId,
|
||||
LastSearchedAt = now.AddDays(-1),
|
||||
ItemTitle = "Movie 2"
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Library now has 3 items — the 3rd was newly added
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
|
||||
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] },
|
||||
new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] }
|
||||
]);
|
||||
|
||||
mockArrClient
|
||||
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
|
||||
.ReturnsAsync([100L]);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — search was triggered for the new item (cycle is NOT considered complete)
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Once);
|
||||
|
||||
// Cycle ID should NOT have changed (cycle is not complete — there's still a new item)
|
||||
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
|
||||
Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Radarr_ItemSwapped_SearchesNewItem()
|
||||
{
|
||||
// Arrange — cycle was complete (2 items searched), but one item was removed and a new one added.
|
||||
// Total count is the same, but the library has changed. The new item should be searched.
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
config.MonitoredOnly = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
var currentCycleId = Guid.NewGuid();
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
CurrentCycleId = currentCycleId,
|
||||
MinCycleTimeDays = 30,
|
||||
TotalEligibleItems = 2 // Stale value from previous run
|
||||
});
|
||||
|
||||
// History: items 1 and 2 were searched
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
CycleId = currentCycleId,
|
||||
LastSearchedAt = now.AddDays(-2),
|
||||
ItemTitle = "Movie 1"
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 2,
|
||||
ItemType = InstanceType.Radarr,
|
||||
CycleId = currentCycleId,
|
||||
LastSearchedAt = now.AddDays(-1),
|
||||
ItemTitle = "Movie 2 (Removed)"
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Library: item 2 was removed, item 3 was added (same total count of 2)
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
|
||||
new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] }
|
||||
]);
|
||||
|
||||
mockArrClient
|
||||
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
|
||||
.ReturnsAsync([100L]);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — search was triggered for the new item
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Once);
|
||||
|
||||
// Cycle ID should NOT have changed (the new item hasn't been searched yet)
|
||||
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
|
||||
Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -220,23 +220,24 @@ public sealed class Seeker : IHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-filter instances
|
||||
instanceConfigs = await FilterInstancesAsync(instanceConfigs);
|
||||
|
||||
if (instanceConfigs.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All instances are waiting for min cycle time to elapse");
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.UseRoundRobin)
|
||||
{
|
||||
// Round-robin: pick the instance with the oldest LastProcessedAt
|
||||
SeekerInstanceConfig nextInstance = instanceConfigs
|
||||
// Round-robin: try instances in order of oldest LastProcessedAt,
|
||||
// stop after the first one that triggers a search.
|
||||
// This prevents cycle-complete-waiting instances from wasting a run.
|
||||
var ordered = instanceConfigs
|
||||
.OrderBy(s => s.LastProcessedAt ?? DateTime.MinValue)
|
||||
.First();
|
||||
.ToList();
|
||||
|
||||
await ProcessSingleInstanceAsync(config, nextInstance, isDryRun);
|
||||
foreach (SeekerInstanceConfig instance in ordered)
|
||||
{
|
||||
bool searched = await ProcessSingleInstanceAsync(config, instance, isDryRun);
|
||||
|
||||
if (searched)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -248,7 +249,7 @@ public sealed class Seeker : IHandler
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun)
|
||||
private async Task<bool> ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun)
|
||||
{
|
||||
ArrInstance arrInstance = instanceConfig.ArrInstance;
|
||||
InstanceType instanceType = arrInstance.ArrConfig.Type;
|
||||
@@ -287,13 +288,14 @@ public sealed class Seeker : IHandler
|
||||
_logger.LogInformation(
|
||||
"Skipping proactive search for {InstanceName} — {Count} items actively downloading (limit: {Limit})",
|
||||
arrInstance.Name, activeDownloads, instanceConfig.ActiveDownloadLimit);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool searched = false;
|
||||
try
|
||||
{
|
||||
await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords);
|
||||
searched = await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -305,9 +307,11 @@ public sealed class Seeker : IHandler
|
||||
instanceConfig.LastProcessedAt = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
_dataContext.SeekerInstanceConfigs.Update(instanceConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return searched;
|
||||
}
|
||||
|
||||
private async Task ProcessInstanceAsync(
|
||||
private async Task<bool> ProcessInstanceAsync(
|
||||
SeekerConfig config,
|
||||
SeekerInstanceConfig instanceConfig,
|
||||
ArrInstance arrInstance,
|
||||
@@ -374,7 +378,7 @@ public sealed class Seeker : IHandler
|
||||
{
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds);
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trigger search (arr client guards the HTTP request via dry run interceptor)
|
||||
@@ -401,6 +405,8 @@ public sealed class Seeker : IHandler
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds);
|
||||
await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentCycleId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<(List<long> SelectedIds, List<string> SelectedNames, List<long> AllLibraryIds)> ProcessRadarrAsync(
|
||||
@@ -894,55 +900,6 @@ public sealed class Seeker : IHandler
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<SeekerInstanceConfig>> FilterInstancesAsync(
|
||||
List<SeekerInstanceConfig> instanceConfigs)
|
||||
{
|
||||
var result = new List<SeekerInstanceConfig>();
|
||||
|
||||
foreach (var ic in instanceConfigs)
|
||||
{
|
||||
// Count distinct items searched in current cycle
|
||||
int cycleItemsSearched = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ArrInstanceId == ic.ArrInstanceId && h.CycleId == ic.CurrentCycleId)
|
||||
.Select(h => h.ExternalItemId)
|
||||
.Distinct()
|
||||
.CountAsync();
|
||||
|
||||
// No searches have been performed
|
||||
if (cycleItemsSearched is 0)
|
||||
{
|
||||
result.Add(ic);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cycle not complete
|
||||
if (cycleItemsSearched < ic.TotalEligibleItems)
|
||||
{
|
||||
result.Add(ic);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cycle is complete, but check if min time has elapsed
|
||||
DateTime? cycleStartedAt = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ArrInstanceId == ic.ArrInstanceId && h.CycleId == ic.CurrentCycleId)
|
||||
.MinAsync(h => (DateTime?)h.LastSearchedAt);
|
||||
|
||||
if (ShouldWaitForMinCycleTime(ic, cycleStartedAt))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}",
|
||||
ic.ArrInstance.Name, ic.MinCycleTimeDays, cycleStartedAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(ic);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the minimum cycle time constraint prevents starting a new cycle.
|
||||
/// Returns true if the cycle started recently and MinCycleTimeDays has not yet elapsed.
|
||||
|
||||
Reference in New Issue
Block a user