Add option to inject blacklist into qBittorrent (#304)

This commit is contained in:
Flaminel
2025-09-15 21:59:49 +03:00
committed by GitHub
parent 83e6a289be
commit 6398ef1cc6
45 changed files with 2106 additions and 163 deletions

View File

@@ -19,6 +19,7 @@ using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -54,6 +55,23 @@ public class ConfigurationController : ControllerBase
_notificationService = notificationService;
}
[HttpGet("blacklist_sync")]
public async Task<IActionResult> GetBlacklistSyncConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("queue_cleaner")]
public async Task<IActionResult> GetQueueCleanerConfig()
{
@@ -1213,6 +1231,64 @@ public class ConfigurationController : ControllerBase
}
}
[HttpPut("blacklist_sync")]
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] BlacklistSyncConfig newConfig)
{
await DataContext.Lock.WaitAsync();
try
{
newConfig.Validate();
var oldConfig = await _dataContext.BlacklistSyncConfigs
.FirstAsync();
bool enabledChanged = oldConfig.Enabled != newConfig.Enabled;
bool becameEnabled = !oldConfig.Enabled && newConfig.Enabled;
bool pathChanged = !(oldConfig.BlacklistPath?.Equals(newConfig.BlacklistPath, StringComparison.InvariantCultureIgnoreCase) ?? true);
var adapterConfig = new TypeAdapterConfig();
adapterConfig.NewConfig<BlacklistSyncConfig, BlacklistSyncConfig>()
.Ignore(dest => dest.Id)
// Cron expression changes are not supported yet for this type of job
.Ignore(dest => dest.CronExpression);
newConfig.Adapt(oldConfig, adapterConfig);
await _dataContext.SaveChangesAsync();
if (enabledChanged)
{
if (becameEnabled)
{
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, newConfig.CronExpression);
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
}
else
{
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
}
}
else if (pathChanged && oldConfig.Enabled)
{
_logger.LogDebug("BlacklistSynchronizer path changed");
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
}
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("sonarr")]
public async Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateSonarrConfigDto newConfigDto)
{

View File

@@ -1,4 +1,6 @@
using Cleanuparr.Application.Features.BlacklistSync;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.DownloadClient;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Infrastructure.Events;
@@ -12,11 +14,11 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Files;
namespace Cleanuparr.Api.DependencyInjection;
@@ -40,6 +42,7 @@ public static class ServicesDI
.AddScoped<WhisparrClient>()
.AddScoped<ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
@@ -51,6 +54,7 @@ public static class ServicesDI
.AddScoped<ArrQueueIterator>()
.AddScoped<DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<FileReader>()
.AddSingleton<IJobManagementService, JobManagementService>()
.AddSingleton<BlocklistProvider>();
}

View File

