diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs
index 07b0a9cc..049af372 100644
--- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs
@@ -118,6 +118,22 @@ public class SearchStatsControllerTests : IDisposable
funnel.CountedAt.ShouldBe(Now.AddMinutes(-5));
}
+ [Fact]
+ public async Task GetSummary_Funnel_SearchedUnitsSubtractsRestingUnits()
+ {
+ ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ Guid currentCycleId = Guid.NewGuid();
+ AddInstanceConfig(radarr.Id, currentCycleId);
+ AddCycle(currentCycleId, radarr.Id, eligibleNow: 10, remainingUnits: 4, startedAt: Now.AddDays(-1),
+ funnelCountedAt: Now, queuedUnits: 1, recentlyGrabbedUnits: 1, restingUnits: 2);
+
+ SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
+
+ InstanceFunnelStat funnel = summary.PerInstanceStats.ShouldHaveSingleItem().Funnel.ShouldNotBeNull();
+ funnel.RestingUnits.ShouldBe(2);
+ funnel.SearchedUnits.ShouldBe(2);
+ }
+
[Fact]
public async Task GetSummary_Funnel_SearchedUnitsNeverGoesNegative()
{
@@ -289,7 +305,8 @@ public class SearchStatsControllerTests : IDisposable
int searchedUnits = 0,
DateTimeOffset? funnelCountedAt = null,
int queuedUnits = 0,
- int recentlyGrabbedUnits = 0)
+ int recentlyGrabbedUnits = 0,
+ int restingUnits = 0)
{
_eventsContext.SeekerCycles.Add(new SeekerCycle
{
@@ -309,6 +326,7 @@ public class SearchStatsControllerTests : IDisposable
TagFilteredItems = 20,
QueuedUnits = queuedUnits,
RecentlyGrabbedUnits = recentlyGrabbedUnits,
+ RestingUnits = restingUnits,
FunnelCountedAt = funnelCountedAt,
});
_eventsContext.SaveChanges();
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs
index 8bf14bb9..5cef6b4d 100644
--- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs
@@ -157,7 +157,7 @@ public class SeekerPreviewControllerTests : IDisposable
return sonarr;
}
- private ArrInstance AddRadarr(Guid? currentCycleId = null, int minCycleTimeDays = 7)
+ private ArrInstance AddRadarr(Guid? currentCycleId = null, int minCycleTimeDays = 7, bool restUnitsEnabled = false)
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
@@ -166,6 +166,7 @@ public class SeekerPreviewControllerTests : IDisposable
Enabled = true,
CurrentCycleId = currentCycleId ?? Guid.NewGuid(),
MinCycleTimeDays = minCycleTimeDays,
+ RestUnitsEnabled = restUnitsEnabled,
});
_dataContext.SaveChanges();
return radarr;
@@ -247,6 +248,53 @@ public class SeekerPreviewControllerTests : IDisposable
response.Funnel.CountedAt.ShouldBe(Now);
}
+ [Fact]
+ public async Task GetPreview_RestingMovie_MapsTheRestFieldsAndCountsItInTheFunnel()
+ {
+ Guid cycleId = Guid.NewGuid();
+ ArrInstance radarr = AddRadarr(cycleId, restUnitsEnabled: true);
+ AddCycle(cycleId, radarr.Id, Now.AddDays(-1), searchedUnits: 1);
+ _eventsContext.SeekerHistory.Add(new SeekerHistory
+ {
+ ArrInstanceId = radarr.Id,
+ ExternalItemId = 1,
+ ItemType = InstanceType.Radarr,
+ CycleId = Guid.NewGuid(),
+ LastSearchedAt = Now.AddDays(-2),
+ ItemTitle = "Movie 1",
+ ConsecutiveMisses = 4,
+ });
+ _eventsContext.SaveChanges();
+ SetupMovies(Movie(1), Movie(2));
+
+ SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
+
+ SeekerPreviewExclusionResponse excluded = response.Excluded.ShouldHaveSingleItem();
+ excluded.Name.ShouldBe("Movie 1");
+ excluded.Reason.ShouldBe(SeekerExclusionReason.Resting);
+ excluded.ConsecutiveMisses.ShouldBe(4);
+ excluded.RestCyclesLeft.ShouldBe(2);
+ excluded.RestUntil.ShouldBe(Now.AddDays(-2).AddDays(30));
+ response.NextUp.ShouldHaveSingleItem().Name.ShouldBe("Movie 2");
+ response.Funnel.RestingUnits.ShouldBe(1);
+ response.Funnel.SearchedUnits.ShouldBe(0);
+ response.Funnel.RemainingUnits.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetPreview_NonRestingExclusion_LeavesTheRestFieldsNull()
+ {
+ ArrInstance radarr = AddRadarr();
+ SetupMovies(Movie(1), Movie(3, status: "announced"));
+
+ SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
+
+ SeekerPreviewExclusionResponse excluded = response.Excluded.ShouldHaveSingleItem();
+ excluded.ConsecutiveMisses.ShouldBeNull();
+ excluded.RestCyclesLeft.ShouldBeNull();
+ excluded.RestUntil.ShouldBeNull();
+ }
+
[Fact]
public async Task GetPreview_QueuedMovie_LeavesItOutOfNextUp()
{
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceFunnelStat.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceFunnelStat.cs
index c5d71533..effdf766 100644
--- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceFunnelStat.cs
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceFunnelStat.cs
@@ -15,6 +15,7 @@ public sealed record InstanceFunnelStat
public int EligibleUnits { get; init; }
public int QueuedUnits { get; init; }
public int RecentlyGrabbedUnits { get; init; }
+ public int RestingUnits { get; init; }
public int SearchedUnits { get; init; }
public int RemainingUnits { get; init; }
public DateTimeOffset CountedAt { get; init; }
@@ -33,7 +34,8 @@ public sealed record InstanceFunnelStat
EligibleUnits = cycle.EligibleNow,
QueuedUnits = cycle.QueuedUnits,
RecentlyGrabbedUnits = cycle.RecentlyGrabbedUnits,
- SearchedUnits = Math.Max(0, cycle.EligibleNow - cycle.QueuedUnits - cycle.RecentlyGrabbedUnits - cycle.RemainingUnits),
+ RestingUnits = cycle.RestingUnits,
+ SearchedUnits = Math.Max(0, cycle.EligibleNow - cycle.QueuedUnits - cycle.RecentlyGrabbedUnits - cycle.RestingUnits - cycle.RemainingUnits),
RemainingUnits = cycle.RemainingUnits,
CountedAt = countedAt,
};
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerPreviewExclusionResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerPreviewExclusionResponse.cs
index d0dcf3cb..84c2b2d0 100644
--- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerPreviewExclusionResponse.cs
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerPreviewExclusionResponse.cs
@@ -9,4 +9,19 @@ public sealed record SeekerPreviewExclusionResponse
{
public string Name { get; init; } = string.Empty;
public SeekerExclusionReason Reason { get; init; }
+
+ ///
+ /// Set while the reason is , as are the other rest fields.
+ ///
+ public int? ConsecutiveMisses { get; init; }
+
+ ///
+ /// Cycle ends before the item is searched again.
+ ///
+ public int? RestCyclesLeft { get; init; }
+
+ ///
+ /// When the rest ends, however many cycles it has left.
+ ///
+ public DateTimeOffset? RestUntil { get; init; }
}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs
index 8e05239f..7de47eab 100644
--- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerPreviewController.cs
@@ -154,7 +154,14 @@ public sealed class SeekerPreviewController : ControllerBase
.Select(c => new SeekerPreviewUnitResponse { Name = c.Name, Reason = c.Reason, ContentKind = c.ContentKind })
.ToList(),
Excluded = evaluation.Excluded
- .Select(e => new SeekerPreviewExclusionResponse { Name = e.Name, Reason = e.Reason })
+ .Select(e => new SeekerPreviewExclusionResponse
+ {
+ Name = e.Name,
+ Reason = e.Reason,
+ ConsecutiveMisses = e.Rest?.ConsecutiveMisses,
+ RestCyclesLeft = e.Rest?.CyclesLeft,
+ RestUntil = e.Rest?.Until,
+ })
.ToList(),
});
}
@@ -172,7 +179,8 @@ public sealed class SeekerPreviewController : ControllerBase
EligibleUnits = evaluation.Units.Count,
QueuedUnits = evaluation.QueuedUnits,
RecentlyGrabbedUnits = evaluation.RecentlyGrabbedUnits,
- SearchedUnits = evaluation.Units.Count - evaluation.QueuedUnits - evaluation.RecentlyGrabbedUnits - remaining,
+ RestingUnits = evaluation.RestingUnits,
+ SearchedUnits = Math.Max(0, evaluation.Units.Count - evaluation.QueuedUnits - evaluation.RecentlyGrabbedUnits - evaluation.RestingUnits - remaining),
RemainingUnits = remaining,
CountedAt = _timeProvider.GetUtcNow(),
};
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs
index 852cf71f..1a055772 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs
@@ -3108,6 +3108,99 @@ public class SeekerTests : IDisposable
newCycle.FunnelCountedAt.ShouldBe(_fixture.TimeProvider.GetUtcNow());
}
+ private void AddRestingMovieHistory(ArrInstance instance, long movieId, Guid cycleId)
+ {
+ _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
+ {
+ ArrInstanceId = instance.Id,
+ ExternalItemId = movieId,
+ ItemType = InstanceType.Radarr,
+ CycleId = cycleId,
+ LastSearchedAt = _fixture.TimeProvider.GetUtcNow().AddDays(-2),
+ ItemTitle = $"Movie {movieId}",
+ ConsecutiveMisses = 3,
+ });
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Radarr_RestingMovie_RecordsRestingUnitsOnTheCurrentCycle()
+ {
+ // Arrange
+ (ArrInstance radarrInstance, _) = await SetupFunnelInstanceAsync(InstanceType.Radarr, c => c.RestUnitsEnabled = true);
+ AddRestingMovieHistory(radarrInstance, 1, Guid.NewGuid());
+ await _fixture.EventsContext.SaveChangesAsync();
+ StubFunnelMovies(radarrInstance, [ReleasedMovie(1), ReleasedMovie(2), ReleasedMovie(3)]);
+
+ // Act
+ await CreateSut().ExecuteAsync();
+
+ // Assert
+ SeekerCycle cycle = await GetCycleAsync(radarrInstance.Id);
+ cycle.RestingUnits.ShouldBe(1);
+ cycle.QueuedUnits.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Radarr_EveryUnsearchedUnitRests_TriggersNoSearch()
+ {
+ // Arrange: movie 1 was searched in the open cycle, movies 2 and 3 rest
+ Guid currentCycleId = Guid.NewGuid();
+ (ArrInstance radarrInstance, IArrClient arrClient) = await SetupFunnelInstanceAsync(InstanceType.Radarr, c =>
+ {
+ c.RestUnitsEnabled = true;
+ c.CurrentCycleId = currentCycleId;
+ });
+ _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
+ {
+ Id = currentCycleId,
+ ArrInstanceId = radarrInstance.Id,
+ StartedAt = _fixture.TimeProvider.GetUtcNow().AddHours(-1),
+ SearchedUnits = 1,
+ });
+ _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
+ {
+ ArrInstanceId = radarrInstance.Id,
+ ExternalItemId = 1,
+ ItemType = InstanceType.Radarr,
+ CycleId = currentCycleId,
+ LastSearchedAt = _fixture.TimeProvider.GetUtcNow().AddHours(-1),
+ ItemTitle = "Movie 1",
+ });
+ AddRestingMovieHistory(radarrInstance, 2, Guid.NewGuid());
+ AddRestingMovieHistory(radarrInstance, 3, Guid.NewGuid());
+ await _fixture.EventsContext.SaveChangesAsync();
+ StubFunnelMovies(radarrInstance, [ReleasedMovie(1), ReleasedMovie(2), ReleasedMovie(3)]);
+
+ // Act
+ await CreateSut().ExecuteAsync();
+
+ // Assert
+ await arrClient.DidNotReceive().SearchItemAsync(radarrInstance, Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Radarr_EveryUnitRestsAndNoneWasSearched_DoesNotCompleteTheCycle()
+ {
+ // Arrange
+ (ArrInstance radarrInstance, IArrClient arrClient) = await SetupFunnelInstanceAsync(InstanceType.Radarr, c => c.RestUnitsEnabled = true);
+ AddRestingMovieHistory(radarrInstance, 1, Guid.NewGuid());
+ AddRestingMovieHistory(radarrInstance, 2, Guid.NewGuid());
+ await _fixture.EventsContext.SaveChangesAsync();
+ StubFunnelMovies(radarrInstance, [ReleasedMovie(1), ReleasedMovie(2)]);
+
+ // Act
+ await CreateSut().ExecuteAsync();
+
+ // Assert
+ await arrClient.DidNotReceive().SearchItemAsync(radarrInstance, Arg.Any());
+ await _fixture.EventPublisher.DidNotReceive()
+ .PublishSeekerCycleCompleted(Arg.Any(), Arg.Any());
+
+ SeekerCycle cycle = await GetCycleAsync(radarrInstance.Id);
+ cycle.RestingUnits.ShouldBe(2);
+ cycle.CompletedAt.ShouldBeNull();
+ }
+
#endregion
#region Post-Release Grace Period Tests
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
index 8794eed9..8cf79b25 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
@@ -591,6 +591,7 @@ public sealed class Seeker : IHandler
cycle.TagFilteredItems = evaluation.TagFilteredItems;
cycle.QueuedUnits = evaluation.QueuedUnits;
cycle.RecentlyGrabbedUnits = evaluation.RecentlyGrabbedUnits;
+ cycle.RestingUnits = evaluation.RestingUnits;
cycle.FunnelCountedAt = _timeProvider.GetUtcNow();
}