This commit is contained in:
Flaminel
2025-05-17 20:11:28 +03:00
parent 1b47921ac5
commit d2eb9e50e0
16 changed files with 518 additions and 270 deletions

View File

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

View 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);
}
}

View 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
}
}

View 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": []
}

View 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"
}
]
}

View File

@@ -0,0 +1,3 @@
{
"ignored_downloads": []
}

View File

@@ -0,0 +1,5 @@
{
"enabled": false,
"import_failed_max_strikes": -1,
"instances": []
}

View 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": ""
}

View File

@@ -0,0 +1,5 @@
{
"enabled": false,
"import_failed_max_strikes": -1,
"instances": []
}

View 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"
}
]
}