mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-04 06:18:08 -05:00
fix #13
This commit is contained in:
@@ -1,16 +1,5 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadCleaner;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Quartz;
|
||||
using Quartz.Spi;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
@@ -18,125 +7,14 @@ public static class QuartzDI
|
||||
{
|
||||
public static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz(q =>
|
||||
{
|
||||
// Configure quartz to use job-specific config files
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
q.AddJobs(serviceProvider);
|
||||
})
|
||||
.AddQuartz()
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
});
|
||||
})
|
||||
// Register BackgroundJobManager as a hosted service
|
||||
.AddSingleton<BackgroundJobManager>()
|
||||
.AddHostedService(provider => provider.GetRequiredService<BackgroundJobManager>());
|
||||
|
||||
private static void AddJobs(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IServiceProvider serviceProvider
|
||||
)
|
||||
{
|
||||
var configManager = serviceProvider.GetRequiredService<IConfigManager>();
|
||||
|
||||
// Get configurations from JSON files
|
||||
ContentBlockerConfig? contentBlockerConfig = configManager.GetContentBlockerConfig();
|
||||
|
||||
if (contentBlockerConfig != null)
|
||||
{
|
||||
q.AddJob<ContentBlocker>(contentBlockerConfig, contentBlockerConfig.CronExpression);
|
||||
}
|
||||
|
||||
QueueCleanerConfig? queueCleanerConfig = configManager.GetQueueCleanerConfig();
|
||||
|
||||
if (queueCleanerConfig != null)
|
||||
{
|
||||
if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true })
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty);
|
||||
q.AddJobListener(new JobChainingListener(nameof(ContentBlocker), nameof(QueueCleaner)));
|
||||
}
|
||||
else
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, queueCleanerConfig.CronExpression);
|
||||
}
|
||||
}
|
||||
|
||||
DownloadCleanerConfig? downloadCleanerConfig = configManager.GetDownloadCleanerConfig();
|
||||
|
||||
if (downloadCleanerConfig != null)
|
||||
{
|
||||
q.AddJob<DownloadCleaner>(downloadCleanerConfig, downloadCleanerConfig.CronExpression);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJob<T>(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IJobConfig? config,
|
||||
string trigger
|
||||
) where T: GenericHandler
|
||||
{
|
||||
string typeName = typeof(T).Name;
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException($"{typeName} configuration is null");
|
||||
}
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasTrigger = trigger.Length > 0;
|
||||
|
||||
q.AddJob<GenericJob<T>>(opts =>
|
||||
{
|
||||
opts.WithIdentity(typeName);
|
||||
|
||||
if (!hasTrigger)
|
||||
{
|
||||
// jobs with no triggers need to be stored durably
|
||||
opts.StoreDurably();
|
||||
}
|
||||
});
|
||||
|
||||
// skip empty triggers
|
||||
if (!hasTrigger)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||
.WithIdentity("ExampleTrigger")
|
||||
.StartNow()
|
||||
.WithCronSchedule(trigger)
|
||||
.Build();
|
||||
|
||||
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||
TimeSpan triggerValue = nextFireTimes[1] - nextFireTimes[0];
|
||||
|
||||
if (triggerValue > Constants.TriggerMaxLimit)
|
||||
{
|
||||
throw new Exception($"{trigger} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (triggerValue > StaticConfiguration.TriggerValue)
|
||||
{
|
||||
StaticConfiguration.TriggerValue = triggerValue;
|
||||
}
|
||||
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-trigger")
|
||||
.WithCronSchedule(trigger, x =>x.WithMisfireHandlingInstructionDoNothing())
|
||||
.StartNow();
|
||||
});
|
||||
|
||||
// Startup trigger
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-startup-trigger")
|
||||
.StartNow();
|
||||
});
|
||||
}
|
||||
// Jobs are now managed by BackgroundJobManager
|
||||
}
|
||||
280
code/Executable/Jobs/BackgroundJobManager.cs
Normal file
280
code/Executable/Jobs/BackgroundJobManager.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadCleaner;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Quartz;
|
||||
using Quartz.Impl.Matchers;
|
||||
using Quartz.Spi;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Manages background jobs in the application.
|
||||
/// This class is responsible for reading configurations and scheduling jobs.
|
||||
/// </summary>
|
||||
public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly IConfigManager _configManager;
|
||||
private readonly ILogger<BackgroundJobManager> _logger;
|
||||
private IScheduler? _scheduler;
|
||||
|
||||
public BackgroundJobManager(
|
||||
ISchedulerFactory schedulerFactory,
|
||||
IConfigManager configManager,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<BackgroundJobManager> logger)
|
||||
{
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_configManager = configManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background job manager.
|
||||
/// This method is called when the application starts.
|
||||
/// </summary>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting BackgroundJobManager");
|
||||
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||
|
||||
await InitializeJobsFromConfiguration(cancellationToken);
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the background job manager.
|
||||
/// This method is called when the application stops.
|
||||
/// </summary>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping BackgroundJobManager");
|
||||
|
||||
if (_scheduler != null)
|
||||
{
|
||||
// Don't shutdown the scheduler as it's managed by QuartzHostedService
|
||||
await _scheduler.Standby(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes jobs based on current configuration settings.
|
||||
/// </summary>
|
||||
private async Task InitializeJobsFromConfiguration(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
// Get configurations from JSON files
|
||||
ContentBlockerConfig? contentBlockerConfig = await _configManager.GetContentBlockerConfigAsync();
|
||||
QueueCleanerConfig? queueCleanerConfig = await _configManager.GetQueueCleanerConfigAsync();
|
||||
DownloadCleanerConfig? downloadCleanerConfig = await _configManager.GetDownloadCleanerConfigAsync();
|
||||
|
||||
// Add ContentBlocker job if enabled
|
||||
if (contentBlockerConfig?.Enabled == true)
|
||||
{
|
||||
await AddContentBlockerJob(contentBlockerConfig, cancellationToken);
|
||||
}
|
||||
|
||||
// Add QueueCleaner job if enabled
|
||||
if (queueCleanerConfig?.Enabled == true)
|
||||
{
|
||||
// Check if we need to chain it after ContentBlocker
|
||||
bool shouldChainAfterContentBlocker =
|
||||
contentBlockerConfig?.Enabled == true &&
|
||||
queueCleanerConfig.RunSequentially;
|
||||
|
||||
await AddQueueCleanerJob(queueCleanerConfig, shouldChainAfterContentBlocker, cancellationToken);
|
||||
}
|
||||
|
||||
// Add DownloadCleaner job if enabled
|
||||
if (downloadCleanerConfig?.Enabled == true)
|
||||
{
|
||||
await AddDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the ContentBlocker job to the scheduler.
|
||||
/// </summary>
|
||||
public async Task AddContentBlockerJob(ContentBlockerConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AddJobWithTrigger<ContentBlocker>(
|
||||
config,
|
||||
config.CronExpression,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the QueueCleaner job to the scheduler.
|
||||
/// </summary>
|
||||
public async Task AddQueueCleanerJob(
|
||||
QueueCleanerConfig config,
|
||||
bool chainAfterContentBlocker = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var jobKey = new JobKey(nameof(QueueCleaner));
|
||||
|
||||
// If the job should be chained after ContentBlocker, add it without a cron trigger
|
||||
if (chainAfterContentBlocker)
|
||||
{
|
||||
await AddJobWithoutTrigger<QueueCleaner>(cancellationToken);
|
||||
|
||||
// Add job listener to chain QueueCleaner after ContentBlocker
|
||||
if (_scheduler != null)
|
||||
{
|
||||
var chainListener = new JobChainingListener(nameof(ContentBlocker), nameof(QueueCleaner));
|
||||
_scheduler.ListenerManager.AddJobListener(chainListener, KeyMatcher<JobKey>.KeyEquals(new JobKey(nameof(ContentBlocker))));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add job with normal cron trigger
|
||||
await AddJobWithTrigger<QueueCleaner>(
|
||||
config,
|
||||
config.CronExpression,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the DownloadCleaner job to the scheduler.
|
||||
/// </summary>
|
||||
public async Task AddDownloadCleanerJob(DownloadCleanerConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AddJobWithTrigger<DownloadCleaner>(
|
||||
config,
|
||||
config.CronExpression,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to add a job with a cron trigger.
|
||||
/// </summary>
|
||||
private async Task AddJobWithTrigger<T>(
|
||||
Common.Configuration.IJobConfig config,
|
||||
string cronExpression,
|
||||
CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string typeName = typeof(T).Name;
|
||||
var jobKey = new JobKey(typeName);
|
||||
|
||||
// Create job detail
|
||||
var jobDetail = JobBuilder.Create<GenericJob<T>>()
|
||||
.StoreDurably()
|
||||
.WithIdentity(jobKey)
|
||||
.Build();
|
||||
|
||||
// Add job to scheduler
|
||||
await _scheduler.AddJob(jobDetail, true, cancellationToken);
|
||||
|
||||
// Validate the cron expression
|
||||
if (!string.IsNullOrEmpty(cronExpression))
|
||||
{
|
||||
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||
.WithIdentity("ValidationTrigger")
|
||||
.StartNow()
|
||||
.WithCronSchedule(cronExpression)
|
||||
.Build();
|
||||
|
||||
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||
TimeSpan triggerValue = nextFireTimes[1] - nextFireTimes[0];
|
||||
|
||||
if (triggerValue > Constants.TriggerMaxLimit)
|
||||
{
|
||||
throw new Exception($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (triggerValue > StaticConfiguration.TriggerValue)
|
||||
{
|
||||
StaticConfiguration.TriggerValue = triggerValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Create cron trigger
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{typeName}-trigger")
|
||||
.ForJob(jobKey)
|
||||
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
// Create startup trigger to run immediately
|
||||
var startupTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{typeName}-startup-trigger")
|
||||
.ForJob(jobKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
// Schedule job with both triggers
|
||||
await _scheduler.ScheduleJob(trigger, cancellationToken);
|
||||
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Added job {JobName} with cron expression {CronExpression}",
|
||||
typeName, cronExpression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
string typeName = typeof(T).Name;
|
||||
var jobKey = new JobKey(typeName);
|
||||
|
||||
// Create job detail that is durable (can exist without triggers)
|
||||
var jobDetail = JobBuilder.Create<GenericJob<T>>()
|
||||
.WithIdentity(jobKey)
|
||||
.StoreDurably()
|
||||
.Build();
|
||||
|
||||
// Add job to scheduler
|
||||
await _scheduler.AddJob(jobDetail, true, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Added job {JobName} without trigger (will be chained)", typeName);
|
||||
}
|
||||
}
|
||||
22
code/Executable/config/content_blocker.json
Normal file
22
code/Executable/config/content_blocker.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"cron_expression": "0 0/5 * * * ?",
|
||||
"ignore_private": false,
|
||||
"delete_private": false,
|
||||
"ignored_downloads_path": "",
|
||||
"sonarr": {
|
||||
"enabled": false,
|
||||
"type": 0,
|
||||
"path": null
|
||||
},
|
||||
"radarr": {
|
||||
"enabled": false,
|
||||
"type": 0,
|
||||
"path": null
|
||||
},
|
||||
"lidarr": {
|
||||
"enabled": false,
|
||||
"type": 0,
|
||||
"path": null
|
||||
}
|
||||
}
|
||||
11
code/Executable/config/download_cleaner.json
Normal file
11
code/Executable/config/download_cleaner.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"cron_expression": "0 0 * * * ?",
|
||||
"categories": [],
|
||||
"delete_private": false,
|
||||
"ignored_downloads_path": "",
|
||||
"unlinked_target_category": "cleanuperr-unlinked",
|
||||
"unlinked_use_tag": false,
|
||||
"unlinked_ignored_root_dir": "",
|
||||
"unlinked_categories": []
|
||||
}
|
||||
13
code/Executable/config/download_client.json
Normal file
13
code/Executable/config/download_client.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"clients": [
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "1ded77f9-199c-4325-9de6-7758b691e5e6",
|
||||
"name": "qbittorrent",
|
||||
"host": "http://localhost:8080",
|
||||
"username": "test",
|
||||
"password": "testing",
|
||||
"type": "qbittorrent"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
code/Executable/config/ignored_downloads.json
Normal file
3
code/Executable/config/ignored_downloads.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignored_downloads": []
|
||||
}
|
||||
5
code/Executable/config/lidarr.json
Normal file
5
code/Executable/config/lidarr.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"import_failed_max_strikes": -1,
|
||||
"instances": []
|
||||
}
|
||||
22
code/Executable/config/queue_cleaner.json
Normal file
22
code/Executable/config/queue_cleaner.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"cron_expression": "0/30 * * * * ?",
|
||||
"run_sequentially": false,
|
||||
"ignored_downloads_path": "",
|
||||
"import_failed_max_strikes": 3,
|
||||
"import_failed_ignore_private": false,
|
||||
"import_failed_delete_private": false,
|
||||
"import_failed_ignore_patterns": [],
|
||||
"stalled_max_strikes": 3,
|
||||
"stalled_reset_strikes_on_progress": false,
|
||||
"stalled_ignore_private": false,
|
||||
"stalled_delete_private": false,
|
||||
"downloading_metadata_max_strikes": 3,
|
||||
"slow_max_strikes": 3,
|
||||
"slow_reset_strikes_on_progress": false,
|
||||
"slow_ignore_private": false,
|
||||
"slow_delete_private": false,
|
||||
"slow_min_speed": "1KB",
|
||||
"slow_max_time": 0,
|
||||
"slow_ignore_above_size": ""
|
||||
}
|
||||
5
code/Executable/config/radarr.json
Normal file
5
code/Executable/config/radarr.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"import_failed_max_strikes": -1,
|
||||
"instances": []
|
||||
}
|
||||
13
code/Executable/config/sonarr.json
Normal file
13
code/Executable/config/sonarr.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"search_type": "episode",
|
||||
"import_failed_max_strikes": -1,
|
||||
"instances": [
|
||||
{
|
||||
"id": "c260fdf2-5f79-4449-b40b-1dce1e8ef2ac",
|
||||
"name": "sonarr1",
|
||||
"url": "http://localhost:8989",
|
||||
"api_key": "425d1e713f0c405cbbf359ac0502c1f4"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user