Files
Cleanuparr/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs
2026-03-21 21:48:27 +02:00

189 lines
7.6 KiB
C#

using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
using Cleanuparr.Shared.Helpers;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/configuration")]
[Authorize]
public sealed class SeekerConfigController : ControllerBase
{
private readonly ILogger<SeekerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public SeekerConfigController(
ILogger<SeekerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("seeker")]
public async Task<IActionResult> GetSeekerConfig()
{
var config = await _dataContext.SeekerConfigs
.AsNoTracking()
.FirstAsync();
// Get all Sonarr/Radarr instances with their seeker configs
var arrInstances = await _dataContext.ArrInstances
.AsNoTracking()
.Include(a => a.ArrConfig)
.Where(a => a.ArrConfig.Type == InstanceType.Sonarr || a.ArrConfig.Type == InstanceType.Radarr)
.ToListAsync();
var arrInstanceIds = arrInstances.Select(a => a.Id).ToHashSet();
var seekerInstanceConfigs = await _dataContext.SeekerInstanceConfigs
.AsNoTracking()
.Where(s => arrInstanceIds.Contains(s.ArrInstanceId))
.ToListAsync();
var instanceResponses = arrInstances.Select(instance =>
{
var seekerConfig = seekerInstanceConfigs.FirstOrDefault(s => s.ArrInstanceId == instance.Id);
return new SeekerInstanceConfigResponse
{
ArrInstanceId = instance.Id,
InstanceName = instance.Name,
InstanceType = instance.ArrConfig.Type,
Enabled = seekerConfig?.Enabled ?? false,
SkipTags = seekerConfig?.SkipTags ?? [],
LastProcessedAt = seekerConfig?.LastProcessedAt,
ArrInstanceEnabled = instance.Enabled,
ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 0,
};
}).ToList();
var response = new SeekerConfigResponse
{
SearchEnabled = config.SearchEnabled,
SearchInterval = config.SearchInterval,
ProactiveSearchEnabled = config.ProactiveSearchEnabled,
SelectionStrategy = config.SelectionStrategy,
MonitoredOnly = config.MonitoredOnly,
UseCutoff = config.UseCutoff,
UseCustomFormatScore = config.UseCustomFormatScore,
UseRoundRobin = config.UseRoundRobin,
Instances = instanceResponses,
};
return Ok(response);
}
[HttpPut("seeker")]
public async Task<IActionResult> UpdateSeekerConfig([FromBody] UpdateSeekerConfigRequest request)
{
if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30)))
{
return StatusCode(503, "Database is busy, please try again");
}
try
{
var config = await _dataContext.SeekerConfigs.FirstAsync();
ushort previousInterval = config.SearchInterval;
bool previousUseCustomFormatScore = config.UseCustomFormatScore;
bool previousSearchEnabled = config.SearchEnabled;
bool previousProactiveSearchEnabled = config.ProactiveSearchEnabled;
request.ApplyTo(config);
config.Validate();
if (request.ProactiveSearchEnabled && !request.Instances.Any(i => i.Enabled))
{
throw new Domain.Exceptions.ValidationException(
"At least one instance must be enabled when proactive search is enabled");
}
// Sync instance configs
var existingInstanceConfigs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
foreach (var instanceReq in request.Instances)
{
var existing = existingInstanceConfigs
.FirstOrDefault(e => e.ArrInstanceId == instanceReq.ArrInstanceId);
if (existing is not null)
{
existing.Enabled = instanceReq.Enabled;
existing.SkipTags = instanceReq.SkipTags;
existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit;
}
else
{
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = instanceReq.ArrInstanceId,
Enabled = instanceReq.Enabled,
SkipTags = instanceReq.SkipTags,
ActiveDownloadLimit = instanceReq.ActiveDownloadLimit,
});
}
}
await _dataContext.SaveChangesAsync();
// Update Quartz trigger if SearchInterval changed
if (config.SearchInterval != previousInterval)
{
_logger.LogInformation("Search interval changed from {Old} to {New} minutes, updating Seeker schedule",
previousInterval, config.SearchInterval);
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
}
// Toggle CustomFormatScoreSyncer job when UseCustomFormatScore changes
if (config.UseCustomFormatScore != previousUseCustomFormatScore)
{
if (config.UseCustomFormatScore)
{
_logger.LogInformation("UseCustomFormatScore enabled, starting CustomFormatScoreSyncer job");
await _jobManagementService.StartJob(JobType.CustomFormatScoreSyncer, null, Constants.CustomFormatScoreSyncerCron);
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
else
{
_logger.LogInformation("UseCustomFormatScore disabled, stopping CustomFormatScoreSyncer job");
await _jobManagementService.StopJob(JobType.CustomFormatScoreSyncer);
}
}
// Trigger CustomFormatScoreSyncer once when search or proactive search is re-enabled with custom format scores active
if (previousUseCustomFormatScore && config.UseCustomFormatScore)
{
bool searchJustEnabled = !previousSearchEnabled && config.SearchEnabled;
bool proactiveJustEnabled = !previousProactiveSearchEnabled && config.ProactiveSearchEnabled;
if (searchJustEnabled || proactiveJustEnabled)
{
_logger.LogInformation("Search re-enabled with UseCustomFormatScore active, triggering CustomFormatScoreSyncer");
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
}
}
return Ok(new { Message = "Seeker configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Seeker configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}