From fb6ccfd011e7ae7f2ec2a6c0a32578d0cbb54465 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 29 Jun 2025 19:54:15 +0300 Subject: [PATCH] Add Readarr support (#191) --- .../Controllers/ConfigurationController.cs | 159 +++++ .../Controllers/StatusController.cs | 8 + .../DependencyInjection/ApiDI.cs | 3 - .../DependencyInjection/ServicesDI.cs | 1 + .../Arr/Dtos/UpdateReadarrConfigDto.cs | 9 + .../Features/ContentBlocker/ContentBlocker.cs | 9 +- .../DownloadCleaner/DownloadCleaner.cs | 2 + .../Features/QueueCleaner/QueueCleaner.cs | 3 + .../Entities/Arr/Queue/Image.cs | 2 +- .../Entities/Arr/Queue/LidarrImage.cs | 2 +- .../Entities/Arr/Queue/QueueAlbum.cs | 2 +- .../Entities/Arr/Queue/QueueBook.cs | 6 + .../Entities/Arr/Queue/QueueListResponse.cs | 4 +- .../Entities/Arr/Queue/QueueMovie.cs | 2 +- .../Entities/Arr/Queue/QueueRecord.cs | 11 +- .../Entities/Arr/Queue/QueueSeries.cs | 2 +- .../Entities/Arr/Queue/ReadarrImage.cs | 8 + .../Entities/Readarr/Author.cs | 8 + .../Entities/Readarr/Book.cs | 12 + .../Entities/Readarr/ReadarrCommand.cs | 8 + .../Features/Arr/ArrClient.cs | 1 + .../Features/Arr/ArrClientFactory.cs | 6 +- .../Features/Arr/ArrQueueIterator.cs | 3 +- .../Features/Arr/Interfaces/IArrClient.cs | 3 +- .../Features/Arr/Interfaces/IReadarrClient.cs | 5 + .../Features/Arr/LidarrClient.cs | 1 + .../Features/Arr/RadarrClient.cs | 1 + .../Features/Arr/ReadarrClient.cs | 152 +++++ .../Features/Arr/SonarrClient.cs | 1 + .../ContentBlocker/BlocklistProvider.cs | 11 + .../Models/QueueItemRemoveRequest.cs | 3 +- .../DownloadRemover/QueueItemRemover.cs | 3 +- .../Features/Jobs/GenericHandler.cs | 70 +- .../Notifications/NotificationPublisher.cs | 2 + .../Logging/LoggingInitializer.cs | 52 -- .../Cleanuparr.Persistence/DataContext.cs | 4 + .../20250628231105_AddReadarr.Designer.cs | 620 ++++++++++++++++++ .../Data/20250628231105_AddReadarr.cs | 57 ++ .../Data/DataContextModelSnapshot.cs | 18 + .../ContentBlocker/ContentBlockerConfig.cs | 3 + code/frontend/src/app/app.routes.ts | 1 + .../core/services/configuration.service.ts | 62 ++ .../sidebar-content.component.html | 6 + .../content-blocker-settings.component.html | 70 ++ .../content-blocker-settings.component.ts | 20 +- .../settings/readarr/readarr-config.store.ts | 365 +++++++++++ .../readarr/readarr-settings.component.html | 230 +++++++ .../readarr/readarr-settings.component.scss | 5 + .../readarr/readarr-settings.component.ts | 415 ++++++++++++ .../models/content-blocker-config.model.ts | 1 + .../app/shared/models/readarr-config.model.ts | 14 + docs/docs/3_supported-apps.mdx | 2 +- docs/src/pages/index.tsx | 2 +- 53 files changed, 2337 insertions(+), 133 deletions(-) create mode 100644 code/backend/Cleanuparr.Application/Features/Arr/Dtos/UpdateReadarrConfigDto.cs create mode 100644 code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueBook.cs create mode 100644 code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs create mode 100644 code/backend/Cleanuparr.Domain/Entities/Readarr/Author.cs create mode 100644 code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs create mode 100644 code/backend/Cleanuparr.Domain/Entities/Readarr/ReadarrCommand.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IReadarrClient.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Logging/LoggingInitializer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.cs create mode 100644 code/frontend/src/app/settings/readarr/readarr-config.store.ts create mode 100644 code/frontend/src/app/settings/readarr/readarr-settings.component.html create mode 100644 code/frontend/src/app/settings/readarr/readarr-settings.component.scss create mode 100644 code/frontend/src/app/settings/readarr/readarr-settings.component.ts create mode 100644 code/frontend/src/app/shared/models/readarr-config.model.ts diff --git a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs index 836324cb..0d396898 100644 --- a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs @@ -309,6 +309,24 @@ public class ConfigurationController : ControllerBase } } + [HttpGet("readarr")] + public async Task 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()); + } + finally + { + DataContext.Lock.Release(); + } + } + [HttpGet("notifications")] public async Task GetNotificationsConfig() { @@ -773,6 +791,37 @@ public class ConfigurationController : ControllerBase DataContext.Lock.Release(); } } + + [HttpPut("readarr")] + public async Task 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(); + } + } /// /// 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 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()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Readarr instance"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("readarr/instances/{id}")] + public async Task 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()); + } + 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 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(); + } + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Controllers/StatusController.cs b/code/backend/Cleanuparr.Api/Controllers/StatusController.cs index 5f93ef9b..b79348f4 100644 --- a/code/backend/Cleanuparr.Api/Controllers/StatusController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/StatusController.cs @@ -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 } } }; diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs index 2829d924..507ae924 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs @@ -40,9 +40,6 @@ public static class ApiDI // Add health status broadcaster services.AddHostedService(); - // Add logging initializer service - services.AddHostedService(); - services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index 809943ee..12652185 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -37,6 +37,7 @@ public static class ServicesDI .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() diff --git a/code/backend/Cleanuparr.Application/Features/Arr/Dtos/UpdateReadarrConfigDto.cs b/code/backend/Cleanuparr.Application/Features/Arr/Dtos/UpdateReadarrConfigDto.cs new file mode 100644 index 00000000..b64662db --- /dev/null +++ b/code/backend/Cleanuparr.Application/Features/Arr/Dtos/UpdateReadarrConfigDto.cs @@ -0,0 +1,9 @@ +namespace Cleanuparr.Application.Features.Arr.Dtos; + +/// +/// DTO for updating Readarr configuration basic settings (instances managed separately) +/// +public record UpdateReadarrConfigDto +{ + public short FailedImportMaxStrikes { get; init; } = -1; +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Application/Features/ContentBlocker/ContentBlocker.cs b/code/backend/Cleanuparr.Application/Features/ContentBlocker/ContentBlocker.cs index e82edcfe..32c03f20 100644 --- a/code/backend/Cleanuparr.Application/Features/ContentBlocker/ContentBlocker.cs +++ b/code/backend/Cleanuparr.Application/Features/ContentBlocker/ContentBlocker.cs @@ -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(nameof(InstanceType.Sonarr)); var radarrConfig = ContextProvider.Get(nameof(InstanceType.Radarr)); var lidarrConfig = ContextProvider.Get(nameof(InstanceType.Lidarr)); + var readarrConfig = ContextProvider.Get(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) diff --git a/code/backend/Cleanuparr.Application/Features/DownloadCleaner/DownloadCleaner.cs b/code/backend/Cleanuparr.Application/Features/DownloadCleaner/DownloadCleaner.cs index 857f429a..4608d463 100644 --- a/code/backend/Cleanuparr.Application/Features/DownloadCleaner/DownloadCleaner.cs +++ b/code/backend/Cleanuparr.Application/Features/DownloadCleaner/DownloadCleaner.cs @@ -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(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Radarr)), InstanceType.Radarr, true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true); + await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Readarr)), InstanceType.Readarr, true); if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0) { diff --git a/code/backend/Cleanuparr.Application/Features/QueueCleaner/QueueCleaner.cs b/code/backend/Cleanuparr.Application/Features/QueueCleaner/QueueCleaner.cs index 17b9f272..3824302d 100644 --- a/code/backend/Cleanuparr.Application/Features/QueueCleaner/QueueCleaner.cs +++ b/code/backend/Cleanuparr.Application/Features/QueueCleaner/QueueCleaner.cs @@ -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(nameof(InstanceType.Sonarr)); var radarrConfig = ContextProvider.Get(nameof(InstanceType.Radarr)); var lidarrConfig = ContextProvider.Get(nameof(InstanceType.Lidarr)); + var readarrConfig = ContextProvider.Get(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) diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs index fb141d47..073c9877 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/Image.cs @@ -1,4 +1,4 @@ -namespace Data.Models.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; public record Image { diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs index 4ce747bc..f8f9938c 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/LidarrImage.cs @@ -1,4 +1,4 @@ -namespace Data.Models.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; public record LidarrImage { diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueAlbum.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueAlbum.cs index 036311cd..f9d63616 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueAlbum.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueAlbum.cs @@ -1,4 +1,4 @@ -namespace Data.Models.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; public sealed record QueueAlbum { diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueBook.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueBook.cs new file mode 100644 index 00000000..8d6b86c9 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueBook.cs @@ -0,0 +1,6 @@ +namespace Cleanuparr.Domain.Entities.Arr.Queue; + +public sealed record QueueBook +{ + public List Images { get; init; } = []; +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs index 0ae08e4b..31e5732d 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueListResponse.cs @@ -1,4 +1,6 @@ -namespace Data.Models.Arr.Queue; +using Data.Models.Arr.Queue; + +namespace Cleanuparr.Domain.Entities.Arr.Queue; public record QueueListResponse { diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueMovie.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueMovie.cs index 72cce6f4..dfb826ff 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueMovie.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueMovie.cs @@ -1,4 +1,4 @@ -namespace Data.Models.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; public sealed record QueueMovie { diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs index 95bdef28..ca76a78c 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs @@ -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; } diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueSeries.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueSeries.cs index dc453b69..67c83e62 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueSeries.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueSeries.cs @@ -1,4 +1,4 @@ -namespace Data.Models.Arr.Queue; +namespace Cleanuparr.Domain.Entities.Arr.Queue; public sealed record QueueSeries { diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs new file mode 100644 index 00000000..bc15d0f9 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/ReadarrImage.cs @@ -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; } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Readarr/Author.cs b/code/backend/Cleanuparr.Domain/Entities/Readarr/Author.cs new file mode 100644 index 00000000..0044edb8 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Readarr/Author.cs @@ -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; +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs b/code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs new file mode 100644 index 00000000..0f9f25da --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Readarr/Book.cs @@ -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(); +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Readarr/ReadarrCommand.cs b/code/backend/Cleanuparr.Domain/Entities/Readarr/ReadarrCommand.cs new file mode 100644 index 00000000..7978858e --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Readarr/ReadarrCommand.cs @@ -0,0 +1,8 @@ +namespace Cleanuparr.Domain.Entities.Readarr; + +public sealed record ReadarrCommand +{ + public string Name { get; set; } = string.Empty; + + public List BookIds { get; set; } = []; +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs index 78a52713..71241753 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs index e0b1c823..7f67577c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClientFactory.cs @@ -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") }; } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs index 6e1580bc..ee282bc0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrQueueIterator.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs index b2385489..5a16ee6a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IReadarrClient.cs new file mode 100644 index 00000000..4578f462 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IReadarrClient.cs @@ -0,0 +1,5 @@ +namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces; + +public interface IReadarrClient : IArrClient +{ +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs index b810001e..1bf06f70 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs index cc72c039..09e547d7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs new file mode 100644 index 00000000..91b23c0e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs @@ -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 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? items) + { + if (items?.Count is null or 0) + { + return; + } + + List 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(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 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 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(responseBody); + } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index ed5ac911..70be33b8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs index af225b48..7634ebea 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/ContentBlocker/BlocklistProvider.cs @@ -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); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs index 05a7a9b5..2860ff12 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index e0961efd..a3b93614 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs index ac972997..4a427ba0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs @@ -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; } - // /// - // /// Initialize download services based on configuration - // /// - // protected async Task> 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 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") }; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index 6f70ea70..41fcd1db 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -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)) }; diff --git a/code/backend/Cleanuparr.Infrastructure/Logging/LoggingInitializer.cs b/code/backend/Cleanuparr.Infrastructure/Logging/LoggingInitializer.cs deleted file mode 100644 index 54b675b8..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Logging/LoggingInitializer.cs +++ /dev/null @@ -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 _logger; - private readonly EventPublisher _eventPublisher; - private readonly Random random = new(); - - public LoggingInitializer(ILogger 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); - } - } -} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index b9be3ae9..e3928011 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -77,6 +77,10 @@ public class DataContext : DbContext { cp.Property(s => s.BlocklistType).HasConversion>(); }); + entity.ComplexProperty(e => e.Readarr, cp => + { + cp.Property(s => s.BlocklistType).HasConversion>(); + }); }); // Configure ArrConfig -> ArrInstance relationship diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs new file mode 100644 index 00000000..38f1e647 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.Designer.cs @@ -0,0 +1,620 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.Property("UnlinkedIgnoredRootDir") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dir"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("LogLevel") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.PrimitiveCollection("IgnoredPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_ignored_patterns"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + }); + + b.ComplexProperty>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_delete_private"); + + b1.Property("IgnoreAboveSize") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_ignore_above_size"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("slow_max_strikes"); + + b1.Property("MaxTime") + .HasColumnType("REAL") + .HasColumnName("slow_max_time"); + + b1.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_min_speed"); + + b1.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("slow_reset_strikes_on_progress"); + }); + + b.ComplexProperty>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_delete_private"); + + b1.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_downloading_metadata_max_strikes"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_max_strikes"); + + b1.Property("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 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.cs new file mode 100644 index 00000000..f184279d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250628231105_AddReadarr.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddReadarr : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "readarr_blocklist_path", + table: "content_blocker_configs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "readarr_blocklist_type", + table: "content_blocker_configs", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + 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 = ''"); + } + + /// + 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"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 6f86f673..7f9ecd9b 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -143,6 +143,24 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnName("radarr_enabled"); }); + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => { b1.IsRequired(); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/ContentBlocker/ContentBlockerConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/ContentBlocker/ContentBlockerConfig.cs index 686fc283..4d162180 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/ContentBlocker/ContentBlockerConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/ContentBlocker/ContentBlockerConfig.cs @@ -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) diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index 034c1b36..bfe5fd0f 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -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) }, ]; diff --git a/code/frontend/src/app/core/services/configuration.service.ts b/code/frontend/src/app/core/services/configuration.service.ts index 44e85d8e..9e20a75a 100644 --- a/code/frontend/src/app/core/services/configuration.service.ts +++ b/code/frontend/src/app/core/services/configuration.service.ts @@ -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 { + return this.http.get(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 { + return this.http.put(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 { + return this.http.post(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 { + return this.http.put(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 { + return this.http.delete(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}`)); + }) + ); + } } diff --git a/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.html b/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.html index 472d1fdb..b948d01f 100644 --- a/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.html +++ b/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.html @@ -47,6 +47,12 @@ Lidarr + + + Readarr + + + + + + + @if (active) { + + } @else { + + } + + Readarr Settings + + +
+
+ +
+ + When enabled, the Readarr blocklist will be used for content filtering +
+
+ +
+ + +
+ +
+ Path is required when Readarr blocklist is enabled + Path to the blocklist file or URL +
+
+ +
+ +
+ + Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches) +
+
+
+
+
diff --git a/code/frontend/src/app/settings/content-blocker/content-blocker-settings.component.ts b/code/frontend/src/app/settings/content-blocker/content-blocker-settings.component.ts index 661508ac..351a9643 100644 --- a/code/frontend/src/app/settings/content-blocker/content-blocker-settings.component.ts +++ b/code/frontend/src/app/settings/content-blocker/content-blocker-settings.component.ts @@ -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 diff --git a/code/frontend/src/app/settings/readarr/readarr-config.store.ts b/code/frontend/src/app/settings/readarr/readarr-config.store.ts new file mode 100644 index 00000000..b50f2b35 --- /dev/null +++ b/code/frontend/src/app/settings/readarr/readarr-config.store.ts @@ -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( + 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( + (config$: Observable) => 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) { + 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( + (instance$: Observable) => 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( + (id$: Observable) => 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(); + } + }) +) {} \ No newline at end of file diff --git a/code/frontend/src/app/settings/readarr/readarr-settings.component.html b/code/frontend/src/app/settings/readarr/readarr-settings.component.html new file mode 100644 index 00000000..f0f6e98b --- /dev/null +++ b/code/frontend/src/app/settings/readarr/readarr-settings.component.html @@ -0,0 +1,230 @@ +
+
+

