added min cycle time

This commit is contained in:
Flaminel
2026-03-24 21:43:29 +02:00
parent 3515a9568a
commit bd28123bb1
16 changed files with 637 additions and 9 deletions

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -19,4 +19,6 @@ public sealed record SeekerInstanceConfigResponse
public bool ArrInstanceEnabled { get; init; }
public int ActiveDownloadLimit { get; init; }
public int MinCycleTimeDays { get; init; }
}

View File

@@ -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();

View File

@@ -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,
});
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -9,6 +9,7 @@ export interface InstanceSearchStat {
currentRunId: string | null;
cycleItemsSearched: number;
cycleItemsTotal: number;
cycleStartedAt: string | null;
}
export interface SearchStatsSummary {

View File

@@ -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',
},
};

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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,
})),
};

View File

@@ -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;
}

View File

@@ -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>