Compare commits

...

1 Commits

Author SHA1 Message Date
Flaminel
fb6ccfd011 Add Readarr support (#191) 2025-06-29 19:54:15 +03:00
53 changed files with 2337 additions and 133 deletions

View File

@@ -309,6 +309,24 @@ public class ConfigurationController : ControllerBase
}
}
[HttpGet("readarr")]
public async Task<IActionResult> GetReadarrConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
return Ok(config.Adapt<ArrConfigDto>());
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("notifications")]
public async Task<IActionResult> GetNotificationsConfig()
{
@@ -773,6 +791,37 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPut("readarr")]
public async Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateReadarrConfigDto newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
// Get existing config
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes;
// Validate the configuration
config.Validate();
// Persist the configuration
await _dataContext.SaveChangesAsync();
return Ok(new { Message = "Readarr configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Readarr configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
/// <summary>
/// Updates a job schedule based on configuration changes
@@ -1137,4 +1186,114 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPost("readarr/instances")]
public async Task<IActionResult> CreateReadarrInstance([FromBody] CreateArrInstanceDto newInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config to add the instance to
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
// Create the new instance
var instance = new ArrInstance
{
Enabled = newInstance.Enabled,
Name = newInstance.Name,
Url = new Uri(newInstance.Url),
ApiKey = newInstance.ApiKey,
ArrConfigId = config.Id,
};
// Add to the config's instances collection
await _dataContext.ArrInstances.AddAsync(instance);
// Save changes
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetReadarrConfig), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Readarr instance");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("readarr/instances/{id}")]
public async Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Update the instance properties
instance.Enabled = updatedInstance.Enabled;
instance.Name = updatedInstance.Name;
instance.Url = new Uri(updatedInstance.Url);
instance.ApiKey = updatedInstance.ApiKey;
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("readarr/instances/{id}")]
public async Task<IActionResult> DeleteReadarrInstance(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Remove the instance
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -52,6 +52,10 @@ public class StatusController : ControllerBase
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
@@ -80,6 +84,10 @@ public class StatusController : ControllerBase
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};

View File

@@ -40,9 +40,6 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
// Add logging initializer service
services.AddHostedService<LoggingInitializer>();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo

View File

@@ -37,6 +37,7 @@ public static class ServicesDI
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ReadarrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()

View File

