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