@@ -1,4 +1,6 @@
using Cleanuparr.Application.Features.BlacklistSync;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.DownloadClient;
using Cleanuparr.Application.Features.MalwareBlocker;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Domain.Exceptions;
@@ -8,6 +10,8 @@ using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Quartz;
@@ -45,12 +49,12 @@ public class BackgroundJobManager : IHostedService
{
try
{
_logger.LogInformation("Starting BackgroundJobManager");
_logger.LogDebug("Starting BackgroundJobManager");
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
await InitializeJobsFromConfiguration(cancellationToken);
_logger.LogInformation("BackgroundJobManager started");
_logger.LogDebug("BackgroundJobManager started");
}
catch (Exception ex)
{
@@ -64,15 +68,15 @@ public class BackgroundJobManager : IHostedService
/// </summary>
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping BackgroundJobManager");
_logger.LogDebug("Stopping BackgroundJobManager");
if (_scheduler != null)
{
// Don't shutdown the scheduler as it's managed by QuartzHostedService
// Don't shut down the scheduler as it's managed by QuartzHostedService
await _scheduler.Standby(cancellationToken);
}
_logger.LogInformation("BackgroundJobManager stopped");
_logger.LogDebug("BackgroundJobManager stopped");
}
/// <summary>
@@ -86,7 +90,6 @@ public class BackgroundJobManager : IHostedService
throw new InvalidOperationException("Scheduler not initialized");
}
// Use scoped DataContext to prevent memory leaks
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
@@ -100,11 +103,15 @@ public class BackgroundJobManager : IHostedService
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
}
/// <summary>
@@ -120,7 +127,7 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
}
}
@@ -137,7 +144,7 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
}
}
@@ -152,7 +159,21 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
/// </summary>
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
{
// Always register the job definition
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
if (config.Enabled)
{
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
}
}
@@ -160,10 +181,9 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add triggers for an existing job.
/// </summary>
private async Task AddTriggersForJob<T>(
IJobConfig config,
string cronExpression,
CancellationToken cancellationToken = default)
where T : GenericHandler
where T : IHandler
{
if (_scheduler == null)
{
@@ -228,7 +248,7 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
where T : GenericHandler
where T : IHandler
{
if (_scheduler == null)
{
@@ -254,6 +274,6 @@ public class BackgroundJobManager : IHostedService
// Add job to scheduler
await _scheduler.AddJob(jobDetail, true, cancellationToken);
_logger.LogInformation("Registered job {name} without trigger", typeName);
_logger.LogDebug("Registered job {name} without trigger", typeName);
}
}

View File

@@ -0,0 +1,162 @@
using System.Security.Cryptography;
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Application.Features.BlacklistSync;
public sealed class BlacklistSynchronizer : IHandler
{
private readonly ILogger<BlacklistSynchronizer> _logger;
private readonly DataContext _dataContext;
private readonly DownloadServiceFactory _downloadServiceFactory;
private readonly FileReader _fileReader;
private readonly IDryRunInterceptor _dryRunInterceptor;
public BlacklistSynchronizer(
ILogger<BlacklistSynchronizer> logger,
DataContext dataContext,
DownloadServiceFactory downloadServiceFactory,
FileReader fileReader,
IDryRunInterceptor dryRunInterceptor
)
{
_logger = logger;
_dataContext = dataContext;
_downloadServiceFactory = downloadServiceFactory;
_fileReader = fileReader;
_dryRunInterceptor = dryRunInterceptor;
}
public async Task ExecuteAsync()
{
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync();
if (!config.Enabled)
{
_logger.LogDebug("Blacklist sync is disabled");
return;
}
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
{
_logger.LogWarning("Blacklist sync path is not configured");
return;
}
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
string currentHash = ComputeHash(excludedFileNames);
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
_logger.LogDebug("Blacklist synchronization completed");
}
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
{
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
.AsNoTracking()
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
.ToListAsync();
if (qBittorrentClients.Count is 0)
{
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
return;
}
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
// Pull existing sync history for this hash
var alreadySynced = await _dataContext.BlacklistSyncHistory
.AsNoTracking()
.Where(s => s.Hash == currentHash)
.Select(x => x.DownloadClientId)
.ToListAsync();
// Only update clients not present in history for current hash
foreach (var clientConfig in qBittorrentClients)
{
try
{
if (alreadySynced.Contains(clientConfig.Id))
{
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
continue;
}
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
if (downloadService is not QBitService qBitService)
{
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
continue;
}
try
{
await qBitService.LoginAsync();
await qBitService.UpdateBlacklistAsync(excludedFileNames);
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
// Insert history row marking this client as synced for current hash
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
{
Hash = currentHash,
DownloadClientId = clientConfig.Id
});
await _dataContext.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
}
finally
{
qBitService.Dispose();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
}
}
}
private static string ComputeHash(string excludedFileNames)
{
using var sha = SHA256.Create();
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
byte[] hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private async Task RemoveOldSyncDataAsync(string currentHash)
{
try
{
await _dataContext.BlacklistSyncHistory
.Where(s => s.Hash != currentHash)
.ExecuteDeleteAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
}
}
}

View File

@@ -6,9 +6,9 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Infrastructure.Interceptors;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;

View File

@@ -3,11 +3,11 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -3,9 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -3,9 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -3,9 +3,9 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Readarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -5,9 +5,9 @@ using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Series = Cleanuparr.Domain.Entities.Sonarr.Series;

View File

@@ -6,9 +6,9 @@ using Cleanuparr.Domain.Entities.Whisparr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -5,8 +5,8 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

View File

@@ -8,11 +8,11 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

View File

@@ -3,9 +3,10 @@ using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

View File

@@ -2,4 +2,5 @@
public interface IQBitService : IDownloadService, IDisposable
{
Task UpdateBlacklistAsync(string blacklistPath);
}

View File

@@ -2,11 +2,13 @@ using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
@@ -45,11 +47,11 @@ public partial class QBitService : DownloadService, IQBitService
try
{
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
_logger.LogDebug("Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
_logger.LogDebug("Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to login to QBittorrent client {clientId}", _downloadClientConfig.Id);
_logger.LogError(ex, "Failed to login to qBittorrent client {clientId}", _downloadClientConfig.Id);
throw;
}
}
@@ -65,15 +67,15 @@ public partial class QBitService : DownloadService, IQBitService
if (hasCredentials)
{
// If credentials are provided, we must be able to login for the service to be healthy
// If credentials are provided, we must be able to log in for the service to be healthy
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
_logger.LogDebug("Health check: Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
_logger.LogDebug("Health check: Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
}
else
{
// If no credentials, test connectivity using version endpoint
await _client.GetApiVersionAsync();
_logger.LogDebug("Health check: Successfully connected to QBittorrent client {clientId}", _downloadClientConfig.Id);
_logger.LogDebug("Health check: Successfully connected to qBittorrent client {clientId}", _downloadClientConfig.Id);
}
stopwatch.Stop();
@@ -88,7 +90,7 @@ public partial class QBitService : DownloadService, IQBitService
{
stopwatch.Stop();
_logger.LogWarning(ex, "Health check failed for QBittorrent client {clientId}", _downloadClientConfig.Id);
_logger.LogWarning(ex, "Health check failed for qBittorrent client {clientId}", _downloadClientConfig.Id);
return new HealthCheckResult
{
@@ -98,6 +100,23 @@ public partial class QBitService : DownloadService, IQBitService
};
}
}
/// <summary>
/// Syncs blacklist patterns from configured file to qBittorrent excluded file names
/// </summary>
/// <param name="excludedFileNames">List of excluded file names for qBittorrent</param>
public async Task UpdateBlacklistAsync(string excludedFileNames)
{
Preferences preferences = new()
{
AdditionalData = new Dictionary<string, JToken>
{
{ "excluded_file_names", excludedFileNames }
}
};
await _client.SetPreferencesAsync(preferences);
}
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
{

View File

@@ -3,8 +3,8 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Transmission.API.RPC;

View File

@@ -3,8 +3,8 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

View File

@@ -7,7 +7,6 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
@@ -19,7 +18,6 @@ public sealed class BlocklistProvider
{
private readonly ILogger<BlocklistProvider> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly Dictionary<InstanceType, string> _configHashes = new();
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
@@ -31,37 +29,12 @@ public sealed class BlocklistProvider
public BlocklistProvider(
ILogger<BlocklistProvider> logger,
IServiceScopeFactory scopeFactory,
IMemoryCache cache,
IHttpClientFactory httpClientFactory
IMemoryCache cache
)
{
_logger = logger;
_scopeFactory = scopeFactory;
_cache = cache;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType)
{
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
{
return false;
}
string hash = GenerateSettingsHash(settings);
var interval = GetLoadInterval(settings.BlocklistPath);
var identifier = $"{instanceType}_{settings.BlocklistPath}";
if (ShouldReloadBlocklist(identifier, interval) || !_configHashes.TryGetValue(instanceType, out string? oldHash) || hash != oldHash)
{
_logger.LogDebug("Loading {instance} blocklist", instanceType);
await LoadPatternsAndRegexesAsync(settings, instanceType);
_configHashes[instanceType] = hash;
_lastLoadTimes[identifier] = DateTime.UtcNow;
return true;
}
return false;
}
public async Task LoadBlocklistsAsync()
@@ -70,6 +43,8 @@ public sealed class BlocklistProvider
{
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
var fileReader = scope.ServiceProvider.GetRequiredService<FileReader>();
int changedCount = 0;
var malwareBlockerConfig = await dataContext.ContentBlockerConfigs
.AsNoTracking()
@@ -92,14 +67,14 @@ public sealed class BlocklistProvider
foreach (var kv in instances)
{
if (await EnsureInstanceLoadedAsync(kv.Value, kv.Key))
if (await EnsureInstanceLoadedAsync(kv.Value, kv.Key, fileReader))
{
changedCount++;
}
}
// Always check and update malware patterns
await LoadMalwarePatternsAsync();
await LoadMalwarePatternsAsync(fileReader);
if (changedCount > 0)
{
@@ -145,6 +120,29 @@ public sealed class BlocklistProvider
return patterns ?? [];
}
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType, FileReader fileReader)
{
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
{
return false;
}
string hash = GenerateSettingsHash(settings);
var interval = GetLoadInterval(settings.BlocklistPath);
var identifier = $"{instanceType}_{settings.BlocklistPath}";
if (ShouldReloadBlocklist(identifier, interval) || !_configHashes.TryGetValue(instanceType, out string? oldHash) || hash != oldHash)
{
_logger.LogDebug("Loading {instance} blocklist", instanceType);
await LoadPatternsAndRegexesAsync(settings, instanceType, fileReader);
_configHashes[instanceType] = hash;
_lastLoadTimes[identifier] = DateTime.UtcNow;
return true;
}
return false;
}
private TimeSpan GetLoadInterval(string? path)
{
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
@@ -171,7 +169,7 @@ public sealed class BlocklistProvider
return DateTime.UtcNow - lastLoad >= interval;
}
private async Task LoadMalwarePatternsAsync()
private async Task LoadMalwarePatternsAsync(FileReader fileReader)
{
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
@@ -184,7 +182,7 @@ public sealed class BlocklistProvider
{
_logger.LogDebug("Loading malware patterns");
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
string[] filePatterns = await fileReader.ReadContentAsync(MalwareListUrl);
long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
@@ -209,14 +207,14 @@ public sealed class BlocklistProvider
}
}
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType, FileReader fileReader)
{
if (string.IsNullOrEmpty(blocklistSettings.BlocklistPath))
{
return;
}
string[] filePatterns = await ReadContentAsync(blocklistSettings.BlocklistPath);
string[] filePatterns = await fileReader.ReadContentAsync(blocklistSettings.BlocklistPath);
long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
@@ -256,32 +254,6 @@ public sealed class BlocklistProvider
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistSettings.BlocklistPath);
}
private async Task<string[]> ReadContentAsync(string path)
{
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
// http(s) url
return await ReadFromUrlAsync(path);
}
if (File.Exists(path))
{
// local file path
return await File.ReadAllLinesAsync(path);
}
throw new ArgumentException($"blocklist not found | {path}");
}
private async Task<string[]> ReadFromUrlAsync(string url)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadAsStringAsync())
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
}
private string GenerateSettingsHash(BlocklistSettings blocklistSettings)
{
// Create a string that represents the relevant blocklist configuration

View File

@@ -3,8 +3,8 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Notifications;

View File

@@ -0,0 +1,44 @@
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Infrastructure.Helpers;
public class FileReader
{
private readonly HttpClient _httpClient;
public FileReader(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
/// <summary>
/// Reads content from either a local file or HTTP(S) URL
/// Extracted from BlocklistProvider.ReadContentAsync for reuse
/// </summary>
/// <param name="path">File path or URL</param>
/// <returns>Array of lines from the content</returns>
public async Task<string[]> ReadContentAsync(string path)
{
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
return await ReadFromUrlAsync(path);
}
if (File.Exists(path))
{
// local file path
return await File.ReadAllLinesAsync(path);
}
throw new ArgumentException($"File not found: {path}");
}
private async Task<string[]> ReadFromUrlAsync(string url)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadAsStringAsync())
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
}
}