@@ -0,0 +1,9 @@
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Readarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateReadarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -63,6 +64,7 @@ public sealed class ContentBlocker : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
if (config.Sonarr.Enabled)
{
@@ -78,6 +80,11 @@ public sealed class ContentBlocker : GenericHandler
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -130,6 +131,7 @@ public sealed class DownloadCleaner : GenericHandler
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
{

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -42,10 +43,12 @@ public sealed class QueueCleaner : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record Image
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record LidarrImage
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueAlbum
{

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueBook
{
public List<ReadarrImage> Images { get; init; } = [];
}

View File

@@ -1,4 +1,6 @@
namespace Data.Models.Arr.Queue;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public record QueueListResponse
{

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueMovie
{

View File

@@ -1,4 +1,6 @@
namespace Data.Models.Arr.Queue;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueRecord
{
@@ -21,6 +23,13 @@ public sealed record QueueRecord
public QueueAlbum? Album { get; init; }
// Readarr
public long AuthorId { get; init; }
public long BookId { get; init; }
public QueueBook? Book { get; init; }
// common
public required string Title { get; init; }
public string Status { get; init; }

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueSeries
{

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record ReadarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Author
{
public long Id { get; set; }
public string AuthorName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Book
{
public required long Id { get; init; }
public required string Title { get; init; }
public long AuthorId { get; set; }
public Author Author { get; set; } = new();
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record ReadarrCommand
{
public string Name { get; set; } = string.Empty;
public List<long> BookIds { get; set; } = [];
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;

View File

@@ -8,16 +8,19 @@ public sealed class ArrClientFactory
private readonly ISonarrClient _sonarrClient;
private readonly IRadarrClient _radarrClient;
private readonly ILidarrClient _lidarrClient;
private readonly IReadarrClient _readarrClient;
public ArrClientFactory(
SonarrClient sonarrClient,
RadarrClient radarrClient,
LidarrClient lidarrClient
LidarrClient lidarrClient,
ReadarrClient readarrClient
)
{
_sonarrClient = sonarrClient;
_radarrClient = radarrClient;
_lidarrClient = lidarrClient;
_readarrClient = readarrClient;
}
public IArrClient GetClient(InstanceType type) =>
@@ -26,6 +29,7 @@ public sealed class ArrClientFactory
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
InstanceType.Lidarr => _lidarrClient,
InstanceType.Readarr => _readarrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr.Queue;
using Microsoft.Extensions.Logging;

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IReadarrClient : IArrClient
{
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;

View File

@@ -0,0 +1,152 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Readarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Arr;
public class ReadarrClient : ArrClient, IReadarrClient
{
public ReadarrClient(
ILogger<ReadarrClient> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetQueueUrlPath()
{
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
{
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
}
protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v1/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
ReadarrCommand command = new()
{
Name = "BookSearch",
BookIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.AuthorId is 0 || record.BookId is 0)
{
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
{
string status = success ? "triggered" : "failed";
string message = logContext ?? $"book ids: {string.Join(',', command.BookIds)}";
return $"book search {status} | {instanceUrl} | {message}";
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, ReadarrCommand command)
{
try
{
StringBuilder log = new();
foreach (long bookId in command.BookIds)
{
Book? book = await GetBookAsync(arrInstance, bookId);
if (book is null)
{
return null;
}
log.Append($"[{book.Title}]");
}
return log.ToString();
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to compute log context");
}
return null;
}
private async Task<Book?> GetBookAsync(ArrInstance arrInstance, long bookId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Book>(responseBody);
}
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;

View File

@@ -95,6 +95,17 @@ public sealed class BlocklistProvider
changedCount++;
}
// Check and update Lidarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
{
_logger.LogDebug("Loading Readarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
changedCount++;
}
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Context;

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -52,68 +53,6 @@ public abstract class GenericHandler : IHandler
_dataContext = dataContext;
}
// /// <summary>
// /// Initialize download services based on configuration
// /// </summary>
// protected async Task<List<IDownloadService>> GetDownloadServices()
// {
// var clients = await _dataContext.DownloadClients
// .AsNoTracking()
// .ToListAsync();
//
// if (clients.Count is 0)
// {
// _logger.LogWarning("No download clients configured");
// return [];
// }
//
// var enabledClients = await _dataContext.DownloadClients
// .Where(c => c.Enabled)
// .ToListAsync();
//
// if (enabledClients.Count == 0)
// {
// _logger.LogWarning("No enabled download clients available");
// return [];
// }
//
// List<IDownloadService> downloadServices = [];
//
// // Add all enabled clients
// foreach (var client in enabledClients)
// {
// try
// {
// var service = _downloadServiceFactory.GetDownloadService(client);
// if (service != null)
// {
// await service.LoginAsync();
// downloadServices.Add(service);
// _logger.LogDebug("Initialized download client: {name}", client.Name);
// }
// else
// {
// _logger.LogWarning("Download client service not available for: {name}", client.Name);
// }
// }
// catch (Exception ex)
// {
// _logger.LogError(ex, "Failed to initialize download client: {name}", client.Name);
// }
// }
//
// if (downloadServices.Count == 0)
// {
// _logger.LogWarning("No valid download clients found");
// }
// else
// {
// _logger.LogDebug("Initialized {count} download clients", downloadServices.Count);
// }
//
// return downloadServices;
// }
public async Task ExecuteAsync()
{
await DataContext.Lock.WaitAsync();
@@ -130,6 +69,9 @@ public abstract class GenericHandler : IHandler
ContextProvider.Set(nameof(InstanceType.Lidarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Lidarr));
ContextProvider.Set(nameof(InstanceType.Readarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr));
ContextProvider.Set(nameof(QueueCleanerConfig), await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(ContentBlockerConfig), await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.Include(x => x.Categories).AsNoTracking().FirstAsync());
@@ -252,6 +194,10 @@ public abstract class GenericHandler : IHandler
{
Id = record.AlbumId
},
InstanceType.Readarr => new SearchItem
{
Id = record.BookId
},
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,4 +1,5 @@
using System.Globalization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
@@ -166,6 +167,7 @@ public class NotificationPublisher : INotificationPublisher
InstanceType.Sonarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
};

View File

@@ -1,52 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Helpers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog.Context;
namespace Cleanuparr.Infrastructure.Logging;
// TODO remove
public class LoggingInitializer : BackgroundService
{
private readonly ILogger<LoggingInitializer> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Random random = new();
public LoggingInitializer(ILogger<LoggingInitializer> logger, EventPublisher eventPublisher)
{
_logger = logger;
_eventPublisher = eventPublisher;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
return;
while (true)
{
using var _ = LogContext.PushProperty(LogProperties.Category,
random.Next(0, 100) > 50 ? InstanceType.Sonarr.ToString() : InstanceType.Radarr.ToString());
try
{
await _eventPublisher.PublishAsync(
random.Next(0, 100) > 50 ? EventType.DownloadCleaned : EventType.StalledStrike,
"This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.",
EventSeverity.Important,
data: new { Hash = "hash", Name = "name", StrikeCount = "1", Type = "stalled" });
throw new Exception("test exception");
}
catch (Exception exception)
{
_logger.LogCritical("test critical");
_logger.LogTrace("test trace");
_logger.LogDebug("test debug");
_logger.LogWarning("test warn");
_logger.LogError(exception, "This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.");
}
await Task.Delay(10000, stoppingToken);
}
}
}

View File

@@ -77,6 +77,10 @@ public class DataContext : DbContext
{
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
});
entity.ComplexProperty(e => e.Readarr, cp =>
{
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
});
});
// Configure ArrConfig -> ArrInstance relationship

View File

@@ -0,0 +1,620 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
[DbContext(typeof(DataContext))]
[Migration("20250628231105_AddReadarr")]
partial class AddReadarr
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_arr_configs");
b.ToTable("arr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_arr_instances");
b.HasIndex("ArrConfigId")
.HasDatabaseName("ix_arr_instances_arr_config_id");
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Url")
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
.WithMany("Instances")
.HasForeignKey("ArrConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddReadarr : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "readarr_blocklist_path",
table: "content_blocker_configs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "readarr_blocklist_type",
table: "content_blocker_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "readarr_enabled",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.InsertData(
table: "arr_configs",
columns: new[] { "id", "failed_import_max_strikes", "type" },
values: new object[] { new Guid("013994ea-0a5e-4eed-91b7-271f494b6259"), (short)-1, "readarr" });
migrationBuilder.Sql("UPDATE content_blocker_configs SET readarr_blocklist_type = 'blacklist' WHERE readarr_blocklist_type = ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "readarr_blocklist_path",
table: "content_blocker_configs");
migrationBuilder.DropColumn(
name: "readarr_blocklist_type",
table: "content_blocker_configs");
migrationBuilder.DropColumn(
name: "readarr_enabled",
table: "content_blocker_configs");
}
}
}

View File

@@ -143,6 +143,24 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();

View File

@@ -26,11 +26,14 @@ public sealed record ContentBlockerConfig : IJobConfig
public BlocklistSettings Lidarr { get; set; } = new();
public BlocklistSettings Readarr { get; set; } = new();
public void Validate()
{
ValidateBlocklistSettings(Sonarr, "Sonarr");
ValidateBlocklistSettings(Radarr, "Radarr");
ValidateBlocklistSettings(Lidarr, "Lidarr");
ValidateBlocklistSettings(Readarr, "Readarr");
}
private static void ValidateBlocklistSettings(BlocklistSettings settings, string context)

View File

@@ -14,6 +14,7 @@ export const routes: Routes = [
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },
{ path: 'readarr', loadComponent: () => import('./settings/readarr/readarr-settings.component').then(m => m.ReadarrSettingsComponent) },
{ path: 'download-clients', loadComponent: () => import('./settings/download-client/download-client-settings.component').then(m => m.DownloadClientSettingsComponent) },
{ path: 'notifications', loadComponent: () => import('./settings/notification-settings/notification-settings.component').then(m => m.NotificationSettingsComponent) },
];

View File

@@ -6,6 +6,7 @@ import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, Schedul
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model";
import { GeneralConfig } from "../../shared/models/general-config.model";
@@ -327,6 +328,29 @@ export class ConfigurationService {
);
}
/**
* Get Readarr configuration
*/
getReadarrConfig(): Observable<ReadarrConfig> {
return this.http.get<ReadarrConfig>(this.ApplicationPathService.buildApiUrl('/configuration/readarr')).pipe(
catchError((error) => {
console.error("Error fetching Readarr config:", error);
return throwError(() => new Error("Failed to load Readarr configuration"));
})
);
}
/**
* Update Readarr configuration
*/
updateReadarrConfig(config: {failedImportMaxStrikes: number}): Observable<any> {
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/readarr'), config).pipe(
catchError((error) => {
console.error("Error updating Readarr config:", error);
return throwError(() => new Error(error.error?.error || "Failed to update Readarr configuration"));
})
);
}
/**
* Get Download Client configuration
*/
@@ -500,4 +524,42 @@ export class ConfigurationService {
})
);
}
// ===== READARR INSTANCE MANAGEMENT =====
/**
* Create a new Readarr instance
*/
createReadarrInstance(instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.post<ArrInstance>(this.ApplicationPathService.buildApiUrl('/configuration/readarr/instances'), instance).pipe(
catchError((error) => {
console.error("Error creating Readarr instance:", error);
return throwError(() => new Error(error.error?.error || "Failed to create Readarr instance"));
})
);
}
/**
* Update a Readarr instance by ID
*/
updateReadarrInstance(id: string, instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.put<ArrInstance>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`), instance).pipe(
catchError((error) => {
console.error(`Error updating Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to update Readarr instance with ID ${id}`));
})
);
}
/**
* Delete a Readarr instance by ID
*/
deleteReadarrInstance(id: string): Observable<void> {
return this.http.delete<void>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`)).pipe(
catchError((error) => {
console.error(`Error deleting Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to delete Readarr instance with ID ${id}`));
})
);
}
}

