mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-25 17:41:57 -04:00
added min cycle time
This commit is contained in:
@@ -9,4 +9,6 @@ public sealed record UpdateSeekerInstanceConfigRequest
|
||||
public List<string> SkipTags { get; init; } = [];
|
||||
|
||||
public int ActiveDownloadLimit { get; init; } = 0;
|
||||
|
||||
public int MinCycleTimeDays { get; init; } = 5;
|
||||
}
|
||||
@@ -12,4 +12,5 @@ public sealed record InstanceSearchStat
|
||||
public Guid? CurrentRunId { get; init; }
|
||||
public int CycleItemsSearched { get; init; }
|
||||
public int CycleItemsTotal { get; init; }
|
||||
public DateTime? CycleStartedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -19,4 +19,6 @@ public sealed record SeekerInstanceConfigResponse
|
||||
public bool ArrInstanceEnabled { get; init; }
|
||||
|
||||
public int ActiveDownloadLimit { get; init; }
|
||||
|
||||
public int MinCycleTimeDays { get; init; }
|
||||
}
|
||||
@@ -81,6 +81,7 @@ public sealed class SearchStatsController : ControllerBase
|
||||
{
|
||||
InstanceId = g.Key,
|
||||
CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
|
||||
CycleStartedAt = (DateTime?)g.Min(h => h.LastSearchedAt),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -100,6 +101,7 @@ public sealed class SearchStatsController : ControllerBase
|
||||
CurrentRunId = ic.CurrentRunId,
|
||||
CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
|
||||
CycleItemsTotal = ic.TotalEligibleItems,
|
||||
CycleStartedAt = cycleProgress?.CycleStartedAt,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
LastProcessedAt = seekerConfig?.LastProcessedAt,
|
||||
ArrInstanceEnabled = instance.Enabled,
|
||||
ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 3,
|
||||
MinCycleTimeDays = seekerConfig?.MinCycleTimeDays ?? 5,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -121,6 +122,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
existing.Enabled = instanceReq.Enabled;
|
||||
existing.SkipTags = instanceReq.SkipTags;
|
||||
existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit;
|
||||
existing.MinCycleTimeDays = instanceReq.MinCycleTimeDays;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -130,6 +132,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
Enabled = instanceReq.Enabled,
|
||||
SkipTags = instanceReq.SkipTags,
|
||||
ActiveDownloadLimit = instanceReq.ActiveDownloadLimit,
|
||||
MinCycleTimeDays = instanceReq.MinCycleTimeDays,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +718,7 @@ public class SeekerTests : IDisposable
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
var currentRunId = Guid.NewGuid();
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
@@ -728,13 +729,14 @@ public class SeekerTests : IDisposable
|
||||
});
|
||||
|
||||
// Add history entries for both movies in the current cycle
|
||||
// Use dates relative to FakeTimeProvider and far enough back to exceed default MinCycleTimeDays
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = DateTime.UtcNow.AddHours(-1),
|
||||
LastSearchedAt = now.AddDays(-10),
|
||||
ItemTitle = "Movie 1"
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
@@ -743,7 +745,7 @@ public class SeekerTests : IDisposable
|
||||
ExternalItemId = 2,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = DateTime.UtcNow.AddHours(-1),
|
||||
LastSearchedAt = now.AddDays(-10),
|
||||
ItemTitle = "Movie 2"
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
@@ -904,4 +906,481 @@ public class SeekerTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MinCycleTimeDays
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Radarr_CycleComplete_WaitsForMinCycleTime()
|
||||
{
|
||||
// Arrange — all items searched but MinCycleTimeDays has not elapsed
|
||||
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 currentRunId = Guid.NewGuid();
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
CurrentRunId = currentRunId,
|
||||
MinCycleTimeDays = 7,
|
||||
TotalEligibleItems = 2
|
||||
});
|
||||
|
||||
// Cycle started 2 days ago — within the 7-day minimum
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = now.AddDays(-2),
|
||||
ItemTitle = "Movie 1"
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 2,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
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);
|
||||
|
||||
_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 = [] }
|
||||
]);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no search triggered, cycle not reset
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Never);
|
||||
|
||||
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
|
||||
Assert.Equal(currentRunId, instanceConfig.CurrentRunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Radarr_CycleComplete_RestartsAfterMinCycleTimeElapsed()
|
||||
{
|
||||
// Arrange — all items searched and MinCycleTimeDays has elapsed
|
||||
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 currentRunId = Guid.NewGuid();
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
CurrentRunId = currentRunId,
|
||||
MinCycleTimeDays = 7,
|
||||
TotalEligibleItems = 2
|
||||
});
|
||||
|
||||
// Cycle started 10 days ago — beyond the 7-day minimum
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = now.AddDays(-10),
|
||||
ItemTitle = "Movie 1"
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 2,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = now.AddDays(-8),
|
||||
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);
|
||||
|
||||
_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 = [] }
|
||||
]);
|
||||
|
||||
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, cycle was reset
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Once);
|
||||
|
||||
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
|
||||
Assert.NotEqual(currentRunId, instanceConfig.CurrentRunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Radarr_CycleComplete_NoCycleHistory_StartsNewCycle()
|
||||
{
|
||||
// Arrange — cycle complete but no history (cycleStartedAt is null), should not block
|
||||
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 currentRunId = Guid.NewGuid();
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
CurrentRunId = currentRunId,
|
||||
MinCycleTimeDays = 30
|
||||
});
|
||||
|
||||
// History uses a DIFFERENT RunId — current cycle has no history entries
|
||||
var oldRunId = Guid.NewGuid();
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = oldRunId,
|
||||
LastSearchedAt = DateTime.UtcNow.AddDays(-60),
|
||||
ItemTitle = "Movie 1"
|
||||
});
|
||||
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);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableMovie { Id = 1, Title = "Movie 1", 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 (item not in current cycle, so it's selected directly)
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Sonarr_CycleComplete_WaitsForMinCycleTime()
|
||||
{
|
||||
// Arrange — all series seasons searched but MinCycleTimeDays not elapsed
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
config.MonitoredOnly = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
var currentRunId = Guid.NewGuid();
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ArrInstance = sonarrInstance,
|
||||
Enabled = true,
|
||||
CurrentRunId = currentRunId,
|
||||
MinCycleTimeDays = 7,
|
||||
TotalEligibleItems = 1
|
||||
});
|
||||
|
||||
// Series history — season already searched in current cycle (started 2 days ago)
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
ItemType = InstanceType.Sonarr,
|
||||
SeasonNumber = 1,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = now.AddDays(-2),
|
||||
ItemTitle = "Test Series"
|
||||
});
|
||||
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);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } }
|
||||
]);
|
||||
|
||||
var pastDate = now.AddDays(-30);
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodesAsync(It.IsAny<ArrInstance>(), 10))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate }
|
||||
]);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no search triggered, cycle not reset
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(sonarrInstance, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Never);
|
||||
|
||||
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id);
|
||||
Assert.Equal(currentRunId, instanceConfig.CurrentRunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Sonarr_CycleComplete_RestartsAfterMinCycleTimeElapsed()
|
||||
{
|
||||
// Arrange — all series seasons searched and MinCycleTimeDays has elapsed
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
config.MonitoredOnly = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
var currentRunId = Guid.NewGuid();
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ArrInstance = sonarrInstance,
|
||||
Enabled = true,
|
||||
CurrentRunId = currentRunId,
|
||||
MinCycleTimeDays = 7,
|
||||
TotalEligibleItems = 1
|
||||
});
|
||||
|
||||
// Series history — season already searched in current cycle (started 10 days ago)
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
ItemType = InstanceType.Sonarr,
|
||||
SeasonNumber = 1,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = now.AddDays(-10),
|
||||
ItemTitle = "Test Series"
|
||||
});
|
||||
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);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } }
|
||||
]);
|
||||
|
||||
var pastDate = now.AddDays(-30);
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodesAsync(It.IsAny<ArrInstance>(), 10))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate }
|
||||
]);
|
||||
|
||||
mockArrClient
|
||||
.Setup(x => x.SearchItemsAsync(sonarrInstance, It.IsAny<HashSet<SearchItem>>()))
|
||||
.ReturnsAsync([100L]);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — search was triggered, cycle was reset
|
||||
mockArrClient.Verify(
|
||||
x => x.SearchItemsAsync(sonarrInstance, It.IsAny<HashSet<SearchItem>>()),
|
||||
Times.Once);
|
||||
|
||||
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
|
||||
.FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id);
|
||||
Assert.NotEqual(currentRunId, instanceConfig.CurrentRunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RoundRobin_SkipsInstanceWaitingForMinCycleTime()
|
||||
{
|
||||
// Arrange — two Radarr instances: one waiting for MinCycleTimeDays, the other has work to do
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
config.UseRoundRobin = true;
|
||||
config.MonitoredOnly = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
// Instance A: cycle complete, waiting for MinCycleTimeDays (oldest LastProcessedAt — would be picked first)
|
||||
var instanceA = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-a:7878");
|
||||
var runIdA = Guid.NewGuid();
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = instanceA.Id,
|
||||
ArrInstance = instanceA,
|
||||
Enabled = true,
|
||||
CurrentRunId = runIdA,
|
||||
MinCycleTimeDays = 30,
|
||||
TotalEligibleItems = 1,
|
||||
LastProcessedAt = now.AddDays(-5) // Oldest — round-robin would pick this first
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = instanceA.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = runIdA,
|
||||
LastSearchedAt = now.AddDays(-2), // Cycle started 2 days ago, MinCycleTimeDays=30
|
||||
ItemTitle = "Movie A"
|
||||
});
|
||||
|
||||
// Instance B: has work to do (newer LastProcessedAt)
|
||||
var instanceB = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-b:7878");
|
||||
var runIdB = Guid.NewGuid();
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = instanceB.Id,
|
||||
ArrInstance = instanceB,
|
||||
Enabled = true,
|
||||
CurrentRunId = runIdB,
|
||||
MinCycleTimeDays = 5,
|
||||
TotalEligibleItems = 1,
|
||||
LastProcessedAt = now.AddDays(-1)
|
||||
});
|
||||
// No history for instance B — it hasn't searched anything yet
|
||||
|
||||
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);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(instanceB))
|
||||
.ReturnsAsync(
|
||||
[
|
||||
new SearchableMovie { Id = 10, Title = "Movie B", Status = "released", Monitored = true, Tags = [] }
|
||||
]);
|
||||
|
||||
mockArrClient
|
||||
.Setup(x => x.SearchItemsAsync(instanceB, 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 — Instance B was processed (not A which was waiting)
|
||||
_radarrClient.Verify(
|
||||
x => x.GetAllMoviesAsync(instanceB),
|
||||
Times.Once);
|
||||
_radarrClient.Verify(
|
||||
x => x.GetAllMoviesAsync(instanceA),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -217,6 +217,15 @@ public sealed class Seeker : IHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-filter instances
|
||||
instanceConfigs = await FilterInstancesAsync(instanceConfigs);
|
||||
|
||||
if (instanceConfigs.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All instances are waiting for MinCycleTimeDays to elapse");
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.UseRoundRobin)
|
||||
{
|
||||
// Round-robin: pick the instance with the oldest LastProcessedAt
|
||||
@@ -338,7 +347,7 @@ public sealed class Seeker : IHandler
|
||||
.Where(r => r.MovieId > 0)
|
||||
.Select(r => r.MovieId)
|
||||
.ToHashSet();
|
||||
|
||||
|
||||
List<long> selectedIds;
|
||||
(selectedIds, selectedNames, allLibraryIds) = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun, queuedMovieIds);
|
||||
searchItems = selectedIds.Select(id => new SearchItem { Id = id }).ToHashSet();
|
||||
@@ -350,7 +359,7 @@ public sealed class Seeker : IHandler
|
||||
.Where(r => r.SeriesId > 0)
|
||||
.Select(r => (r.SeriesId, r.SeasonNumber))
|
||||
.ToHashSet();
|
||||
|
||||
|
||||
(searchItems, selectedNames, allLibraryIds, historyIds, seasonNumber) =
|
||||
await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, queuedSeasons: queuedSeasons);
|
||||
}
|
||||
@@ -387,7 +396,7 @@ public sealed class Seeker : IHandler
|
||||
|
||||
// Cleanup stale history entries and old cycle history
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds);
|
||||
await CleanupOldCycleHistoryAsync(arrInstance.Id, instanceConfig.CurrentRunId);
|
||||
await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentRunId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,12 +467,14 @@ public sealed class Seeker : IHandler
|
||||
{
|
||||
_logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle",
|
||||
candidates.Count, arrInstance.Name);
|
||||
|
||||
if (!isDryRun)
|
||||
{
|
||||
instanceConfig.CurrentRunId = Guid.NewGuid();
|
||||
_dataContext.SeekerInstanceConfigs.Update(instanceConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
searchHistory = new Dictionary<long, DateTime>();
|
||||
}
|
||||
|
||||
@@ -839,21 +850,85 @@ public sealed class Seeker : IHandler
|
||||
/// Removes history entries from previous cycles that are older than 30 days.
|
||||
/// Recent cycle history is retained for statistics and history viewing.
|
||||
/// </summary>
|
||||
private async Task CleanupOldCycleHistoryAsync(Guid arrInstanceId, Guid currentRunId)
|
||||
private async Task CleanupOldCycleHistoryAsync(ArrInstance arrInstance, Guid currentRunId)
|
||||
{
|
||||
DateTime cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30);
|
||||
|
||||
int deleted = await _dataContext.SeekerHistory
|
||||
.Where(h => h.ArrInstanceId == arrInstanceId
|
||||
.Where(h => h.ArrInstanceId == arrInstance.Id
|
||||
&& h.RunId != currentRunId
|
||||
&& h.LastSearchedAt < cutoff)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deleted > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} old cycle history entries (>30 days) for instance {InstanceId}",
|
||||
deleted, arrInstanceId);
|
||||
_logger.LogDebug("Cleaned up {Count} old cycle history entries (>30 days) for instance {InstanceName}",
|
||||
deleted, arrInstance.Name);
|
||||
}
|
||||
}
|
||||
|
||||
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.RunId == ic.CurrentRunId)
|
||||
.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.RunId == ic.CurrentRunId)
|
||||
.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.
|
||||
/// </summary>
|
||||
private bool ShouldWaitForMinCycleTime(SeekerInstanceConfig instanceConfig, DateTime? cycleStartedAt)
|
||||
{
|
||||
if (cycleStartedAt is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var elapsed = _timeProvider.GetUtcNow().UtcDateTime - cycleStartedAt.Value;
|
||||
return elapsed.TotalDays < instanceConfig.MinCycleTimeDays;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -56,4 +56,10 @@ public sealed record SeekerInstanceConfig
|
||||
/// (SizeLeft > 0) in the arr queue is at or above this threshold. 0 = disabled.
|
||||
/// </summary>
|
||||
public int ActiveDownloadLimit { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of days a cycle must span before a new one can start.
|
||||
/// If a cycle completes faster, no searches are triggered until this time has elapsed.
|
||||
/// </summary>
|
||||
public int MinCycleTimeDays { get; set; } = 5;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface InstanceSearchStat {
|
||||
currentRunId: string | null;
|
||||
cycleItemsSearched: number;
|
||||
cycleItemsTotal: number;
|
||||
cycleStartedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SearchStatsSummary {
|
||||
|
||||
@@ -203,6 +203,7 @@ export class DocumentationService {
|
||||
'enabled': 'instance-enabled',
|
||||
'skipTags': 'instance-skip-tags',
|
||||
'activeDownloadLimit': 'instance-active-download-limit',
|
||||
'minCycleTimeDays': 'instance-min-cycle-time-days',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@
|
||||
<span class="instance-card__stat-label">Cycle Progress</span>
|
||||
</app-tooltip>
|
||||
</div>
|
||||
<div class="instance-card__stat">
|
||||
<span class="instance-card__stat-value instance-card__stat-value--small">
|
||||
{{ inst.cycleStartedAt ? formatCycleDuration(inst.cycleStartedAt) : '—' }}
|
||||
</span>
|
||||
<app-tooltip text="How long the current search cycle has been running">
|
||||
<span class="instance-card__stat-label">Cycle Duration</span>
|
||||
</app-tooltip>
|
||||
</div>
|
||||
<div class="instance-card__stat">
|
||||
<span class="instance-card__stat-value">{{ inst.totalSearchCount }}</span>
|
||||
<span class="instance-card__stat-label">Searches</span>
|
||||
|
||||
@@ -208,6 +208,23 @@ export class SearchStatsComponent implements OnInit {
|
||||
return null;
|
||||
}
|
||||
|
||||
formatCycleDuration(cycleStartedAt: string): string {
|
||||
const start = new Date(cycleStartedAt);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - start.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ${diffHours}h`;
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${diffMinutes}m`;
|
||||
}
|
||||
|
||||
private loadSummary(): void {
|
||||
this.api.getSummary().subscribe({
|
||||
next: (summary) => {
|
||||
|
||||
@@ -109,6 +109,16 @@
|
||||
(valueChange)="updateInstanceActiveDownloadLimit($index, $event)"
|
||||
helpKey="seeker:activeDownloadLimit"
|
||||
/>
|
||||
<app-number-input
|
||||
label="Min Cycle Time (Days)"
|
||||
placeholder="5"
|
||||
hint="Minimum number of days a complete search cycle must span before starting a new one"
|
||||
[value]="instance.minCycleTimeDays"
|
||||
[min]="1"
|
||||
[max]="365"
|
||||
(valueChange)="updateInstanceMinCycleTimeDays($index, $event)"
|
||||
helpKey="seeker:minCycleTimeDays"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (instance.lastProcessedAt) {
|
||||
|
||||
@@ -56,6 +56,7 @@ interface InstanceState {
|
||||
lastProcessedAt?: string;
|
||||
arrInstanceEnabled: boolean;
|
||||
activeDownloadLimit: number;
|
||||
minCycleTimeDays: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -131,6 +132,7 @@ export class SeekerComponent implements OnInit, HasPendingChanges {
|
||||
lastProcessedAt: i.lastProcessedAt,
|
||||
arrInstanceEnabled: i.arrInstanceEnabled,
|
||||
activeDownloadLimit: i.activeDownloadLimit,
|
||||
minCycleTimeDays: i.minCycleTimeDays,
|
||||
})));
|
||||
this.loader.stop();
|
||||
this.savedSnapshot.set(this.buildSnapshot());
|
||||
@@ -192,6 +194,14 @@ export class SeekerComponent implements OnInit, HasPendingChanges {
|
||||
});
|
||||
}
|
||||
|
||||
updateInstanceMinCycleTimeDays(index: number, days: number | null): void {
|
||||
this.instances.update(instances => {
|
||||
const updated = [...instances];
|
||||
updated[index] = { ...updated[index], minCycleTimeDays: days ?? 5 };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
getInstanceIcon(instanceType: string): string {
|
||||
return `icons/ext/${instanceType.toLowerCase()}-light.svg`;
|
||||
}
|
||||
@@ -211,6 +221,7 @@ export class SeekerComponent implements OnInit, HasPendingChanges {
|
||||
enabled: i.enabled,
|
||||
skipTags: i.skipTags,
|
||||
activeDownloadLimit: i.activeDownloadLimit,
|
||||
minCycleTimeDays: i.minCycleTimeDays,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface SeekerInstanceConfig {
|
||||
lastProcessedAt?: string;
|
||||
arrInstanceEnabled: boolean;
|
||||
activeDownloadLimit: number;
|
||||
minCycleTimeDays: number;
|
||||
}
|
||||
|
||||
export interface UpdateSeekerConfig {
|
||||
@@ -40,4 +41,5 @@ export interface UpdateSeekerInstanceConfig {
|
||||
enabled: boolean;
|
||||
skipTags: string[];
|
||||
activeDownloadLimit: number;
|
||||
minCycleTimeDays: number;
|
||||
}
|
||||
|
||||
@@ -191,6 +191,14 @@ Set to `0` to disable this check and always run the proactive search regardless
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Min Cycle Time (Days)"
|
||||
>
|
||||
|
||||
The minimum number of days that a complete search cycle must span before a new cycle can begin. Once all eligible items in an instance have been searched, Seeker will not start a new cycle until this many days have passed since the cycle started.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
</PrefixedSection>
|
||||
|
||||
Reference in New Issue
Block a user