View File

@@ -1,6 +1,5 @@
using System.Reflection;
using Cleanuparr.Persistence;
using Infrastructure.Interceptors;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Interceptors;
namespace Cleanuparr.Infrastructure.Interceptors;
public interface IDryRunInterceptor
{

View File

@@ -7,5 +7,6 @@ public enum JobType
{
QueueCleaner,
MalwareBlocker,
DownloadCleaner
DownloadCleaner,
BlacklistSynchronizer,
}

View File

@@ -7,6 +7,8 @@ using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -43,6 +45,10 @@ public class DataContext : DbContext
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
public DataContext()
{
}
@@ -125,6 +131,19 @@ public class DataContext : DbContext
entity.HasIndex(p => p.Name).IsUnique();
});
// Configure BlacklistSyncState relationships and indexes
modelBuilder.Entity<BlacklistSyncHistory>(entity =>
{
// FK to DownloadClientConfig by DownloadClientId with cascade on delete
entity.HasOne(s => s.DownloadClient)
.WithMany()
.HasForeignKey(s => s.DownloadClientId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(s => new { s.Hash, DownloadClientId = s.DownloadClientId }).IsUnique();
entity.HasIndex(s => s.Hash);
});
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{

View File

@@ -0,0 +1,816 @@
// <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("20250915153159_AddBlacklistSyncSettings")]
partial class AddBlacklistSyncSettings
{
/// <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.BlacklistSync.BlacklistSyncConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("BlacklistPath")
.HasColumnType("TEXT")
.HasColumnName("blacklist_path");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.HasKey("Id")
.HasName("pk_blacklist_sync_configs");
b.ToTable("blacklist_sync_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<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("TimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_time_limit_hours");
});
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
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.MalwareBlocker.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.MalwareBlocker.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.MalwareBlocker.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.MalwareBlocker.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.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_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")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("tags");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_apprise_configs_notification_config_id");
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")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is_enabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name");
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>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_notification_configs");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_notification_configs_name");
b.ToTable("notification_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.State.BlacklistSyncHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadClientId")
.HasColumnType("TEXT")
.HasColumnName("download_client_id");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("hash");
b.HasKey("Id")
.HasName("pk_blacklist_sync_history");
b.HasIndex("DownloadClientId")
.HasDatabaseName("ix_blacklist_sync_history_download_client_id");
b.HasIndex("Hash")
.HasDatabaseName("ix_blacklist_sync_history_hash");
b.HasIndex("Hash", "DownloadClientId")
.IsUnique()
.HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id");
b.ToTable("blacklist_sync_history", (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.Notification.AppriseConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("AppriseConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("NotifiarrConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient")
.WithMany()
.HasForeignKey("DownloadClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id");
b.Navigation("DownloadClient");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddBlacklistSyncSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "blacklist_sync_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
cron_expression = table.Column<string>(type: "TEXT", nullable: false),
blacklist_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_blacklist_sync_configs", x => x.id);
});
migrationBuilder.CreateTable(
name: "blacklist_sync_history",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
hash = table.Column<string>(type: "TEXT", nullable: false),
download_client_id = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_blacklist_sync_history", x => x.id);
table.ForeignKey(
name: "fk_blacklist_sync_history_download_clients_download_client_id",
column: x => x.download_client_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "blacklist_sync_configs",
columns: new[] { "id", "enabled", "cron_expression", "blacklist_path" },
values: new object[] { Guid.NewGuid(), false, "0 0 * * * ?", null });
migrationBuilder.CreateIndex(
name: "ix_blacklist_sync_history_download_client_id",
table: "blacklist_sync_history",
column: "download_client_id");
migrationBuilder.CreateIndex(
name: "ix_blacklist_sync_history_hash",
table: "blacklist_sync_history",
column: "hash");
migrationBuilder.CreateIndex(
name: "ix_blacklist_sync_history_hash_download_client_id",
table: "blacklist_sync_history",
columns: new[] { "hash", "download_client_id" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "blacklist_sync_configs");
migrationBuilder.DropTable(
name: "blacklist_sync_history");
}
}
}