View File

@@ -47,6 +47,12 @@
</div>
<span>Lidarr</span>
</a>
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-book"></i>
</div>
<span>Readarr</span>
</a>
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-download"></i>

View File

@@ -344,6 +344,76 @@
</div>
</p-accordion-content>
</p-accordion-panel>
<!-- Readarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Readarr Settings
</p-accordion-header>
<p-accordion-content>
<div formGroupName="readarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.enabled')"
title="Click for documentation"></i>
Enable Readarr Blocklist
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, the Readarr blocklist will be used for content filtering</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
</label>
<p-fluid>
<div class="field-input">
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
</div>
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="p-error">Path is required when Readarr blocklist is enabled</small>
<small class="form-helper-text">Path to the blocklist file or URL</small>
</p-fluid>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
</label>
<div class="field-input">
<p-select
formControlName="blocklistType"
[options]="[
{ label: 'Blacklist', value: 'Blacklist' },
{ label: 'Whitelist', value: 'Whitelist' }
]"
optionLabel="label"
optionValue="value"
appendTo="body"
></p-select>
<small class="form-helper-text"
>Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches)</small
>
</div>
</div>
</div>
</p-accordion-content>
</p-accordion-panel>
</p-accordion>
<!-- Action buttons -->

View File

@@ -149,6 +149,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: [{ value: "", disabled: true }],
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
}),
readarr: this.formBuilder.group({
enabled: [{ value: false, disabled: true }],
blocklistPath: [{ value: "", disabled: true }],
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
}),
});
// Create an effect to update the form when the configuration changes
@@ -169,6 +174,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
sonarr: config.sonarr,
radarr: config.radarr,
lidarr: config.lidarr,
readarr: config.readarr,
});
// Update all form control states
@@ -278,7 +284,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Listen for changes to blocklist enabled states
['sonarr', 'radarr', 'lidarr'].forEach(arrType => {
['sonarr', 'radarr', 'lidarr', 'readarr'].forEach(arrType => {
const enabledControl = this.contentBlockerForm.get(`${arrType}.enabled`);
if (enabledControl) {
@@ -348,6 +354,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.updateBlocklistDependentControls('sonarr', config.sonarr?.enabled || false);
this.updateBlocklistDependentControls('radarr', config.radarr?.enabled || false);
this.updateBlocklistDependentControls('lidarr', config.lidarr?.enabled || false);
this.updateBlocklistDependentControls('readarr', config.readarr?.enabled || false);
}
}
@@ -407,15 +414,18 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.contentBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("radarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.enable({ onlySelf: true });
// Update dependent controls based on current enabled states
const sonarrEnabled = this.contentBlockerForm.get("sonarr.enabled")?.value || false;
const radarrEnabled = this.contentBlockerForm.get("radarr.enabled")?.value || false;
const lidarrEnabled = this.contentBlockerForm.get("lidarr.enabled")?.value || false;
const readarrEnabled = this.contentBlockerForm.get("readarr.enabled")?.value || false;
this.updateBlocklistDependentControls('sonarr', sonarrEnabled);
this.updateBlocklistDependentControls('radarr', radarrEnabled);
this.updateBlocklistDependentControls('lidarr', lidarrEnabled);
this.updateBlocklistDependentControls('readarr', readarrEnabled);
} else {
// Disable all scheduling controls
cronExpressionControl?.disable();
@@ -440,6 +450,9 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.contentBlockerForm.get("lidarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistType")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistType")?.disable({ onlySelf: true });
// Save current active accordion state before clearing it
this.activeAccordionIndices = [];
@@ -483,6 +496,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
readarr: formValue.readarr || {
enabled: false,
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
};
// Save the configuration

View File

@@ -0,0 +1,365 @@
import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ReadarrConfig } from '../../shared/models/readarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
export interface ReadarrConfigState {
config: ReadarrConfig | null;
loading: boolean;
saving: boolean;
error: string | null;
instanceOperations: number;
}
const initialState: ReadarrConfigState = {
config: null,
loading: false,
saving: false,
error: null,
instanceOperations: 0
};
@Injectable()
export class ReadarrConfigStore extends signalStore(
withState(initialState),
withMethods((store, configService = inject(ConfigurationService)) => ({
/**
* Load the Readarr configuration
*/
loadConfig: rxMethod<void>(
pipe => pipe.pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() => configService.getReadarrConfig().pipe(
tap({
next: (config) => patchState(store, { config, loading: false }),
error: (error) => {
patchState(store, {
loading: false,
error: error.message || 'Failed to load Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save the Readarr global configuration
*/
saveConfig: rxMethod<{failedImportMaxStrikes: number}>(
(globalConfig$: Observable<{failedImportMaxStrikes: number}>) => globalConfig$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(globalConfig => configService.updateReadarrConfig(globalConfig).pipe(
tap({
next: () => {
const currentConfig = store.config();
if (currentConfig) {
// Update the local config with the new global settings
patchState(store, {
config: { ...currentConfig, ...globalConfig },
saving: false
});
}
},
error: (error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save the Readarr configuration
*/
saveFullConfig: rxMethod<ReadarrConfig>(
(config$: Observable<ReadarrConfig>) => config$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(config => configService.updateReadarrConfig(config).pipe(
tap({
next: () => {
patchState(store, {
config,
saving: false
});
},
error: (error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Update config in the store without saving to the backend
*/
updateConfigLocally(config: Partial<ReadarrConfig>) {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, ...config }
});
}
},
/**
* Reset any errors
*/
resetError() {
patchState(store, { error: null });
},
// ===== INSTANCE MANAGEMENT =====
/**
* Create a new Readarr instance
*/
createInstance: rxMethod<CreateArrInstanceDto>(
(instance$: Observable<CreateArrInstanceDto>) => instance$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(instance => configService.createReadarrInstance(instance).pipe(
tap({
next: (newInstance) => {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, instances: [...currentConfig.instances, newInstance] },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || 'Failed to create Readarr instance'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Update a Readarr instance by ID
*/
updateInstance: rxMethod<{ id: string, instance: CreateArrInstanceDto }>(
(params$: Observable<{ id: string, instance: CreateArrInstanceDto }>) => params$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(({ id, instance }) => configService.updateReadarrInstance(id, instance).pipe(
tap({
next: (updatedInstance) => {
const currentConfig = store.config();
if (currentConfig) {
const updatedInstances = currentConfig.instances.map((inst: ArrInstance) =>
inst.id === id ? updatedInstance : inst
);
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || `Failed to update Readarr instance with ID ${id}`
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Delete a Readarr instance by ID
*/
deleteInstance: rxMethod<string>(
(id$: Observable<string>) => id$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(id => configService.deleteReadarrInstance(id).pipe(
tap({
next: () => {
const currentConfig = store.config();
if (currentConfig) {
const updatedInstances = currentConfig.instances.filter((inst: ArrInstance) => inst.id !== id);
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || `Failed to delete Readarr instance with ID ${id}`
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save config and then process instance operations sequentially
*/
saveConfigAndInstances: rxMethod<{
config: ReadarrConfig,
instanceOperations: {
creates: CreateArrInstanceDto[],
updates: Array<{ id: string, instance: CreateArrInstanceDto }>,
deletes: string[]
}
}>(
(params$: Observable<{
config: ReadarrConfig,
instanceOperations: {
creates: CreateArrInstanceDto[],
updates: Array<{ id: string, instance: CreateArrInstanceDto }>,
deletes: string[]
}
}>) => params$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(({ config, instanceOperations }) => {
// First save the main config
return configService.updateReadarrConfig(config).pipe(
tap(() => {
patchState(store, { config });
}),
switchMap(() => {
// Then process instance operations if any
const { creates, updates, deletes } = instanceOperations;
const totalOperations = creates.length + updates.length + deletes.length;
if (totalOperations === 0) {
patchState(store, { saving: false });
return EMPTY;
}
patchState(store, { instanceOperations: totalOperations });
// Prepare all operations
const createOps = creates.map(instance =>
configService.createReadarrInstance(instance).pipe(
catchError(error => {
console.error('Failed to create Readarr instance:', error);
return of(null);
})
)
);
const updateOps = updates.map(({ id, instance }) =>
configService.updateReadarrInstance(id, instance).pipe(
catchError(error => {
console.error('Failed to update Readarr instance:', error);
return of(null);
})
)
);
const deleteOps = deletes.map(id =>
configService.deleteReadarrInstance(id).pipe(
catchError(error => {
console.error('Failed to delete Readarr instance:', error);
return of(null);
})
)
);
// Execute all operations in parallel
return forkJoin([...createOps, ...updateOps, ...deleteOps]).pipe(
tap({
next: (results) => {
const currentConfig = store.config();
if (currentConfig) {
let updatedInstances = [...currentConfig.instances];
let failedCount = 0;
// Process create results
const createResults = results.slice(0, creates.length);
const successfulCreates = createResults.filter(instance => instance !== null) as ArrInstance[];
updatedInstances = [...updatedInstances, ...successfulCreates];
failedCount += createResults.filter(instance => instance === null).length;
// Process update results
const updateResults = results.slice(creates.length, creates.length + updates.length);
updateResults.forEach((result, index) => {
if (result !== null) {
const instanceIndex = updatedInstances.findIndex(inst => inst.id === updates[index].id);
if (instanceIndex !== -1) {
updatedInstances[instanceIndex] = result as ArrInstance;
}
} else {
failedCount++;
}
});
// Process delete results
const deleteResults = results.slice(creates.length + updates.length);
deleteResults.forEach((result, index) => {
if (result !== null) {
// Delete was successful, remove from array
updatedInstances = updatedInstances.filter(inst => inst.id !== deletes[index]);
} else {
failedCount++;
}
});
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: 0,
error: failedCount > 0 ? `${failedCount} operation(s) failed` : null
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: 0,
error: error.message || 'Failed to process instance operations'
});
}
})
);
}),
catchError((error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
return EMPTY;
})
);
})
)
)
})),
withHooks({
onInit({ loadConfig }) {
loadConfig();
}
})
) {}

View File

@@ -0,0 +1,230 @@
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Readarr</h1>
</div>
<!-- Loading/Error State Component -->
<div class="mb-4">
<app-loading-error-state
*ngIf="readarrLoading() || readarrError()"
[loading]="readarrLoading()"
[error]="readarrError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
</div>
<!-- Content - only shown when not loading and no error -->
<div *ngIf="!readarrLoading() && !readarrError()">
<!-- Global Configuration Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Readarr Settings</h2>
<span class="card-subtitle">Configure general Readarr integration settings</span>
</div>
</div>
</ng-template>
<form [formGroup]="globalForm" class="p-fluid">
<div class="field-row">
<label class="field-label">Failed Import Max Strikes</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="failedImportMaxStrikes"
[min]="-1"
[showButtons]="true"
buttonLayout="horizontal"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
></p-inputNumber>
</div>
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
</div>
</div>
<!-- Save Button -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="!globalForm.dirty || !hasGlobalChanges || globalForm.invalid || readarrSaving()"
[loading]="readarrSaving()"
(click)="saveGlobalConfig()"
></button>
</div>
</form>
</p-card>
<!-- Instance Management Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Readarr server instances</span>
</div>
</div>
</ng-template>
<!-- Empty state when no instances -->
<div *ngIf="instances.length === 0" class="empty-instances-message p-3 text-center">
<i class="pi pi-inbox empty-icon"></i>
<p>No Readarr instances configured</p>
<small>Add an instance to start using Readarr integration</small>
</div>
<!-- Instances List -->
<div *ngIf="instances.length > 0" class="instances-list">
<div *ngFor="let instance of instances" class="instance-item">
<div class="instance-header">
<div class="instance-title">
<i class="pi pi-server instance-icon"></i>
<span class="instance-name">{{ instance.name }}</span>
</div>
<div class="instance-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-text p-button-sm"
[disabled]="readarrSaving()"
(click)="openEditInstanceModal(instance)"
pTooltip="Edit instance"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
[disabled]="readarrSaving()"
(click)="deleteInstance(instance)"
pTooltip="Delete instance"
></button>
</div>
</div>
<div class="instance-content">
<div class="instance-field">
<label>{{ instance.url }}</label>
</div>
<div class="instance-field">
<label>Status:
<span [class]="instance.enabled ? 'text-green-500' : 'text-red-500'">
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
icon="pi pi-plus"
label="Add Instance"
class="p-button-outlined"
[disabled]="readarrSaving()"
(click)="openAddInstanceModal()"
></button>
</div>
</p-card>
</div>
</div>
<!-- Instance Modal -->
<p-dialog
[(visible)]="showInstanceModal"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="closeInstanceModal()"
>
<form [formGroup]="instanceForm" class="p-fluid instance-form">
<div class="field flex flex-row">
<label class="field-label">Enabled</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">Enable this Readarr instance</small>
</div>
</div>
<div class="field">
<label for="instance-name">Name *</label>
<input
id="instance-name"
type="text"
pInputText
formControlName="name"
placeholder="My Readarr Instance"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
</div>
<div class="field">
<label for="instance-url">URL *</label>
<input
id="instance-url"
type="text"
pInputText
formControlName="url"
placeholder="http://localhost:8787"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
</div>
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Readarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
</div>
</form>
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || readarrSaving()"
[loading]="readarrSaving()"
(click)="saveInstance()"
></button>
</div>
</ng-template>
</p-dialog>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -0,0 +1,5 @@
/* Readarr Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -0,0 +1,415 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ReadarrConfigStore } from "./readarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
import { InputTextModule } from "primeng/inputtext";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { InputNumberModule } from "primeng/inputnumber";
import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@Component({
selector: "app-readarr-settings",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CardModule,
InputTextModule,
CheckboxModule,
ButtonModule,
InputNumberModule,
ToastModule,
DialogModule,
ConfirmDialogModule,
LoadingErrorStateComponent,
],
providers: [ReadarrConfigStore, ConfirmationService],
templateUrl: "./readarr-settings.component.html",
styleUrls: ["./readarr-settings.component.scss"],
})
export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactivate {
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Forms
globalForm: FormGroup;
instanceForm: FormGroup;
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
editingInstance: ArrInstance | null = null;
// Original form values for tracking changes
private originalGlobalValues: any;
hasGlobalChanges = false;
// Clean up subscriptions
private destroy$ = new Subject<void>();
// Services
private formBuilder = inject(FormBuilder);
private notificationService = inject(NotificationService);
private confirmationService = inject(ConfirmationService);
private readarrStore = inject(ReadarrConfigStore);
// Signals from store
readarrConfig = this.readarrStore.config;
readarrLoading = this.readarrStore.loading;
readarrError = this.readarrStore.error;
readarrSaving = this.readarrStore.saving;
/**
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.globalForm?.dirty || !this.hasGlobalChanges;
}
constructor() {
// Initialize forms
this.globalForm = this.formBuilder.group({
failedImportMaxStrikes: [-1],
});
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
apiKey: ['', Validators.required],
});
// Load Readarr config data
this.readarrStore.loadConfig();
// Setup effect to update form when config changes
effect(() => {
const config = this.readarrConfig();
if (config) {
this.updateGlobalFormFromConfig(config);
}
});
// Track global form changes
this.globalForm.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
}
/**
* Clean up subscriptions when component is destroyed
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Update global form with values from the configuration
*/
private updateGlobalFormFromConfig(config: ReadarrConfig): void {
this.globalForm.patchValue({
failedImportMaxStrikes: config.failedImportMaxStrikes,
});
// Store original values for dirty checking
this.storeOriginalGlobalValues();
}
/**
* Store original global form values for dirty checking
*/
private storeOriginalGlobalValues(): void {
this.originalGlobalValues = JSON.parse(JSON.stringify(this.globalForm.value));
this.globalForm.markAsPristine();
this.hasGlobalChanges = false;
}
/**
* Check if the current global form values are different from the original values
*/
private globalFormValuesChanged(): boolean {
return !this.isEqual(this.globalForm.value, this.originalGlobalValues);
}
/**
* Deep compare two objects for equality
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 == null || obj2 == null) {
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
const val1 = obj1[key];
const val2 = obj2[key];
const areObjects = typeof val1 === "object" && typeof val2 === "object";
if ((areObjects && !this.isEqual(val1, val2)) || (!areObjects && val1 !== val2)) {
return false;
}
}
return true;
}
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
/**
* Mark all controls in a form group as touched
*/
private markFormGroupTouched(formGroup: FormGroup): void {
Object.values(formGroup.controls).forEach((control) => {
control.markAsTouched();
if ((control as any).controls) {
this.markFormGroupTouched(control as FormGroup);
}
});
}
/**
* Check if a form control has an error
*/
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
const control = form.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
}
/**
* Save the global Readarr configuration
*/
saveGlobalConfig(): void {
this.markFormGroupTouched(this.globalForm);
if (this.globalForm.invalid) {
this.notificationService.showError('Please fix the validation errors before saving');
return;
}
if (!this.hasGlobalChanges) {
this.notificationService.showSuccess('No changes detected');
return;
}
const updatedConfig = {
failedImportMaxStrikes: this.globalForm.get('failedImportMaxStrikes')?.value
};
this.readarrStore.saveConfig(updatedConfig);
// Monitor saving completion
this.monitorGlobalSaving();
}
/**
* Monitor global saving completion
*/
private monitorGlobalSaving(): void {
const checkSavingStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Save failed: ${error}`);
this.error.emit(error);
} else {
this.notificationService.showSuccess('Global configuration saved successfully');
this.saved.emit();
// Reset form state without reloading from backend
this.globalForm.markAsPristine();
this.hasGlobalChanges = false;
this.storeOriginalGlobalValues();
}
} else {
setTimeout(checkSavingStatus, 100);
}
};
setTimeout(checkSavingStatus, 100);
}
/**
* Get instances from current config
*/
get instances(): ArrInstance[] {
return this.readarrConfig()?.instances || [];
}
/**
* Open modal to add new instance
*/
openAddInstanceModal(): void {
this.modalMode = 'add';
this.editingInstance = null;
this.instanceForm.reset({
enabled: true,
name: '',
url: '',
apiKey: ''
});
this.showInstanceModal = true;
}
/**
* Open modal to edit existing instance
*/
openEditInstanceModal(instance: ArrInstance): void {
this.modalMode = 'edit';
this.editingInstance = instance;
this.instanceForm.patchValue({
enabled: instance.enabled,
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
});
this.showInstanceModal = true;
}
/**
* Close instance modal
*/
closeInstanceModal(): void {
this.showInstanceModal = false;
this.editingInstance = null;
this.instanceForm.reset();
}
/**
* Save instance (add or edit)
*/
saveInstance(): void {
this.markFormGroupTouched(this.instanceForm);
if (this.instanceForm.invalid) {
this.notificationService.showError('Please fix the validation errors before saving');
return;
}
const instanceData: CreateArrInstanceDto = {
enabled: this.instanceForm.get('enabled')?.value,
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
if (this.modalMode === 'add') {
this.readarrStore.createInstance(instanceData);
} else if (this.editingInstance) {
this.readarrStore.updateInstance({
id: this.editingInstance.id!,
instance: instanceData
});
}
this.monitorInstanceSaving();
}
/**
* Monitor instance saving completion
*/
private monitorInstanceSaving(): void {
const checkSavingStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Operation failed: ${error}`);
} else {
const action = this.modalMode === 'add' ? 'created' : 'updated';
this.notificationService.showSuccess(`Instance ${action} successfully`);
this.closeInstanceModal();
}
} else {
setTimeout(checkSavingStatus, 100);
}
};
setTimeout(checkSavingStatus, 100);
}
/**
* Delete instance with confirmation
*/
deleteInstance(instance: ArrInstance): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete the instance "${instance.name}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.readarrStore.deleteInstance(instance.id!);
// Monitor deletion
const checkDeletionStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Deletion failed: ${error}`);
} else {
this.notificationService.showSuccess('Instance deleted successfully');
}
} else {
setTimeout(checkDeletionStatus, 100);
}
};
setTimeout(checkDeletionStatus, 100);
}
});
}
/**
* Get modal title based on mode
*/
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Readarr Instance' : 'Edit Readarr Instance';
}
}

View File

@@ -41,4 +41,5 @@ export interface ContentBlockerConfig {
sonarr: BlocklistSettings;
radarr: BlocklistSettings;
lidarr: BlocklistSettings;
readarr: BlocklistSettings;
}

View File

@@ -0,0 +1,14 @@
/**
* ReadarrConfig model definitions for the UI
* These models represent the structures used in the API for Readarr configuration
*/
import { ArrInstance } from "./arr-config.model";
/**
* Main ReadarrConfig model representing the configuration for Readarr integration
*/
export interface ReadarrConfig {
failedImportMaxStrikes: number;
instances: ArrInstance[];
}

View File

@@ -18,7 +18,7 @@ Cleanuparr integrates with popular *arr applications and download clients for co
| **Category** | **Application** | **Integration** |
|--------------|-----------------|-----------------|
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr, Whisparr | Full API integration for queue monitoring and search triggers |
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr | Full API integration for queue monitoring and search triggers |
| **Download Clients** | qBittorrent, Deluge, Transmission | Complete download management and monitoring |
</div>

View File

@@ -207,7 +207,7 @@ function IntegrationsSection() {
{ name: "Sonarr", icon: "📺", color: "#3578e5" },
{ name: "Radarr", icon: "🎬", color: "#ffc107" },
{ name: "Lidarr", icon: "🎵", color: "#28a745" },
//{ name: "Readarr", icon: "📚", color: "#6f42c1" },
{ name: "Readarr", icon: "📚", color: "#6f42c1" },
//{ name: "Whisparr", icon: "🔞", color: "#dc3545" },
{ name: "qBittorrent", icon: "⬇️", color: "#17a2b8" },
{ name: "Deluge", icon: "🌊", color: "#fd7e14" },