Compare commits

...

9 Commits

Author SHA1 Message Date
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
35 changed files with 912 additions and 220 deletions

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

@@ -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;

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

@@ -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;

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

@@ -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

@@ -132,7 +132,7 @@ public abstract class GenericHandler : IHandler
.FirstAsync(x => x.Type == InstanceType.Lidarr));
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());

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

@@ -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

@@ -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

@@ -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>
@@ -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">

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>
@@ -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">

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

@@ -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>

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>
@@ -73,9 +70,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

@@ -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">
</i>
API Key
</label>
<div class="field-input">
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
@@ -51,6 +48,10 @@
<!-- Channel ID -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="View documentation for Discord channel ID setup">
</i>
Channel ID
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
@@ -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">
</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">
</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">
</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">
</i>
Event Triggers
</label>
<div class="field-input">
<div class="flex flex-column gap-2">

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>
@@ -116,7 +113,16 @@
<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">
@@ -196,7 +202,16 @@
<!-- 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">
@@ -270,7 +285,16 @@
<!-- 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">
@@ -302,7 +326,16 @@
<!-- 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">

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>
@@ -73,9 +70,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

@@ -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">Sonarr Settings</h2>
<span class="card-subtitle">Configure general Sonarr integration settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -73,9 +70,6 @@
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Sonarr 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

@@ -0,0 +1,195 @@
---
sidebar_position: 8
---
import { Important, Warning } from '@site/src/components/Admonition';
import {
ConfigSection,
EnhancedImportant,
EnhancedWarning,
styles
} from '@site/src/components/documentation';
# Health Checks
Cleanuparr provides comprehensive health check endpoints that are compatible with Docker health checks and Kubernetes probes. This allows for better monitoring, automated failover, and container orchestration.
<div className={styles.documentationPage}>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🏥</span>
Health Check Endpoints
</h2>
<ConfigSection
id="health-liveness"
title="/health - Liveness Probe"
icon="💓"
>
**Purpose**: Basic liveness check to verify the application is running
**Use Case**: Docker HEALTHCHECK and Kubernetes liveness probes
**Response**: HTTP 200 (healthy) or 503 (unhealthy)
**Checks**: Application startup and basic functionality
```bash
curl http://localhost:11011/health
```
</ConfigSection>
<ConfigSection
id="health-readiness"
title="/health/ready - Readiness Probe"
icon="✅"
>
**Purpose**: Verify the application is ready to serve traffic
**Use Case**: Kubernetes readiness probes and load balancer health checks
**Response**: HTTP 200 (ready) or 503 (not ready)
**Checks**: Database connectivity, file system access, download client health
```bash
curl http://localhost:11011/health/ready
```
</ConfigSection>
<ConfigSection
id="health-detailed"
title="/health/detailed - Detailed Status"
icon="📊"
>
**Purpose**: Comprehensive health status for monitoring and debugging
**Use Case**: Monitoring systems and troubleshooting
**Response**: Detailed JSON with status of all components
**Checks**: All health checks with timing and detailed status
```bash
curl http://localhost:11011/health/detailed
```
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🔍</span>
Health Check Components
</h2>
<ConfigSection
id="application-health"
title="Application Health"
icon="🚀"
>
- Verifies the application is running and responsive
- Basic functionality test
- Used for liveness probes
</ConfigSection>
<ConfigSection
id="database-health"
title="Database Health"
icon="🗄️"
>
- Tests database connectivity
- Checks for pending migrations
- Validates schema integrity
- Used for readiness probes
</ConfigSection>
<ConfigSection
id="filesystem-health"
title="File System Health"
icon="📁"
>
- Verifies access to configuration directories
- Validates working directory access
- Used for readiness probes
<EnhancedImportant>
File write tests are not performed during health checks to avoid creating temporary files on every check.
</EnhancedImportant>
</ConfigSection>
<ConfigSection
id="download-client-health"
title="Download Client Health"
icon="⬇️"
>
- Integrates with existing download client monitoring
- Reports status of all configured download clients
- Considers overall health based on client availability
- Used for readiness probes
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🐳</span>
Environment Variables
</h2>
<ConfigSection
id="health-environment-variables"
title="Environment Variables"
icon="🌍"
>
Health check endpoints are affected by these environment variables:
- **`PORT`**: Application port (default: 11011)
- **`BASE_PATH`**: Base path for the application (affects health check URLs)
If you set `BASE_PATH=/cleanuparr`, health checks will be available at:
- `localhost:[PORT]/cleanuparr/health`
- `localhost:[PORT]/cleanuparr/health/ready`
- `localhost:[PORT]/cleanuparr/health/detailed`
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🔧</span>
Troubleshooting
</h2>
<ConfigSection
id="health-common-issues"
title="Common Issues"
icon="⚠️"
>
**Health check timeout**
Increase timeout values if the application is slow to respond
**File system permission errors**
Check container user permissions and mount points
**Download client unavailability**
Review download client configurations and network connectivity
</ConfigSection>
</div>
</div>

View File

@@ -37,11 +37,10 @@ Docker is the preferred installation method as it ensures all dependencies are c
- Docker (version 20.10 or newer)
- Docker Compose (optional, for compose method)
### Docker Run Method
### Docker Run
The simplest way to run Cleanuparr is with a single Docker command:
#### Option 1: GitHub Container Registry (Recommended)
```bash
docker run -d --name cleanuparr \
--restart unless-stopped \
@@ -56,22 +55,7 @@ docker run -d --name cleanuparr \
ghcr.io/cleanuparr:latest
```
#### Option 2: DockerHub
```bash
docker run -d --name cleanuparr \
--restart unless-stopped \
-p 11011:11011 \
-v /path/to/config:/config \
-e PORT=11011 \
-e BASE_PATH= \
-e PUID=1000 \
-e PGID=1000 \
-e UMASK=022 \
-e TZ=Etc/UTC \
cleanuparr/cleanuparr:latest
```
### Docker Compose Method
### Docker Compose
For easier management, create a `docker-compose.yml` file:
@@ -93,6 +77,13 @@ services:
- PGID=1000
- UMASK=022
- TZ=Etc/UTC
# Health check configuration
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11011/health"]
interval: 30s # Check every 30 seconds
timeout: 10s # Allow up to 10 seconds for response
start_period: 30s # Wait 30 seconds before first check
retries: 3 # Mark unhealthy after 3 consecutive failures
```
### Environment Variables
@@ -273,3 +264,6 @@ To run Cleanuparr as a systemd service:
- **Web Interface**: `http://localhost:11011`
- **Base Path**: *(empty)* (for reverse proxy setups, change `BASE_PATH` environment variable)
- **Configuration Location**: Varies by platform and installation method
#### Health Checks
Cleanuparr provides comprehensive health check endpoints for monitoring and container orchestration. For detailed information about available endpoints and configuration, see the [Health Checks documentation](../configuration/health-checks/).