View File

@@ -79,6 +79,32 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("BlacklistPath")
.HasColumnType("TEXT")
.HasColumnName("blacklist_path");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.HasKey("Id")
.HasName("pk_blacklist_sync_configs");
b.ToTable("blacklist_sync_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
@@ -673,6 +699,38 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadClientId")
.HasColumnType("TEXT")
.HasColumnName("download_client_id");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("hash");
b.HasKey("Id")
.HasName("pk_blacklist_sync_history");
b.HasIndex("DownloadClientId")
.HasDatabaseName("ix_blacklist_sync_history_download_client_id");
b.HasIndex("Hash")
.HasDatabaseName("ix_blacklist_sync_history_hash");
b.HasIndex("Hash", "DownloadClientId")
.IsUnique()
.HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id");
b.ToTable("blacklist_sync_history", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
@@ -721,6 +779,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient")
.WithMany()
.HasForeignKey("DownloadClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id");
b.Navigation("DownloadClient");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");

View File

@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
/// <summary>
/// Configuration for Blacklist Synchronization to qBittorrent
/// </summary>
public sealed record BlacklistSyncConfig : IJobConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public bool Enabled { get; set; }
public string CronExpression { get; set; } = "0 0 * * * ?";
public string? BlacklistPath { get; set; }
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(BlacklistPath))
{
throw new ValidationException("Blacklist sync is enabled but the path is not configured");
}
bool isValidPath = Uri.TryCreate(BlacklistPath, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) ||
File.Exists(BlacklistPath);
if (!isValidPath)
{
throw new ValidationException("Blacklist path must be a valid URL or an existing local file path");
}
}
}

View File

@@ -1,8 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using Serilog;
using Serilog.Events;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.General;

View File

