mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
196 lines
8.4 KiB
C#
196 lines
8.4 KiB
C#
using Cleanuparr.Api.Extensions;
|
|
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
|
using Cleanuparr.Domain.Entities.Arr.Queue;
|
|
using Cleanuparr.Domain.Enums;
|
|
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
|
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/preview")]
|
|
[Authorize]
|
|
public sealed class SeekerPreviewController : ControllerBase
|
|
{
|
|
private const int NextUpCount = 10;
|
|
|
|
private readonly ILogger<SeekerPreviewController> _logger;
|
|
private readonly DataContext _dataContext;
|
|
private readonly EventsContext _eventsContext;
|
|
private readonly IArrClientFactory _arrClientFactory;
|
|
private readonly IArrQueueReader _arrQueueReader;
|
|
private readonly SeekerUnitEvaluator _evaluator;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public SeekerPreviewController(
|
|
ILogger<SeekerPreviewController> logger,
|
|
DataContext dataContext,
|
|
EventsContext eventsContext,
|
|
IArrClientFactory arrClientFactory,
|
|
IArrQueueReader arrQueueReader,
|
|
SeekerUnitEvaluator evaluator,
|
|
TimeProvider timeProvider)
|
|
{
|
|
_logger = logger;
|
|
_dataContext = dataContext;
|
|
_eventsContext = eventsContext;
|
|
_arrClientFactory = arrClientFactory;
|
|
_arrQueueReader = arrQueueReader;
|
|
_evaluator = evaluator;
|
|
_timeProvider = timeProvider;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the instance through the Seeker filters without searching or saving anything.
|
|
/// Lists the units the next runs would search and why everything else is left out.
|
|
/// </summary>
|
|
[HttpGet("{arrInstanceId:guid}")]
|
|
public async Task<IActionResult> GetPreview(Guid arrInstanceId, CancellationToken cancellationToken)
|
|
{
|
|
SeekerInstanceConfig? instanceConfig = await _dataContext.SeekerInstanceConfigs
|
|
.AsNoTracking()
|
|
.Include(s => s.ArrInstance.ArrConfig)
|
|
.FirstOrDefaultAsync(s => s.ArrInstanceId == arrInstanceId, cancellationToken);
|
|
|
|
if (instanceConfig is null)
|
|
{
|
|
return this.ProblemResult(StatusCodes.Status404NotFound, $"Seeker instance with ID {arrInstanceId} not found");
|
|
}
|
|
|
|
InstanceType instanceType = instanceConfig.ArrInstance.ArrConfig.Type;
|
|
if (instanceType is not (InstanceType.Sonarr or InstanceType.Radarr))
|
|
{
|
|
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Seeker does not search {instanceType} instances");
|
|
}
|
|
|
|
SeekerConfig config = await _dataContext.SeekerConfigs.AsNoTracking().FirstAsync(cancellationToken);
|
|
bool isDryRun = (await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync(cancellationToken)).DryRun;
|
|
|
|
SeekerCycle? cycle = isDryRun
|
|
? await _eventsContext.SeekerCycles
|
|
.AsNoTracking()
|
|
.Where(c => c.ArrInstanceId == arrInstanceId && c.IsDryRun)
|
|
.OrderByDescending(c => c.StartedAt)
|
|
.FirstOrDefaultAsync(cancellationToken)
|
|
: await _eventsContext.SeekerCycles
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(c => c.Id == instanceConfig.CurrentCycleId && !c.IsDryRun, cancellationToken);
|
|
|
|
List<SeekerHistory> cycleHistory = cycle is null
|
|
? []
|
|
: await _eventsContext.SeekerHistory
|
|
.AsNoTracking()
|
|
.Where(h => h.ArrInstanceId == arrInstanceId && h.CycleId == cycle.Id && h.IsDryRun == isDryRun)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
SeekerEvaluation evaluation;
|
|
try
|
|
{
|
|
IArrClient arrClient = _arrClientFactory.GetClient(instanceType, instanceConfig.ArrInstance.Version);
|
|
List<QueueRecord> queueRecords = await _arrQueueReader.ReadAllAsync(arrClient, instanceConfig.ArrInstance);
|
|
SeekerRecentGrabs recentGrabs = await _evaluator.ReadRecentGrabsAsync(instanceConfig, instanceConfig.ArrInstance, cancellationToken);
|
|
|
|
evaluation = await _evaluator.EvaluateAsync(
|
|
config, instanceConfig, instanceConfig.ArrInstance, queueRecords, recentGrabs, cycleHistory, cancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to build the Seeker preview for {InstanceName}", instanceConfig.ArrInstance.Name);
|
|
return this.ProblemResult(StatusCodes.Status502BadGateway, $"Could not read {instanceConfig.ArrInstance.Name}: {ex.Message}");
|
|
}
|
|
|
|
if (evaluation.WantedUnsupported)
|
|
{
|
|
return this.ProblemResult(StatusCodes.Status502BadGateway, "Seeker needs Radarr v5.6 or newer");
|
|
}
|
|
|
|
DateTimeOffset? waitingUntil = null;
|
|
|
|
// Same rollover as the Seeker job: a cycle with nothing left waits out the minimum cycle time, then starts over.
|
|
if (!evaluation.Remaining.Any() && cycle is not null && (cycle.SearchedUnits > 0 || evaluation.RestingUnits > 0))
|
|
{
|
|
DateTimeOffset minCycleEnd = cycle.StartedAt.AddDays(instanceConfig.MinCycleTimeDays);
|
|
if (_timeProvider.GetUtcNow() < minCycleEnd)
|
|
{
|
|
// A rest ending first frees its unit for the next run.
|
|
DateTimeOffset? earliestRestUntil = evaluation.Excluded
|
|
.Where(e => e.Reason == SeekerExclusionReason.Resting)
|
|
.Select(e => e.Rest?.Until)
|
|
.Min();
|
|
waitingUntil = earliestRestUntil is { } restUntil && restUntil < minCycleEnd
|
|
? restUntil
|
|
: minCycleEnd;
|
|
}
|
|
else
|
|
{
|
|
evaluation = evaluation.WithoutCycleHistory();
|
|
}
|
|
}
|
|
|
|
InstanceFunnelStat funnel = ToFunnel(evaluation);
|
|
SelectionStrategy strategy = SeekerUnitEvaluator.EffectiveStrategy(config.SelectionStrategy, instanceConfig);
|
|
|
|
List<SeekerSearchCandidate> nextUp = waitingUntil is null
|
|
? SeekerUnitEvaluator.SelectNext(evaluation, strategy, NextUpCount)
|
|
: [];
|
|
|
|
HashSet<long> nextUpIds = nextUp.Select(c => c.ItemId).ToHashSet();
|
|
bool randomSeason = evaluation.Remaining
|
|
.Where(u => nextUpIds.Contains(u.ItemId))
|
|
.GroupBy(u => u.ItemId)
|
|
.Any(g => g.Select(u => u.SeasonNumber).Distinct().Count() > 1);
|
|
|
|
return Ok(new SeekerPreviewResponse
|
|
{
|
|
Funnel = funnel,
|
|
CycleWaitingUntil = waitingUntil,
|
|
RandomPick = randomSeason
|
|
|| strategy is not (SelectionStrategy.OldestSearchFirst or SelectionStrategy.NewestFirst)
|
|
|| SeekerUnitEvaluator.HasTiedPicks(evaluation, strategy, nextUpIds),
|
|
NextUp = nextUp
|
|
.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,
|
|
ConsecutiveMisses = e.Rest?.ConsecutiveMisses,
|
|
RestCyclesLeft = e.Rest?.CyclesLeft,
|
|
RestUntil = e.Rest?.Until,
|
|
})
|
|
.ToList(),
|
|
});
|
|
}
|
|
|
|
private InstanceFunnelStat ToFunnel(SeekerEvaluation evaluation)
|
|
{
|
|
int remaining = evaluation.Remaining.Count();
|
|
|
|
return new InstanceFunnelStat
|
|
{
|
|
LibraryItems = evaluation.AllLibraryIds.Count,
|
|
ReleasedItems = evaluation.ReleasedItems,
|
|
MonitoredItems = evaluation.MonitoredItems,
|
|
TagFilteredItems = evaluation.TagFilteredItems,
|
|
EligibleUnits = evaluation.Units.Count,
|
|
QueuedUnits = evaluation.QueuedUnits,
|
|
RecentlyGrabbedUnits = evaluation.RecentlyGrabbedUnits,
|
|
RestingUnits = evaluation.RestingUnits,
|
|
SearchedUnits = Math.Max(0, evaluation.Units.Count - evaluation.QueuedUnits - evaluation.RecentlyGrabbedUnits - evaluation.RestingUnits - remaining),
|
|
RemainingUnits = remaining,
|
|
CountedAt = _timeProvider.GetUtcNow(),
|
|
};
|
|
}
|
|
}
|