mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
added seeker cycle reset endpoint
This commit is contained in:
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,
|
||||
}
|
||||
+3
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user