@@ -5,9 +5,4 @@ public interface IJobConfig : IConfig
bool Enabled { get; set; }
string CronExpression { get; set; }
/// <summary>
/// Indicates whether to use the CronExpression directly (true) or convert from JobSchedule (false)
/// </summary>
bool UseAdvancedScheduling { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Persistence.Models.State;
/// <summary>
/// Tracks which download clients have been synchronized for a specific blacklist content hash.
/// </summary>
public sealed record BlacklistSyncHistory
{
/// <summary>
/// Primary key
/// </summary>
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// SHA-256 hash of the blacklist contents used during synchronization
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// Foreign key to the download client this sync entry applies to
/// </summary>
public required Guid DownloadClientId { get; init; }
/// <summary>
/// Navigation property to the associated download client configuration
/// </summary>
public DownloadClientConfig DownloadClient { get; init; } = null!;
}

View File

@@ -27,6 +27,11 @@ export const routes: Routes = [
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'blacklist-synchronizer',
loadComponent: () => import('./settings/blacklist-sync/blacklist-sync-settings.component').then(m => m.BlacklistSyncSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{ 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) },

View File

@@ -13,6 +13,7 @@ import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-confi
import { GeneralConfig } from "../../shared/models/general-config.model";
import { ApplicationPathService } from "./base-path.service";
import { ErrorHandlerUtil } from "../utils/error-handler.util";
import { BlacklistSyncConfig } from "../../shared/models/blacklist-sync-config.model";
@Injectable({
providedIn: "root",
@@ -33,6 +34,31 @@ export class ConfigurationService {
);
}
/**
* Get Blacklist Sync configuration
*/
getBlacklistSyncConfig(): Observable<BlacklistSyncConfig> {
return this.http.get<BlacklistSyncConfig>(this.ApplicationPathService.buildApiUrl('/configuration/blacklist_sync')).pipe(
catchError((error) => {
console.error("Error fetching Blacklist Sync config:", error);
return throwError(() => new Error("Failed to load Blacklist Sync configuration"));
})
);
}
/**
* Update Blacklist Sync configuration
*/
updateBlacklistSyncConfig(config: BlacklistSyncConfig): Observable<any> {
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/blacklist_sync'), config).pipe(
catchError((error) => {
console.error("Error updating Blacklist Sync config:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Update general configuration
*/

View File

@@ -43,6 +43,8 @@ export class DocumentationService {
'httpCertificateValidation': 'http-certificate-validation',
'searchEnabled': 'search-enabled',
'searchDelay': 'search-delay',
'enableBlacklistSync': 'enable-blacklist-sync',
'blacklistPath': 'blacklist-path',
'log.level': 'log-level',
'log.rollingSizeMB': 'log-rolling-size-mb',
'log.retainedFileCount': 'log-retained-file-count',
@@ -159,4 +161,4 @@ export class DocumentationService {
hasFieldDocumentation(section: string, fieldName: string): boolean {
return !!this.getFieldAnchor(section, fieldName);
}
}
}

View File

@@ -110,6 +110,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
{ route: '/malware-blocker', navigationPath: ['settings', 'malware-blocker'] },
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
{ route: '/blacklist-synchronizer', navigationPath: ['settings', 'blacklist-synchronizer'] },
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
// Other routes will be handled dynamically
@@ -226,6 +227,7 @@ export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
{ id: 'malware-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/malware-blocker' },
{ id: 'download-cleaner', label: 'Download Cleaner', icon: 'pi pi-trash', route: '/download-cleaner' },
{ id: 'blacklist-synchronizer', label: 'Blacklist Synchronizer', icon: 'pi pi-sync', route: '/blacklist-synchronizer' },
{ id: 'notifications', label: 'Notifications', icon: 'pi pi-bell', route: '/notifications' }
]
},

View File

@@ -0,0 +1,91 @@
import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ConfigurationService } from '../../core/services/configuration.service';
import { BlacklistSyncConfig } from '../../shared/models/blacklist-sync-config.model';
import { EMPTY, Observable } from 'rxjs';
import { switchMap, tap, catchError } from 'rxjs/operators';
export interface BlacklistSyncState {
config: BlacklistSyncConfig | null;
loading: boolean;
saving: boolean;
loadError: string | null; // Only for load failures that should show "Not connected"
saveError: string | null; // Only for save failures that should show toast
}
const initialState: BlacklistSyncState = {
config: null,
loading: false,
saving: false,
loadError: null,
saveError: null,
};
@Injectable()
export class BlacklistSyncConfigStore extends signalStore(
withState(initialState),
withMethods((store, configService = inject(ConfigurationService)) => ({
loadConfig: rxMethod<void>(
pipe => pipe.pipe(
tap(() => patchState(store, { loading: true, loadError: null, saveError: null })),
switchMap(() => configService.getBlacklistSyncConfig().pipe(
tap({
next: (config) => patchState(store, { config, loading: false, loadError: null }),
error: (error) => {
const errorMessage = error.message || 'Failed to load Blacklist Sync configuration';
patchState(store, {
loading: false,
loadError: errorMessage // Only load errors should trigger "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = error.message || 'Failed to load Blacklist Sync configuration';
patchState(store, {
loading: false,
loadError: errorMessage // Only load errors should trigger "Not connected" state
});
return EMPTY;
})
))
)
),
saveConfig: rxMethod<BlacklistSyncConfig>(
(config$: Observable<BlacklistSyncConfig>) => config$.pipe(
tap(() => patchState(store, { saving: true, saveError: null })),
switchMap(config => configService.updateBlacklistSyncConfig(config).pipe(
tap({
next: () => patchState(store, { config, saving: false, saveError: null }),
error: (error) => {
const errorMessage = error.message || 'Failed to update Blacklist Sync configuration';
patchState(store, {
saving: false,
saveError: errorMessage // Save errors don't affect "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = error.message || 'Failed to update Blacklist Sync configuration';
patchState(store, {
saving: false,
saveError: errorMessage // Save errors don't affect "Not connected" state
});
return EMPTY;
})
))
)
),
updateConfigLocally(config: Partial<BlacklistSyncConfig>) {
const current = store.config();
if (current) {
patchState(store, { config: { ...current, ...config } });
}
}
})),
withHooks({
onInit({ loadConfig }) {
loadConfig();
}
})
) {}

View File

@@ -0,0 +1,92 @@
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Blacklist Sync</h1>
</div>
<!-- Loading/Error Component -->
<app-loading-error-state
*ngIf="blacklistSyncLoading() || blacklistSyncLoadError()"
[loading]="blacklistSyncLoading()"
[error]="blacklistSyncLoadError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!blacklistSyncLoading() && !blacklistSyncLoadError() && blacklistSyncForm" [formGroup]="blacklistSyncForm" class="p-fluid">
<!-- Blacklist Sync 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">Blacklist Sync Configuration</h2>
<span class="card-subtitle">Configure automatic blacklist synchronization</span>
</div>
</div>
</ng-template>
<div class="card-content">
<!-- Enable Blacklist Sync -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation">
</i>
Enable Blacklist Sync
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true" inputId="blacklistSyncEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, blacklist patterns will be synchronized to download clients hourly</small>
</div>
</div>
<!-- Blacklist Path -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('blacklistPath')"
title="Click for documentation">
</i>
Blacklist Path
</label>
<div>
<div class="field-input">
<input
type="text"
pInputText
formControlName="blacklistPath"
placeholder="File path or http(s) URL"
id="blacklistPath" />
</div>
<small *ngIf="hasError('blacklistPath', 'required')" class="p-error">This field is required when blacklist sync is enabled</small>
<small class="form-helper-text">Path to blacklist file or HTTP(S) URL containing blacklist patterns</small>
</div>
</div>
</div>
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!blacklistSyncForm.dirty || !hasActualChanges) || blacklistSyncForm.invalid || blacklistSyncSaving()"
[loading]="blacklistSyncSaving()"
(click)="saveBlacklistSyncConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetBlacklistSyncConfig()"
></button>
</div>
</p-card>
</form>
</div>

View File

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

View File

@@ -0,0 +1,337 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { BlacklistSyncConfigStore } from "./blacklist-sync-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { BlacklistSyncConfig } from "../../shared/models/blacklist-sync-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 { ToastModule } from "primeng/toast";
import { NotificationService } from '../../core/services/notification.service';
import { DocumentationService } from '../../core/services/documentation.service';
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@Component({
selector: "app-blacklist-sync-settings",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CardModule,
InputTextModule,
CheckboxModule,
ButtonModule,
ToastModule,
LoadingErrorStateComponent,
],
providers: [BlacklistSyncConfigStore],
templateUrl: "./blacklist-sync-settings.component.html",
styleUrls: ["./blacklist-sync-settings.component.scss"],
})
export class BlacklistSyncSettingsComponent implements OnDestroy, CanComponentDeactivate {
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Blacklist Sync Configuration Form
blacklistSyncForm: FormGroup;
// Original form values for tracking changes
private originalFormValues: any;
// Track whether the form has actual changes compared to original values
hasActualChanges = false;
// Inject the necessary services
private formBuilder = inject(FormBuilder);
private notificationService = inject(NotificationService);
private documentationService = inject(DocumentationService);
private blacklistSyncConfigStore = inject(BlacklistSyncConfigStore);
// Signals from the store
readonly blacklistSyncConfig = this.blacklistSyncConfigStore.config;
readonly blacklistSyncLoading = this.blacklistSyncConfigStore.loading;
readonly blacklistSyncSaving = this.blacklistSyncConfigStore.saving;
readonly blacklistSyncLoadError = this.blacklistSyncConfigStore.loadError; // Only for "Not connected" state
readonly blacklistSyncSaveError = this.blacklistSyncConfigStore.saveError; // Only for toast notifications
// Subject for unsubscribing from observables when component is destroyed
private destroy$ = new Subject<void>();
// Flag to track if form has been initially loaded to avoid showing dialog on page load
private formInitialized = false;
/**
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.blacklistSyncForm.dirty;
}
/**
* Open field-specific documentation in a new tab
* @param fieldName The form field name (e.g., 'enabled', 'blacklistPath')
*/
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('blacklist-sync', fieldName);
}
constructor() {
// Initialize the blacklist sync settings form
this.blacklistSyncForm = this.formBuilder.group({
enabled: [false],
blacklistPath: ['', [Validators.required]],
});
// Effect to handle configuration changes
effect(() => {
const config = this.blacklistSyncConfig();
if (config) {
// Reset form with the config values
this.blacklistSyncForm.patchValue({
enabled: config.enabled,
blacklistPath: config.blacklistPath || '',
});
// Store original values for dirty checking
this.storeOriginalValues();
// Update blacklist path controls state based on loaded configuration
const blacklistSyncEnabled = config.enabled ?? false;
this.updateBlacklistPathControlState(blacklistSyncEnabled);
// Mark form as initialized to enable confirmation dialogs for user actions
this.formInitialized = true;
// Mark form as pristine since we've just loaded the data
this.blacklistSyncForm.markAsPristine();
}
});
// Effect to handle load errors - emit to LoadingErrorStateComponent for "Not connected" display
effect(() => {
const loadErrorMessage = this.blacklistSyncLoadError();
if (loadErrorMessage) {
// Load errors should be shown as "Not connected to server" in LoadingErrorStateComponent
this.error.emit(loadErrorMessage);
}
});
// Effect to handle save errors - show as toast notifications for user to fix
effect(() => {
const saveErrorMessage = this.blacklistSyncSaveError();
if (saveErrorMessage) {
// Always show save errors as a toast so the user sees the backend message.
this.notificationService.showError(saveErrorMessage);
}
});
// Set up listeners for form value changes
this.setupFormValueChangeListeners();
}
/**
* Set up listeners for form control value changes
*/
private setupFormValueChangeListeners(): void {
// Listen to all form changes to check for actual differences from original values
this.blacklistSyncForm.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasActualChanges = this.formValuesChanged();
});
// Listen for changes to the 'enabled' control
const enabledControl = this.blacklistSyncForm.get('enabled');
if (enabledControl) {
enabledControl.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((enabled: boolean) => {
this.updateBlacklistPathControlState(enabled);
});
}
}
/**
* Clean up subscriptions when component is destroyed
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Check if the current form values are different from the original values
*/
private formValuesChanged(): boolean {
if (!this.originalFormValues) return false;
const currentValues = this.blacklistSyncForm.getRawValue();
return !this.isEqual(currentValues, this.originalFormValues);
}
/**
* Update blacklist path control state based on enabled value
*/
private updateBlacklistPathControlState(enabled: boolean): void {
const blacklistPathControl = this.blacklistSyncForm.get('blacklistPath');
if (enabled) {
blacklistPathControl?.enable({ emitEvent: false });
} else {
blacklistPathControl?.disable({ emitEvent: false });
}
}
/**
* Validate all form controls, including disabled ones
*/
private validateAllFormControls(formGroup: FormGroup): void {
Object.keys(formGroup.controls).forEach(key => {
const control = formGroup.get(key);
if (control instanceof FormGroup) {
this.validateAllFormControls(control);
} else {
// Force validation even on disabled controls
control?.updateValueAndValidity({ onlySelf: true });
control?.markAsTouched();
}
});
}
/**
* Deep compare two objects for equality
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 !== 'object' || obj1 === null ||
typeof obj2 !== 'object' || obj2 === null) {
return obj1 === obj2;
}
if (Array.isArray(obj1) && Array.isArray(obj2)) {
if (obj1.length !== obj2.length) return false;
for (let i = 0; i < obj1.length; i++) {
if (!this.isEqual(obj1[i], obj2[i])) return false;
}
return true;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!keys2.includes(key)) return false;
if (!this.isEqual(obj1[key], obj2[key])) return false;
}
return true;
}
/**
* Store original form values for dirty checking
*/
private storeOriginalValues(): void {
// Create a deep copy of the form values to ensure proper comparison
this.originalFormValues = JSON.parse(JSON.stringify(this.blacklistSyncForm.getRawValue()));
this.hasActualChanges = false;
}
/**
* Save the blacklist sync configuration
*/
saveBlacklistSyncConfig(): void {
// Force validation on all controls, including disabled ones
this.validateAllFormControls(this.blacklistSyncForm);
// Mark all form controls as touched to trigger validation messages
this.markFormGroupTouched(this.blacklistSyncForm);
if (this.blacklistSyncForm.invalid) {
this.notificationService.showValidationError();
return;
}
const formValues = this.blacklistSyncForm.getRawValue();
const config: BlacklistSyncConfig = {
id: this.blacklistSyncConfig()?.id || '',
enabled: formValues.enabled,
blacklistPath: formValues.blacklistPath || undefined,
};
// Save the configuration
this.blacklistSyncConfigStore.saveConfig(config);
// Setup a one-time check to mark form as pristine after successful save
const checkSaveCompletion = () => {
const saving = this.blacklistSyncSaving();
const saveError = this.blacklistSyncSaveError();
if (!saving && !saveError) {
// Mark form as pristine after successful save
this.blacklistSyncForm.markAsPristine();
// Update original values reference
this.storeOriginalValues();
// Emit saved event
this.saved.emit();
// Display success message
this.notificationService.showSuccess('Blacklist Sync configuration saved successfully.');
} else if (!saving && saveError) {
// If there's a save error, we can stop checking
// Toast notification is already handled by the effect above
} else {
// If still saving, check again in a moment
setTimeout(checkSaveCompletion, 100);
}
};
// Start checking for save completion
checkSaveCompletion();
}
/**
* Reset the blacklist sync configuration form to default values
*/
resetBlacklistSyncConfig(): void {
this.blacklistSyncForm.reset({
enabled: false,
blacklistPath: '',
});
// Update blacklist path control state after reset
this.updateBlacklistPathControlState(false); // enabled defaults to false
// Mark form as dirty so the save button is enabled after reset
this.blacklistSyncForm.markAsDirty();
}
/**
* 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(controlName: string, errorName: string): boolean {
const control = this.blacklistSyncForm.get(controlName);
// Check for errors on both enabled and disabled controls that have been touched
return control ? control.hasError(errorName) : false;
}
}

View File

@@ -13,7 +13,7 @@
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!generalLoading() && !generalLoadError()" [formGroup]="generalForm" class="p-fluid">
<form *ngIf="!generalLoading() && !generalLoadError() && generalForm" [formGroup]="generalForm" class="p-fluid">
<!-- General Configuration Card -->
<p-card styleClass="settings-card mb-4">
@@ -194,6 +194,7 @@
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>
</div>
</div>
</p-card>

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { Component, EventEmitter, OnInit, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
@@ -136,7 +136,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
searchEnabled: [true],
searchDelay: [30, [Validators.required, Validators.min(1), Validators.max(300)]],
ignoredDownloads: [[]],
// Nested logging configuration form group
log: this.formBuilder.group({
level: [LogEventLevel.Information],
rollingSizeMB: [10, [Validators.required, Validators.min(0), Validators.max(100)]],
@@ -146,8 +145,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
archiveRetainedCount: [{ value: 60, disabled: false }, [Validators.required, Validators.min(0), Validators.max(100)]],
archiveTimeLimitHours: [{ value: 720, disabled: false }, [Validators.required, Validators.min(0), Validators.max(1440)]], // max 60 days
}),
// Temporary backward compatibility - will be removed
logLevel: [LogEventLevel.Information],
});
// Effect to handle configuration changes
@@ -164,9 +161,8 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
searchEnabled: config.searchEnabled,
searchDelay: config.searchDelay,
ignoredDownloads: config.ignoredDownloads || [],
// New nested logging configuration
log: config.log || {
level: config.logLevel || LogEventLevel.Information, // Fall back to old property
level: LogEventLevel.Information,
rollingSizeMB: 10,
retainedFileCount: 5,
timeLimitHours: 24,
@@ -174,8 +170,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
archiveRetainedCount: 60,
archiveTimeLimitHours: 720,
},
// Temporary backward compatibility
logLevel: config.logLevel || config.log?.level || LogEventLevel.Information,
});
// Store original values for dirty checking
@@ -439,10 +433,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
searchEnabled: formValues.searchEnabled,
searchDelay: formValues.searchDelay,
ignoredDownloads: formValues.ignoredDownloads || [],
// New nested logging configuration
log: formValues.log as LoggingConfig,
// Temporary backward compatibility - keep logLevel for now
logLevel: formValues.log?.level || formValues.logLevel,
};
// Save the configuration
@@ -488,7 +479,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
searchEnabled: true,
searchDelay: 30,
ignoredDownloads: [],
// Reset nested logging configuration to defaults
log: {
level: LogEventLevel.Information,
rollingSizeMB: 10,
@@ -498,8 +488,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
archiveRetainedCount: 60,
archiveTimeLimitHours: 720,
},
// Temporary backward compatibility
logLevel: LogEventLevel.Information,
});
// Update archive controls state after reset
@@ -528,7 +516,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
hasError(controlName: string, errorName: string): boolean {
const control = this.generalForm.get(controlName);
// Check for errors on both enabled and disabled controls that have been touched
return control ? (control.dirty || control.touched) && control.hasError(errorName) : false;
return control ? control.hasError(errorName) : false;
}
/**
@@ -542,7 +530,7 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva
const control = parentControl.get(controlName);
// Check for errors on both enabled and disabled controls that have been touched
return control ? (control.dirty || control.touched) && control.hasError(errorName) : false;
return control ? control.hasError(errorName) : false;
}
/**

View File

@@ -0,0 +1,5 @@
export interface BlacklistSyncConfig {
id: string;
enabled: boolean;
blacklistPath?: string;
}

View File

@@ -1,6 +1,5 @@
import { CertificateValidationType } from './certificate-validation-type.enum';
import { LoggingConfig } from './logging-config.model';
import { LogEventLevel } from './log-event-level.enum';
export interface GeneralConfig {
displaySupportBanner: boolean;
@@ -10,9 +9,6 @@ export interface GeneralConfig {
httpCertificateValidation: CertificateValidationType;
searchEnabled: boolean;
searchDelay: number;
// New logging configuration structure
log?: LoggingConfig;
// Temporary backward compatibility - will be removed in task 7
logLevel?: LogEventLevel;
ignoredDownloads: string[];
}

View File

@@ -3,11 +3,11 @@ sidebar_position: 1
---
import { Note, Warning } from '@site/src/components/Admonition';
import {
ConfigSection,
EnhancedNote,
import {
ConfigSection,
EnhancedNote,
EnhancedWarning,
styles
styles
} from '@site/src/components/documentation';
# General
@@ -124,6 +124,82 @@ A lower value or `0` will result in faster searches, but may cause issues such a
</ConfigSection>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>📦</span>
Download Management
</h2>
<ConfigSection
id="ignored-downloads"
title="Ignored Downloads"
icon="🚯"
>
Downloads matching these patterns will be ignored during all cleaning operations. Patterns can match any of these:
- torrent hash
- qBittorrent tag or category
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- torrent tracker domain
**Examples:**
```
fa800a7d7c443a2c3561d1f8f393c089036dade1
tv-sonarr
qbit-tag
mytracker.com
```
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>⛔</span>
Blacklist Sync
</h2>
<ConfigSection
id="enable-blacklist-sync"
title="Enable Blacklist Sync"
icon="✅"
>
When enabled, Cleanuparr will automatically synchronize blacklist entries to all enabled qBittorrent clients every hour. The sync happens when the blacklist path or content changes.
<EnhancedNote>
This feature updates the qBittorrent "Excluded file names" setting, but does not enable it.
</EnhancedNote>
</ConfigSection>
<ConfigSection
id="blacklist-path"
title="Blacklist Path"
icon="🗂️"
>
Path to the blacklist content. This can be a local file path or an HTTP/HTTPS URL. The application computes a content hash and only pushes updates when the content changes.
**Examples:**
```
/data/blacklists/qbit-exclusions.txt
https://example.com/blacklist.txt
```
<EnhancedWarning>
If Blacklist Sync is enabled, this field is required. For remote URLs, ensure the server is reachable from Cleanuparr and the certificate is valid (or adjust HTTP certificate validation settings).
</EnhancedWarning>
</ConfigSection>
</div>
</div>
<div className={styles.section}>
@@ -209,37 +285,4 @@ Maximum age (in hours) for archived logs before they are deleted. Older files be
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>📦</span>
Download Management
</h2>
<ConfigSection
id="ignored-downloads"
title="Ignored Downloads"
icon="🚯"
>
Downloads matching these patterns will be ignored during all cleaning operations. Patterns can match any of these:
- torrent hash
- qBittorrent tag or category
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- torrent tracker domain
**Examples:**
```
fa800a7d7c443a2c3561d1f8f393c089036dade1
tv-sonarr
qbit-tag
mytracker.com
```
</ConfigSection>
</div>
</div>