added seeker cycle reset endpoint

This commit is contained in:
Flaminel committed 2026-10-20 22:48:17 +03:00
1 parent f621ba8e07
commit 5391246476
8 files changed
+769 -3

No files matched your search

@@ -0,0 +1,231 @@
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Seeker;
public class SeekerCycleControllerTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly SeekerCycleLock _cycleLock = new();
private readonly SeekerCycleController _controller;
public SeekerCycleControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
_controller = new SeekerCycleController(
NullLogger<SeekerCycleController>.Instance,
_dataContext,
_eventsContext,
new FakeTimeProvider(Now),
_cycleLock);
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task ResetCycle_OpenCycle_ClosesItAsResetAndMakesTheNewCycleCurrent()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid oldCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, oldCycleId);
AddCycle(oldCycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3, eligibleNow: 10);
IActionResult result = await _controller.ResetCycle(radarr.Id);
result.ShouldBeOfType<NoContentResult>();
SeekerCycle old = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == oldCycleId);
old.CompletedAt.ShouldBe(Now);
old.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Reset);
SeekerInstanceConfig config = await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync();
config.CurrentCycleId.ShouldNotBe(oldCycleId);
SeekerCycle next = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == config.CurrentCycleId);
next.ArrInstanceId.ShouldBe(radarr.Id);
next.StartedAt.ShouldBe(Now);
next.CompletedAt.ShouldBeNull();
next.SearchedUnits.ShouldBe(0);
next.EligibleAtStart.ShouldBe(10);
next.EligibleNow.ShouldBe(10);
next.RemainingUnits.ShouldBe(10);
next.IsDryRun.ShouldBeFalse();
}
[Fact]
public async Task ResetCycle_CycleWaitingOnMinCycleTime_KeepsItsCompletedReason()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid oldCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, oldCycleId);
AddCycle(oldCycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3, completedAt: Now.AddDays(-1));
IActionResult result = await _controller.ResetCycle(radarr.Id);
result.ShouldBeOfType<NoContentResult>();
SeekerCycle old = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == oldCycleId);
old.CompletedAt.ShouldBe(Now.AddDays(-1));
old.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Completed);
SeekerInstanceConfig config = await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync();
config.CurrentCycleId.ShouldNotBe(oldCycleId);
(await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(2);
}
[Fact]
public async Task ResetCycle_NothingSearchedYet_ReturnsConflictAndChangesNothing()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid cycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, cycleId);
AddCycle(cycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 0);
IActionResult result = await _controller.ResetCycle(radarr.Id);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status409Conflict);
(await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(1);
(await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync()).CurrentCycleId.ShouldBe(cycleId);
}
[Fact]
public async Task ResetCycle_NoCycleRow_ReturnsConflict()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddInstanceConfig(radarr.Id, Guid.NewGuid());
IActionResult result = await _controller.ResetCycle(radarr.Id);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status409Conflict);
(await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(0);
}
[Fact]
public async Task ResetCycle_SeekerProcessingInstance_ReturnsConflictAndChangesNothing()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid cycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, cycleId);
AddCycle(cycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3);
_cycleLock.TryEnter(radarr.Id).ShouldBeTrue();
IActionResult result = await _controller.ResetCycle(radarr.Id);
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status409Conflict);
(await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync()).CompletedAt.ShouldBeNull();
(await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync()).CurrentCycleId.ShouldBe(cycleId);
}
[Fact]
public async Task ResetCycle_AfterReset_ReleasesTheLock()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid cycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, cycleId);
AddCycle(cycleId, radarr.Id, startedAt: Now.AddDays(-2), searchedUnits: 3);
await _controller.ResetCycle(radarr.Id);
_cycleLock.TryEnter(radarr.Id).ShouldBeTrue();
}
[Fact]
public async Task ResetCycle_UnknownInstance_ReturnsNotFound()
{
IActionResult result = await _controller.ResetCycle(Guid.NewGuid());
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
public async Task ResetCycle_DryRunOn_ResetsTheNewestDryCycleAndLeavesTheLiveCycleAlone()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
(await _dataContext.GeneralConfigs.FirstAsync()).DryRun = true;
await _dataContext.SaveChangesAsync();
Guid liveCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, liveCycleId);
AddCycle(liveCycleId, radarr.Id, startedAt: Now.AddDays(-5), searchedUnits: 4);
Guid olderDryCycleId = Guid.NewGuid();
Guid dryCycleId = Guid.NewGuid();
AddCycle(olderDryCycleId, radarr.Id, startedAt: Now.AddDays(-9), searchedUnits: 2, completedAt: Now.AddDays(-8), isDryRun: true);
AddCycle(dryCycleId, radarr.Id, startedAt: Now.AddDays(-1), searchedUnits: 2, eligibleNow: 6, isDryRun: true);
IActionResult result = await _controller.ResetCycle(radarr.Id);
result.ShouldBeOfType<NoContentResult>();
SeekerCycle dry = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == dryCycleId);
dry.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Reset);
SeekerCycle live = await _eventsContext.SeekerCycles.AsNoTracking().SingleAsync(c => c.Id == liveCycleId);
live.CompletedAt.ShouldBeNull();
(await _dataContext.SeekerInstanceConfigs.AsNoTracking().SingleAsync()).CurrentCycleId.ShouldBe(liveCycleId);
SeekerCycle next = await _eventsContext.SeekerCycles.AsNoTracking()
.Where(c => c.IsDryRun)
.OrderByDescending(c => c.StartedAt)
.FirstAsync();
next.Id.ShouldNotBe(dryCycleId);
next.StartedAt.ShouldBe(Now);
next.EligibleNow.ShouldBe(6);
}
private void AddInstanceConfig(Guid arrInstanceId, Guid currentCycleId)
{
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = arrInstanceId,
Enabled = true,
CurrentCycleId = currentCycleId,
});
_dataContext.SaveChanges();
}
private void AddCycle(
Guid id,
Guid arrInstanceId,
DateTimeOffset startedAt,
int searchedUnits,
int eligibleNow = 5,
DateTimeOffset? completedAt = null,
bool isDryRun = false)
{
_eventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = id,
ArrInstanceId = arrInstanceId,
StartedAt = startedAt,
CompletedAt = completedAt,
CompletionReason = completedAt is null ? null : SeekerCycleCompletionReason.Completed,
EligibleAtStart = eligibleNow,
EligibleNow = eligibleNow,
RemainingUnits = completedAt is null ? eligibleNow - searchedUnits : 0,
SearchedUnits = searchedUnits,
IsDryRun = isDryRun,
});
_eventsContext.SaveChanges();
}
}
@@ -17,6 +17,7 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
@@ -47,6 +48,7 @@ public static class ServicesDI
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
.AddScoped<IDryRunPurger, DryRunPurger>()
.AddSingleton<DryRunActivity>()
.AddSingleton<SeekerCycleLock>()
.AddScoped<CertificateValidationService>()
.AddScoped<ISonarrClient, SonarrClient>()
.AddScoped<ISportarrClient, SportarrClient>()
@@ -0,0 +1,113 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/seeker/cycles")]
[Authorize]
public sealed class SeekerCycleController : ControllerBase
{
private readonly ILogger<SeekerCycleController> _logger;
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly TimeProvider _timeProvider;
private readonly SeekerCycleLock _cycleLock;
public SeekerCycleController(
ILogger<SeekerCycleController> logger,
DataContext dataContext,
EventsContext eventsContext,
TimeProvider timeProvider,
SeekerCycleLock cycleLock)
{
_logger = logger;
_dataContext = dataContext;
_eventsContext = eventsContext;
_timeProvider = timeProvider;
_cycleLock = cycleLock;
}
/// <summary>
/// Closes the instance's current cycle and starts a new one, skipping the minimum cycle time once.
/// Dry run resets its own cycle and leaves the live one alone.
/// </summary>
[HttpPost("{arrInstanceId:guid}/reset")]
public async Task<IActionResult> ResetCycle(Guid arrInstanceId)
{
if (!_cycleLock.TryEnter(arrInstanceId))
{
return this.ProblemResult(StatusCodes.Status409Conflict, "The Seeker is searching this instance, try again in a moment");
}
try
{
SeekerInstanceConfig? instanceConfig = await _dataContext.SeekerInstanceConfigs
.Include(s => s.ArrInstance)
.FirstOrDefaultAsync(s => s.ArrInstanceId == arrInstanceId);
if (instanceConfig is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeker instance with ID {arrInstanceId} not found");
}
bool isDryRun = (await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync()).DryRun;
SeekerCycle? cycle = isDryRun
? await _eventsContext.SeekerCycles
.Where(c => c.ArrInstanceId == arrInstanceId && c.IsDryRun)
.OrderByDescending(c => c.StartedAt)
.FirstOrDefaultAsync()
: await _eventsContext.SeekerCycles
.FirstOrDefaultAsync(c => c.Id == instanceConfig.CurrentCycleId && !c.IsDryRun);
if (cycle is null || cycle.SearchedUnits == 0)
{
return this.ProblemResult(StatusCodes.Status409Conflict, "The current cycle has not searched anything yet");
}
DateTimeOffset now = _timeProvider.GetUtcNow();
// A cycle waiting out the minimum cycle time already finished, so it keeps its reason.
if (cycle.CompletedAt is null)
{
cycle.CompletedAt = now;
cycle.CompletionReason = SeekerCycleCompletionReason.Reset;
}
SeekerCycle next = new()
{
ArrInstanceId = arrInstanceId,
StartedAt = now,
EligibleAtStart = cycle.EligibleNow,
EligibleNow = cycle.EligibleNow,
RemainingUnits = cycle.EligibleNow,
IsDryRun = isDryRun,
};
_eventsContext.SeekerCycles.Add(next);
if (!isDryRun)
{
instanceConfig.CurrentCycleId = next.Id;
await _dataContext.SaveChangesAsync();
}
await _eventsContext.SaveChangesAsync();
_logger.LogInformation("Cycle reset on {InstanceName} after {Count} searches",
instanceConfig.ArrInstance.Name, cycle.SearchedUnits);
return NoContent();
}
finally
{
_cycleLock.Release(arrInstanceId);
}
}
}
@@ -12,4 +12,9 @@ public enum SeekerCycleCompletionReason
/// Every eligible unit was searched
/// </summary>
Completed,
/// <summary>
/// The user started a new cycle before this one finished
/// </summary>
Reset,
}
@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.State;
using Microsoft.Extensions.Hosting;
@@ -47,7 +48,8 @@ public class SeekerIntegrationTests : IDisposable
_fixture.DryRunInterceptor,
environment,
_fixture.TimeProvider,
_fixture.StatusNotifier);
_fixture.StatusNotifier,
new SeekerCycleLock());
}
[Fact]
@@ -4,6 +4,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
@@ -32,6 +33,7 @@ public class SeekerTests : IDisposable
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly IHostEnvironment _hostingEnvironment;
private readonly IStatusNotifier _statusNotifier;
private readonly SeekerCycleLock _cycleLock = new();
public SeekerTests(JobHandlerFixture fixture)
{
@@ -114,7 +116,8 @@ public class SeekerTests : IDisposable
_dryRunInterceptor,
_hostingEnvironment,
_fixture.TimeProvider,
_statusNotifier
_statusNotifier,
_cycleLock
);
}
@@ -1927,6 +1930,260 @@ public class SeekerTests : IDisposable
instanceConfig.CurrentCycleId.ShouldNotBe(currentCycleId);
}
[Fact]
public async Task ExecuteAsync_Radarr_AfterCycleReset_SearchesAgainWithinMinCycleTime()
{
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
Guid resetCycleId = Guid.NewGuid();
Guid newCycleId = Guid.NewGuid();
DateTimeOffset now = _fixture.TimeProvider.GetUtcNow();
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = newCycleId,
MinCycleTimeDays = 7
});
// The state SeekerCycleController.ResetCycle leaves behind: both movies searched a day ago in the reset cycle.
_fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = resetCycleId,
ArrInstanceId = radarrInstance.Id,
StartedAt = now.AddDays(-2),
CompletedAt = now.AddMinutes(-1),
CompletionReason = SeekerCycleCompletionReason.Reset,
EligibleAtStart = 2,
EligibleNow = 2,
SearchedUnits = 2,
});
_fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = newCycleId,
ArrInstanceId = radarrInstance.Id,
StartedAt = now.AddMinutes(-1),
EligibleAtStart = 2,
EligibleNow = 2,
RemainingUnits = 2,
});
foreach (int movieId in new[] { 1, 2 })
{
_fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = movieId,
ItemType = InstanceType.Radarr,
CycleId = resetCycleId,
LastSearchedAt = now.AddDays(-1),
ItemTitle = $"Movie {movieId}"
});
}
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
List<SearchableMovie> movies =
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }
];
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>(movies));
StubRadarrWanted(radarrInstance, movies);
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
await CreateSut().ExecuteAsync();
await mockArrClient.Received(1)
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>());
SeekerInstanceConfig instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
instanceConfig.CurrentCycleId.ShouldBe(newCycleId);
}
[Fact]
public async Task ExecuteAsync_Radarr_HoldsCycleLockWhileProcessingInstance()
{
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = Guid.NewGuid(),
MinCycleTimeDays = 0
});
await _fixture.DataContext.SaveChangesAsync();
IArrClient mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
bool lockFreeDuringRun = true;
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(_ =>
{
lockFreeDuringRun = _cycleLock.TryEnter(radarrInstance.Id);
return ToAsyncEnumerable<SearchableMovie>([]);
});
await CreateSut().ExecuteAsync();
lockFreeDuringRun.ShouldBeFalse();
_cycleLock.TryEnter(radarrInstance.Id).ShouldBeTrue();
}
[Fact]
public async Task ExecuteAsync_Radarr_CycleResetWhileWaitingForLock_KeepsResetCycle()
{
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
Guid runCycleId = Guid.NewGuid();
Guid resetCycleId = Guid.NewGuid();
DateTimeOffset now = _fixture.TimeProvider.GetUtcNow();
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = runCycleId,
MinCycleTimeDays = 0
});
// Every movie is already searched, so a run on this cycle would complete it.
_fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = runCycleId,
ArrInstanceId = radarrInstance.Id,
StartedAt = now.AddDays(-2),
EligibleAtStart = 2,
EligibleNow = 2,
SearchedUnits = 2,
});
foreach (int movieId in new[] { 1, 2 })
{
_fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = movieId,
ItemType = InstanceType.Radarr,
CycleId = runCycleId,
LastSearchedAt = now.AddDays(-1),
ItemTitle = $"Movie {movieId}"
});
}
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
List<SearchableMovie> movies =
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }
];
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable(movies));
StubRadarrWanted(radarrInstance, movies);
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
TaskCompletionSource jobStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
_dryRunInterceptor.IsDryRunEnabled().Returns(_ =>
{
jobStarted.TrySetResult();
return false;
});
_cycleLock.TryEnter(radarrInstance.Id).ShouldBeTrue();
Task jobTask = CreateSut().ExecuteAsync();
// Nothing observable marks the job as parked on the lock, so give it time to load its configs.
await jobStarted.Task;
await Task.Delay(250);
jobTask.IsCompleted.ShouldBeFalse();
await _fixture.EventsContext.SeekerCycles
.Where(c => c.Id == runCycleId)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.CompletedAt, now)
.SetProperty(c => c.CompletionReason, SeekerCycleCompletionReason.Reset));
_fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = resetCycleId,
ArrInstanceId = radarrInstance.Id,
StartedAt = now,
EligibleAtStart = 2,
EligibleNow = 2,
RemainingUnits = 2,
});
await _fixture.EventsContext.SaveChangesAsync();
await _fixture.DataContext.SeekerInstanceConfigs
.Where(s => s.ArrInstanceId == radarrInstance.Id)
.ExecuteUpdateAsync(s => s.SetProperty(c => c.CurrentCycleId, resetCycleId));
_cycleLock.Release(radarrInstance.Id);
await jobTask;
SeekerCycle runCycle = await _fixture.EventsContext.SeekerCycles
.AsNoTracking()
.FirstAsync(c => c.Id == runCycleId);
runCycle.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Reset);
SeekerInstanceConfig instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.AsNoTracking()
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
instanceConfig.CurrentCycleId.ShouldBe(resetCycleId);
SeekerCycle resetCycle = await _fixture.EventsContext.SeekerCycles
.AsNoTracking()
.FirstAsync(c => c.Id == resetCycleId);
resetCycle.CompletedAt.ShouldBeNull();
}
[Fact]
public async Task ExecuteAsync_Radarr_CycleComplete_NoCycleHistory_StartsNewCycle()
{
@@ -2073,6 +2330,101 @@ public class SeekerTests : IDisposable
instanceConfig.CurrentCycleId.ShouldBe(currentCycleId);
}
[Fact]
public async Task ExecuteAsync_Sonarr_AfterCycleReset_SearchesAgainWithinMinCycleTime()
{
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
Guid resetCycleId = Guid.NewGuid();
Guid newCycleId = Guid.NewGuid();
DateTimeOffset now = _fixture.TimeProvider.GetUtcNow();
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = newCycleId,
MinCycleTimeDays = 7
});
// The state SeekerCycleController.ResetCycle leaves behind: both seasons searched a day ago in the reset cycle.
_fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = resetCycleId,
ArrInstanceId = sonarrInstance.Id,
StartedAt = now.AddDays(-2),
CompletedAt = now.AddMinutes(-1),
CompletionReason = SeekerCycleCompletionReason.Reset,
EligibleAtStart = 2,
EligibleNow = 2,
SearchedUnits = 2,
});
_fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = newCycleId,
ArrInstanceId = sonarrInstance.Id,
StartedAt = now.AddMinutes(-1),
EligibleAtStart = 2,
EligibleNow = 2,
RemainingUnits = 2,
});
foreach (int seasonNumber in new[] { 1, 2 })
{
_fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
ItemType = InstanceType.Sonarr,
SeasonNumber = seasonNumber,
CycleId = resetCycleId,
LastSearchedAt = now.AddDays(-1),
ItemTitle = "Test Series"
});
}
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_sonarrClient
.StreamAllSeriesAsync(sonarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableSeries>([
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } }
]));
DateTime pastDate = now.UtcDateTime.AddDays(-30);
StubSonarrWanted(sonarrInstance,
[
new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 2, Monitored = true, HasFile = false, AirDateUtc = pastDate },
new SearchableEpisode { Id = 102, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 2, Monitored = true, HasFile = false, AirDateUtc = pastDate }
]);
mockArrClient
.SearchItemAsync(sonarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
await CreateSut().ExecuteAsync();
await mockArrClient.Received(1)
.SearchItemAsync(sonarrInstance, Arg.Any<SearchItem>());
SeekerInstanceConfig instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id);
instanceConfig.CurrentCycleId.ShouldBe(newCycleId);
}
[Fact]
public async Task ExecuteAsync_Sonarr_CycleComplete_RestartsAfterMinCycleTimeElapsed()
{
@@ -62,6 +62,7 @@ public sealed class Seeker : IHandler
private readonly IHostEnvironment _environment;
private readonly TimeProvider _timeProvider;
private readonly IStatusNotifier _statusNotifier;
private readonly SeekerCycleLock _cycleLock;
public Seeker(
ILogger<Seeker> logger,
@@ -75,7 +76,8 @@ public sealed class Seeker : IHandler
IDryRunInterceptor dryRunInterceptor,
IHostEnvironment environment,
TimeProvider timeProvider,
IStatusNotifier statusNotifier)
IStatusNotifier statusNotifier,
SeekerCycleLock cycleLock)
{
_logger = logger;
_dataContext = dataContext;
@@ -89,6 +91,7 @@ public sealed class Seeker : IHandler
_environment = environment;
_timeProvider = timeProvider;
_statusNotifier = statusNotifier;
_cycleLock = cycleLock;
}
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
@@ -278,6 +281,23 @@ public sealed class Seeker : IHandler
}
private async Task<bool> ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun, CancellationToken cancellationToken)
{
Guid arrInstanceId = instanceConfig.ArrInstanceId;
await _cycleLock.WaitAsync(arrInstanceId, cancellationToken);
try
{
// A cycle reset may have changed CurrentCycleId while this run waited for the lock
await _dataContext.Entry(instanceConfig).ReloadAsync(cancellationToken);
return await ProcessLockedInstanceAsync(config, instanceConfig, isDryRun, cancellationToken);
}
finally
{
_cycleLock.Release(arrInstanceId);
}
}
private async Task<bool> ProcessLockedInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun, CancellationToken cancellationToken)
{
ArrInstance arrInstance = instanceConfig.ArrInstance;
InstanceType instanceType = arrInstance.ArrConfig.Type;
@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
namespace Cleanuparr.Infrastructure.Features.Seeker;
/// <summary>
/// Serializes writes to an instance's Seeker cycle state between the Seeker job and the cycle reset endpoint.
/// </summary>
public sealed class SeekerCycleLock
{
private readonly ConcurrentDictionary<Guid, SemaphoreSlim> _locks = new();
/// <summary>
/// Waits until the instance's lock is free, then takes it.
/// </summary>
public Task WaitAsync(Guid arrInstanceId, CancellationToken cancellationToken = default)
{
return GetLock(arrInstanceId).WaitAsync(cancellationToken);
}
/// <summary>
/// Takes the instance's lock if it is free.
/// </summary>
/// <returns>Whether the lock was taken.</returns>
public bool TryEnter(Guid arrInstanceId)
{
return GetLock(arrInstanceId).Wait(0);
}
/// <summary>
/// Releases the instance's lock.
/// </summary>
public void Release(Guid arrInstanceId)
{
GetLock(arrInstanceId).Release();
}
private SemaphoreSlim GetLock(Guid arrInstanceId)
{
return _locks.GetOrAdd(arrInstanceId, _ => new SemaphoreSlim(1, 1));
}
}