diff --git a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs index f2320fdb..818107bb 100644 --- a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs @@ -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 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 GetQueueCleanerConfig() { @@ -1213,6 +1231,64 @@ public class ConfigurationController : ControllerBase } } + [HttpPut("blacklist_sync")] + public async Task 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() + .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 UpdateSonarrConfig([FromBody] UpdateSonarrConfigDto newConfigDto) { diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index 7cd0e440..5b76688d 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -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() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -51,6 +54,7 @@ public static class ServicesDI .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddSingleton() .AddSingleton(); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs index 7cc5e14f..49bf8cd7 100644 --- a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs +++ b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs @@ -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 /// 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"); } /// @@ -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(); @@ -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); } /// @@ -120,7 +127,7 @@ public class BackgroundJobManager : IHostedService // Only add triggers if the job is enabled if (config.Enabled) { - await AddTriggersForJob(config, config.CronExpression, cancellationToken); + await AddTriggersForJob(config.CronExpression, cancellationToken); } } @@ -137,7 +144,7 @@ public class BackgroundJobManager : IHostedService // Only add triggers if the job is enabled if (config.Enabled) { - await AddTriggersForJob(config, config.CronExpression, cancellationToken); + await AddTriggersForJob(config.CronExpression, cancellationToken); } } @@ -152,7 +159,21 @@ public class BackgroundJobManager : IHostedService // Only add triggers if the job is enabled if (config.Enabled) { - await AddTriggersForJob(config, config.CronExpression, cancellationToken); + await AddTriggersForJob(config.CronExpression, cancellationToken); + } + } + + /// + /// Registers the BlacklistSync job and optionally adds triggers based on general configuration. + /// + public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default) + { + // Always register the job definition + await AddJobWithoutTrigger(cancellationToken); + + if (config.Enabled) + { + await AddTriggersForJob(config.CronExpression, cancellationToken); } } @@ -160,10 +181,9 @@ public class BackgroundJobManager : IHostedService /// Helper method to add triggers for an existing job. /// private async Task AddTriggersForJob( - 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). /// private async Task AddJobWithoutTrigger(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); } } diff --git a/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs b/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs new file mode 100644 index 00000000..012b49b3 --- /dev/null +++ b/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs @@ -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 _logger; + private readonly DataContext _dataContext; + private readonly DownloadServiceFactory _downloadServiceFactory; + private readonly FileReader _fileReader; + private readonly IDryRunInterceptor _dryRunInterceptor; + + public BlacklistSynchronizer( + ILogger 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 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"); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index d20de85b..0966d102 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs index b1940807..e8e7fb78 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs index 5bd606b3..456c8fdb 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs index 6f881dcf..034aa375 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs index e6daadb4..98f3f712 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index e3fa8f1e..04914b58 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrClient.cs index 846c6ed7..642d87b7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrClient.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs index db896d08..f04ceda4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index 557e8ae4..d83a7c3c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs index 0b34fcc1..767dbafd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBitService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBitService.cs index bb11438f..5735e5e4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBitService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/IQBitService.cs @@ -2,4 +2,5 @@ public interface IQBitService : IDownloadService, IDisposable { + Task UpdateBlacklistAsync(string blacklistPath); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs index 0d70b59a..bf464019 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs @@ -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 }; } } + + /// + /// Syncs blacklist patterns from configured file to qBittorrent excluded file names + /// + /// List of excluded file names for qBittorrent + public async Task UpdateBlacklistAsync(string excludedFileNames) + { + Preferences preferences = new() + { + AdditionalData = new Dictionary + { + { "excluded_file_names", excludedFileNames } + } + }; + + await _client.SetPreferencesAsync(preferences); + } private async Task> GetTrackersAsync(string hash) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs index 444bda3f..dda21561 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs index 7d2788ad..462dd1c0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs index cef494c7..173c156f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/MalwareBlocker/BlocklistProvider.cs @@ -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 _logger; private readonly IServiceScopeFactory _scopeFactory; - private readonly HttpClient _httpClient; private readonly IMemoryCache _cache; private readonly Dictionary _configHashes = new(); private readonly Dictionary _lastLoadTimes = new(); @@ -31,37 +29,12 @@ public sealed class BlocklistProvider public BlocklistProvider( ILogger logger, IServiceScopeFactory scopeFactory, - IMemoryCache cache, - IHttpClientFactory httpClientFactory + IMemoryCache cache ) { _logger = logger; _scopeFactory = scopeFactory; _cache = cache; - _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); - } - - private async Task 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(); + var fileReader = scope.ServiceProvider.GetRequiredService(); + 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 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 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 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 diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index dee6eaf1..3b381244 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -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; diff --git a/code/backend/Cleanuparr.Infrastructure/Helpers/FileReader.cs b/code/backend/Cleanuparr.Infrastructure/Helpers/FileReader.cs new file mode 100644 index 00000000..df387a83 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Helpers/FileReader.cs @@ -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); + } + + /// + /// Reads content from either a local file or HTTP(S) URL + /// Extracted from BlocklistProvider.ReadContentAsync for reuse + /// + /// File path or URL + /// Array of lines from the content + public async Task 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 ReadFromUrlAsync(string url) + { + using HttpResponseMessage response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadAsStringAsync()) + .Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries); + } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs b/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs index 631bbeba..4f010bbd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs @@ -1,6 +1,5 @@ using System.Reflection; using Cleanuparr.Persistence; -using Infrastructure.Interceptors; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs b/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs index 2602e06e..958bd6e4 100644 --- a/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Interceptors/IDryRunInterceptor.cs @@ -1,4 +1,4 @@ -namespace Infrastructure.Interceptors; +namespace Cleanuparr.Infrastructure.Interceptors; public interface IDryRunInterceptor { diff --git a/code/backend/Cleanuparr.Infrastructure/Models/JobType.cs b/code/backend/Cleanuparr.Infrastructure/Models/JobType.cs index 18186e12..c7bcb7dd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Models/JobType.cs +++ b/code/backend/Cleanuparr.Infrastructure/Models/JobType.cs @@ -7,5 +7,6 @@ public enum JobType { QueueCleaner, MalwareBlocker, - DownloadCleaner + DownloadCleaner, + BlacklistSynchronizer, } diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index 587fc861..44bb4b10 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -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 AppriseConfigs { get; set; } + public DbSet BlacklistSyncHistory { get; set; } + + public DbSet 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(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()) { diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.Designer.cs new file mode 100644 index 00000000..be8767e3 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.Designer.cs @@ -0,0 +1,816 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.Property("UnlinkedIgnoredRootDir") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dir"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.ComplexProperty>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.PrimitiveCollection("IgnoredPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_ignored_patterns"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + }); + + b.ComplexProperty>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_delete_private"); + + b1.Property("IgnoreAboveSize") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_ignore_above_size"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("slow_max_strikes"); + + b1.Property("MaxTime") + .HasColumnType("REAL") + .HasColumnName("slow_max_time"); + + b1.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_min_speed"); + + b1.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("slow_reset_strikes_on_progress"); + }); + + b.ComplexProperty>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_delete_private"); + + b1.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_downloading_metadata_max_strikes"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_max_strikes"); + + b1.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("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 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.cs new file mode 100644 index 00000000..8511da3b --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddBlacklistSyncSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "blacklist_sync_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + cron_expression = table.Column(type: "TEXT", nullable: false), + blacklist_path = table.Column(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(type: "TEXT", nullable: false), + hash = table.Column(type: "TEXT", nullable: false), + download_client_id = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "blacklist_sync_configs"); + + migrationBuilder.DropTable( + name: "blacklist_sync_history"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index bec48fc8..1a095902 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("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("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("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"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/BlacklistSync/BlacklistSyncConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/BlacklistSync/BlacklistSyncConfig.cs new file mode 100644 index 00000000..c65fe3d1 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/BlacklistSync/BlacklistSyncConfig.cs @@ -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; + +/// +/// Configuration for Blacklist Synchronization to qBittorrent +/// +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"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs index 7f67278a..ed8248c4 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs @@ -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; diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/IJobConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/IJobConfig.cs index c4740be1..062b8d1a 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/IJobConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/IJobConfig.cs @@ -5,9 +5,4 @@ public interface IJobConfig : IConfig bool Enabled { get; set; } string CronExpression { get; set; } - - /// - /// Indicates whether to use the CronExpression directly (true) or convert from JobSchedule (false) - /// - bool UseAdvancedScheduling { get; set; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Models/State/BlacklistSyncHistory.cs b/code/backend/Cleanuparr.Persistence/Models/State/BlacklistSyncHistory.cs new file mode 100644 index 00000000..ea61a609 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/State/BlacklistSyncHistory.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Persistence.Models.Configuration; + +namespace Cleanuparr.Persistence.Models.State; + +/// +/// Tracks which download clients have been synchronized for a specific blacklist content hash. +/// +public sealed record BlacklistSyncHistory +{ + /// + /// Primary key + /// + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// SHA-256 hash of the blacklist contents used during synchronization + /// + public required string Hash { get; init; } + + /// + /// Foreign key to the download client this sync entry applies to + /// + public required Guid DownloadClientId { get; init; } + + /// + /// Navigation property to the associated download client configuration + /// + public DownloadClientConfig DownloadClient { get; init; } = null!; +} diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index ba05bbef..4feb9e62 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -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) }, diff --git a/code/frontend/src/app/core/services/configuration.service.ts b/code/frontend/src/app/core/services/configuration.service.ts index 7a87a7c7..3aa632c3 100644 --- a/code/frontend/src/app/core/services/configuration.service.ts +++ b/code/frontend/src/app/core/services/configuration.service.ts @@ -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 { + return this.http.get(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 { + return this.http.put(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 */ diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index fded3bb6..baed7bc4 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -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); } -} +} diff --git a/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.ts b/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.ts index 409d2db9..cfa0d35e 100644 --- a/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.ts +++ b/code/frontend/src/app/layout/sidebar-content/sidebar-content.component.ts @@ -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' } ] }, diff --git a/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-config.store.ts b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-config.store.ts new file mode 100644 index 00000000..d9b682a8 --- /dev/null +++ b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-config.store.ts @@ -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( + 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( + (config$: Observable) => 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) { + const current = store.config(); + if (current) { + patchState(store, { config: { ...current, ...config } }); + } + } + })), + withHooks({ + onInit({ loadConfig }) { + loadConfig(); + } + }) +) {} diff --git a/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.html b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.html new file mode 100644 index 00000000..e41f643f --- /dev/null +++ b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.html @@ -0,0 +1,92 @@ +
+
+

Blacklist Sync

+
+ + + + + +
+ + + + +
+
+

Blacklist Sync Configuration

+ Configure automatic blacklist synchronization +
+
+
+ +
+ +
+ +
+ + When enabled, blacklist patterns will be synchronized to download clients hourly +
+
+ + +
+ +
+
+ +
+ This field is required when blacklist sync is enabled + Path to blacklist file or HTTP(S) URL containing blacklist patterns +
+
+ +
+ + +
+
+
diff --git a/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.scss b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.scss new file mode 100644 index 00000000..db9f1ac7 --- /dev/null +++ b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.scss @@ -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'; diff --git a/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.ts b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.ts new file mode 100644 index 00000000..707f6912 --- /dev/null +++ b/code/frontend/src/app/settings/blacklist-sync/blacklist-sync-settings.component.ts @@ -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(); + @Output() error = new EventEmitter(); + + // 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(); + + // 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; + } +} diff --git a/code/frontend/src/app/settings/general-settings/general-settings.component.html b/code/frontend/src/app/settings/general-settings/general-settings.component.html index 6f470e7b..0fc26409 100644 --- a/code/frontend/src/app/settings/general-settings/general-settings.component.html +++ b/code/frontend/src/app/settings/general-settings/general-settings.component.html @@ -13,7 +13,7 @@ > -
+ @@ -194,6 +194,7 @@ Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker) + diff --git a/code/frontend/src/app/settings/general-settings/general-settings.component.ts b/code/frontend/src/app/settings/general-settings/general-settings.component.ts index d4b894f9..2e203e1a 100644 --- a/code/frontend/src/app/settings/general-settings/general-settings.component.ts +++ b/code/frontend/src/app/settings/general-settings/general-settings.component.ts @@ -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; } /** diff --git a/code/frontend/src/app/shared/models/blacklist-sync-config.model.ts b/code/frontend/src/app/shared/models/blacklist-sync-config.model.ts new file mode 100644 index 00000000..beda1b52 --- /dev/null +++ b/code/frontend/src/app/shared/models/blacklist-sync-config.model.ts @@ -0,0 +1,5 @@ +export interface BlacklistSyncConfig { + id: string; + enabled: boolean; + blacklistPath?: string; +} diff --git a/code/frontend/src/app/shared/models/general-config.model.ts b/code/frontend/src/app/shared/models/general-config.model.ts index bbee51ad..726d729d 100644 --- a/code/frontend/src/app/shared/models/general-config.model.ts +++ b/code/frontend/src/app/shared/models/general-config.model.ts @@ -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[]; } diff --git a/docs/docs/configuration/general/index.mdx b/docs/docs/configuration/general/index.mdx index 4e343af9..773fbb1a 100644 --- a/docs/docs/configuration/general/index.mdx +++ b/docs/docs/configuration/general/index.mdx @@ -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 +
+ +

+ 📦 + Download Management +

+ + + +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 +``` + + + +
+ +
+ +

+ + Blacklist Sync +

+ + + +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. + + +This feature updates the qBittorrent "Excluded file names" setting, but does not enable it. + + + + + + +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 +``` + + +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). + + + + +
+
@@ -209,37 +285,4 @@ Maximum age (in hours) for archived logs before they are deleted. Older files be
-
- -

- 📦 - Download Management -

- - - -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 -``` - - - -
- \ No newline at end of file