From bd28123bb1b588942f31a392d0447ea2bb2e15f9 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 24 Mar 2026 21:43:29 +0200 Subject: [PATCH] added min cycle time --- .../UpdateSeekerInstanceConfigRequest.cs | 2 + .../Contracts/Responses/InstanceSearchStat.cs | 1 + .../Responses/SeekerInstanceConfigResponse.cs | 2 + .../Controllers/SearchStatsController.cs | 2 + .../Controllers/SeekerConfigController.cs | 3 + .../Features/Jobs/SeekerTests.cs | 483 +++++++++++++++++- .../Features/Jobs/Seeker.cs | 89 +++- .../Seeker/SeekerInstanceConfig.cs | 6 + .../app/core/models/search-stats.models.ts | 1 + .../core/services/documentation.service.ts | 1 + .../search-stats/search-stats.component.html | 8 + .../search-stats/search-stats.component.ts | 17 + .../settings/seeker/seeker.component.html | 10 + .../settings/seeker/seeker.component.ts | 11 + .../app/shared/models/seeker-config.model.ts | 2 + docs/docs/configuration/seeker/index.mdx | 8 + 16 files changed, 637 insertions(+), 9 deletions(-) diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerInstanceConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerInstanceConfigRequest.cs index ebfa9cea..1d8d1f02 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerInstanceConfigRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerInstanceConfigRequest.cs @@ -9,4 +9,6 @@ public sealed record UpdateSeekerInstanceConfigRequest public List SkipTags { get; init; } = []; public int ActiveDownloadLimit { get; init; } = 0; + + public int MinCycleTimeDays { get; init; } = 5; } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceSearchStat.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceSearchStat.cs index 42620d3b..dd7ddd79 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceSearchStat.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceSearchStat.cs @@ -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; } } diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerInstanceConfigResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerInstanceConfigResponse.cs index 11acf237..57b7ede6 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerInstanceConfigResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerInstanceConfigResponse.cs @@ -19,4 +19,6 @@ public sealed record SeekerInstanceConfigResponse public bool ArrInstanceEnabled { get; init; } public int ActiveDownloadLimit { get; init; } + + public int MinCycleTimeDays { get; init; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs index 85d8586a..86b51eb3 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs @@ -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(); diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs index 133eb98a..e64692d6 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs @@ -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, }); } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index de5642ae..c7490253 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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())) + .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>()), + 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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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>()), + 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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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>()), + 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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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(), 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())) + .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>()), + 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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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(), 10)) + .ReturnsAsync( + [ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(sonarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) + .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>()), + 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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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 } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index a4b731dc..732639d6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -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 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(); } @@ -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. /// - 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> FilterInstancesAsync( + List instanceConfigs) + { + var result = new List(); + + 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; + } + + /// + /// 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. + /// + 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; + } + } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs index 9c414525..211d7b0e 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs @@ -56,4 +56,10 @@ public sealed record SeekerInstanceConfig /// (SizeLeft > 0) in the arr queue is at or above this threshold. 0 = disabled. /// public int ActiveDownloadLimit { get; set; } = 3; + + /// + /// 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. + /// + public int MinCycleTimeDays { get; set; } = 5; } diff --git a/code/frontend/src/app/core/models/search-stats.models.ts b/code/frontend/src/app/core/models/search-stats.models.ts index 9991148f..faeae66f 100644 --- a/code/frontend/src/app/core/models/search-stats.models.ts +++ b/code/frontend/src/app/core/models/search-stats.models.ts @@ -9,6 +9,7 @@ export interface InstanceSearchStat { currentRunId: string | null; cycleItemsSearched: number; cycleItemsTotal: number; + cycleStartedAt: string | null; } export interface SearchStatsSummary { diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 699ffa2d..ac5f9ce3 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -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', }, }; diff --git a/code/frontend/src/app/features/search-stats/search-stats.component.html b/code/frontend/src/app/features/search-stats/search-stats.component.html index 635416ab..bb9cab69 100644 --- a/code/frontend/src/app/features/search-stats/search-stats.component.html +++ b/code/frontend/src/app/features/search-stats/search-stats.component.html @@ -62,6 +62,14 @@ Cycle Progress +
+ + {{ inst.cycleStartedAt ? formatCycleDuration(inst.cycleStartedAt) : '—' }} + + + Cycle Duration + +
{{ inst.totalSearchCount }} Searches diff --git a/code/frontend/src/app/features/search-stats/search-stats.component.ts b/code/frontend/src/app/features/search-stats/search-stats.component.ts index 1f61f8a4..9cf24327 100644 --- a/code/frontend/src/app/features/search-stats/search-stats.component.ts +++ b/code/frontend/src/app/features/search-stats/search-stats.component.ts @@ -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) => { diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.html b/code/frontend/src/app/features/settings/seeker/seeker.component.html index 2d068921..1becbabc 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.html +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.html @@ -109,6 +109,16 @@ (valueChange)="updateInstanceActiveDownloadLimit($index, $event)" helpKey="seeker:activeDownloadLimit" /> +
} @if (instance.lastProcessedAt) { diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.ts b/code/frontend/src/app/features/settings/seeker/seeker.component.ts index ad6d1506..c51ae348 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.ts +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.ts @@ -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, })), }; diff --git a/code/frontend/src/app/shared/models/seeker-config.model.ts b/code/frontend/src/app/shared/models/seeker-config.model.ts index a2e61cd7..2179c02e 100644 --- a/code/frontend/src/app/shared/models/seeker-config.model.ts +++ b/code/frontend/src/app/shared/models/seeker-config.model.ts @@ -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; } diff --git a/docs/docs/configuration/seeker/index.mdx b/docs/docs/configuration/seeker/index.mdx index e7d61a70..c5dd4636 100644 --- a/docs/docs/configuration/seeker/index.mdx +++ b/docs/docs/configuration/seeker/index.mdx @@ -191,6 +191,14 @@ Set to `0` to disable this check and always run the proactive search regardless + + +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. + + +