mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-23 22:18:39 -05:00
Add option to inject blacklist into qBittorrent (#304)
This commit is contained in:
@@ -19,6 +19,7 @@ using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -54,6 +55,23 @@ public class ConfigurationController : ControllerBase
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
[HttpGet("blacklist_sync")]
|
||||
public async Task<IActionResult> GetBlacklistSyncConfig()
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("queue_cleaner")]
|
||||
public async Task<IActionResult> GetQueueCleanerConfig()
|
||||
{
|
||||
@@ -1213,6 +1231,64 @@ public class ConfigurationController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("blacklist_sync")]
|
||||
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] BlacklistSyncConfig newConfig)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
newConfig.Validate();
|
||||
|
||||
var oldConfig = await _dataContext.BlacklistSyncConfigs
|
||||
.FirstAsync();
|
||||
|
||||
bool enabledChanged = oldConfig.Enabled != newConfig.Enabled;
|
||||
bool becameEnabled = !oldConfig.Enabled && newConfig.Enabled;
|
||||
bool pathChanged = !(oldConfig.BlacklistPath?.Equals(newConfig.BlacklistPath, StringComparison.InvariantCultureIgnoreCase) ?? true);
|
||||
|
||||
var adapterConfig = new TypeAdapterConfig();
|
||||
adapterConfig.NewConfig<BlacklistSyncConfig, BlacklistSyncConfig>()
|
||||
.Ignore(dest => dest.Id)
|
||||
// Cron expression changes are not supported yet for this type of job
|
||||
.Ignore(dest => dest.CronExpression);
|
||||
|
||||
newConfig.Adapt(oldConfig, adapterConfig);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
if (enabledChanged)
|
||||
{
|
||||
if (becameEnabled)
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
|
||||
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, newConfig.CronExpression);
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
|
||||
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
}
|
||||
else if (pathChanged && oldConfig.Enabled)
|
||||
{
|
||||
_logger.LogDebug("BlacklistSynchronizer path changed");
|
||||
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
}
|
||||
|
||||
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("sonarr")]
|
||||
public async Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateSonarrConfigDto newConfigDto)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
@@ -12,11 +14,11 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Files;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
@@ -40,6 +42,7 @@ public static class ServicesDI
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
@@ -51,6 +54,7 @@ public static class ServicesDI
|
||||
.AddScoped<ArrQueueIterator>()
|
||||
.AddScoped<DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<BlocklistProvider>();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Application.Features.BlacklistSync;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.DownloadClient;
|
||||
using Cleanuparr.Application.Features.MalwareBlocker;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
@@ -8,6 +10,8 @@ using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Quartz;
|
||||
@@ -45,12 +49,12 @@ public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting BackgroundJobManager");
|
||||
_logger.LogDebug("Starting BackgroundJobManager");
|
||||
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||
|
||||
await InitializeJobsFromConfiguration(cancellationToken);
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager started");
|
||||
_logger.LogDebug("BackgroundJobManager started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -64,15 +68,15 @@ public class BackgroundJobManager : IHostedService
|
||||
/// </summary>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping BackgroundJobManager");
|
||||
_logger.LogDebug("Stopping BackgroundJobManager");
|
||||
|
||||
if (_scheduler != null)
|
||||
{
|
||||
// Don't shutdown the scheduler as it's managed by QuartzHostedService
|
||||
// Don't shut down the scheduler as it's managed by QuartzHostedService
|
||||
await _scheduler.Standby(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackgroundJobManager stopped");
|
||||
_logger.LogDebug("BackgroundJobManager stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,7 +90,6 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
// Use scoped DataContext to prevent memory leaks
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
@@ -100,11 +103,15 @@ public class BackgroundJobManager : IHostedService
|
||||
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -120,7 +127,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +144,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<MalwareBlocker>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +159,21 @@ public class BackgroundJobManager : IHostedService
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
|
||||
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
|
||||
/// </summary>
|
||||
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +181,9 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add triggers for an existing job.
|
||||
/// </summary>
|
||||
private async Task AddTriggersForJob<T>(
|
||||
IJobConfig config,
|
||||
string cronExpression,
|
||||
CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -228,7 +248,7 @@ public class BackgroundJobManager : IHostedService
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
where T : GenericHandler
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
@@ -254,6 +274,6 @@ public class BackgroundJobManager : IHostedService
|
||||
// Add job to scheduler
|
||||
await _scheduler.AddJob(jobDetail, true, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Registered job {name} without trigger", typeName);
|
||||
_logger.LogDebug("Registered job {name} without trigger", typeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Application.Features.BlacklistSync;
|
||||
|
||||
public sealed class BlacklistSynchronizer : IHandler
|
||||
{
|
||||
private readonly ILogger<BlacklistSynchronizer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly FileReader _fileReader;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public BlacklistSynchronizer(
|
||||
ILogger<BlacklistSynchronizer> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
FileReader fileReader,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_fileReader = fileReader;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Blacklist sync is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BlacklistPath))
|
||||
{
|
||||
_logger.LogWarning("Blacklist sync path is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] patterns = await _fileReader.ReadContentAsync(config.BlacklistPath);
|
||||
string excludedFileNames = string.Join('\n', patterns.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
string currentHash = ComputeHash(excludedFileNames);
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
|
||||
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
|
||||
|
||||
_logger.LogDebug("Blacklist synchronization completed");
|
||||
}
|
||||
|
||||
private async Task SyncBlacklist(string currentHash, string excludedFileNames)
|
||||
{
|
||||
List<DownloadClientConfig> qBittorrentClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting blacklist synchronization for {Count} qBittorrent clients", qBittorrentClients.Count);
|
||||
|
||||
// Pull existing sync history for this hash
|
||||
var alreadySynced = await _dataContext.BlacklistSyncHistory
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Hash == currentHash)
|
||||
.Select(x => x.DownloadClientId)
|
||||
.ToListAsync();
|
||||
|
||||
// Only update clients not present in history for current hash
|
||||
foreach (var clientConfig in qBittorrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (alreadySynced.Contains(clientConfig.Id))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientName} already synced for current blacklist, skipping", clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig);
|
||||
if (downloadService is not QBitService qBitService)
|
||||
{
|
||||
_logger.LogError("Expected QBitService but got {ServiceType} for client {ClientName}", downloadService.GetType().Name, clientConfig.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await qBitService.LoginAsync();
|
||||
await qBitService.UpdateBlacklistAsync(excludedFileNames);
|
||||
|
||||
_logger.LogDebug("Successfully updated blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
|
||||
// Insert history row marking this client as synced for current hash
|
||||
_dataContext.BlacklistSyncHistory.Add(new BlacklistSyncHistory
|
||||
{
|
||||
Hash = currentHash,
|
||||
DownloadClientId = clientConfig.Id
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update blacklist for qBittorrent client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
qBitService.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create download service for client {ClientName}", clientConfig.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string excludedFileNames)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(excludedFileNames);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task RemoveOldSyncDataAsync(string currentHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dataContext.BlacklistSyncHistory
|
||||
.Where(s => s.Hash != currentHash)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old blacklist sync history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
public interface IQBitService : IDownloadService, IDisposable
|
||||
{
|
||||
Task UpdateBlacklistAsync(string blacklistPath);
|
||||
}
|
||||
@@ -2,11 +2,13 @@ using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
@@ -45,11 +47,11 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
try
|
||||
{
|
||||
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
|
||||
_logger.LogDebug("Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to login to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogError(ex, "Failed to login to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -65,15 +67,15 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
|
||||
if (hasCredentials)
|
||||
{
|
||||
// If credentials are provided, we must be able to login for the service to be healthy
|
||||
// If credentials are provided, we must be able to log in for the service to be healthy
|
||||
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
|
||||
_logger.LogDebug("Health check: Successfully logged in to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Health check: Successfully logged in to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If no credentials, test connectivity using version endpoint
|
||||
await _client.GetApiVersionAsync();
|
||||
_logger.LogDebug("Health check: Successfully connected to QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogDebug("Health check: Successfully connected to qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
@@ -88,7 +90,7 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogWarning(ex, "Health check failed for QBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
_logger.LogWarning(ex, "Health check failed for qBittorrent client {clientId}", _downloadClientConfig.Id);
|
||||
|
||||
return new HealthCheckResult
|
||||
{
|
||||
@@ -98,6 +100,23 @@ public partial class QBitService : DownloadService, IQBitService
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs blacklist patterns from configured file to qBittorrent excluded file names
|
||||
/// </summary>
|
||||
/// <param name="excludedFileNames">List of excluded file names for qBittorrent</param>
|
||||
public async Task UpdateBlacklistAsync(string excludedFileNames)
|
||||
{
|
||||
Preferences preferences = new()
|
||||
{
|
||||
AdditionalData = new Dictionary<string, JToken>
|
||||
{
|
||||
{ "excluded_file_names", excludedFileNames }
|
||||
}
|
||||
};
|
||||
|
||||
await _client.SetPreferencesAsync(preferences);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -19,7 +18,6 @@ public sealed class BlocklistProvider
|
||||
{
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly Dictionary<InstanceType, string> _configHashes = new();
|
||||
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
|
||||
@@ -31,37 +29,12 @@ public sealed class BlocklistProvider
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IMemoryCache cache,
|
||||
IHttpClientFactory httpClientFactory
|
||||
IMemoryCache cache
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_cache = cache;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType)
|
||||
{
|
||||
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string hash = GenerateSettingsHash(settings);
|
||||
var interval = GetLoadInterval(settings.BlocklistPath);
|
||||
var identifier = $"{instanceType}_{settings.BlocklistPath}";
|
||||
|
||||
if (ShouldReloadBlocklist(identifier, interval) || !_configHashes.TryGetValue(instanceType, out string? oldHash) || hash != oldHash)
|
||||
{
|
||||
_logger.LogDebug("Loading {instance} blocklist", instanceType);
|
||||
await LoadPatternsAndRegexesAsync(settings, instanceType);
|
||||
_configHashes[instanceType] = hash;
|
||||
_lastLoadTimes[identifier] = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task LoadBlocklistsAsync()
|
||||
@@ -70,6 +43,8 @@ public sealed class BlocklistProvider
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
var fileReader = scope.ServiceProvider.GetRequiredService<FileReader>();
|
||||
|
||||
int changedCount = 0;
|
||||
var malwareBlockerConfig = await dataContext.ContentBlockerConfigs
|
||||
.AsNoTracking()
|
||||
@@ -92,14 +67,14 @@ public sealed class BlocklistProvider
|
||||
|
||||
foreach (var kv in instances)
|
||||
{
|
||||
if (await EnsureInstanceLoadedAsync(kv.Value, kv.Key))
|
||||
if (await EnsureInstanceLoadedAsync(kv.Value, kv.Key, fileReader))
|
||||
{
|
||||
changedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Always check and update malware patterns
|
||||
await LoadMalwarePatternsAsync();
|
||||
await LoadMalwarePatternsAsync(fileReader);
|
||||
|
||||
if (changedCount > 0)
|
||||
{
|
||||
@@ -145,6 +120,29 @@ public sealed class BlocklistProvider
|
||||
return patterns ?? [];
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType, FileReader fileReader)
|
||||
{
|
||||
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string hash = GenerateSettingsHash(settings);
|
||||
var interval = GetLoadInterval(settings.BlocklistPath);
|
||||
var identifier = $"{instanceType}_{settings.BlocklistPath}";
|
||||
|
||||
if (ShouldReloadBlocklist(identifier, interval) || !_configHashes.TryGetValue(instanceType, out string? oldHash) || hash != oldHash)
|
||||
{
|
||||
_logger.LogDebug("Loading {instance} blocklist", instanceType);
|
||||
await LoadPatternsAndRegexesAsync(settings, instanceType, fileReader);
|
||||
_configHashes[instanceType] = hash;
|
||||
_lastLoadTimes[identifier] = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private TimeSpan GetLoadInterval(string? path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) && Uri.TryCreate(path, UriKind.Absolute, out var uri))
|
||||
@@ -171,7 +169,7 @@ public sealed class BlocklistProvider
|
||||
return DateTime.UtcNow - lastLoad >= interval;
|
||||
}
|
||||
|
||||
private async Task LoadMalwarePatternsAsync()
|
||||
private async Task LoadMalwarePatternsAsync(FileReader fileReader)
|
||||
{
|
||||
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
|
||||
@@ -184,7 +182,7 @@ public sealed class BlocklistProvider
|
||||
{
|
||||
_logger.LogDebug("Loading malware patterns");
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(MalwareListUrl);
|
||||
string[] filePatterns = await fileReader.ReadContentAsync(MalwareListUrl);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
@@ -209,14 +207,14 @@ public sealed class BlocklistProvider
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType)
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType, FileReader fileReader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(blocklistSettings.BlocklistPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] filePatterns = await ReadContentAsync(blocklistSettings.BlocklistPath);
|
||||
string[] filePatterns = await fileReader.ReadContentAsync(blocklistSettings.BlocklistPath);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
@@ -256,32 +254,6 @@ public sealed class BlocklistProvider
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistSettings.BlocklistPath);
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
// http(s) url
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"blocklist not found | {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private string GenerateSettingsHash(BlocklistSettings blocklistSettings)
|
||||
{
|
||||
// Create a string that represents the relevant blocklist configuration
|
||||
|
||||
@@ -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;
|
||||
|
||||
44
code/backend/Cleanuparr.Infrastructure/Helpers/FileReader.cs
Normal file
44
code/backend/Cleanuparr.Infrastructure/Helpers/FileReader.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Helpers;
|
||||
|
||||
public class FileReader
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public FileReader(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads content from either a local file or HTTP(S) URL
|
||||
/// Extracted from BlocklistProvider.ReadContentAsync for reuse
|
||||
/// </summary>
|
||||
/// <param name="path">File path or URL</param>
|
||||
/// <returns>Array of lines from the content</returns>
|
||||
public async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"File not found: {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Infrastructure.Interceptors;
|
||||
namespace Cleanuparr.Infrastructure.Interceptors;
|
||||
|
||||
public interface IDryRunInterceptor
|
||||
{
|
||||
|
||||
@@ -7,5 +7,6 @@ public enum JobType
|
||||
{
|
||||
QueueCleaner,
|
||||
MalwareBlocker,
|
||||
DownloadCleaner
|
||||
DownloadCleaner,
|
||||
BlacklistSynchronizer,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -43,6 +45,10 @@ public class DataContext : DbContext
|
||||
|
||||
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
|
||||
|
||||
public DataContext()
|
||||
{
|
||||
}
|
||||
@@ -125,6 +131,19 @@ public class DataContext : DbContext
|
||||
|
||||
entity.HasIndex(p => p.Name).IsUnique();
|
||||
});
|
||||
|
||||
// Configure BlacklistSyncState relationships and indexes
|
||||
modelBuilder.Entity<BlacklistSyncHistory>(entity =>
|
||||
{
|
||||
// FK to DownloadClientConfig by DownloadClientId with cascade on delete
|
||||
entity.HasOne(s => s.DownloadClient)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.DownloadClientId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(s => new { s.Hash, DownloadClientId = s.DownloadClientId }).IsUnique();
|
||||
entity.HasIndex(s => s.Hash);
|
||||
});
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
|
||||
816
code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.Designer.cs
generated
Normal file
816
code/backend/Cleanuparr.Persistence/Migrations/Data/20250915153159_AddBlacklistSyncSettings.Designer.cs
generated
Normal file
@@ -0,0 +1,816 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20250915153159_AddBlacklistSyncSettings")]
|
||||
partial class AddBlacklistSyncSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<short>("FailedImportMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_configs");
|
||||
|
||||
b.ToTable("arr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<Guid>("ArrConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("arr_config_id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_arr_instances");
|
||||
|
||||
b.HasIndex("ArrConfigId")
|
||||
.HasDatabaseName("ix_arr_instances_arr_config_id");
|
||||
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("BlacklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blacklist_path");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_configs");
|
||||
|
||||
b.ToTable("blacklist_sync_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadCleanerConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_cleaner_config_id");
|
||||
|
||||
b.Property<double>("MaxRatio")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_ratio");
|
||||
|
||||
b.Property<double>("MaxSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("max_seed_time");
|
||||
|
||||
b.Property<double>("MinSeedTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("min_seed_time");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_clean_categories");
|
||||
|
||||
b.HasIndex("DownloadCleanerConfigId")
|
||||
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
|
||||
|
||||
b.ToTable("clean_categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.PrimitiveCollection<string>("UnlinkedCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_categories");
|
||||
|
||||
b.Property<bool>("UnlinkedEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_enabled");
|
||||
|
||||
b.Property<string>("UnlinkedIgnoredRootDir")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_ignored_root_dir");
|
||||
|
||||
b.Property<string>("UnlinkedTargetCategory")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("unlinked_target_category");
|
||||
|
||||
b.Property<bool>("UnlinkedUseTag")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("unlinked_use_tag");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_cleaner_configs");
|
||||
|
||||
b.ToTable("download_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<string>("TypeName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type_name");
|
||||
|
||||
b.Property<string>("UrlBase")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url_base");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_clients");
|
||||
|
||||
b.ToTable("download_clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("DisplaySupportBanner")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("display_support_banner");
|
||||
|
||||
b.Property<bool>("DryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("dry_run");
|
||||
|
||||
b.Property<string>("EncryptionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("encryption_key");
|
||||
|
||||
b.Property<string>("HttpCertificateValidation")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("http_certificate_validation");
|
||||
|
||||
b.Property<ushort>("HttpMaxRetries")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_max_retries");
|
||||
|
||||
b.Property<ushort>("HttpTimeout")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("http_timeout");
|
||||
|
||||
b.PrimitiveCollection<string>("IgnoredDownloads")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<ushort>("SearchDelay")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_delay");
|
||||
|
||||
b.Property<bool>("SearchEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("search_enabled");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("ArchiveEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_enabled");
|
||||
|
||||
b1.Property<ushort>("ArchiveRetainedCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_retained_count");
|
||||
|
||||
b1.Property<ushort>("ArchiveTimeLimitHours")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_archive_time_limit_hours");
|
||||
|
||||
b1.Property<string>("Level")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_level");
|
||||
|
||||
b1.Property<ushort>("RetainedFileCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_retained_file_count");
|
||||
|
||||
b1.Property<ushort>("RollingSizeMB")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_rolling_size_mb");
|
||||
|
||||
b1.Property<ushort>("TimeLimitHours")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("log_time_limit_hours");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_general_configs");
|
||||
|
||||
b.ToTable("general_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ignore_private");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lidarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("lidarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("radarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("radarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("readarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("readarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_path");
|
||||
|
||||
b1.Property<string>("BlocklistType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("sonarr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sonarr_enabled");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("BlocklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("whisparr_blocklist_path");
|
||||
|
||||
b1.Property<int>("BlocklistType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_blocklist_type");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("whisparr_enabled");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_content_blocker_configs");
|
||||
|
||||
b.ToTable("content_blocker_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tags");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_apprise_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_apprise_configs_notification_config_id");
|
||||
|
||||
b.ToTable("apprise_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<string>("ChannelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notifiarr_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
|
||||
|
||||
b.ToTable("notifiarr_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<bool>("OnCategoryChanged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_category_changed");
|
||||
|
||||
b.Property<bool>("OnDownloadCleaned")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_download_cleaned");
|
||||
|
||||
b.Property<bool>("OnFailedImportStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_failed_import_strike");
|
||||
|
||||
b.Property<bool>("OnQueueItemDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_queue_item_deleted");
|
||||
|
||||
b.Property<bool>("OnSlowStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_slow_strike");
|
||||
|
||||
b.Property<bool>("OnStalledStrike")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("on_stalled_strike");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_notification_configs");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_notification_configs_name");
|
||||
|
||||
b.ToTable("notification_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_delete_private");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_ignore_private");
|
||||
|
||||
b1.PrimitiveCollection<string>("IgnoredPatterns")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failed_import_ignored_patterns");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_import_max_strikes");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_delete_private");
|
||||
|
||||
b1.Property<string>("IgnoreAboveSize")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_ignore_above_size");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_max_strikes");
|
||||
|
||||
b1.Property<double>("MaxTime")
|
||||
.HasColumnType("REAL")
|
||||
.HasColumnName("slow_max_time");
|
||||
|
||||
b1.Property<string>("MinSpeed")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("slow_min_speed");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("slow_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_delete_private");
|
||||
|
||||
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_downloading_metadata_max_strikes");
|
||||
|
||||
b1.Property<bool>("IgnorePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_ignore_private");
|
||||
|
||||
b1.Property<ushort>("MaxStrikes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_max_strikes");
|
||||
|
||||
b1.Property<bool>("ResetStrikesOnProgress")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("stalled_reset_strikes_on_progress");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_queue_cleaner_configs");
|
||||
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadClientId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_id");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_history");
|
||||
|
||||
b.HasIndex("DownloadClientId")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_download_client_id");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash");
|
||||
|
||||
b.HasIndex("Hash", "DownloadClientId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id");
|
||||
|
||||
b.ToTable("blacklist_sync_history", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("ArrConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
|
||||
|
||||
b.Navigation("ArrConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
|
||||
.WithMany("Categories")
|
||||
.HasForeignKey("DownloadCleanerConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
|
||||
|
||||
b.Navigation("DownloadCleanerConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
|
||||
.WithOne("AppriseConfiguration")
|
||||
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id");
|
||||
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
|
||||
.WithOne("NotifiarrConfiguration")
|
||||
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id");
|
||||
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient")
|
||||
.WithMany()
|
||||
.HasForeignKey("DownloadClientId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id");
|
||||
|
||||
b.Navigation("DownloadClient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
|
||||
{
|
||||
b.Navigation("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
|
||||
{
|
||||
b.Navigation("AppriseConfiguration");
|
||||
|
||||
b.Navigation("NotifiarrConfiguration");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBlacklistSyncSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "blacklist_sync_configs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
cron_expression = table.Column<string>(type: "TEXT", nullable: false),
|
||||
blacklist_path = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_blacklist_sync_configs", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "blacklist_sync_history",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
hash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
download_client_id = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_blacklist_sync_history", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_blacklist_sync_history_download_clients_download_client_id",
|
||||
column: x => x.download_client_id,
|
||||
principalTable: "download_clients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "blacklist_sync_configs",
|
||||
columns: new[] { "id", "enabled", "cron_expression", "blacklist_path" },
|
||||
values: new object[] { Guid.NewGuid(), false, "0 0 * * * ?", null });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_blacklist_sync_history_download_client_id",
|
||||
table: "blacklist_sync_history",
|
||||
column: "download_client_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_blacklist_sync_history_hash",
|
||||
table: "blacklist_sync_history",
|
||||
column: "hash");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_blacklist_sync_history_hash_download_client_id",
|
||||
table: "blacklist_sync_history",
|
||||
columns: new[] { "hash", "download_client_id" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "blacklist_sync_configs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "blacklist_sync_history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,32 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.ToTable("arr_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("BlacklistPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blacklist_path");
|
||||
|
||||
b.Property<string>("CronExpression")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_configs");
|
||||
|
||||
b.ToTable("blacklist_sync_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -673,6 +699,38 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.ToTable("queue_cleaner_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("DownloadClientId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_id");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_blacklist_sync_history");
|
||||
|
||||
b.HasIndex("DownloadClientId")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_download_client_id");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash");
|
||||
|
||||
b.HasIndex("Hash", "DownloadClientId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id");
|
||||
|
||||
b.ToTable("blacklist_sync_history", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
|
||||
@@ -721,6 +779,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
b.Navigation("NotificationConfig");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient")
|
||||
.WithMany()
|
||||
.HasForeignKey("DownloadClientId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id");
|
||||
|
||||
b.Navigation("DownloadClient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Blacklist Synchronization to qBittorrent
|
||||
/// </summary>
|
||||
public sealed record BlacklistSyncConfig : IJobConfig
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string CronExpression { get; set; } = "0 0 * * * ?";
|
||||
|
||||
public string? BlacklistPath { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BlacklistPath))
|
||||
{
|
||||
throw new ValidationException("Blacklist sync is enabled but the path is not configured");
|
||||
}
|
||||
|
||||
bool isValidPath = Uri.TryCreate(BlacklistPath, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) ||
|
||||
File.Exists(BlacklistPath);
|
||||
|
||||
if (!isValidPath)
|
||||
{
|
||||
throw new ValidationException("Blacklist path must be a valid URL or an existing local file path");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,9 +5,4 @@ public interface IJobConfig : IConfig
|
||||
bool Enabled { get; set; }
|
||||
|
||||
string CronExpression { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to use the CronExpression directly (true) or convert from JobSchedule (false)
|
||||
/// </summary>
|
||||
bool UseAdvancedScheduling { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.State;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which download clients have been synchronized for a specific blacklist content hash.
|
||||
/// </summary>
|
||||
public sealed record BlacklistSyncHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key
|
||||
/// </summary>
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the blacklist contents used during synchronization
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the download client this sync entry applies to
|
||||
/// </summary>
|
||||
public required Guid DownloadClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation property to the associated download client configuration
|
||||
/// </summary>
|
||||
public DownloadClientConfig DownloadClient { get; init; } = null!;
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-confi
|
||||
import { GeneralConfig } from "../../shared/models/general-config.model";
|
||||
import { ApplicationPathService } from "./base-path.service";
|
||||
import { ErrorHandlerUtil } from "../utils/error-handler.util";
|
||||
import { BlacklistSyncConfig } from "../../shared/models/blacklist-sync-config.model";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
@@ -33,6 +34,31 @@ export class ConfigurationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Blacklist Sync configuration
|
||||
*/
|
||||
getBlacklistSyncConfig(): Observable<BlacklistSyncConfig> {
|
||||
return this.http.get<BlacklistSyncConfig>(this.ApplicationPathService.buildApiUrl('/configuration/blacklist_sync')).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error fetching Blacklist Sync config:", error);
|
||||
return throwError(() => new Error("Failed to load Blacklist Sync configuration"));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Blacklist Sync configuration
|
||||
*/
|
||||
updateBlacklistSyncConfig(config: BlacklistSyncConfig): Observable<any> {
|
||||
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/blacklist_sync'), config).pipe(
|
||||
catchError((error) => {
|
||||
console.error("Error updating Blacklist Sync config:", error);
|
||||
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
|
||||
return throwError(() => new Error(errorMessage));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update general configuration
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
|
||||
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||
import { ConfigurationService } from '../../core/services/configuration.service';
|
||||
import { BlacklistSyncConfig } from '../../shared/models/blacklist-sync-config.model';
|
||||
import { EMPTY, Observable } from 'rxjs';
|
||||
import { switchMap, tap, catchError } from 'rxjs/operators';
|
||||
|
||||
export interface BlacklistSyncState {
|
||||
config: BlacklistSyncConfig | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
loadError: string | null; // Only for load failures that should show "Not connected"
|
||||
saveError: string | null; // Only for save failures that should show toast
|
||||
}
|
||||
|
||||
const initialState: BlacklistSyncState = {
|
||||
config: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
loadError: null,
|
||||
saveError: null,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BlacklistSyncConfigStore extends signalStore(
|
||||
withState(initialState),
|
||||
withMethods((store, configService = inject(ConfigurationService)) => ({
|
||||
loadConfig: rxMethod<void>(
|
||||
pipe => pipe.pipe(
|
||||
tap(() => patchState(store, { loading: true, loadError: null, saveError: null })),
|
||||
switchMap(() => configService.getBlacklistSyncConfig().pipe(
|
||||
tap({
|
||||
next: (config) => patchState(store, { config, loading: false, loadError: null }),
|
||||
error: (error) => {
|
||||
const errorMessage = error.message || 'Failed to load Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
loading: false,
|
||||
loadError: errorMessage // Only load errors should trigger "Not connected" state
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError((error) => {
|
||||
const errorMessage = error.message || 'Failed to load Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
loading: false,
|
||||
loadError: errorMessage // Only load errors should trigger "Not connected" state
|
||||
});
|
||||
return EMPTY;
|
||||
})
|
||||
))
|
||||
)
|
||||
),
|
||||
saveConfig: rxMethod<BlacklistSyncConfig>(
|
||||
(config$: Observable<BlacklistSyncConfig>) => config$.pipe(
|
||||
tap(() => patchState(store, { saving: true, saveError: null })),
|
||||
switchMap(config => configService.updateBlacklistSyncConfig(config).pipe(
|
||||
tap({
|
||||
next: () => patchState(store, { config, saving: false, saveError: null }),
|
||||
error: (error) => {
|
||||
const errorMessage = error.message || 'Failed to update Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
saving: false,
|
||||
saveError: errorMessage // Save errors don't affect "Not connected" state
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError((error) => {
|
||||
const errorMessage = error.message || 'Failed to update Blacklist Sync configuration';
|
||||
patchState(store, {
|
||||
saving: false,
|
||||
saveError: errorMessage // Save errors don't affect "Not connected" state
|
||||
});
|
||||
return EMPTY;
|
||||
})
|
||||
))
|
||||
)
|
||||
),
|
||||
updateConfigLocally(config: Partial<BlacklistSyncConfig>) {
|
||||
const current = store.config();
|
||||
if (current) {
|
||||
patchState(store, { config: { ...current, ...config } });
|
||||
}
|
||||
}
|
||||
})),
|
||||
withHooks({
|
||||
onInit({ loadConfig }) {
|
||||
loadConfig();
|
||||
}
|
||||
})
|
||||
) {}
|
||||
@@ -0,0 +1,92 @@
|
||||
<div class="settings-container">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h1>Blacklist Sync</h1>
|
||||
</div>
|
||||
|
||||
<!-- Loading/Error Component -->
|
||||
<app-loading-error-state
|
||||
*ngIf="blacklistSyncLoading() || blacklistSyncLoadError()"
|
||||
[loading]="blacklistSyncLoading()"
|
||||
[error]="blacklistSyncLoadError()"
|
||||
loadingMessage="Loading settings..."
|
||||
errorMessage="Could not connect to server"
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<form *ngIf="!blacklistSyncLoading() && !blacklistSyncLoadError() && blacklistSyncForm" [formGroup]="blacklistSyncForm" class="p-fluid">
|
||||
|
||||
<!-- Blacklist Sync Configuration Card -->
|
||||
<p-card styleClass="settings-card mb-4">
|
||||
<ng-template pTemplate="header">
|
||||
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
|
||||
<div class="header-title-container">
|
||||
<h2 class="card-title m-0">Blacklist Sync Configuration</h2>
|
||||
<span class="card-subtitle">Configure automatic blacklist synchronization</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="card-content">
|
||||
<!-- Enable Blacklist Sync -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Enable Blacklist Sync
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="blacklistSyncEnabled"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, blacklist patterns will be synchronized to download clients hourly</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blacklist Path -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('blacklistPath')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Blacklist Path
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="blacklistPath"
|
||||
placeholder="File path or http(s) URL"
|
||||
id="blacklistPath" />
|
||||
</div>
|
||||
<small *ngIf="hasError('blacklistPath', 'required')" class="p-error">This field is required when blacklist sync is enabled</small>
|
||||
<small class="form-helper-text">Path to blacklist file or HTTP(S) URL containing blacklist patterns</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card-footer mt-3">
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Save"
|
||||
icon="pi pi-save"
|
||||
class="p-button-primary"
|
||||
[disabled]="(!blacklistSyncForm.dirty || !hasActualChanges) || blacklistSyncForm.invalid || blacklistSyncSaving()"
|
||||
[loading]="blacklistSyncSaving()"
|
||||
(click)="saveBlacklistSyncConfig()"
|
||||
></button>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
label="Reset"
|
||||
icon="pi pi-refresh"
|
||||
class="p-button-secondary p-button-outlined ml-2"
|
||||
(click)="resetBlacklistSyncConfig()"
|
||||
></button>
|
||||
</div>
|
||||
</p-card>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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';
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { BlacklistSyncConfigStore } from "./blacklist-sync-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
import { BlacklistSyncConfig } from "../../shared/models/blacklist-sync-config.model";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
import { InputTextModule } from "primeng/inputtext";
|
||||
import { CheckboxModule } from "primeng/checkbox";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { NotificationService } from '../../core/services/notification.service';
|
||||
import { DocumentationService } from '../../core/services/documentation.service';
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-blacklist-sync-settings",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
CardModule,
|
||||
InputTextModule,
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
ToastModule,
|
||||
LoadingErrorStateComponent,
|
||||
],
|
||||
providers: [BlacklistSyncConfigStore],
|
||||
templateUrl: "./blacklist-sync-settings.component.html",
|
||||
styleUrls: ["./blacklist-sync-settings.component.scss"],
|
||||
})
|
||||
export class BlacklistSyncSettingsComponent implements OnDestroy, CanComponentDeactivate {
|
||||
@Output() saved = new EventEmitter<void>();
|
||||
@Output() error = new EventEmitter<string>();
|
||||
|
||||
// Blacklist Sync Configuration Form
|
||||
blacklistSyncForm: FormGroup;
|
||||
|
||||
// Original form values for tracking changes
|
||||
private originalFormValues: any;
|
||||
|
||||
// Track whether the form has actual changes compared to original values
|
||||
hasActualChanges = false;
|
||||
|
||||
// Inject the necessary services
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private notificationService = inject(NotificationService);
|
||||
private documentationService = inject(DocumentationService);
|
||||
private blacklistSyncConfigStore = inject(BlacklistSyncConfigStore);
|
||||
|
||||
// Signals from the store
|
||||
readonly blacklistSyncConfig = this.blacklistSyncConfigStore.config;
|
||||
readonly blacklistSyncLoading = this.blacklistSyncConfigStore.loading;
|
||||
readonly blacklistSyncSaving = this.blacklistSyncConfigStore.saving;
|
||||
readonly blacklistSyncLoadError = this.blacklistSyncConfigStore.loadError; // Only for "Not connected" state
|
||||
readonly blacklistSyncSaveError = this.blacklistSyncConfigStore.saveError; // Only for toast notifications
|
||||
|
||||
// Subject for unsubscribing from observables when component is destroyed
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Flag to track if form has been initially loaded to avoid showing dialog on page load
|
||||
private formInitialized = false;
|
||||
|
||||
/**
|
||||
* Check if component can be deactivated (navigation guard)
|
||||
*/
|
||||
canDeactivate(): boolean {
|
||||
return !this.blacklistSyncForm.dirty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open field-specific documentation in a new tab
|
||||
* @param fieldName The form field name (e.g., 'enabled', 'blacklistPath')
|
||||
*/
|
||||
openFieldDocs(fieldName: string): void {
|
||||
this.documentationService.openFieldDocumentation('blacklist-sync', fieldName);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the blacklist sync settings form
|
||||
this.blacklistSyncForm = this.formBuilder.group({
|
||||
enabled: [false],
|
||||
blacklistPath: ['', [Validators.required]],
|
||||
});
|
||||
|
||||
// Effect to handle configuration changes
|
||||
effect(() => {
|
||||
const config = this.blacklistSyncConfig();
|
||||
if (config) {
|
||||
// Reset form with the config values
|
||||
this.blacklistSyncForm.patchValue({
|
||||
enabled: config.enabled,
|
||||
blacklistPath: config.blacklistPath || '',
|
||||
});
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
|
||||
// Update blacklist path controls state based on loaded configuration
|
||||
const blacklistSyncEnabled = config.enabled ?? false;
|
||||
this.updateBlacklistPathControlState(blacklistSyncEnabled);
|
||||
|
||||
// Mark form as initialized to enable confirmation dialogs for user actions
|
||||
this.formInitialized = true;
|
||||
|
||||
// Mark form as pristine since we've just loaded the data
|
||||
this.blacklistSyncForm.markAsPristine();
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to handle load errors - emit to LoadingErrorStateComponent for "Not connected" display
|
||||
effect(() => {
|
||||
const loadErrorMessage = this.blacklistSyncLoadError();
|
||||
if (loadErrorMessage) {
|
||||
// Load errors should be shown as "Not connected to server" in LoadingErrorStateComponent
|
||||
this.error.emit(loadErrorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to handle save errors - show as toast notifications for user to fix
|
||||
effect(() => {
|
||||
const saveErrorMessage = this.blacklistSyncSaveError();
|
||||
if (saveErrorMessage) {
|
||||
// Always show save errors as a toast so the user sees the backend message.
|
||||
this.notificationService.showError(saveErrorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up listeners for form value changes
|
||||
this.setupFormValueChangeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up listeners for form control value changes
|
||||
*/
|
||||
private setupFormValueChangeListeners(): void {
|
||||
// Listen to all form changes to check for actual differences from original values
|
||||
this.blacklistSyncForm.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.hasActualChanges = this.formValuesChanged();
|
||||
});
|
||||
|
||||
// Listen for changes to the 'enabled' control
|
||||
const enabledControl = this.blacklistSyncForm.get('enabled');
|
||||
if (enabledControl) {
|
||||
enabledControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((enabled: boolean) => {
|
||||
this.updateBlacklistPathControlState(enabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up subscriptions when component is destroyed
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current form values are different from the original values
|
||||
*/
|
||||
private formValuesChanged(): boolean {
|
||||
if (!this.originalFormValues) return false;
|
||||
|
||||
const currentValues = this.blacklistSyncForm.getRawValue();
|
||||
return !this.isEqual(currentValues, this.originalFormValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update blacklist path control state based on enabled value
|
||||
*/
|
||||
private updateBlacklistPathControlState(enabled: boolean): void {
|
||||
const blacklistPathControl = this.blacklistSyncForm.get('blacklistPath');
|
||||
|
||||
if (enabled) {
|
||||
blacklistPathControl?.enable({ emitEvent: false });
|
||||
} else {
|
||||
blacklistPathControl?.disable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all form controls, including disabled ones
|
||||
*/
|
||||
private validateAllFormControls(formGroup: FormGroup): void {
|
||||
Object.keys(formGroup.controls).forEach(key => {
|
||||
const control = formGroup.get(key);
|
||||
if (control instanceof FormGroup) {
|
||||
this.validateAllFormControls(control);
|
||||
} else {
|
||||
// Force validation even on disabled controls
|
||||
control?.updateValueAndValidity({ onlySelf: true });
|
||||
control?.markAsTouched();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep compare two objects for equality
|
||||
*/
|
||||
private isEqual(obj1: any, obj2: any): boolean {
|
||||
if (obj1 === obj2) return true;
|
||||
|
||||
if (typeof obj1 !== 'object' || obj1 === null ||
|
||||
typeof obj2 !== 'object' || obj2 === null) {
|
||||
return obj1 === obj2;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj1) && Array.isArray(obj2)) {
|
||||
if (obj1.length !== obj2.length) return false;
|
||||
for (let i = 0; i < obj1.length; i++) {
|
||||
if (!this.isEqual(obj1[i], obj2[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const keys1 = Object.keys(obj1);
|
||||
const keys2 = Object.keys(obj2);
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key)) return false;
|
||||
|
||||
if (!this.isEqual(obj1[key], obj2[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store original form values for dirty checking
|
||||
*/
|
||||
private storeOriginalValues(): void {
|
||||
// Create a deep copy of the form values to ensure proper comparison
|
||||
this.originalFormValues = JSON.parse(JSON.stringify(this.blacklistSyncForm.getRawValue()));
|
||||
this.hasActualChanges = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the blacklist sync configuration
|
||||
*/
|
||||
saveBlacklistSyncConfig(): void {
|
||||
// Force validation on all controls, including disabled ones
|
||||
this.validateAllFormControls(this.blacklistSyncForm);
|
||||
|
||||
// Mark all form controls as touched to trigger validation messages
|
||||
this.markFormGroupTouched(this.blacklistSyncForm);
|
||||
|
||||
if (this.blacklistSyncForm.invalid) {
|
||||
this.notificationService.showValidationError();
|
||||
return;
|
||||
}
|
||||
|
||||
const formValues = this.blacklistSyncForm.getRawValue();
|
||||
|
||||
const config: BlacklistSyncConfig = {
|
||||
id: this.blacklistSyncConfig()?.id || '',
|
||||
enabled: formValues.enabled,
|
||||
blacklistPath: formValues.blacklistPath || undefined,
|
||||
};
|
||||
|
||||
// Save the configuration
|
||||
this.blacklistSyncConfigStore.saveConfig(config);
|
||||
|
||||
// Setup a one-time check to mark form as pristine after successful save
|
||||
const checkSaveCompletion = () => {
|
||||
const saving = this.blacklistSyncSaving();
|
||||
const saveError = this.blacklistSyncSaveError();
|
||||
|
||||
if (!saving && !saveError) {
|
||||
// Mark form as pristine after successful save
|
||||
this.blacklistSyncForm.markAsPristine();
|
||||
// Update original values reference
|
||||
this.storeOriginalValues();
|
||||
// Emit saved event
|
||||
this.saved.emit();
|
||||
// Display success message
|
||||
this.notificationService.showSuccess('Blacklist Sync configuration saved successfully.');
|
||||
} else if (!saving && saveError) {
|
||||
// If there's a save error, we can stop checking
|
||||
// Toast notification is already handled by the effect above
|
||||
} else {
|
||||
// If still saving, check again in a moment
|
||||
setTimeout(checkSaveCompletion, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Start checking for save completion
|
||||
checkSaveCompletion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the blacklist sync configuration form to default values
|
||||
*/
|
||||
resetBlacklistSyncConfig(): void {
|
||||
this.blacklistSyncForm.reset({
|
||||
enabled: false,
|
||||
blacklistPath: '',
|
||||
});
|
||||
|
||||
// Update blacklist path control state after reset
|
||||
this.updateBlacklistPathControlState(false); // enabled defaults to false
|
||||
|
||||
// Mark form as dirty so the save button is enabled after reset
|
||||
this.blacklistSyncForm.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all controls in a form group as touched
|
||||
*/
|
||||
private markFormGroupTouched(formGroup: FormGroup): void {
|
||||
Object.values(formGroup.controls).forEach((control) => {
|
||||
control.markAsTouched();
|
||||
|
||||
if ((control as any).controls) {
|
||||
this.markFormGroupTouched(control as FormGroup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form control has an error after it's been touched
|
||||
*/
|
||||
hasError(controlName: string, errorName: string): boolean {
|
||||
const control = this.blacklistSyncForm.get(controlName);
|
||||
// Check for errors on both enabled and disabled controls that have been touched
|
||||
return control ? control.hasError(errorName) : false;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
></app-loading-error-state>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<form *ngIf="!generalLoading() && !generalLoadError()" [formGroup]="generalForm" class="p-fluid">
|
||||
<form *ngIf="!generalLoading() && !generalLoadError() && generalForm" [formGroup]="generalForm" class="p-fluid">
|
||||
|
||||
<!-- General Configuration Card -->
|
||||
<p-card styleClass="settings-card mb-4">
|
||||
@@ -194,6 +194,7 @@
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</p-card>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface BlacklistSyncConfig {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
blacklistPath?: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ sidebar_position: 1
|
||||
---
|
||||
|
||||
import { Note, Warning } from '@site/src/components/Admonition';
|
||||
import {
|
||||
ConfigSection,
|
||||
EnhancedNote,
|
||||
import {
|
||||
ConfigSection,
|
||||
EnhancedNote,
|
||||
EnhancedWarning,
|
||||
styles
|
||||
styles
|
||||
} from '@site/src/components/documentation';
|
||||
|
||||
# General
|
||||
@@ -124,6 +124,82 @@ A lower value or `0` will result in faster searches, but may cause issues such a
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<h2 className={styles.sectionTitle}>
|
||||
<span className={styles.sectionIcon}>📦</span>
|
||||
Download Management
|
||||
</h2>
|
||||
|
||||
<ConfigSection
|
||||
id="ignored-downloads"
|
||||
title="Ignored Downloads"
|
||||
icon="🚯"
|
||||
>
|
||||
|
||||
Downloads matching these patterns will be ignored during all cleaning operations. Patterns can match any of these:
|
||||
- torrent hash
|
||||
- qBittorrent tag or category
|
||||
- Deluge label
|
||||
- Transmission category (last directory from the save location)
|
||||
- µTorrent label
|
||||
- torrent tracker domain
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
fa800a7d7c443a2c3561d1f8f393c089036dade1
|
||||
tv-sonarr
|
||||
qbit-tag
|
||||
mytracker.com
|
||||
```
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<h2 className={styles.sectionTitle}>
|
||||
<span className={styles.sectionIcon}>⛔</span>
|
||||
Blacklist Sync
|
||||
</h2>
|
||||
|
||||
<ConfigSection
|
||||
id="enable-blacklist-sync"
|
||||
title="Enable Blacklist Sync"
|
||||
icon="✅"
|
||||
>
|
||||
|
||||
When enabled, Cleanuparr will automatically synchronize blacklist entries to all enabled qBittorrent clients every hour. The sync happens when the blacklist path or content changes.
|
||||
|
||||
<EnhancedNote>
|
||||
This feature updates the qBittorrent "Excluded file names" setting, but does not enable it.
|
||||
</EnhancedNote>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
id="blacklist-path"
|
||||
title="Blacklist Path"
|
||||
icon="🗂️"
|
||||
>
|
||||
|
||||
Path to the blacklist content. This can be a local file path or an HTTP/HTTPS URL. The application computes a content hash and only pushes updates when the content changes.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
/data/blacklists/qbit-exclusions.txt
|
||||
https://example.com/blacklist.txt
|
||||
```
|
||||
|
||||
<EnhancedWarning>
|
||||
If Blacklist Sync is enabled, this field is required. For remote URLs, ensure the server is reachable from Cleanuparr and the certificate is valid (or adjust HTTP certificate validation settings).
|
||||
</EnhancedWarning>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
@@ -209,37 +285,4 @@ Maximum age (in hours) for archived logs before they are deleted. Older files be
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<h2 className={styles.sectionTitle}>
|
||||
<span className={styles.sectionIcon}>📦</span>
|
||||
Download Management
|
||||
</h2>
|
||||
|
||||
<ConfigSection
|
||||
id="ignored-downloads"
|
||||
title="Ignored Downloads"
|
||||
icon="🚯"
|
||||
>
|
||||
|
||||
Downloads matching these patterns will be ignored during all cleaning operations. Patterns can match any of these:
|
||||
- torrent hash
|
||||
- qBittorrent tag or category
|
||||
- Deluge label
|
||||
- Transmission category (last directory from the save location)
|
||||
- µTorrent label
|
||||
- torrent tracker domain
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
fa800a7d7c443a2c3561d1f8f393c089036dade1
|
||||
tv-sonarr
|
||||
qbit-tag
|
||||
mytracker.com
|
||||
```
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user