Readarr

+
+ + +
+ +
+ + +
+ + + + +
+
+

Readarr Settings

+ Configure general Readarr integration settings +
+
+
+ +
+
+ +
+
+ +
+ Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable) +
+
+ + + +
+
+ + + + +
+
+

Instances

+ Manage Readarr server instances +
+
+
+ + +
+ +

No Readarr instances configured

+ Add an instance to start using Readarr integration +
+ + +
+
+
+
+ + {{ instance.name }} +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + +
+
+
+ + + +
+
+ +
+ + Enable this Readarr instance +
+
+ +
+ + + Name is required +
+ +
+ + + URL is required + URL must be a valid URL + URL must use http or https protocol +
+ +
+ + + API key is required +
+
+ + + + +
+ + + \ No newline at end of file diff --git a/code/frontend/src/app/settings/readarr/readarr-settings.component.scss b/code/frontend/src/app/settings/readarr/readarr-settings.component.scss new file mode 100644 index 00000000..4fe95948 --- /dev/null +++ b/code/frontend/src/app/settings/readarr/readarr-settings.component.scss @@ -0,0 +1,5 @@ +/* Readarr Settings Styles */ + +@use '../styles/settings-shared.scss'; +@use '../styles/arr-shared.scss'; +@use '../settings-page/settings-page.component.scss'; \ No newline at end of file diff --git a/code/frontend/src/app/settings/readarr/readarr-settings.component.ts b/code/frontend/src/app/settings/readarr/readarr-settings.component.ts new file mode 100644 index 00000000..21d7a5f0 --- /dev/null +++ b/code/frontend/src/app/settings/readarr/readarr-settings.component.ts @@ -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(); + @Output() error = new EventEmitter(); + + // 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(); + + // 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'; + } +} \ No newline at end of file diff --git a/code/frontend/src/app/shared/models/content-blocker-config.model.ts b/code/frontend/src/app/shared/models/content-blocker-config.model.ts index ffba3227..017162bf 100644 --- a/code/frontend/src/app/shared/models/content-blocker-config.model.ts +++ b/code/frontend/src/app/shared/models/content-blocker-config.model.ts @@ -41,4 +41,5 @@ export interface ContentBlockerConfig { sonarr: BlocklistSettings; radarr: BlocklistSettings; lidarr: BlocklistSettings; + readarr: BlocklistSettings; } diff --git a/code/frontend/src/app/shared/models/readarr-config.model.ts b/code/frontend/src/app/shared/models/readarr-config.model.ts new file mode 100644 index 00000000..45acd1ed --- /dev/null +++ b/code/frontend/src/app/shared/models/readarr-config.model.ts @@ -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[]; +} \ No newline at end of file diff --git a/docs/docs/3_supported-apps.mdx b/docs/docs/3_supported-apps.mdx index adbaf8b5..332c1b4c 100644 --- a/docs/docs/3_supported-apps.mdx +++ b/docs/docs/3_supported-apps.mdx @@ -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 | diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 53dedc1f..71407543 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -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" },