using Cleanuparr.Api.Extensions; using Cleanuparr.Api.Features.Arr.Contracts.Requests; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Api.Features.Arr.Contracts.Responses; using Cleanuparr.Infrastructure.Health; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Shared.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; namespace Cleanuparr.Api.Features.Arr.Controllers; [ApiController] [Route("api/configuration")] [Authorize] public sealed class ArrConfigController : ControllerBase { private readonly ILogger _logger; private readonly DataContext _dataContext; private readonly EventsContext _eventsContext; private readonly IInstanceHealthChecker _healthChecker; private readonly IEventPublisher _eventPublisher; /// /// Creates the controller with its injected dependencies. /// public ArrConfigController( ILogger logger, DataContext dataContext, EventsContext eventsContext, IInstanceHealthChecker healthChecker, IEventPublisher eventPublisher) { _logger = logger; _dataContext = dataContext; _eventsContext = eventsContext; _healthChecker = healthChecker; _eventPublisher = eventPublisher; } [HttpGet("sonarr")] public Task GetSonarrConfig() => GetArrConfig(InstanceType.Sonarr); [HttpGet("radarr")] public Task GetRadarrConfig() => GetArrConfig(InstanceType.Radarr); [HttpGet("lidarr")] public Task GetLidarrConfig() => GetArrConfig(InstanceType.Lidarr); [HttpGet("readarr")] public Task GetReadarrConfig() => GetArrConfig(InstanceType.Readarr); [HttpGet("whisparr")] public Task GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr); [HttpGet("sportarr")] public Task GetSportarrConfig() => GetArrConfig(InstanceType.Sportarr); [HttpGet("lazylibrarian")] public Task GetLazyLibrarianConfig() => GetArrConfig(InstanceType.LazyLibrarian); [HttpPut("sonarr")] public Task UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request) => UpdateArrConfig(InstanceType.Sonarr, request); [HttpPut("radarr")] public Task UpdateRadarrConfig([FromBody] UpdateArrConfigRequest request) => UpdateArrConfig(InstanceType.Radarr, request); [HttpPut("lidarr")] public Task UpdateLidarrConfig([FromBody] UpdateArrConfigRequest request) => UpdateArrConfig(InstanceType.Lidarr, request); [HttpPut("readarr")] public Task UpdateReadarrConfig([FromBody] UpdateArrConfigRequest request) => UpdateArrConfig(InstanceType.Readarr, request); [HttpPut("whisparr")] public Task UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request) => UpdateArrConfig(InstanceType.Whisparr, request); [HttpPut("sportarr")] public Task UpdateSportarrConfig([FromBody] UpdateArrConfigRequest request) => UpdateArrConfig(InstanceType.Sportarr, request); [HttpPut("lazylibrarian")] public Task UpdateLazyLibrarianConfig([FromBody] UpdateArrConfigRequest request) => UpdateArrConfig(InstanceType.LazyLibrarian, request); [HttpPost("sonarr/instances")] public Task CreateSonarrInstance([FromBody] ArrInstanceRequest request) => CreateArrInstance(InstanceType.Sonarr, request); [HttpPut("sonarr/instances/{id}")] public Task UpdateSonarrInstance(Guid id, [FromBody] ArrInstanceRequest request) => UpdateArrInstance(InstanceType.Sonarr, id, request); [HttpDelete("sonarr/instances/{id}")] public Task DeleteSonarrInstance(Guid id) => DeleteArrInstance(InstanceType.Sonarr, id); [HttpPost("radarr/instances")] public Task CreateRadarrInstance([FromBody] ArrInstanceRequest request) => CreateArrInstance(InstanceType.Radarr, request); [HttpPut("radarr/instances/{id}")] public Task UpdateRadarrInstance(Guid id, [FromBody] ArrInstanceRequest request) => UpdateArrInstance(InstanceType.Radarr, id, request); [HttpDelete("radarr/instances/{id}")] public Task DeleteRadarrInstance(Guid id) => DeleteArrInstance(InstanceType.Radarr, id); [HttpPost("lidarr/instances")] public Task CreateLidarrInstance([FromBody] ArrInstanceRequest request) => CreateArrInstance(InstanceType.Lidarr, request); [HttpPut("lidarr/instances/{id}")] public Task UpdateLidarrInstance(Guid id, [FromBody] ArrInstanceRequest request) => UpdateArrInstance(InstanceType.Lidarr, id, request); [HttpDelete("lidarr/instances/{id}")] public Task DeleteLidarrInstance(Guid id) => DeleteArrInstance(InstanceType.Lidarr, id); [HttpPost("readarr/instances")] public Task CreateReadarrInstance([FromBody] ArrInstanceRequest request) => CreateArrInstance(InstanceType.Readarr, request); [HttpPut("readarr/instances/{id}")] public Task UpdateReadarrInstance(Guid id, [FromBody] ArrInstanceRequest request) => UpdateArrInstance(InstanceType.Readarr, id, request); [HttpDelete("readarr/instances/{id}")] public Task DeleteReadarrInstance(Guid id) => DeleteArrInstance(InstanceType.Readarr, id); [HttpPost("whisparr/instances")] public Task CreateWhisparrInstance([FromBody] ArrInstanceRequest request) => CreateArrInstance(InstanceType.Whisparr, request); [HttpPut("whisparr/instances/{id}")] public Task UpdateWhisparrInstance(Guid id, [FromBody] ArrInstanceRequest request) => UpdateArrInstance(InstanceType.Whisparr, id, request); [HttpDelete("whisparr/instances/{id}")] public Task DeleteWhisparrInstance(Guid id) => DeleteArrInstance(InstanceType.Whisparr, id); [HttpPost("sportarr/instances")] public Task CreateSportarrInstance([FromBody] ArrInstanceRequest request) => CreateArrInstance(InstanceType.Sportarr, request); [HttpPut("sportarr/instances/{id}")] public Task UpdateSportarrInstance(Guid id, [FromBody] ArrInstanceRequest request) => UpdateArrInstance(InstanceType.Sportarr, id, request); [HttpDelete("sportarr/instances/{id}")] public Task DeleteSportarrInstance(Guid id) => DeleteArrInstance(InstanceType.Sportarr, id); [HttpPost("lazylibrarian/instances")] public Task CreateLazyLibrarianInstance([FromBody] ArrInstanceRequest request) => CreateArrInstance(InstanceType.LazyLibrarian, request); [HttpPut("lazylibrarian/instances/{id}")] public Task UpdateLazyLibrarianInstance(Guid id, [FromBody] ArrInstanceRequest request) => UpdateArrInstance(InstanceType.LazyLibrarian, id, request); [HttpDelete("lazylibrarian/instances/{id}")] public Task DeleteLazyLibrarianInstance(Guid id) => DeleteArrInstance(InstanceType.LazyLibrarian, id); [HttpPost("sonarr/instances/test")] public Task TestSonarrInstance([FromBody] TestArrInstanceRequest request) => TestArrInstance(InstanceType.Sonarr, request); [HttpPost("radarr/instances/test")] public Task TestRadarrInstance([FromBody] TestArrInstanceRequest request) => TestArrInstance(InstanceType.Radarr, request); [HttpPost("lidarr/instances/test")] public Task TestLidarrInstance([FromBody] TestArrInstanceRequest request) => TestArrInstance(InstanceType.Lidarr, request); [HttpPost("readarr/instances/test")] public Task TestReadarrInstance([FromBody] TestArrInstanceRequest request) => TestArrInstance(InstanceType.Readarr, request); [HttpPost("whisparr/instances/test")] public Task TestWhisparrInstance([FromBody] TestArrInstanceRequest request) => TestArrInstance(InstanceType.Whisparr, request); [HttpPost("sportarr/instances/test")] public Task TestSportarrInstance([FromBody] TestArrInstanceRequest request) => TestArrInstance(InstanceType.Sportarr, request); [HttpPost("lazylibrarian/instances/test")] public Task TestLazyLibrarianInstance([FromBody] TestArrInstanceRequest request) => TestArrInstance(InstanceType.LazyLibrarian, request); private async Task GetArrConfig(InstanceType type) { await DataContext.Lock.WaitAsync(); try { var config = await _dataContext.ArrConfigs .Include(x => x.Instances) .AsNoTracking() .FirstAsync(x => x.Type == type); config.Instances = config.Instances .OrderBy(i => i.Name) .ToList(); return Ok(ToDto(config)); } finally { DataContext.Lock.Release(); } } private async Task UpdateArrConfig(InstanceType type, UpdateArrConfigRequest request) { await DataContext.Lock.WaitAsync(); try { var config = await _dataContext.ArrConfigs .FirstAsync(x => x.Type == type); config.FailedImportMaxStrikes = request.FailedImportMaxStrikes; config.Validate(); await _dataContext.SaveChangesAsync(); return Ok(new { Message = $"{type} configuration updated successfully" }); } finally { DataContext.Lock.Release(); } } private async Task CreateArrInstance(InstanceType type, ArrInstanceRequest request) { await DataContext.Lock.WaitAsync(); try { var config = await _dataContext.ArrConfigs .FirstAsync(x => x.Type == type); var instance = request.ToEntity(config.Id); await _dataContext.ArrInstances.AddAsync(instance); await _dataContext.SaveChangesAsync(); return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, ToDto(instance)); } finally { DataContext.Lock.Release(); } } private async Task UpdateArrInstance(InstanceType type, Guid id, ArrInstanceRequest request) { await DataContext.Lock.WaitAsync(); try { var config = await _dataContext.ArrConfigs .Include(c => c.Instances) .FirstAsync(x => x.Type == type); var instance = config.Instances.FirstOrDefault(i => i.Id == id); if (instance is null) { return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found"); } request.ApplyTo(instance); await _dataContext.SaveChangesAsync(); return Ok(ToDto(instance)); } finally { DataContext.Lock.Release(); } } private async Task DeleteArrInstance(InstanceType type, Guid id) { await DataContext.Lock.WaitAsync(); try { var config = await _dataContext.ArrConfigs .Include(c => c.Instances) .FirstAsync(x => x.Type == type); var instance = config.Instances.FirstOrDefault(i => i.Id == id); if (instance is null) { return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found"); } await using IDbContextTransaction transaction = await _dataContext.Database.BeginTransactionAsync(); try { config.Instances.Remove(instance); await _dataContext.SaveChangesAsync(); await DeleteSeekerEventsForInstanceAsync(id); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } await FailStrandedSearchEventsForInstanceAsync(id); return NoContent(); } finally { DataContext.Lock.Release(); } } private async Task DeleteSeekerEventsForInstanceAsync(Guid arrInstanceId, CancellationToken cancellationToken = default) { await using IDbContextTransaction transaction = await _eventsContext.Database.BeginTransactionAsync(cancellationToken); try { await _eventsContext.CustomFormatScoreEntries .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); await _eventsContext.CustomFormatScoreHistory .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); await _eventsContext.SeekerHistory .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); await _eventsContext.SeekerCycles .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); await _eventsContext.SearchQueue .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); await _eventsContext.SeekerCommandTrackers .Where(e => e.ArrInstanceId == arrInstanceId) .ExecuteDeleteAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } catch { await transaction.RollbackAsync(cancellationToken); throw; } } private async Task FailStrandedSearchEventsForInstanceAsync(Guid arrInstanceId) { try { await _eventPublisher.FailStrandedSearchEvents(arrInstanceId); } catch (Exception exception) { _logger.LogError(exception, "Failed to mark the search events of instance {InstanceId} as failed, the seeker command monitor will retry", arrInstanceId); } } private async Task TestArrInstance(InstanceType type, TestArrInstanceRequest request) { try { string? resolvedApiKey = null; if (request.ApiKey.IsPlaceholder() && request.InstanceId.HasValue) { var existingInstance = await _dataContext.ArrInstances .AsNoTracking() .FirstOrDefaultAsync(i => i.Id == request.InstanceId.Value); if (existingInstance is null) { return this.ProblemResult(StatusCodes.Status404NotFound, $"Instance with ID {request.InstanceId.Value} not found"); } resolvedApiKey = existingInstance.ApiKey; } var testInstance = request.ToTestInstance(resolvedApiKey); await _healthChecker.CheckAsync(type, testInstance); return Ok(new { Message = $"Connection to {type} instance successful" }); } catch (Exception ex) { _logger.LogError(ex, "Failed to test {Type} instance connection", type); return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}"); } } private static ArrConfigResponse ToDto(ArrConfig config) => new() { Id = config.Id, Type = config.Type, FailedImportMaxStrikes = config.FailedImportMaxStrikes, Instances = config.Instances.Select(ToDto).ToList(), }; private static ArrInstanceResponse ToDto(ArrInstance instance) => new() { Id = instance.Id, Enabled = instance.Enabled, Version = instance.Version, Name = instance.Name, Url = instance.Url.ToString(), ApiKey = instance.ApiKey, ExternalUrl = instance.ExternalUrl?.ToString(), }; private static string GetConfigActionName(InstanceType type) => type switch { InstanceType.Sonarr => nameof(GetSonarrConfig), InstanceType.Radarr => nameof(GetRadarrConfig), InstanceType.Lidarr => nameof(GetLidarrConfig), InstanceType.Readarr => nameof(GetReadarrConfig), InstanceType.Whisparr => nameof(GetWhisparrConfig), InstanceType.Sportarr => nameof(GetSportarrConfig), InstanceType.LazyLibrarian => nameof(GetLazyLibrarianConfig), _ => nameof(GetSonarrConfig), }; }