Compare commits

...

19 Commits

Author SHA1 Message Date
Flaminel
88f40438af Fix validations and increased strikes limits (#212) 2025-07-01 13:18:50 +03:00
Flaminel
0a9ec06841 removed forgotten release step from MacOS workflow 2025-07-01 11:05:00 +03:00
Flaminel
a0ca6ec4b8 Add curl to the Docker image (#211) 2025-07-01 10:06:22 +03:00
Flaminel
eb6cf96470 Fix cron expression inputs (#203) 2025-07-01 01:00:43 +03:00
Flaminel
2ca0616771 Add date on dashboard logs and events (#205) 2025-07-01 01:00:30 +03:00
Flaminel
bc85144e60 Improve deploy workflows (#206) 2025-07-01 01:00:16 +03:00
Flaminel
236e31c841 Add download client name on debug logs (#207) 2025-07-01 00:59:52 +03:00
Flaminel
7a15139aa6 Fix autocomplete input on mobile phones (#196) 2025-06-30 13:28:14 +03:00
Flaminel
fb6ccfd011 Add Readarr support (#191) 2025-06-29 19:54:15 +03:00
Flaminel
ef85e2b690 Fix docs broken links (#190) 2025-06-29 01:03:24 +03:00
Flaminel
bb734230aa Add health checks (#181) 2025-06-29 00:00:55 +03:00
Flaminel
aa31c31955 Remove right-side icons from settings cards (#183) 2025-06-29 00:00:25 +03:00
Flaminel
1a89822f36 Change icon direction for UI accordions (#182) 2025-06-29 00:00:11 +03:00
Flaminel
fc9e0eca36 Fix some small UI stuff (#185) 2025-06-28 23:59:49 +03:00
Flaminel
0010dcb1c6 Fix jobs not being scheduled according to the cron expression (#187) 2025-06-28 23:55:08 +03:00
Flaminel
0ab8611f29 removed Docker Hub reference 2025-06-28 11:52:34 +03:00
Flaminel
9e02408a7e Fix download cleaner categories not being fetched (#177) 2025-06-28 00:08:58 +03:00
Flaminel
1bd0db05e6 updated readme 2025-06-27 21:32:22 +03:00
Flaminel
fb438f2ca7 Fix base paths being incorrectly configured for download clients (#173) 2025-06-27 19:44:46 +03:00
111 changed files with 3779 additions and 661 deletions

View File

@@ -134,22 +134,4 @@ jobs:
./artifacts/*.zip
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
id: release
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
fail_on_unmatched_files: true
target_commitish: main
generate_release_notes: true
files: |
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip
# Removed individual release step - handled by main release workflow

View File

@@ -363,14 +363,4 @@ jobs:
path: '${{ env.pkgName }}'
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}
# Removed individual release step - handled by main release workflow

View File

@@ -363,14 +363,4 @@ jobs:
path: '${{ env.pkgName }}'
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}
# Removed individual release step - handled by main release workflow

View File

@@ -88,19 +88,6 @@ jobs:
run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
- name: Create sample configuration
shell: pwsh
run: |
# Create config directory
New-Item -ItemType Directory -Force -Path "config"
$config = @{
"HTTP_PORTS" = 11011
"BASE_PATH" = "/"
}
$config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8
- name: Setup Inno Setup
shell: pwsh
run: |
@@ -158,14 +145,4 @@ jobs:
path: installer/${{ env.installerName }}
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
installer/${{ env.installerName }}
# Removed individual release step - handled by main release workflow

View File

@@ -12,34 +12,67 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> **Features:**
> - Strike system to mark bad downloads.
> - Remove and block downloads that reached a maximum number of strikes.
> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#failed-import-max-strikes)
> - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#stalled-max-strikes)
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/queue-cleaner#slow-max-strikes)
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/content-blocker)
> - Remove and block downloads that are **failing to be imported** by the arrs.
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
> - Automatically trigger a search for downloads removed from the arrs.
> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/download-cleaner#seeding-settings)
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/download-cleaner#enable-unlinked-downloads-management)
> - Notify on strike or download removal. [configuration](https://cleanuparr.github.io/Cleanuparr/docs/configuration/notifications)
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
Cleanuparr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
## 🎯 Supported Applications
## Quick Start
### *Arr Applications
- **Sonarr** (TV Shows)
- **Radarr** (Movies)
- **Lidarr** (Music)
> [!NOTE]
>
> 1. **Docker (Recommended)**
> Pull the Docker image from `ghcr.io/cleanuparr/cleanuparr:latest`.
>
> 2. **Unraid (for Unraid users)**
> Use the Unraid Community App.
>
> 3. **Manual Installation (if you're not using Docker)**
> Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
# Docs
### Platforms
- **Docker** (Linux, Windows, macOS)
- **Windows** (Native installer)
- **macOS** (Intel & Apple Silicon)
- **Linux** (Portable executable)
- **Unraid** (Community Apps)
Docs can be found [here](https://cleanuparr.github.io/Cleanuparr/).
## 🚀 Quick Start
```bash
docker run -d --name cleanuparr \
--restart unless-stopped \
-p 11011:11011 \
-v /path/to/config:/config \
-e PORT=11011 \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface
After installation, open your browser and navigate to:
```
http://localhost:11011
```
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
## 📖 Documentation & Support
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>

View File

@@ -45,6 +45,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management and timezone support
RUN apt-get update && apt-get install -y \
curl \
tzdata \
gosu \
&& rm -rf /var/lib/apt/lists/*

View File

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

View File

@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Health check endpoints for Docker and Kubernetes
/// </summary>
[ApiController]
[Route("[controller]")]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
private readonly ILogger<HealthController> _logger;
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger)
{
_healthCheckService = healthCheckService;
_logger = logger;
}
/// <summary>
/// Basic liveness probe - checks if the application is running
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
/// </summary>
[HttpGet]
[Route("/health")]
public async Task<IActionResult> GetHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("liveness"));
return result.Status == HealthStatus.Healthy
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed");
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Readiness probe - checks if the application is ready to serve traffic
/// Used by Kubernetes readiness probes
/// </summary>
[HttpGet]
[Route("/health/ready")]
public async Task<IActionResult> GetReadiness()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("readiness"));
if (result.Status == HealthStatus.Healthy)
{
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
}
// For readiness, we consider degraded as not ready
return StatusCode(503, new {
status = "not_ready",
timestamp = DateTime.UtcNow,
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
.ToDictionary(e => e.Key, e => new {
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Readiness check failed");
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Detailed health status - for monitoring and debugging
/// </summary>
[HttpGet]
[Route("/health/detailed")]
public async Task<IActionResult> GetDetailedHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync();
var response = new
{
status = result.Status.ToString().ToLowerInvariant(),
timestamp = DateTime.UtcNow,
totalDuration = result.TotalDuration.TotalMilliseconds,
entries = result.Entries.ToDictionary(
e => e.Key,
e => new
{
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
tags = e.Value.Tags,
data = e.Value.Data,
exception = e.Value.Exception?.Message
})
};
return result.Status == HealthStatus.Healthy
? Ok(response)
: StatusCode(503, response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Detailed health check failed");
return StatusCode(503, new {
status = "unhealthy",
error = "Detailed health check failed",
timestamp = DateTime.UtcNow
});
}
}
}

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Api.Models;
using Cleanuparr.Infrastructure.Models;
using Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;

View File

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

View File

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

View File

@@ -83,9 +83,17 @@ public static class MainDI
/// </summary>
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
services
// Register the health check service
// Register the existing health check service for download clients
.AddSingleton<IHealthCheckService, HealthCheckService>()
// Register the background service for periodic health checks
.AddHostedService<HealthCheckBackgroundService>();
.AddHostedService<HealthCheckBackgroundService>()
// Add ASP.NET Core health checks
.AddHealthChecks()
.AddCheck<ApplicationHealthCheck>("application", tags: ["liveness"])
.AddCheck<DatabaseHealthCheck>("database", tags: ["readiness"])
.AddCheck<FileSystemHealthCheck>("filesystem", tags: ["readiness"])
.AddCheck<DownloadClientsHealthCheck>("download_clients", tags: ["readiness"])
.Services;
}

View File

@@ -12,9 +12,9 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Infrastructure.Interceptors;
using Infrastructure.Services.Interfaces;
using Infrastructure.Verticals.Files;
namespace Cleanuparr.Api.DependencyInjection;
@@ -37,6 +37,7 @@ public static class ServicesDI
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ReadarrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text;
namespace Cleanuparr.Api;
/// <summary>
/// Custom health check response writers for different formats
/// </summary>
public static class HealthCheckResponseWriter
{
/// <summary>
/// Writes a minimal plain text response suitable for Docker health checks
/// </summary>
public static async Task WriteMinimalPlaintext(HttpContext context, HealthReport report)
{
context.Response.ContentType = "text/plain";
var status = report.Status switch
{
HealthStatus.Healthy => "healthy",
HealthStatus.Degraded => "degraded",
HealthStatus.Unhealthy => "unhealthy",
_ => "unknown"
};
await context.Response.WriteAsync(status, Encoding.UTF8);
}
}

View File

@@ -175,7 +175,7 @@ public class BackgroundJobManager : IHostedService
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
.WithIdentity("ValidationTrigger")
.StartNow()
.WithCronSchedule(cronExpression)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.Build();
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
@@ -197,26 +197,26 @@ public class BackgroundJobManager : IHostedService
}
}
// Create cron trigger
// Create main cron trigger with consistent naming (matches JobManagementService)
var trigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-trigger")
.ForJob(jobKey)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.StartNow()
.Build();
// Create startup trigger to run immediately
// Schedule the main trigger
await _scheduler.ScheduleJob(trigger, cancellationToken);
// Trigger immediate execution for startup using a one-time trigger
var startupTrigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-startup-trigger")
.WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.StartNow()
.Build();
// Schedule job with both triggers
await _scheduler.ScheduleJob(trigger, cancellationToken);
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
_logger.LogInformation("Added triggers for job {name} with cron expression {CronExpression}",
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
typeName, cronExpression);
}

View File

@@ -4,6 +4,8 @@ using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -147,6 +149,19 @@ logConfig.WriteTo.Sink(signalRSink);
Log.Logger = logConfig.CreateLogger();
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.ConfigureApi();
await app.RunAsync();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />

View File

@@ -1,6 +1,5 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Extensions;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -168,9 +168,7 @@ public sealed class DelugeClient
content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json");
UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/json"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/json";
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/json";
var responseMessage = await _httpClient.PostAsync(uriBuilder.Uri, content);
responseMessage.EnsureSuccessStatusCode();

View File

@@ -21,7 +21,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -52,7 +52,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}
if (contents is null)

View File

@@ -25,7 +25,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -44,7 +44,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}

View File

@@ -20,7 +20,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
return result;
}

View File

@@ -97,7 +97,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name);
_logger.LogDebug("failed to find torrent properties | {name}", download.Name);
return;
}

View File

@@ -19,7 +19,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -38,7 +38,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
return result;
}

View File

@@ -53,9 +53,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService
)
{
UriBuilder uriBuilder = new(_downloadClientConfig.Url);
uriBuilder.Path = string.IsNullOrEmpty(_downloadClientConfig.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/rpc"
: $"{uriBuilder.Path.TrimEnd('/')}/{_downloadClientConfig.UrlBase.TrimStart('/').TrimEnd('/')}/rpc";
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/rpc";
_client = new Client(
_httpClient,
uriBuilder.Uri.ToString(),

View File

@@ -19,7 +19,7 @@ public partial class TransmissionService
if (download?.FileStats is null || download.FileStats.Length == 0)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}

View File

@@ -22,7 +22,7 @@ public partial class TransmissionService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Basic application health check that verifies the application is running
/// </summary>
public class ApplicationHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
// Basic liveness check - if we can execute this, the app is running
return Task.FromResult(HealthCheckResult.Healthy("Application is running"));
}
}

View File

@@ -0,0 +1,50 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies database connectivity
/// </summary>
public class DatabaseHealthCheck : IHealthCheck
{
private readonly DataContext _dataContext;
private readonly ILogger<DatabaseHealthCheck> _logger;
public DatabaseHealthCheck(DataContext dataContext, ILogger<DatabaseHealthCheck> logger)
{
_dataContext = dataContext;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Try to execute a simple query to verify database connectivity
var canConnect = await _dataContext.Database.CanConnectAsync(cancellationToken);
if (!canConnect)
{
return HealthCheckResult.Unhealthy("Cannot connect to database");
}
// Optionally check if database schema is up to date
var pendingMigrations = await _dataContext.Database.GetPendingMigrationsAsync(cancellationToken);
if (pendingMigrations.Any())
{
_logger.LogWarning("Database has pending migrations: {migrations}", string.Join(", ", pendingMigrations));
return HealthCheckResult.Degraded($"Database has {pendingMigrations.Count()} pending migrations");
}
return HealthCheckResult.Healthy("Database connection successful");
}
catch (Exception ex)
{
_logger.LogError(ex, "Database health check failed");
return HealthCheckResult.Unhealthy("Database health check failed", ex);
}
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies download clients are healthy
/// </summary>
public class DownloadClientsHealthCheck : IHealthCheck
{
private readonly IHealthCheckService _healthCheckService;
private readonly ILogger<DownloadClientsHealthCheck> _logger;
public DownloadClientsHealthCheck(IHealthCheckService healthCheckService, ILogger<DownloadClientsHealthCheck> logger)
{
_healthCheckService = healthCheckService;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Get current health status of all clients without triggering new checks
var allClientHealth = _healthCheckService.GetAllClientHealth();
if (!allClientHealth.Any())
{
// No clients configured - this might be ok depending on the deployment
return HealthCheckResult.Healthy("No download clients configured");
}
var healthyClients = allClientHealth.Values.Where(h => h.IsHealthy).ToList();
var unhealthyClients = allClientHealth.Values.Where(h => !h.IsHealthy).ToList();
var totalClients = allClientHealth.Count;
if (unhealthyClients.Any())
{
var unhealthyNames = string.Join(", ", unhealthyClients.Select(c => c.ClientName));
var message = $"{unhealthyClients.Count}/{totalClients} download clients unhealthy: {unhealthyNames}";
// If more than half are unhealthy, consider it unhealthy
if (unhealthyClients.Count > totalClients / 2)
{
return HealthCheckResult.Unhealthy(message);
}
// Otherwise, it's degraded
return HealthCheckResult.Degraded(message);
}
return HealthCheckResult.Healthy($"All {totalClients} download clients are healthy");
}
catch (Exception ex)
{
_logger.LogError(ex, "Download clients health check failed");
return HealthCheckResult.Unhealthy("Download clients health check failed", ex);
}
}
}

View File

@@ -0,0 +1,76 @@
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies file system access to critical directories
/// </summary>
public class FileSystemHealthCheck : IHealthCheck
{
private readonly ILogger<FileSystemHealthCheck> _logger;
public FileSystemHealthCheck(ILogger<FileSystemHealthCheck> logger)
{
_logger = logger;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var issues = new List<string>();
// Check config directory access
var configPath = ConfigurationPathProvider.GetConfigPath();
if (!CheckDirectoryAccess(configPath, "config"))
{
issues.Add($"Cannot access config directory: {configPath}");
}
// Check current working directory
var currentDir = Directory.GetCurrentDirectory();
if (!CheckDirectoryAccess(currentDir, "working"))
{
issues.Add($"Cannot access working directory: {currentDir}");
}
if (issues.Any())
{
var message = $"File system issues detected: {string.Join(", ", issues)}";
return Task.FromResult(HealthCheckResult.Unhealthy(message));
}
return Task.FromResult(HealthCheckResult.Healthy("File system access verified"));
}
catch (Exception ex)
{
_logger.LogError(ex, "File system health check failed");
return Task.FromResult(HealthCheckResult.Unhealthy("File system health check failed", ex));
}
}
private bool CheckDirectoryAccess(string path, string description)
{
try
{
if (!Directory.Exists(path))
{
_logger.LogWarning("Directory does not exist: {path} ({description})", path, description);
return false;
}
// Try to enumerate directory contents
_ = Directory.GetFiles(path).Take(1).ToList();
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cannot access {description} directory: {path}", description, path);
return false;
}
}
}

View File

@@ -1,6 +1,5 @@
using System.Net;
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;

View File

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

View File

@@ -9,29 +9,3 @@ public enum JobType
ContentBlocker,
DownloadCleaner
}
/// <summary>
/// Extension methods for JobType enum
/// </summary>
public static class JobTypeExtensions
{
/// <summary>
/// Converts a JobType enum to its string representation
/// </summary>
/// <param name="jobType">The job type to convert</param>
/// <returns>String representation of the job type</returns>
public static string ToJobName(this JobType jobType) => jobType.ToString();
/// <summary>
/// Parses a string to JobType enum
/// </summary>
/// <param name="jobName">The job name to parse</param>
/// <returns>JobType if successful, null if parsing failed</returns>
public static JobType? TryParseJobType(string jobName)
{
if (string.IsNullOrEmpty(jobName))
return null;
return Enum.TryParse<JobType>(jobName, true, out var result) ? result : null;
}
}

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Infrastructure.Models;
using Quartz;
namespace Infrastructure.Services.Interfaces;
namespace Cleanuparr.Infrastructure.Services.Interfaces;
public interface IJobManagementService
{
@@ -8,7 +9,9 @@ public interface IJobManagementService
Task<bool> StopJob(JobType jobType);
Task<bool> PauseJob(JobType jobType);
Task<bool> ResumeJob(JobType jobType);
Task<IReadOnlyList<JobInfo>> GetAllJobs();
Task<bool> TriggerJobOnce(JobType jobType);
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
Task<JobInfo> GetJob(JobType jobType);
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);
Task<ITrigger?> GetMainTrigger(JobType jobType);
}

View File

@@ -1,10 +1,11 @@
using System.Collections.Concurrent;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Infrastructure.Services.Interfaces;
using Microsoft.Extensions.Logging;
using Quartz;
using Quartz.Impl.Matchers;
using Quartz.Spi;
namespace Cleanuparr.Infrastructure.Services;
@@ -22,7 +23,7 @@ public class JobManagementService : IJobManagementService
public async Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null)
{
string jobName = jobType.ToJobName();
string jobName = jobType.ToString();
string? cronExpression = null;
// Validate and set the cron expression
@@ -59,60 +60,48 @@ public class JobManagementService : IJobManagementService
// Check if job exists, create it if it doesn't
if (!await scheduler.CheckExists(jobKey))
{
_logger.LogInformation("Job {name} does not exist, creating it", jobName);
// Create the job based on its type
if (!await CreateJobIfNotExists(scheduler, jobType, jobKey))
{
_logger.LogError("Failed to create job {name}", jobName);
return false;
}
_logger.LogError("Job {name} does not exist in scheduler. " +
"Jobs should be created at startup by BackgroundJobManager.", jobName);
return false;
}
// Store the job key for later use
_jobKeys.TryAdd(jobName, jobKey);
// If cron expression is provided, update the trigger
// Clean up all existing triggers for this job first
await CleanupAllTriggersForJob(scheduler, jobKey);
// If cron expression is provided, create and schedule the main trigger
if (!string.IsNullOrEmpty(cronExpression))
{
var triggerKey = new TriggerKey($"{jobName}Trigger");
var existingTrigger = await scheduler.GetTrigger(triggerKey);
var triggerKey = new TriggerKey($"{jobName}-trigger");
var newTrigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.Build();
if (existingTrigger != null)
{
var newTrigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithCronSchedule(cronExpression)
.Build();
await scheduler.RescheduleJob(triggerKey, newTrigger);
}
else
{
var trigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithCronSchedule(cronExpression)
.Build();
await scheduler.ScheduleJob(trigger);
}
await scheduler.ScheduleJob(newTrigger);
// Compute next fire time for logging
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes((IOperableTrigger)newTrigger, null, 1);
_logger.LogInformation("Job {name} scheduled with cron expression '{cronExpression}', next run at {nextRunTime}",
jobName, cronExpression, nextFireTimes.FirstOrDefault().LocalDateTime);
// Optionally trigger immediate execution for startup
// await TriggerJobImmediately(scheduler, jobKey, "startup");
}
else
{
// If no trigger exists, create a simple one-time trigger
var triggers = await scheduler.GetTriggersOfJob(jobKey);
if (!triggers.Any())
{
var trigger = TriggerBuilder.Create()
.WithIdentity($"{jobName}Trigger")
.ForJob(jobKey)
.StartNow()
.Build();
await scheduler.ScheduleJob(trigger);
}
// If no cron expression, create a one-time trigger to run now
var oneTimeTrigger = TriggerBuilder.Create()
.WithIdentity($"{jobName}-onetime-trigger")
.ForJob(jobKey)
.StartNow()
.Build();
await scheduler.ScheduleJob(oneTimeTrigger);
_logger.LogInformation("Job {name} scheduled for immediate one-time execution", jobName);
}
// Resume the job if it's paused
@@ -128,21 +117,86 @@ public class JobManagementService : IJobManagementService
}
/// <summary>
/// Creates a job in the scheduler if it doesn't exist based on the job type.
/// Note: Since this is in the Infrastructure layer, we cannot directly reference Application layer job types.
/// Job creation is now handled at startup by BackgroundJobManager.
/// Cleans up all existing triggers for a job to ensure a clean state
/// </summary>
private Task<bool> CreateJobIfNotExists(IScheduler scheduler, JobType jobType, JobKey jobKey)
private async Task CleanupAllTriggersForJob(IScheduler scheduler, JobKey jobKey)
{
_logger.LogError("Job {jobName} of type {jobType} does not exist in scheduler. " +
"Jobs should be created at startup by BackgroundJobManager, regardless of enabled status.",
jobKey.Name, jobType);
return Task.FromResult(false);
try
{
var existingTriggers = await scheduler.GetTriggersOfJob(jobKey);
foreach (var trigger in existingTriggers)
{
await scheduler.UnscheduleJob(trigger.Key);
_logger.LogDebug("Removed existing trigger {triggerKey} for job {jobKey}",
trigger.Key.Name, jobKey.Name);
}
if (existingTriggers.Any())
{
_logger.LogDebug("Cleaned up {count} existing triggers for job {jobName}",
existingTriggers.Count, jobKey.Name);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error cleaning up triggers for job {jobName}", jobKey.Name);
}
}
/// <summary>
/// Triggers a job immediately with a one-time trigger
/// </summary>
private async Task TriggerJobImmediately(IScheduler scheduler, JobKey jobKey, string reason)
{
try
{
var immediateTrigger = TriggerBuilder.Create()
.WithIdentity($"{jobKey.Name}-immediate-{reason}-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.StartNow()
.Build();
await scheduler.ScheduleJob(immediateTrigger);
_logger.LogDebug("Triggered job {jobName} immediately for reason: {reason}", jobKey.Name, reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name);
}
}
/// <summary>
/// Gets the main scheduled trigger for a job (excludes one-time triggers)
/// </summary>
public async Task<ITrigger?> GetMainTrigger(JobType jobType)
{
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobName);
if (!await scheduler.CheckExists(jobKey))
{
return null;
}
// Look for the main trigger (follows our naming convention)
var mainTriggerKey = new TriggerKey($"{jobName}-trigger");
return await scheduler.GetTrigger(mainTriggerKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName);
return null;
}
}
public async Task<bool> StopJob(JobType jobType)
{
string jobName = jobType.ToJobName();
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -154,12 +208,8 @@ public class JobManagementService : IJobManagementService
return false;
}
// Unschedule all triggers for this job
var triggers = await scheduler.GetTriggersOfJob(jobKey);
foreach (var trigger in triggers)
{
await scheduler.UnscheduleJob(trigger.Key);
}
// Clean up all triggers for this job (reuse our centralized method)
await CleanupAllTriggersForJob(scheduler, jobKey);
_logger.LogInformation("Job {name} stopped successfully", jobName);
return true;
@@ -173,7 +223,7 @@ public class JobManagementService : IJobManagementService
public async Task<bool> PauseJob(JobType jobType)
{
string jobName = jobType.ToJobName();
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -198,7 +248,7 @@ public class JobManagementService : IJobManagementService
public async Task<bool> ResumeJob(JobType jobType)
{
string jobName = jobType.ToJobName();
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -221,11 +271,11 @@ public class JobManagementService : IJobManagementService
}
}
public async Task<IReadOnlyList<JobInfo>> GetAllJobs()
public async Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null)
{
try
{
var scheduler = await _schedulerFactory.GetScheduler();
scheduler ??= await _schedulerFactory.GetScheduler();
var result = new List<JobInfo>();
var jobGroups = await scheduler.GetJobGroupNames();
@@ -283,7 +333,7 @@ public class JobManagementService : IJobManagementService
public async Task<JobInfo> GetJob(JobType jobType)
{
string jobName = jobType.ToJobName();
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -339,12 +389,37 @@ public class JobManagementService : IJobManagementService
}
}
public async Task<bool> TriggerJobOnce(JobType jobType)
{
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobName);
if (!await scheduler.CheckExists(jobKey))
{
_logger.LogError("Job {name} does not exist", jobName);
return false;
}
await TriggerJobImmediately(scheduler, jobKey, "manual");
_logger.LogInformation("Job {name} triggered for one-time execution", jobName);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering job {jobName}", jobName);
return false;
}
}
public async Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
{
if (schedule == null)
throw new ArgumentNullException(nameof(schedule));
string jobName = jobType.ToJobName();
string jobName = jobType.ToString();
string cronExpression = schedule.ToCronExpression();
try
@@ -358,24 +433,18 @@ public class JobManagementService : IJobManagementService
return false;
}
var triggerKey = new TriggerKey($"{jobName}Trigger");
var existingTrigger = await scheduler.GetTrigger(triggerKey);
// Clean up all existing triggers for this job
await CleanupAllTriggersForJob(scheduler, jobKey);
// Create new trigger with consistent naming
var triggerKey = new TriggerKey($"{jobName}-trigger");
var newTrigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithSchedule(SimpleScheduleBuilder.RepeatSecondlyForever(10))
.WithCronSchedule(cronExpression)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.Build();
if (existingTrigger != null)
{
await scheduler.RescheduleJob(triggerKey, newTrigger);
}
else
{
await scheduler.ScheduleJob(newTrigger);
}
await scheduler.ScheduleJob(newTrigger);
_logger.LogInformation("Job {name} schedule updated successfully to {cronExpression}", jobName, cronExpression);
return true;

View File

@@ -35,7 +35,7 @@ public static class CronValidationHelper
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
.WithIdentity("ValidationTrigger")
.StartNow()
.WithCronSchedule(cronExpression)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.Build();
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export class ApplicationPathService {
*/
getDocumentationBaseUrl(): string {
if (isDevMode()) {
return 'http://localhost:3000';
return 'http://localhost:3000/Cleanuparr';
}
return 'https://cleanuparr.github.io/Cleanuparr';
@@ -59,7 +59,7 @@ export class ApplicationPathService {
*/
buildDocumentationUrl(section: string, fieldAnchor?: string): string {
const baseUrl = this.getDocumentationBaseUrl();
let url = `${baseUrl}/cleanuparr/docs/configuration/${section}`;
let url = `${baseUrl}/docs/configuration/${section}`;
if (fieldAnchor) {
url += `#${fieldAnchor}`;

View File

@@ -6,6 +6,7 @@ import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, Schedul
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model";
import { GeneralConfig } from "../../shared/models/general-config.model";
@@ -63,7 +64,10 @@ export class ConfigurationService {
* Update queue cleaner configuration
*/
updateQueueCleanerConfig(config: QueueCleanerConfig): Observable<QueueCleanerConfig> {
config.cronExpression = this.convertJobScheduleToCron(config.jobSchedule!);
// Generate cron expression if using basic scheduling
if (!config.useAdvancedScheduling && config.jobSchedule) {
config.cronExpression = this.convertJobScheduleToCron(config.jobSchedule);
}
return this.http.put<QueueCleanerConfig>(this.ApplicationPathService.buildApiUrl('/configuration/queue_cleaner'), config).pipe(
catchError((error) => {
console.error("Error updating queue cleaner config:", error);
@@ -112,32 +116,32 @@ export class ConfigurationService {
*/
private tryExtractJobScheduleFromCron(cronExpression: string): JobSchedule | undefined {
// Patterns we support:
// Seconds: */n * * ? * * *
// Minutes: 0 */n * ? * * *
// Hours: 0 0 */n ? * * *
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
// Hours: 0 0 */n ? * * * or 0 0 0/n ? * * * (Quartz.NET format)
try {
const parts = cronExpression.split(" ");
if (parts.length !== 7) return undefined;
// Every n seconds
if (parts[0].startsWith("*/") && parts[1] === "*") {
// Every n seconds - handle both */n and 0/n formats
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
const seconds = parseInt(parts[0].substring(2));
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
return { every: seconds, type: ScheduleUnit.Seconds };
}
}
// Every n minutes
if (parts[0] === "0" && parts[1].startsWith("*/")) {
// Every n minutes - handle both */n and 0/n formats
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
const minutes = parseInt(parts[1].substring(2));
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
return { every: minutes, type: ScheduleUnit.Minutes };
}
}
// Every n hours
if (parts[0] === "0" && parts[1] === "0" && parts[2].startsWith("*/")) {
// Every n hours - handle both */n and 0/n formats
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
const hours = parseInt(parts[2].substring(2));
if (!isNaN(hours) && hours > 0 && hours < 24) {
return { every: hours, type: ScheduleUnit.Hours };
@@ -155,31 +159,31 @@ export class ConfigurationService {
*/
private convertJobScheduleToCron(schedule: JobSchedule): string {
if (!schedule || schedule.every <= 0) {
return "0 0/5 * * * ?"; // Default: every 5 minutes
return "0 0/5 * * * ?"; // Default: every 5 minutes (Quartz.NET format)
}
switch (schedule.type) {
case ScheduleUnit.Seconds:
if (schedule.every < 60) {
return `*/${schedule.every} * * ? * * *`;
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
}
break;
case ScheduleUnit.Minutes:
if (schedule.every < 60) {
return `0 */${schedule.every} * ? * * *`;
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
}
break;
case ScheduleUnit.Hours:
if (schedule.every < 24) {
return `0 0 */${schedule.every} ? * * *`;
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
}
break;
}
// Fallback to default
return "0 0/5 * * * ?";
return "0 0/5 * * * ?"; // Default: every 5 minutes (Quartz.NET format)
}
/**
@@ -188,32 +192,32 @@ export class ConfigurationService {
*/
private tryExtractContentBlockerJobScheduleFromCron(cronExpression: string): ContentBlockerJobSchedule | undefined {
// Patterns we support:
// Seconds: */n * * ? * * *
// Minutes: 0 */n * ? * * *
// Hours: 0 0 */n ? * * *
// Seconds: */n * * ? * * * or 0/n * * ? * * * (Quartz.NET format)
// Minutes: 0 */n * ? * * * or 0 0/n * ? * * * (Quartz.NET format)
// Hours: 0 0 */n ? * * * or 0 0 0/n ? * * * (Quartz.NET format)
try {
const parts = cronExpression.split(" ");
if (parts.length !== 7) return undefined;
// Every n seconds
if (parts[0].startsWith("*/") && parts[1] === "*") {
// Every n seconds - handle both */n and 0/n formats
if ((parts[0].startsWith("*/") || parts[0].startsWith("0/")) && parts[1] === "*") {
const seconds = parseInt(parts[0].substring(2));
if (!isNaN(seconds) && seconds > 0 && seconds < 60) {
return { every: seconds, type: ContentBlockerScheduleUnit.Seconds };
}
}
// Every n minutes
if (parts[0] === "0" && parts[1].startsWith("*/")) {
// Every n minutes - handle both */n and 0/n formats
if (parts[0] === "0" && (parts[1].startsWith("*/") || parts[1].startsWith("0/"))) {
const minutes = parseInt(parts[1].substring(2));
if (!isNaN(minutes) && minutes > 0 && minutes < 60) {
return { every: minutes, type: ContentBlockerScheduleUnit.Minutes };
}
}
// Every n hours
if (parts[0] === "0" && parts[1] === "0" && parts[2].startsWith("*/")) {
// Every n hours - handle both */n and 0/n formats
if (parts[0] === "0" && parts[1] === "0" && (parts[2].startsWith("*/") || parts[2].startsWith("0/"))) {
const hours = parseInt(parts[2].substring(2));
if (!isNaN(hours) && hours > 0 && hours < 24) {
return { every: hours, type: ContentBlockerScheduleUnit.Hours };
@@ -231,31 +235,31 @@ export class ConfigurationService {
*/
private convertContentBlockerJobScheduleToCron(schedule: ContentBlockerJobSchedule): string {
if (!schedule || schedule.every <= 0) {
return "0 0/5 * * * ?"; // Default: every 5 minutes
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
switch (schedule.type) {
case ContentBlockerScheduleUnit.Seconds:
if (schedule.every < 60) {
return `*/${schedule.every} * * ? * * *`;
return `0/${schedule.every} * * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Minutes:
if (schedule.every < 60) {
return `0 */${schedule.every} * ? * * *`;
return `0 0/${schedule.every} * ? * * *`; // Quartz.NET format
}
break;
case ContentBlockerScheduleUnit.Hours:
if (schedule.every < 24) {
return `0 0 */${schedule.every} ? * * *`;
return `0 0 0/${schedule.every} ? * * *`; // Quartz.NET format
}
break;
}
// Fallback to default
return "0 0/5 * * * ?";
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
/**
@@ -327,6 +331,29 @@ export class ConfigurationService {
);
}
/**
* Get Readarr configuration
*/
getReadarrConfig(): Observable<ReadarrConfig> {
return this.http.get<ReadarrConfig>(this.ApplicationPathService.buildApiUrl('/configuration/readarr')).pipe(
catchError((error) => {
console.error("Error fetching Readarr config:", error);
return throwError(() => new Error("Failed to load Readarr configuration"));
})
);
}
/**
* Update Readarr configuration
*/
updateReadarrConfig(config: {failedImportMaxStrikes: number}): Observable<any> {
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/readarr'), config).pipe(
catchError((error) => {
console.error("Error updating Readarr config:", error);
return throwError(() => new Error(error.error?.error || "Failed to update Readarr configuration"));
})
);
}
/**
* Get Download Client configuration
*/
@@ -500,4 +527,42 @@ export class ConfigurationService {
})
);
}
// ===== READARR INSTANCE MANAGEMENT =====
/**
* Create a new Readarr instance
*/
createReadarrInstance(instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.post<ArrInstance>(this.ApplicationPathService.buildApiUrl('/configuration/readarr/instances'), instance).pipe(
catchError((error) => {
console.error("Error creating Readarr instance:", error);
return throwError(() => new Error(error.error?.error || "Failed to create Readarr instance"));
})
);
}
/**
* Update a Readarr instance by ID
*/
updateReadarrInstance(id: string, instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.put<ArrInstance>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`), instance).pipe(
catchError((error) => {
console.error(`Error updating Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to update Readarr instance with ID ${id}`));
})
);
}
/**
* Delete a Readarr instance by ID
*/
deleteReadarrInstance(id: string): Observable<void> {
return this.http.delete<void>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`)).pipe(
catchError((error) => {
console.error(`Error deleting Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to delete Readarr instance with ID ${id}`));
})
);
}
}

View File

@@ -46,7 +46,7 @@
<p-tag [severity]="getLogSeverity(log.level)" [value]="log.level"></p-tag>
<span class="text-xs text-color-secondary" *ngIf="log.category">{{log.category}}</span>
</div>
<span class="text-xs text-color-secondary">{{log.timestamp | date:'HH:mm:ss'}}</span>
<span class="text-xs text-color-secondary">{{ log.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
<div class="timeline-message"
[pTooltip]="log.message"
@@ -112,7 +112,7 @@
<p-tag [severity]="getEventSeverity(event.severity)" [value]="event.severity"></p-tag>
<span class="text-xs text-color-secondary">{{formatEventType(event.eventType)}}</span>
</div>
<span class="text-xs text-color-secondary">{{event.timestamp | date:'HH:mm:ss'}}</span>
<span class="text-xs text-color-secondary">{{event.timestamp | date: 'yyyy-MM-dd HH:mm:ss'}}</span>
</div>
<div class="timeline-message"
[pTooltip]="event.message"

View File

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

View File

@@ -122,22 +122,22 @@ export class ContentBlockerConfigStore extends signalStore(
*/
generateCronExpression(schedule: JobSchedule): string {
if (!schedule) {
return "0/5 * * * * ?"; // Default: every 5 seconds
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
// Cron format: Seconds Minutes Hours Day-of-month Month Day-of-week Year
switch (schedule.type) {
case ScheduleUnit.Seconds:
return `0/${schedule.every} * * ? * * *`; // Every n seconds
return `0/${schedule.every} * * ? * * *`; // Every n seconds (Quartz.NET format)
case ScheduleUnit.Minutes:
return `0 0/${schedule.every} * ? * * *`; // Every n minutes
return `0 0/${schedule.every} * ? * * *`; // Every n minutes (Quartz.NET format)
case ScheduleUnit.Hours:
return `0 0 0/${schedule.every} ? * * *`; // Every n hours
return `0 0 0/${schedule.every} ? * * *`; // Every n hours (Quartz.NET format)
default:
return "0/5 * * * * ?"; // Default: every 5 seconds
return "0/5 * * * * ?"; // Default: every 5 seconds (Quartz.NET format)
}
}
})),

View File

@@ -7,9 +7,6 @@
<h2 class="card-title m-0">Content Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-ban text-xl"></i>
</div>
</div>
</ng-template>
@@ -104,7 +101,7 @@
<div class="field-input">
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
</div>
<small *ngIf="contentBlockerForm.get('cronExpression')?.hasError('required') && contentBlockerForm.get('cronExpression')?.touched" class="p-error">Cron expression is required</small>
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
</div>
</div>
@@ -140,7 +137,16 @@
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Sonarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>Sonarr Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Sonarr Settings
</p-accordion-header>
<p-accordion-content>
<div formGroupName="sonarr">
<div class="field-row">
@@ -201,7 +207,16 @@
<!-- Radarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>Radarr Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Radarr Settings
</p-accordion-header>
<p-accordion-content>
<div formGroupName="radarr">
<div class="field-row">
@@ -262,7 +277,16 @@
<!-- Lidarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="2">
<p-accordion-header>Lidarr Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Lidarr Settings
</p-accordion-header>
<p-accordion-content>
<div formGroupName="lidarr">
<div class="field-row">
@@ -320,6 +344,76 @@
</div>
</p-accordion-content>
</p-accordion-panel>
<!-- Readarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Readarr Settings
</p-accordion-header>
<p-accordion-content>
<div formGroupName="readarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.enabled')"
title="Click for documentation"></i>
Enable Readarr Blocklist
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, the Readarr blocklist will be used for content filtering</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
</label>
<p-fluid>
<div class="field-input">
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
</div>
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="p-error">Path is required when Readarr blocklist is enabled</small>
<small class="form-helper-text">Path to the blocklist file or URL</small>
</p-fluid>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
</label>
<div class="field-input">
<p-select
formControlName="blocklistType"
[options]="[
{ label: 'Blacklist', value: 'Blacklist' },
{ label: 'Whitelist', value: 'Whitelist' }
]"
optionLabel="label"
optionValue="value"
appendTo="body"
></p-select>
<small class="form-helper-text"
>Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches)</small
>
</div>
</div>
</div>
</p-accordion-content>
</p-accordion-panel>
</p-accordion>
<!-- Action buttons -->

View File

@@ -127,7 +127,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
cronExpression: [{ value: '', disabled: true }, [Validators.required]],
jobSchedule: this.formBuilder.group({
every: [{ value: 5, disabled: true }, [Validators.required, Validators.min(1)]],
type: [{ value: ScheduleUnit.Minutes, disabled: true }],
type: [{ value: ScheduleUnit.Seconds, disabled: true }],
}),
ignorePrivate: [{ value: false, disabled: true }],
@@ -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
@@ -162,13 +167,14 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
cronExpression: config.cronExpression,
jobSchedule: config.jobSchedule || {
every: 5,
type: ScheduleUnit.Minutes
type: ScheduleUnit.Seconds
},
ignorePrivate: config.ignorePrivate,
deletePrivate: config.deletePrivate,
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
@@ -551,6 +569,11 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
readarr: {
enabled: false,
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
});
// Manually update control states after reset
@@ -558,6 +581,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.updateBlocklistDependentControls('sonarr', false);
this.updateBlocklistDependentControls('radarr', false);
this.updateBlocklistDependentControls('lidarr', false);
this.updateBlocklistDependentControls('readarr', false);
// Mark form as dirty so the save button is enabled after reset
this.contentBlockerForm.markAsDirty();
@@ -581,7 +605,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.contentBlockerForm.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -596,7 +620,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
} else if (scheduleType === ScheduleUnit.Hours) {
return this.scheduleValueOptions[ScheduleUnit.Hours];
}
return this.scheduleValueOptions[ScheduleUnit.Minutes]; // Default to minutes
return this.scheduleValueOptions[ScheduleUnit.Seconds]; // Default to seconds
}
/**
@@ -609,7 +633,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
const control = parentControl.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}

View File

@@ -7,9 +7,6 @@
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic download cleanup</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -104,7 +101,7 @@
<div class="field-input">
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
</div>
<small *ngIf="downloadCleanerForm.get('cronExpression')?.hasError('required') && downloadCleanerForm.get('cronExpression')?.touched" class="p-error">Cron expression is required</small>
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
</div>
</div>
@@ -113,7 +110,16 @@
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Seeding Settings -->
<p-accordion-panel [disabled]="!downloadCleanerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>Seeding Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Seeding Settings
</p-accordion-header>
<p-accordion-content>
<!-- Delete Private Option -->
<div class="field-row">
@@ -230,7 +236,16 @@
<!-- Unlinked Download Settings -->
<p-accordion-panel [disabled]="!downloadCleanerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>Unlinked Download Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Unlinked Download Settings
</p-accordion-header>
<p-accordion-content>
<div class="field-row">
<label class="field-label">
@@ -302,6 +317,13 @@
</label>
<div>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="unlinkedCategories"
placeholder="Add category"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="unlinkedCategories"
multiple
@@ -310,6 +332,7 @@
[suggestions]="unlinkedCategoriesSuggestions"
(completeMethod)="onUnlinkedCategoriesComplete($event)"
placeholder="Add category and press Enter"
class="desktop-only"
>
</p-autocomplete>
</div>

View File

@@ -11,6 +11,7 @@ import {
createDefaultCategory
} from "../../shared/models/download-cleaner-config.model";
import { ScheduleUnit, ScheduleOptions } from "../../shared/models/queue-cleaner-config.model";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -54,7 +55,8 @@ import { DocumentationService } from "../../core/services/documentation.service"
TableModule,
LoadingErrorStateComponent,
ConfirmDialogModule,
NgIf
NgIf,
MobileAutocompleteComponent,
],
providers: [ConfirmationService],
templateUrl: "./download-cleaner-settings.component.html",
@@ -360,21 +362,27 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
.pipe(takeUntil(this.destroy$))
.subscribe(useAdvanced => {
const enabled = this.downloadCleanerForm.get('enabled')?.value || false;
if (enabled) {
const cronExpressionControl = this.downloadCleanerForm.get('cronExpression');
const jobScheduleGroup = this.downloadCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
const cronExpressionControl = this.downloadCleanerForm.get('cronExpression');
const jobScheduleGroup = this.downloadCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
// Update scheduling controls based on mode, regardless of enabled state
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
// Then respect the main enabled state - if disabled, disable all scheduling controls
if (!enabled) {
cronExpressionControl?.disable();
everyControl?.disable();
typeControl?.disable();
}
});
}
@@ -461,19 +469,14 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
* Update form control disabled states based on the configuration
*/
private updateFormControlDisabledStates(config: DownloadCleanerConfig): void {
// Update main controls based on enabled state
// Update main form controls based on the 'enabled' state
this.updateMainControlsState(config.enabled);
// Update schedule controls based on advanced scheduling
const cronControl = this.downloadCleanerForm.get('cronExpression');
const jobScheduleControl = this.downloadCleanerForm.get('jobSchedule');
if (config.useAdvancedScheduling) {
jobScheduleControl?.disable({ emitEvent: false });
cronControl?.enable({ emitEvent: false });
} else {
cronControl?.disable({ emitEvent: false });
jobScheduleControl?.enable({ emitEvent: false });
// Update other dependent controls only if the main feature is enabled
if (config.enabled) {
// Update unlinked controls based on current unlinkedEnabled value
const unlinkedEnabled = config.unlinkedEnabled || false;
this.updateUnlinkedControlsState(unlinkedEnabled);
}
}
@@ -550,14 +553,17 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
// Get form values including disabled controls
const formValues = this.downloadCleanerForm.getRawValue();
// Determine the correct cron expression to use
const cronExpression: string = formValues.useAdvancedScheduling ?
formValues.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.downloadCleanerStore.generateCronExpression(formValues.jobSchedule);
// Create config object from form values
const config: DownloadCleanerConfig = {
enabled: formValues.enabled,
useAdvancedScheduling: formValues.useAdvancedScheduling,
cronExpression: formValues.useAdvancedScheduling ?
formValues.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.downloadCleanerStore.generateCronExpression(formValues.jobSchedule),
cronExpression: cronExpression,
jobSchedule: formValues.jobSchedule,
categories: formValues.categories,
deletePrivate: formValues.deletePrivate,
@@ -654,14 +660,14 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.downloadCleanerForm.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
* Check if the form has the unlinked categories validation error
*/
hasUnlinkedCategoriesError(): boolean {
return this.downloadCleanerForm.touched && this.downloadCleanerForm.hasError('unlinkedCategoriesRequired');
return this.downloadCleanerForm.dirty && this.downloadCleanerForm.hasError('unlinkedCategoriesRequired');
}
/**
@@ -689,7 +695,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
}
const control = parentControl.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -700,7 +706,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
if (!categoryGroup) return false;
const control = categoryGroup.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -709,7 +715,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
hasCategoryControlError(categoryIndex: number, controlName: string, errorName: string): boolean {
const categoryGroup = this.categoriesFormArray.at(categoryIndex);
const control = categoryGroup.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -717,7 +723,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
*/
hasCategoryGroupError(categoryIndex: number, errorName: string): boolean {
const categoryGroup = this.categoriesFormArray.at(categoryIndex);
return categoryGroup ? categoryGroup.touched && categoryGroup.hasError(errorName) : false;
return categoryGroup ? categoryGroup.dirty && categoryGroup.hasError(errorName) : false;
}
/**

View File

@@ -25,9 +25,6 @@
<h2 class="card-title m-0">Download Clients</h2>
<span class="card-subtitle">Manage download client instances</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-download text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -156,7 +156,7 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
*/
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
const control = form.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
return control !== null && control.hasError(errorName) && control.dirty;
}
/**
@@ -363,8 +363,9 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
const clientType = this.clientForm.get('type')?.value;
const hostControl = this.clientForm.get('host');
const usernameControl = this.clientForm.get('username');
const urlBaseControl = this.clientForm.get('urlBase');
if (!hostControl || !usernameControl) return;
if (!hostControl || !usernameControl || !urlBaseControl) return;
hostControl.setValidators([
Validators.required,
@@ -377,6 +378,11 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
usernameControl.clearValidators();
}
// Set default URL base for Transmission
if (clientType === DownloadClientType.Transmission) {
urlBaseControl.setValue('transmission');
}
// Update validation state
hostControl.updateValueAndValidity();
usernameControl.updateValueAndValidity();

View File

@@ -7,9 +7,6 @@
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -30,7 +27,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('displaySupportBanner')"
title="View documentation for support banner display">
title="Click for documentation">
</i>
Display Support Banner
</label>
@@ -45,7 +42,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="View documentation for dry run mode">
title="Click for documentation">
</i>
Dry Run
</label>
@@ -60,7 +57,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="View documentation for HTTP retry configuration">
title="Click for documentation">
</i>
Maximum HTTP Retries
</label>
@@ -84,7 +81,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="View documentation for HTTP timeout configuration">
title="Click for documentation">
</i>
HTTP Timeout (seconds)
</label>
@@ -108,7 +105,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="View documentation for certificate validation options">
title="Click for documentation">
</i>
Certificate Validation
</label>
@@ -129,7 +126,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="View documentation for automatic search functionality">
title="Click for documentation">
</i>
Enable Search
</label>
@@ -143,7 +140,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="View documentation for search delay configuration">
title="Click for documentation">
</i>
Search Delay (seconds)
</label>
@@ -168,7 +165,7 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('logLevel')"
title="View documentation for log level configuration">
title="Click for documentation">
</i>
Log Level
</label>
@@ -189,11 +186,18 @@
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="View documentation for download ignore patterns">
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="ignoredDownloads"
@@ -201,6 +205,7 @@
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>

View File

@@ -19,10 +19,12 @@ import { NotificationService } from '../../core/services/notification.service';
import { DocumentationService } from '../../core/services/documentation.service';
import { SelectModule } from "primeng/select";
import { ChipsModule } from "primeng/chips";
import { ChipModule } from "primeng/chip";
import { AutoCompleteModule } from "primeng/autocomplete";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
@Component({
selector: "app-general-settings",
@@ -36,11 +38,13 @@ import { ConfirmationService } from "primeng/api";
ButtonModule,
InputNumberModule,
ChipsModule,
ChipModule,
ToastModule,
SelectModule,
AutoCompleteModule,
LoadingErrorStateComponent,
ConfirmDialogModule,
MobileAutocompleteComponent,
],
providers: [GeneralConfigStore, ConfirmationService],
templateUrl: "./general-settings.component.html",
@@ -351,7 +355,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.generalForm.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**

View File

@@ -22,12 +22,9 @@
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Global Settings</h2>
<h2 class="card-title m-0">Lidarr Settings</h2>
<span class="card-subtitle">Configure general Lidarr integration settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -45,6 +42,9 @@
decrementButtonIcon="pi pi-minus"
></p-inputNumber>
</div>
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
</div>
</div>
@@ -73,9 +73,6 @@
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Lidarr server instances</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -82,7 +82,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
constructor() {
// Initialize forms
this.globalForm = this.formBuilder.group({
failedImportMaxStrikes: [-1],
failedImportMaxStrikes: [-1, [Validators.required, Validators.min(-1), Validators.max(5000)]],
});
this.instanceForm = this.formBuilder.group({
@@ -211,11 +211,31 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
}
/**
* Check if a form control has an error
* Check if a form control has an error after it's been touched
*/
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
const control = form.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
if (formOrControlName instanceof FormGroup) {
// For instance form
const control = formOrControlName.get(controlNameOrErrorName);
return control !== null && control.hasError(errorName!) && control.dirty;
} else {
// For global form
const control = this.globalForm.get(formOrControlName);
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
}
}
/**
* Get nested form control errors
*/
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
const parentControl = this.globalForm.get(parentName);
if (!parentControl || !(parentControl instanceof FormGroup)) {
return false;
}
const control = parentControl.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -406,8 +426,6 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
});
}
/**
* Get modal title based on mode
*/

View File

@@ -10,9 +10,6 @@
<h2 class="card-title m-0">Notification Configuration</h2>
<span class="card-subtitle">Configure notification settings for Notifiarr and Apprise</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-bell text-xl"></i>
</div>
</div>
</ng-template>
@@ -36,11 +33,11 @@
<!-- API Key -->
<div class="field-row">
<label class="field-label">
API Key
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.apiKey')"
title="View documentation for Notifiarr API key setup">
title="Click for documentation">
</i>
API Key
</label>
<div class="field-input">
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
@@ -51,10 +48,14 @@
<!-- Channel ID -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="Click for documentation">
</i>
Channel ID
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="View documentation for Discord channel ID setup">
title="Click for documentation">
</i>
</label>
<div class="field-input">
@@ -66,11 +67,11 @@
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
Event Triggers
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="View documentation for notification event types">
title="Click for documentation">
</i>
Event Triggers
</label>
<div class="field-input">
<div class="flex flex-column gap-2">
@@ -112,11 +113,11 @@
<!-- URL -->
<div class="field-row">
<label class="field-label">
URL
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('apprise.url')"
title="View documentation for Apprise server URL setup">
title="Click for documentation">
</i>
URL
</label>
<div class="field-input">
<input type="text" pInputText formControlName="url" inputId="appriseUrl" placeholder="Enter Apprise URL" />
@@ -127,11 +128,11 @@
<!-- Key -->
<div class="field-row">
<label class="field-label">
Key
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('apprise.key')"
title="View documentation for Apprise configuration key">
title="Click for documentation">
</i>
Key
</label>
<div class="field-input">
<input type="text" pInputText formControlName="key" inputId="appriseKey" placeholder="Enter key" />
@@ -142,11 +143,11 @@
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
Event Triggers
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="View documentation for notification event types">
title="Click for documentation">
</i>
Event Triggers
</label>
<div class="field-input">
<div class="flex flex-column gap-2">

View File

@@ -311,7 +311,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.notificationForm.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -319,7 +319,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
*/
hasNestedError(groupName: string, controlName: string, errorName: string): boolean {
const control = this.notificationForm.get(`${groupName}.${controlName}`);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**

View File

@@ -7,9 +7,6 @@
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -29,9 +26,8 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="View documentation for this setting">
</i>
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Queue Cleaner
</label>
<div class="field-input">
@@ -44,9 +40,8 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="View documentation for scheduling modes">
</i>
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
</label>
<div class="field-input">
@@ -98,16 +93,15 @@
<div class="field-row" *ngIf="queueCleanerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="View cron expression documentation and examples">
</i>
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
</label>
<div>
<div class="field-input">
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
</div>
<small *ngIf="queueCleanerForm.get('cronExpression')?.hasError('required') && queueCleanerForm.get('cronExpression')?.touched" class="p-error">Cron expression is required</small>
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
</div>
</div>
@@ -116,25 +110,36 @@
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Failed Import Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>Failed Import Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Failed Import Settings
</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.maxStrikes')"
title="View documentation for failed import strike system">
</i>
(click)="openFieldDocs('failedImport.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
</label>
<div class="field-input">
<p-inputNumber
formControlName="maxStrikes"
[showButtons]="true"
[min]="0"
[max]="10"
buttonLayout="horizontal"
>
</p-inputNumber>
<div>
<div class="field-input">
<p-inputNumber
formControlName="maxStrikes"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
>
</p-inputNumber>
</div>
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
<small class="form-helper-text"
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
>
@@ -144,9 +149,8 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignorePrivate')"
title="View documentation for private torrent handling">
</i>
(click)="openFieldDocs('failedImport.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
@@ -158,9 +162,8 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.deletePrivate')"
title="View documentation for private torrent deletion">
</i>
(click)="openFieldDocs('failedImport.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
@@ -172,18 +175,25 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignoredPatterns')"
title="View documentation for pattern matching and examples">
</i>
(click)="openFieldDocs('failedImport.ignoredPatterns')"
title="Click for documentation"></i>
Ignored Patterns
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredPatterns"
placeholder="Add pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredPatterns"
multiple
fluid
[typeahead]="false"
placeholder="Add pattern and press Enter"
class="desktop-only"
>
</p-autocomplete>
<small class="form-helper-text"
@@ -196,14 +206,22 @@
<!-- Stalled Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>Stalled Download Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Stalled Download Settings
</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.maxStrikes')"
title="View documentation for stalled download strike system">
</i>
(click)="openFieldDocs('stalled.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
</label>
<div>
@@ -217,7 +235,7 @@
</p-inputNumber>
</div>
<small *ngIf="hasNestedError('stalled', 'maxStrikes', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('stalled', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 100</small>
<small *ngIf="hasNestedError('stalled', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
<small class="form-helper-text"
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
>
@@ -227,9 +245,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
title="View documentation for strike reset behavior">
</i>
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
</label>
<div class="field-input">
@@ -241,9 +258,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.ignorePrivate')"
title="View documentation for private torrent handling">
</i>
(click)="openFieldDocs('stalled.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
@@ -255,9 +271,8 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.deletePrivate')"
title="View documentation for private torrent deletion">
</i>
(click)="openFieldDocs('stalled.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
@@ -270,14 +285,22 @@
<!-- Downloading Metadata Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="2">
<p-accordion-header>Downloading Metadata Settings (qBittorrent only)</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Downloading Metadata Settings (qBittorrent only)
</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
title="View documentation for metadata download handling">
</i>
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
title="Click for documentation"></i>
Max Strikes for Downloading Metadata
</label>
<div>
@@ -291,7 +314,7 @@
</p-inputNumber>
</div>
<small *ngIf="hasNestedError('stalled', 'downloadingMetadataMaxStrikes', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('stalled', 'downloadingMetadataMaxStrikes', 'max')" class="p-error">Value cannot exceed 100</small>
<small *ngIf="hasNestedError('stalled', 'downloadingMetadataMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
<small class="form-helper-text"
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
>
@@ -302,14 +325,22 @@
<!-- Slow Download Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>Slow Download Settings</p-accordion-header>
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Slow Download Settings
</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.maxStrikes')"
title="View documentation for slow download strike system">
</i>
(click)="openFieldDocs('slow.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
</label>
<div>
@@ -323,7 +354,7 @@
</p-inputNumber>
</div>
<small *ngIf="hasNestedError('slow', 'maxStrikes', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('slow', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 100</small>
<small *ngIf="hasNestedError('slow', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
<small class="form-helper-text"
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
>
@@ -333,9 +364,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
title="View documentation for strike reset behavior">
</i>
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
</label>
<div class="field-input">
@@ -347,9 +377,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.ignorePrivate')"
title="View documentation for private torrent handling">
</i>
(click)="openFieldDocs('slow.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
</label>
<div class="field-input">
@@ -361,9 +390,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.deletePrivate')"
title="View documentation for private torrent deletion">
</i>
(click)="openFieldDocs('slow.deletePrivate')"
title="Click for documentation"></i>
Delete Private
</label>
<div class="field-input">
@@ -375,9 +403,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.minSpeed')"
title="View speed threshold guidelines and recommendations">
</i>
(click)="openFieldDocs('slow.minSpeed')"
title="Click for documentation"></i>
Minimum Speed
</label>
<div class="field-input">
@@ -394,21 +421,22 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.maxTime')"
title="View documentation for maximum slow download time">
</i>
(click)="openFieldDocs('slow.maxTime')"
title="Click for documentation"></i>
Maximum Time (hours)
</label>
<div class="field-input">
<p-inputNumber
formControlName="maxTime"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
>
</p-inputNumber>
<div>
<div class="field-input">
<p-inputNumber
formControlName="maxTime"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
>
</p-inputNumber>
</div>
<small *ngIf="hasNestedError('slow', 'maxTime', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('slow', 'maxTime', 'max')" class="p-error">Value cannot exceed 168</small>
<small *ngIf="hasNestedError('slow', 'maxTime', 'max')" class="p-error">Value cannot exceed 1000</small>
<small class="form-helper-text">Maximum time allowed for slow downloads (0 means disabled)</small>
</div>
</div>
@@ -416,9 +444,8 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('slow.ignoreAboveSize')"
title="View size exemption strategy and recommended thresholds">
</i>
(click)="openFieldDocs('slow.ignoreAboveSize')"
title="Click for documentation"></i>
Ignore Above Size
</label>
<div class="field-input">

View File

@@ -14,6 +14,7 @@ import {
} from "../../shared/models/queue-cleaner-config.model";
import { SettingsCardComponent } from "../components/settings-card/settings-card.component";
import { ByteSizeInputComponent } from "../../shared/components/byte-size-input/byte-size-input.component";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -54,6 +55,7 @@ import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
AutoCompleteModule,
DropdownModule,
LoadingErrorStateComponent,
MobileAutocompleteComponent,
],
providers: [QueueCleanerConfigStore],
templateUrl: "./queue-cleaner-settings.component.html",
@@ -140,7 +142,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
// Failed Import settings - nested group
failedImport: this.formBuilder.group({
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
ignorePrivate: [{ value: false, disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
ignoredPatterns: [{ value: [], disabled: true }],
@@ -148,21 +150,21 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
// Stalled settings - nested group
stalled: this.formBuilder.group({
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
resetStrikesOnProgress: [{ value: false, disabled: true }],
ignorePrivate: [{ value: false, disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
downloadingMetadataMaxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
downloadingMetadataMaxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
}),
// Slow Download settings - nested group
slow: this.formBuilder.group({
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(100)]],
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
resetStrikesOnProgress: [{ value: false, disabled: true }],
ignorePrivate: [{ value: false, disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
minSpeed: [{ value: "", disabled: true }],
maxTime: [{ value: 0, disabled: true }, [Validators.required, Validators.min(0), Validators.max(168)]],
maxTime: [{ value: 0, disabled: true }, [Validators.required, Validators.min(0), Validators.max(1000)]],
ignoreAboveSize: [{ value: "", disabled: true }],
}),
@@ -260,21 +262,27 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
advancedControl.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((useAdvanced: boolean) => {
const enabled = this.queueCleanerForm.get('enabled')?.value || false;
if (enabled) {
const cronExpressionControl = this.queueCleanerForm.get('cronExpression');
const jobScheduleGroup = this.queueCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
const cronExpressionControl = this.queueCleanerForm.get('cronExpression');
const jobScheduleGroup = this.queueCleanerForm.get('jobSchedule') as FormGroup;
const everyControl = jobScheduleGroup?.get('every');
const typeControl = jobScheduleGroup?.get('type');
// Update scheduling controls based on mode, regardless of enabled state
if (useAdvanced) {
if (cronExpressionControl) cronExpressionControl.enable();
if (everyControl) everyControl.disable();
if (typeControl) typeControl.disable();
} else {
if (cronExpressionControl) cronExpressionControl.disable();
if (everyControl) everyControl.enable();
if (typeControl) typeControl.enable();
}
// Then respect the main enabled state - if disabled, disable all scheduling controls
if (!enabled) {
cronExpressionControl?.disable();
everyControl?.disable();
typeControl?.disable();
}
});
}
@@ -519,14 +527,17 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
// Make a copy of the form values
const formValue = this.queueCleanerForm.getRawValue();
// Determine the correct cron expression to use
const cronExpression: string = formValue.useAdvancedScheduling ?
formValue.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.queueCleanerStore.generateCronExpression(formValue.jobSchedule);
// Create the config object to be saved
const queueCleanerConfig: QueueCleanerConfig = {
enabled: formValue.enabled,
useAdvancedScheduling: formValue.useAdvancedScheduling,
cronExpression: formValue.useAdvancedScheduling ?
formValue.cronExpression :
// If in basic mode, generate cron expression from the schedule
this.queueCleanerStore.generateCronExpression(formValue.jobSchedule),
cronExpression: cronExpression,
jobSchedule: formValue.jobSchedule,
failedImport: {
maxStrikes: formValue.failedImport?.maxStrikes || 0,
@@ -664,7 +675,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.queueCleanerForm.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -692,7 +703,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
}
const control = parentControl.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control ? control.dirty && control.hasError(errorName) : false;
}

View File

@@ -22,12 +22,9 @@
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Global Settings</h2>
<h2 class="card-title m-0">Radarr Settings</h2>
<span class="card-subtitle">Configure general Radarr integration settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -45,6 +42,9 @@
decrementButtonIcon="pi pi-minus"
></p-inputNumber>
</div>
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
</div>
</div>
@@ -73,9 +73,6 @@
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Radarr server instances</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -82,7 +82,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
constructor() {
// Initialize forms
this.globalForm = this.formBuilder.group({
failedImportMaxStrikes: [-1],
failedImportMaxStrikes: [-1, [Validators.required, Validators.min(-1), Validators.max(5000)]],
});
this.instanceForm = this.formBuilder.group({
@@ -211,11 +211,31 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
}
/**
* Check if a form control has an error
* Check if a form control has an error after it's been touched
*/
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
const control = form.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
if (formOrControlName instanceof FormGroup) {
// For instance form
const control = formOrControlName.get(controlNameOrErrorName);
return control !== null && control.hasError(errorName!) && control.dirty;
} else {
// For global form
const control = this.globalForm.get(formOrControlName);
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
}
}
/**
* Get nested form control errors
*/
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
const parentControl = this.globalForm.get(parentName);
if (!parentControl || !(parentControl instanceof FormGroup)) {
return false;
}
const control = parentControl.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
@@ -406,8 +426,6 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
});
}
/**
* Get modal title based on mode
*/

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,435 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ReadarrConfigStore } from "./readarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
import { InputTextModule } from "primeng/inputtext";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { InputNumberModule } from "primeng/inputnumber";
import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@Component({
selector: "app-readarr-settings",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CardModule,
InputTextModule,
CheckboxModule,
ButtonModule,
InputNumberModule,
ToastModule,
DialogModule,
ConfirmDialogModule,
LoadingErrorStateComponent,
],
providers: [ReadarrConfigStore, ConfirmationService],
templateUrl: "./readarr-settings.component.html",
styleUrls: ["./readarr-settings.component.scss"],
})
export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactivate {
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Forms
globalForm: FormGroup;
instanceForm: FormGroup;
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
editingInstance: ArrInstance | null = null;
// Original form values for tracking changes
private originalGlobalValues: any;
hasGlobalChanges = false;
// Clean up subscriptions
private destroy$ = new Subject<void>();
// Services
private formBuilder = inject(FormBuilder);
private notificationService = inject(NotificationService);
private confirmationService = inject(ConfirmationService);
private readarrStore = inject(ReadarrConfigStore);
// Signals from store
readarrConfig = this.readarrStore.config;
readarrLoading = this.readarrStore.loading;
readarrError = this.readarrStore.error;
readarrSaving = this.readarrStore.saving;
/**
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.globalForm?.dirty || !this.hasGlobalChanges;
}
constructor() {
// Initialize forms
this.globalForm = this.formBuilder.group({
failedImportMaxStrikes: [-1, [Validators.required, Validators.min(-1), Validators.max(5000)]],
});
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 after it's been touched
*/
hasError(formOrControlName: FormGroup | string, controlNameOrErrorName: string, errorName?: string): boolean {
if (formOrControlName instanceof FormGroup) {
// For instance form
const control = formOrControlName.get(controlNameOrErrorName);
return control !== null && control.hasError(errorName!) && control.dirty;
} else {
// For global form
const control = this.globalForm.get(formOrControlName);
return control ? control.dirty && control.hasError(controlNameOrErrorName) : false;
}
}
/**
* 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';
}
/**
* Get nested form control errors
*/
hasNestedError(parentName: string, controlName: string, errorName: string): boolean {
const parentControl = this.globalForm.get(parentName);
if (!parentControl || !(parentControl instanceof FormGroup)) {
return false;
}
const control = parentControl.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
}
}

Some files were not shown because too many files have changed in this diff Show More