Compare commits

...

23 Commits

Author SHA1 Message Date
Flaminel
16e823b8d3 Add status json to Cloudflare pages (#323) 2025-09-29 21:43:15 +03:00
Flaminel
f2f11e3472 Fix UI caching for Download Cleaner page (#316) 2025-09-29 21:37:25 +03:00
Flaminel
a3549c80a9 Fix icons paths (#322) 2025-09-29 21:36:59 +03:00
Flaminel
2b9c347ed6 Fix docs build workflow (#315) 2025-09-25 12:23:20 +03:00
Flaminel
98ccee866d Fix sidebar styling (#313) 2025-09-25 12:06:46 +03:00
Flaminel
911849c6dd Fix blacklist synchronizer docs (#307) 2025-09-16 23:30:38 +03:00
Flaminel
cce3bb2c4a Add ntfy support (#300) 2025-09-15 22:08:48 +03:00
Flaminel
bcc117cd0d Fix slow strikes not being reset (#305) 2025-09-15 22:03:18 +03:00
Flaminel
8e20a68ae2 Improve frontend layout (#299) 2025-09-15 22:03:03 +03:00
Flaminel
736c146f25 Add ignored downloads setting per job (#301) 2025-09-15 22:02:03 +03:00
Flaminel
6398ef1cc6 Add option to inject blacklist into qBittorrent (#304) 2025-09-15 21:59:49 +03:00
Flaminel
83e6a289be Change docs build triggers (#303) 2025-09-15 20:55:35 +03:00
Julien Virey
5662118b01 Add documentation about archlinux package (#296) 2025-09-06 01:30:06 +03:00
Flaminel
22dfc7b40d Fix blocklist provider reporting wrong number of loaded blocklists (#293) 2025-09-04 22:14:46 +03:00
Flaminel
a51e387453 Fix log level change not taking effect (#292) 2025-09-04 22:12:57 +03:00
Flaminel
c7d2ec7311 Fix notification provider update (#291) 2025-09-03 23:48:02 +03:00
Flaminel
bb9ac5b67b Fix notifications migration when no event type is enabled (#290) 2025-09-03 21:12:55 +03:00
Flaminel
f93494adb2 Rework notifications system (#284) 2025-09-02 23:18:22 +03:00
Flaminel
7201520411 Add configurable log retention (#279) 2025-09-02 00:17:16 +03:00
Flaminel
2a1e65e1af Make sidebar scrollable (#285) 2025-09-02 00:16:38 +03:00
Flaminel
da318c3339 Fix HTTPS schema for Cloudflare pages links (#286) 2025-09-02 00:16:27 +03:00
Flaminel
7149b6243f Add .sql to the blacklist (#287) 2025-09-02 00:16:12 +03:00
Flaminel
11f5a28c04 Improve download client health checks (#288) 2025-09-02 00:15:09 +03:00
216 changed files with 14154 additions and 3474 deletions

View File

@@ -0,0 +1,30 @@
name: Deploy to Cloudflare Pages
on:
push:
tags:
- "v*.*.*"
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Create status files
run: |
mkdir -p status
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
# Cache static files for 10 minutes
cat > status/_headers << 'EOF'
/*
Cache-Control: public, max-age=600, s-maxage=600
EOF
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "status"
command: pages deploy . --project-name=cleanuparr-status

View File

@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
on:
push:
branches: [main]
paths:
- 'docs/**'
tags:
- "v*.*.*"
workflow_dispatch: {}
permissions:
contents: read
@@ -22,6 +22,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Set up Node.js
uses: actions/setup-node@v4

View File

@@ -640,6 +640,7 @@
*.spm
*.spr
*.spt
*.sql
*.sqf
*.sqx
*.sqz

View File

File diff suppressed because it is too large Load Diff

View File

@@ -157,12 +157,12 @@ public static class ApiDI
icons = new[]
{
new {
src = "assets/icons/icon-192x192.png",
src = "icons/icon-192x192.png",
sizes = "192x192",
type = "image/png"
},
new {
src = "assets/icons/icon-512x512.png",
src = "icons/icon-512x512.png",
sizes = "512x512",
type = "image/png"
}

View File

@@ -1,10 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Shared.Helpers;
using Cleanuparr.Infrastructure.Logging;
using Serilog;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Cleanuparr.Api.DependencyInjection;
@@ -12,82 +7,10 @@ public static class LoggingDI
{
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
{
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
Log.Logger = LoggingConfigManager
.CreateLoggerConfiguration()
.CreateLogger();
return builder.ClearProviders().AddSerilog();
}
public static LoggerConfiguration GetDefaultLoggerConfiguration()
{
LoggerConfiguration logConfig = new();
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
// Determine job name padding
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding
List<string> categoryNames = [
InstanceType.Sonarr.ToString(),
InstanceType.Radarr.ToString(),
InstanceType.Lidarr.ToString(),
InstanceType.Readarr.ToString(),
InstanceType.Whisparr.ToString(),
"SYSTEM"
];
int catPadding = categoryNames.Max(x => x.Length) + 2;
// Apply padding values to templates
string consoleTemplate = consoleOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
string fileTemplate = fileOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
// Configure base logger with dynamic level control
logConfig
.MinimumLevel.Is(LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
// Create the logs directory
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
if (!Directory.Exists(logsPath))
{
try
{
Directory.CreateDirectory(logsPath);
}
catch (Exception exception)
{
throw new Exception($"Failed to create log directory | {logsPath}", exception);
}
}
// Add main log file
logConfig.WriteTo.File(
path: Path.Combine(logsPath, "cleanuparr-.txt"),
formatter: new ExpressionTemplate(fileTemplate),
fileSizeLimitBytes: 10L * 1024 * 1024,
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
shared: true
);
logConfig
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
return logConfig;
}
}

View File

@@ -17,14 +17,13 @@ public static class MainDI
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
services
.AddLogging(builder => builder.ClearProviders().AddConsole())
.AddHttpClients(configuration)
.AddSingleton<MemoryCache>()
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
.AddServices()
.AddHealthServices()
.AddQuartzServices(configuration)
.AddNotifications(configuration)
.AddNotifications()
.AddMassTransit(config =>
{
config.DisableUsageTelemetry();
@@ -36,7 +35,8 @@ public static class MainDI
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
@@ -71,7 +71,8 @@ public static class MainDI
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);

View File

@@ -1,20 +1,20 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Infrastructure.Verticals.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
namespace Cleanuparr.Api.DependencyInjection;
public static class NotificationsDI
{
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
services
// Notification configs are now managed through ConfigManager
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
.AddTransient<INotificationProvider, NotifiarrProvider>()
.AddTransient<IAppriseProxy, AppriseProxy>()
.AddTransient<INotificationProvider, AppriseProvider>()
.AddTransient<INotificationPublisher, NotificationPublisher>()
.AddTransient<INotificationFactory, NotificationFactory>()
.AddTransient<NotificationService>();
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()
.AddScoped<INotificationPublisher, NotificationPublisher>()
.AddScoped<NotificationService>();
}

View File

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

View File

@@ -6,7 +6,7 @@ namespace Cleanuparr.Api;
public static class HostExtensions
{
public static async Task<IHost> Init(this WebApplication app)
public static async Task<IHost> InitAsync(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
@@ -20,22 +20,25 @@ public static class HostExtensions
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
// Apply db migrations
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
await using var scope = scopeFactory.CreateAsyncScope();
await using var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
return app;
}
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
{
// Apply events db migrations
await using var eventsContext = EventsContext.CreateStaticInstance();
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
{
await eventsContext.Database.MigrateAsync();
}
await using var configContext = scope.ServiceProvider.GetRequiredService<DataContext>();
// Apply data db migrations
await using var configContext = DataContext.CreateStaticInstance();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();
}
return app;
return builder;
}
}

View File

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

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateAppriseProviderDto : CreateNotificationProviderBaseDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateNotifiarrProviderDto : CreateNotificationProviderBaseDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,20 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public abstract record CreateNotificationProviderBaseDto
{
public string Name { get; init; } = string.Empty;
public bool IsEnabled { get; init; } = true;
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record CreateNtfyProviderDto : CreateNotificationProviderBaseDto
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestAppriseProviderDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestNotifiarrProviderDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record TestNtfyProviderDto
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateAppriseProviderDto : CreateNotificationProviderBaseDto
{
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateNotifiarrProviderDto : CreateNotificationProviderBaseDto
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,22 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Models.NotificationProviders;
public sealed record UpdateNtfyProviderDto : CreateNotificationProviderBaseDto
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -29,6 +29,8 @@ public class UpdateDownloadCleanerConfigDto
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
public List<string> UnlinkedCategories { get; set; } = [];
public List<string> IgnoredDownloads { get; set; } = [];
}
public class CleanCategoryDto

View File

@@ -2,14 +2,18 @@ using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.SignalR;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
await builder.InitAsync();
builder.Logging.AddLogging();
// Fix paths for single-file deployment on macOS
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
@@ -68,14 +72,6 @@ builder.Services.AddCors(options =>
});
});
// Register services needed for logging first
builder.Services
.AddScoped<LoggingConfigManager>()
.AddSingleton<SignalRLogSink>();
// Add logging with proper service provider
builder.Logging.AddLogging();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
builder.Host.UseWindowsService(options =>
@@ -130,28 +126,11 @@ if (basePath is not null)
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
// Initialize the host
await app.Init();
await app.InitAsync();
// Get LoggingConfigManager (will be created if not already registered)
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
using (var scope = scopeFactory.CreateScope())
{
var configManager = scope.ServiceProvider.GetRequiredService<LoggingConfigManager>();
// Get the dynamic level switch for controlling log levels
var levelSwitch = configManager.GetLevelSwitch();
// Get the SignalRLogSink instance
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
logConfig.MinimumLevel.ControlledBy(levelSwitch);
// Add to Serilog pipeline
logConfig.WriteTo.Sink(signalRSink);
Log.Logger = logConfig.CreateLogger();
}
// Configure the app hub for SignalR
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
SignalRLogSink.Instance.SetAppHubContext(appHub);
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
@@ -168,4 +147,6 @@ app.MapHealthChecks("/health/ready", new HealthCheckOptions
app.ConfigureApi();
await app.RunAsync();
await app.RunAsync();
await Log.CloseAndFlushAsync();

View File

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

View File

@@ -59,7 +59,8 @@ public sealed class DownloadCleaner : GenericHandler
return;
}
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<DownloadCleanerConfig>().IgnoredDownloads);
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<object>>();

View File

@@ -94,7 +94,8 @@ public sealed class MalwareBlocker : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());

View File

@@ -54,7 +54,8 @@ public sealed class QueueCleaner : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
IReadOnlyList<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>().IgnoredDownloads;
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<QueueCleanerConfig>().IgnoredDownloads);
using var _ = LogContext.PushProperty(LogProperties.Category, instanceType.ToString());

View File

@@ -0,0 +1,13 @@
namespace Cleanuparr.Domain.Enums;
public enum NotificationEventType
{
Test,
FailedImportStrike,
StalledStrike,
SlowSpeedStrike,
SlowTimeStrike,
QueueItemDeleted,
DownloadCleaned,
CategoryChanged
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum NotificationProviderType
{
Notifiarr,
Apprise,
Ntfy
}

View File

@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyAuthenticationType
{
None,
BasicAuth,
AccessToken
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Domain.Enums;
public enum NtfyPriority
{
Min = 1,
Low = 2,
Default = 3,
High = 4,
Max = 5
}

View File

@@ -18,6 +18,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,10 +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 Infrastructure.Verticals.Notifications;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -79,6 +78,7 @@ public class EventPublisher
StrikeType.FailedImport => EventType.FailedImportStrike,
StrikeType.SlowSpeed => EventType.SlowSpeedStrike,
StrikeType.SlowTime => EventType.SlowTimeStrike,
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
};
dynamic data;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,11 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -125,7 +125,7 @@ public abstract class DownloadService : IDownloadService
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Slow.ResetStrikesOnProgress)
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
{
return;
}
@@ -145,7 +145,7 @@ public abstract class DownloadService : IDownloadService
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.Slow.ResetStrikesOnProgress)
if (!queueCleanerConfig.Slow.ResetStrikesOnProgress)
{
return;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
@@ -19,7 +18,6 @@ public sealed class BlocklistProvider
{
private readonly ILogger<BlocklistProvider> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly Dictionary<InstanceType, string> _configHashes = new();
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
@@ -31,14 +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);
}
public async Task LoadBlocklistsAsync()
@@ -47,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()
@@ -58,78 +56,25 @@ public sealed class BlocklistProvider
return;
}
// Check and update Sonarr blocklist if needed
string sonarrHash = GenerateSettingsHash(malwareBlockerConfig.Sonarr);
var sonarrInterval = GetLoadInterval(malwareBlockerConfig.Sonarr.BlocklistPath);
var sonarrIdentifier = $"Sonarr_{malwareBlockerConfig.Sonarr.BlocklistPath}";
if (ShouldReloadBlocklist(sonarrIdentifier, sonarrInterval) || !_configHashes.TryGetValue(InstanceType.Sonarr, out string? oldSonarrHash) || sonarrHash != oldSonarrHash)
var instances = new Dictionary<InstanceType, BlocklistSettings>
{
_logger.LogDebug("Loading Sonarr blocklist");
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Sonarr, InstanceType.Sonarr);
_configHashes[InstanceType.Sonarr] = sonarrHash;
_lastLoadTimes[sonarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Radarr blocklist if needed
string radarrHash = GenerateSettingsHash(malwareBlockerConfig.Radarr);
var radarrInterval = GetLoadInterval(malwareBlockerConfig.Radarr.BlocklistPath);
var radarrIdentifier = $"Radarr_{malwareBlockerConfig.Radarr.BlocklistPath}";
if (ShouldReloadBlocklist(radarrIdentifier, radarrInterval) || !_configHashes.TryGetValue(InstanceType.Radarr, out string? oldRadarrHash) || radarrHash != oldRadarrHash)
{ InstanceType.Sonarr, malwareBlockerConfig.Sonarr },
{ InstanceType.Radarr, malwareBlockerConfig.Radarr },
{ InstanceType.Lidarr, malwareBlockerConfig.Lidarr },
{ InstanceType.Readarr, malwareBlockerConfig.Readarr },
{ InstanceType.Whisparr, malwareBlockerConfig.Whisparr }
};
foreach (var kv in instances)
{
_logger.LogDebug("Loading Radarr blocklist");
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Radarr, InstanceType.Radarr);
_configHashes[InstanceType.Radarr] = radarrHash;
_lastLoadTimes[radarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Lidarr blocklist if needed
string lidarrHash = GenerateSettingsHash(malwareBlockerConfig.Lidarr);
var lidarrInterval = GetLoadInterval(malwareBlockerConfig.Lidarr.BlocklistPath);
var lidarrIdentifier = $"Lidarr_{malwareBlockerConfig.Lidarr.BlocklistPath}";
if (ShouldReloadBlocklist(lidarrIdentifier, lidarrInterval) || !_configHashes.TryGetValue(InstanceType.Lidarr, out string? oldLidarrHash) || lidarrHash != oldLidarrHash)
{
_logger.LogDebug("Loading Lidarr blocklist");
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Lidarr, InstanceType.Lidarr);
_configHashes[InstanceType.Lidarr] = lidarrHash;
_lastLoadTimes[lidarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Readarr blocklist if needed
string readarrHash = GenerateSettingsHash(malwareBlockerConfig.Readarr);
var readarrInterval = GetLoadInterval(malwareBlockerConfig.Readarr.BlocklistPath);
var readarrIdentifier = $"Readarr_{malwareBlockerConfig.Readarr.BlocklistPath}";
if (ShouldReloadBlocklist(readarrIdentifier, readarrInterval) || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
{
_logger.LogDebug("Loading Readarr blocklist");
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
_lastLoadTimes[readarrIdentifier] = DateTime.UtcNow;
changedCount++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(malwareBlockerConfig.Whisparr);
var whisparrInterval = GetLoadInterval(malwareBlockerConfig.Whisparr.BlocklistPath);
var whisparrIdentifier = $"Whisparr_{malwareBlockerConfig.Whisparr.BlocklistPath}";
if (ShouldReloadBlocklist(whisparrIdentifier, whisparrInterval) || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
{
_logger.LogDebug("Loading Whisparr blocklist");
await LoadPatternsAndRegexesAsync(malwareBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
_lastLoadTimes[whisparrIdentifier] = DateTime.UtcNow;
changedCount++;
if (await EnsureInstanceLoadedAsync(kv.Value, kv.Key, fileReader))
{
changedCount++;
}
}
// Always check and update malware patterns
await LoadMalwarePatternsAsync();
await LoadMalwarePatternsAsync(fileReader);
if (changedCount > 0)
{
@@ -175,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))
@@ -201,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);
@@ -214,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 };
@@ -239,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 };
@@ -286,36 +254,10 @@ 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
var configStr = $"{blocklistSettings.BlocklistPath ?? string.Empty}|{blocklistSettings.BlocklistType}";
var configStr = $"{blocklistSettings.Enabled}|{blocklistSettings.BlocklistPath ?? string.Empty}|{blocklistSettings.BlocklistType}";
// Create SHA256 hash of the configuration string
using var sha = SHA256.Create();

View File

@@ -1,97 +1,62 @@
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using System.Text;
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
public sealed class AppriseProvider : NotificationProviderBase<AppriseConfig>
{
private readonly IAppriseProxy _proxy;
public override string Name => "Apprise";
public AppriseProvider(DataContext dataContext, IAppriseProxy proxy)
: base(dataContext.AppriseConfigs)
public AppriseProvider(
string name,
NotificationProviderType type,
AppriseConfig config,
IAppriseProxy proxy
) : base(name, type, config)
{
_proxy = proxy;
}
public override async Task OnFailedImportStrike(FailedImportStrikeNotification notification)
public override async Task SendNotificationAsync(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnStalledStrike(StalledStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnSlowStrike(SlowStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnQueueItemDeleted(QueueItemDeletedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
ApprisePayload payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
public override async Task OnDownloadCleaned(DownloadCleanedNotification notification)
private ApprisePayload BuildPayload(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
public override async Task OnCategoryChanged(CategoryChangedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
private ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
body.AppendLine();
body.AppendLine($"Instance type: {notification.InstanceType.ToString()}");
body.AppendLine($"Url: {notification.InstanceUrl}");
body.AppendLine($"Download hash: {notification.Hash}");
NotificationType notificationType = context.Severity switch
{
EventSeverity.Warning => NotificationType.Warning,
EventSeverity.Important => NotificationType.Failure,
_ => NotificationType.Info
};
foreach (NotificationField field in notification.Fields ?? [])
string body = BuildBody(context);
return new ApprisePayload
{
body.AppendLine($"{field.Title}: {field.Text}");
}
ApprisePayload payload = new()
{
Title = notification.Title,
Body = body.ToString(),
Title = context.Title,
Body = body,
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;
}
private ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
body.AppendLine();
foreach (NotificationField field in notification.Fields ?? [])
private static string BuildBody(NotificationContext context)
{
var body = new StringBuilder();
body.AppendLine(context.Description);
body.AppendLine();
foreach ((string key, string value) in context.Data)
{
body.AppendLine($"{field.Title}: {field.Text}");
body.AppendLine($"{key}: {value}");
}
ApprisePayload payload = new()
{
Title = notification.Title,
Body = body.ToString(),
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;
return body.ToString();
}
}
}

View File

@@ -26,16 +26,17 @@ public sealed class AppriseProxy : IAppriseProxy
NullValueHandling = NullValueHandling.Ignore
});
UriBuilder uriBuilder = new(config.Url.ToString());
var parsedUrl = config.Uri!;
UriBuilder uriBuilder = new(parsedUrl);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/notify/{config.Key}";
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Method = HttpMethod.Post;
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(config.Url.UserInfo))
if (!string.IsNullOrEmpty(parsedUrl.UserInfo))
{
var byteArray = Encoding.ASCII.GetBytes(config.Url.UserInfo);
var byteArray = Encoding.ASCII.GetBytes(parsedUrl.UserInfo);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
}

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Infrastructure.Verticals.Notifications;
using MassTransit;
using Microsoft.Extensions.Logging;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Notifications.Consumers;
@@ -23,22 +23,39 @@ public sealed class NotificationConsumer<T> : IConsumer<T> where T : Notificatio
switch (context.Message)
{
case FailedImportStrikeNotification failedMessage:
await _notificationService.Notify(failedMessage);
await _notificationService.SendNotificationAsync(
NotificationEventType.FailedImportStrike,
ConvertToNotificationContext(failedMessage, NotificationEventType.FailedImportStrike));
break;
case StalledStrikeNotification stalledMessage:
await _notificationService.Notify(stalledMessage);
await _notificationService.SendNotificationAsync(
NotificationEventType.StalledStrike,
ConvertToNotificationContext(stalledMessage, NotificationEventType.StalledStrike));
break;
case SlowStrikeNotification slowMessage:
await _notificationService.Notify(slowMessage);
case SlowSpeedStrikeNotification slowMessage:
await _notificationService.SendNotificationAsync(
NotificationEventType.SlowSpeedStrike,
ConvertToNotificationContext(slowMessage, NotificationEventType.SlowSpeedStrike));
break;
case SlowTimeStrikeNotification slowTimeMessage:
await _notificationService.SendNotificationAsync(
NotificationEventType.SlowTimeStrike,
ConvertToNotificationContext(slowTimeMessage, NotificationEventType.SlowTimeStrike));
break;
case QueueItemDeletedNotification queueItemDeleteMessage:
await _notificationService.Notify(queueItemDeleteMessage);
await _notificationService.SendNotificationAsync(
NotificationEventType.QueueItemDeleted,
ConvertToNotificationContext(queueItemDeleteMessage, NotificationEventType.QueueItemDeleted));
break;
case DownloadCleanedNotification downloadCleanedNotification:
await _notificationService.Notify(downloadCleanedNotification);
await _notificationService.SendNotificationAsync(
NotificationEventType.DownloadCleaned,
ConvertToNotificationContext(downloadCleanedNotification, NotificationEventType.DownloadCleaned));
break;
case CategoryChangedNotification categoryChangedNotification:
await _notificationService.Notify(categoryChangedNotification);
await _notificationService.SendNotificationAsync(
NotificationEventType.CategoryChanged,
ConvertToNotificationContext(categoryChangedNotification, NotificationEventType.CategoryChanged));
break;
default:
throw new NotImplementedException();
@@ -49,7 +66,44 @@ public sealed class NotificationConsumer<T> : IConsumer<T> where T : Notificatio
}
catch (Exception exception)
{
_logger.LogError(exception, "error while processing notifications");
_logger.LogError(exception, "Error while processing notifications");
}
}
private static NotificationContext ConvertToNotificationContext(Notification notification, NotificationEventType eventType)
{
var severity = notification.Level switch
{
NotificationLevel.Important => EventSeverity.Important,
NotificationLevel.Warning => EventSeverity.Warning,
_ => EventSeverity.Information
};
var data = new Dictionary<string, string>();
Uri? image = null;
if (notification is ArrNotification arrNotification)
{
data.Add("Instance type", arrNotification.InstanceType.ToString());
data.Add("Url", arrNotification.InstanceUrl.ToString());
data.Add("Hash", arrNotification.Hash);
image = arrNotification.Image;
}
foreach (var field in notification.Fields ?? [])
{
data[field.Key] = field.Value;
}
return new NotificationContext
{
EventType = eventType,
Title = notification.Title,
Description = notification.Description,
Severity = severity,
Data = data,
Image = image
};
}
}

View File

@@ -0,0 +1,15 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationConfigurationService
{
Task<List<NotificationProviderDto>> GetActiveProvidersAsync();
Task<List<NotificationProviderDto>> GetProvidersForEventAsync(NotificationEventType eventType);
Task<NotificationProviderDto?> GetProviderByIdAsync(Guid id);
Task InvalidateCacheAsync();
}

View File

@@ -1,18 +0,0 @@
using Infrastructure.Verticals.Notifications;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationFactory
{
List<INotificationProvider> OnFailedImportStrikeEnabled();
List<INotificationProvider> OnStalledStrikeEnabled();
List<INotificationProvider> OnSlowStrikeEnabled();
List<INotificationProvider> OnQueueItemDeletedEnabled();
List<INotificationProvider> OnDownloadCleanedEnabled();
List<INotificationProvider> OnCategoryChangedEnabled();
}

View File

@@ -1,29 +1,13 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationProvider<T> : INotificationProvider
where T : NotificationConfig
{
new T Config { get; }
}
public interface INotificationProvider
{
NotificationConfig Config { get; }
string Name { get; }
Task OnFailedImportStrike(FailedImportStrikeNotification notification);
Task OnStalledStrike(StalledStrikeNotification notification);
NotificationProviderType Type { get; }
Task OnSlowStrike(SlowStrikeNotification notification);
Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
Task OnDownloadCleaned(DownloadCleanedNotification notification);
Task OnCategoryChanged(CategoryChangedNotification notification);
}
Task SendNotificationAsync(NotificationContext context);
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public interface INotificationProviderFactory
{
INotificationProvider CreateProvider(NotificationProviderDto config);
}

View File

@@ -0,0 +1,18 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationContext
{
public NotificationEventType EventType { get; init; }
public required string Title { get; init; }
public required string Description { get; init; }
public Dictionary<string, string> Data { get; init; } = new();
public EventSeverity Severity { get; init; } = EventSeverity.Information;
public Uri? Image { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationEventFlags
{
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
}

View File

@@ -2,7 +2,7 @@
public sealed record NotificationField
{
public required string Title { get; init; }
public required string Key { get; init; }
public required string Text { get; init; }
public required string Value { get; init; }
}

View File

@@ -0,0 +1,18 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationProviderDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public NotificationProviderType Type { get; init; }
public bool IsEnabled { get; init; }
public NotificationEventFlags Events { get; init; } = new();
public object Configuration { get; init; } = new();
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record SlowSpeedStrikeNotification : ArrNotification
{
}

View File

@@ -1,5 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record SlowStrikeNotification : ArrNotification
public sealed record SlowTimeStrikeNotification : ArrNotification
{
}

View File

@@ -1,77 +1,52 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using Mapster;
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
public class NotifiarrProvider : NotificationProvider<NotifiarrConfig>
public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig>
{
private readonly DataContext _dataContext;
private readonly INotifiarrProxy _proxy;
private const string WarningColor = "f0ad4e";
private const string ImportantColor = "bb2124";
private const string Logo = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
public override string Name => "Notifiarr";
public NotifiarrProvider(DataContext dataContext, INotifiarrProxy proxy)
: base(dataContext.NotifiarrConfigs)
public NotifiarrProvider(
string name,
NotificationProviderType type,
NotifiarrConfig config,
INotifiarrProxy proxy)
: base(name, type, config)
{
_dataContext = dataContext;
_proxy = proxy;
}
public override async Task OnFailedImportStrike(FailedImportStrikeNotification notification)
public override async Task SendNotificationAsync(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification, WarningColor), Config);
}
public override async Task OnStalledStrike(StalledStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, WarningColor), Config);
}
public override async Task OnSlowStrike(SlowStrikeNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, WarningColor), Config);
}
public override async Task OnQueueItemDeleted(QueueItemDeletedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification, ImportantColor), Config);
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
public override async Task OnDownloadCleaned(DownloadCleanedNotification notification)
private NotifiarrPayload BuildPayload(NotificationContext context)
{
await _proxy.SendNotification(BuildPayload(notification), Config);
}
public override async Task OnCategoryChanged(CategoryChangedNotification notification)
{
await _proxy.SendNotification(BuildPayload(notification), Config);
}
var color = context.Severity switch
{
EventSeverity.Warning => "f0ad4e",
EventSeverity.Important => "bb2124",
_ => "28a745"
};
private NotifiarrPayload BuildPayload(ArrNotification notification, string color)
{
NotifiarrPayload payload = new()
const string logo = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
return new NotifiarrPayload
{
Discord = new()
{
Color = color,
Text = new()
{
Title = notification.Title,
Icon = Logo,
Description = notification.Description,
Fields = new()
{
new() { Title = "Instance type", Text = notification.InstanceType.ToString() },
new() { Title = "Url", Text = notification.InstanceUrl.ToString() },
new() { Title = "Download hash", Text = notification.Hash }
}
Title = context.Title,
Icon = logo,
Description = context.Description,
Fields = BuildFields(context)
},
Ids = new Ids
{
@@ -79,70 +54,22 @@ public class NotifiarrProvider : NotificationProvider<NotifiarrConfig>
},
Images = new()
{
Thumbnail = new Uri(Logo),
Image = notification.Image
Thumbnail = new Uri(logo),
Image = context.Image
}
}
};
payload.Discord.Text.Fields.AddRange(notification.Fields?.Adapt<List<Field>>() ?? []);
return payload;
}
private NotifiarrPayload BuildPayload(DownloadCleanedNotification notification)
private List<Field> BuildFields(NotificationContext context)
{
NotifiarrPayload payload = new()
{
Discord = new()
{
Color = ImportantColor,
Text = new()
{
Title = notification.Title,
Icon = Logo,
Description = notification.Description,
Fields = notification.Fields?.Adapt<List<Field>>() ?? []
},
Ids = new Ids
{
Channel = Config.ChannelId
},
Images = new()
{
Thumbnail = new Uri(Logo)
}
}
};
return payload;
}
var fields = new List<Field>();
private NotifiarrPayload BuildPayload(CategoryChangedNotification notification)
{
NotifiarrPayload payload = new()
foreach ((string key, string value) in context.Data)
{
Discord = new()
{
Color = WarningColor,
Text = new()
{
Title = notification.Title,
Icon = Logo,
Description = notification.Description,
Fields = notification.Fields?.Adapt<List<Field>>() ?? []
},
Ids = new Ids
{
Channel = Config.ChannelId
},
Images = new()
{
Thumbnail = new Uri(Logo)
}
}
};
return payload;
fields.Add(new() { Title = key, Text = value });
}
return fields;
}
}
}

View File

@@ -0,0 +1,166 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public sealed class NotificationConfigurationService : INotificationConfigurationService
{
private readonly DataContext _dataContext;
private readonly ILogger<NotificationConfigurationService> _logger;
private List<NotificationProviderDto>? _cachedProviders;
private readonly SemaphoreSlim _cacheSemaphore = new(1, 1);
public NotificationConfigurationService(
DataContext dataContext,
ILogger<NotificationConfigurationService> logger)
{
_dataContext = dataContext;
_logger = logger;
}
public async Task<List<NotificationProviderDto>> GetActiveProvidersAsync()
{
await _cacheSemaphore.WaitAsync();
try
{
if (_cachedProviders != null)
{
return _cachedProviders.Where(p => p.IsEnabled).ToList();
}
}
finally
{
_cacheSemaphore.Release();
}
await LoadProvidersAsync();
await _cacheSemaphore.WaitAsync();
try
{
return _cachedProviders?.Where(p => p.IsEnabled).ToList() ?? new List<NotificationProviderDto>();
}
finally
{
_cacheSemaphore.Release();
}
}
public async Task<List<NotificationProviderDto>> GetProvidersForEventAsync(NotificationEventType eventType)
{
var activeProviders = await GetActiveProvidersAsync();
return activeProviders.Where(provider => IsEventEnabled(provider.Events, eventType)).ToList();
}
public async Task<NotificationProviderDto?> GetProviderByIdAsync(Guid id)
{
var allProviders = await GetActiveProvidersAsync();
return allProviders.FirstOrDefault(p => p.Id == id);
}
public async Task InvalidateCacheAsync()
{
await _cacheSemaphore.WaitAsync();
try
{
_cachedProviders = null;
}
finally
{
_cacheSemaphore.Release();
}
_logger.LogDebug("Notification provider cache invalidated");
}
private async Task LoadProvidersAsync()
{
try
{
var providers = await _dataContext.Set<NotificationConfig>()
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.AsNoTracking()
.ToListAsync();
var dtos = providers.Select(MapToDto).ToList();
await _cacheSemaphore.WaitAsync();
try
{
_cachedProviders = dtos;
}
finally
{
_cacheSemaphore.Release();
}
_logger.LogDebug("Loaded {count} notification providers", dtos.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load notification providers");
await _cacheSemaphore.WaitAsync();
try
{
_cachedProviders = new List<NotificationProviderDto>();
}
finally
{
_cacheSemaphore.Release();
}
}
}
private static NotificationProviderDto MapToDto(NotificationConfig config)
{
var events = new NotificationEventFlags
{
OnFailedImportStrike = config.OnFailedImportStrike,
OnStalledStrike = config.OnStalledStrike,
OnSlowStrike = config.OnSlowStrike,
OnQueueItemDeleted = config.OnQueueItemDeleted,
OnDownloadCleaned = config.OnDownloadCleaned,
OnCategoryChanged = config.OnCategoryChanged
};
var configuration = config.Type switch
{
NotificationProviderType.Notifiarr => config.NotifiarrConfiguration,
NotificationProviderType.Apprise => config.AppriseConfiguration,
NotificationProviderType.Ntfy => config.NtfyConfiguration,
_ => new object()
};
return new NotificationProviderDto
{
Id = config.Id,
Name = config.Name,
Type = config.Type,
IsEnabled = config.IsEnabled && config.IsConfigured && config.HasAnyEventEnabled,
Events = events,
Configuration = configuration ?? new object()
};
}
private static bool IsEventEnabled(NotificationEventFlags events, NotificationEventType eventType)
{
return eventType switch
{
NotificationEventType.FailedImportStrike => events.OnFailedImportStrike,
NotificationEventType.StalledStrike => events.OnStalledStrike,
NotificationEventType.SlowSpeedStrike or NotificationEventType.SlowTimeStrike => events.OnSlowStrike,
NotificationEventType.QueueItemDeleted => events.OnQueueItemDeleted,
NotificationEventType.DownloadCleaned => events.OnDownloadCleaned,
NotificationEventType.CategoryChanged => events.OnCategoryChanged,
NotificationEventType.Test => true,
_ => false
};
}
}

View File

@@ -1,47 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications;
public class NotificationFactory : INotificationFactory
{
private readonly IEnumerable<INotificationProvider> _providers;
public NotificationFactory(IEnumerable<INotificationProvider> providers)
{
_providers = providers;
}
protected List<INotificationProvider> ActiveProviders() =>
_providers
.Where(x => x.Config.IsValid())
.Where(provider => provider.Config.IsEnabled)
.ToList();
public List<INotificationProvider> OnFailedImportStrikeEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnFailedImportStrike)
.ToList();
public List<INotificationProvider> OnStalledStrikeEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnStalledStrike)
.ToList();
public List<INotificationProvider> OnSlowStrikeEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnSlowStrike)
.ToList();
public List<INotificationProvider> OnQueueItemDeletedEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnQueueItemDeleted)
.ToList();
public List<INotificationProvider> OnDownloadCleanedEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnDownloadCleaned)
.ToList();
public List<INotificationProvider> OnCategoryChangedEnabled() =>
ActiveProviders()
.Where(n => n.Config.OnCategoryChanged)
.ToList();
}

View File

@@ -1,35 +0,0 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public abstract class NotificationProvider<T> : INotificationProvider<T>
where T : NotificationConfig
{
protected readonly DbSet<T> _notificationConfig;
protected T? _config;
public T Config => _config ??= _notificationConfig.AsNoTracking().First();
NotificationConfig INotificationProvider.Config => Config;
protected NotificationProvider(DbSet<T> notificationConfig)
{
_notificationConfig = notificationConfig;
}
public abstract string Name { get; }
public abstract Task OnFailedImportStrike(FailedImportStrikeNotification notification);
public abstract Task OnStalledStrike(StalledStrikeNotification notification);
public abstract Task OnSlowStrike(SlowStrikeNotification notification);
public abstract Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
public abstract Task OnDownloadCleaned(DownloadCleanedNotification notification);
public abstract Task OnCategoryChanged(CategoryChangedNotification notification);
}

View File

@@ -0,0 +1,23 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public abstract class NotificationProviderBase<TConfig> : INotificationProvider
where TConfig : class
{
protected TConfig Config { get; }
public string Name { get; }
public NotificationProviderType Type { get; }
protected NotificationProviderBase(string name, NotificationProviderType type, TConfig config)
{
Name = name;
Type = type;
Config = config;
}
public abstract Task SendNotificationAsync(NotificationContext context);
}

View File

@@ -0,0 +1,54 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public sealed class NotificationProviderFactory : INotificationProviderFactory
{
private readonly IServiceProvider _serviceProvider;
public NotificationProviderFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public INotificationProvider CreateProvider(NotificationProviderDto config)
{
return config.Type switch
{
NotificationProviderType.Notifiarr => CreateNotifiarrProvider(config),
NotificationProviderType.Apprise => CreateAppriseProvider(config),
NotificationProviderType.Ntfy => CreateNtfyProvider(config),
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
};
}
private INotificationProvider CreateNotifiarrProvider(NotificationProviderDto config)
{
var notifiarrConfig = (NotifiarrConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<INotifiarrProxy>();
return new NotifiarrProvider(config.Name, config.Type, notifiarrConfig, proxy);
}
private INotificationProvider CreateAppriseProvider(NotificationProviderDto config)
{
var appriseConfig = (AppriseConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IAppriseProxy>();
return new AppriseProvider(config.Name, config.Type, appriseConfig, proxy);
}
private INotificationProvider CreateNtfyProvider(NotificationProviderDto config)
{
var ntfyConfig = (NtfyConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<INtfyProxy>();
return new NtfyProvider(config.Name, config.Type, ntfyConfig, proxy);
}
}

View File

@@ -1,63 +1,41 @@
using System.Globalization;
using System.Globalization;
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 Mapster;
using MassTransit;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public class NotificationPublisher : INotificationPublisher
{
private readonly ILogger<INotificationPublisher> _logger;
private readonly IBus _messageBus;
private readonly ILogger<NotificationPublisher> _logger;
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly INotificationConfigurationService _configurationService;
private readonly INotificationProviderFactory _providerFactory;
public NotificationPublisher(ILogger<INotificationPublisher> logger, IBus messageBus, IDryRunInterceptor dryRunInterceptor)
public NotificationPublisher(
ILogger<NotificationPublisher> logger,
IDryRunInterceptor dryRunInterceptor,
INotificationConfigurationService configurationService,
INotificationProviderFactory providerFactory)
{
_logger = logger;
_messageBus = messageBus;
_dryRunInterceptor = dryRunInterceptor;
_configurationService = configurationService;
_providerFactory = providerFactory;
}
public virtual async Task NotifyStrike(StrikeType strikeType, int strikeCount)
{
try
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
Uri? imageUrl = GetImageFromContext(record, instanceType);
ArrNotification notification = new()
{
InstanceType = instanceType,
InstanceUrl = instanceUrl,
Hash = record.DownloadId.ToLowerInvariant(),
Title = $"Strike received with reason: {strikeType}",
Description = record.Title,
Image = imageUrl,
Fields = [new() { Title = "Strike count", Text = strikeCount.ToString() }]
};
var eventType = MapStrikeTypeToEventType(strikeType);
var context = BuildStrikeNotificationContext(strikeType, strikeCount, eventType);
switch (strikeType)
{
case StrikeType.Stalled:
case StrikeType.DownloadingMetadata:
await NotifyInternal(notification.Adapt<StalledStrikeNotification>());
break;
case StrikeType.FailedImport:
await NotifyInternal(notification.Adapt<FailedImportStrikeNotification>());
break;
case StrikeType.SlowSpeed:
case StrikeType.SlowTime:
await NotifyInternal(notification.Adapt<SlowStrikeNotification>());
break;
}
await SendNotificationAsync(eventType, context);
}
catch (Exception ex)
{
@@ -69,27 +47,12 @@ public class NotificationPublisher : INotificationPublisher
{
try
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
Uri? imageUrl = GetImageFromContext(record, instanceType);
QueueItemDeletedNotification notification = new()
{
InstanceType = instanceType,
InstanceUrl = instanceUrl,
Hash = record.DownloadId.ToLowerInvariant(),
Title = $"Deleting item from queue with reason: {reason}",
Description = record.Title,
Image = imageUrl,
Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }]
};
await NotifyInternal(notification);
var context = BuildQueueItemDeletedContext(removeFromClient, reason);
await SendNotificationAsync(NotificationEventType.QueueItemDeleted, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "failed to notify queue item deleted");
_logger.LogError(ex, "Failed to notify queue item deleted");
}
}
@@ -97,67 +60,174 @@ public class NotificationPublisher : INotificationPublisher
{
try
{
DownloadCleanedNotification notification = new()
{
Title = $"Cleaned item from download client with reason: {reason}",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
new() { Title = "Category", Text = categoryName.ToLowerInvariant() },
new() { Title = "Ratio", Text = $"{ratio.ToString(CultureInfo.InvariantCulture)}%" },
new()
{
Title = "Seeding hours", Text = $"{Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture)}h"
}
],
Level = NotificationLevel.Important
};
await NotifyInternal(notification);
var context = BuildDownloadCleanedContext(ratio, seedingTime, categoryName, reason);
await SendNotificationAsync(NotificationEventType.DownloadCleaned, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "failed to notify download cleaned");
_logger.LogError(ex, "Failed to notify download cleaned");
}
}
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
CategoryChangedNotification notification = new()
try
{
Title = isTag? "Tag added" : "Category changed",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() }
],
Level = NotificationLevel.Important
};
var context = BuildCategoryChangedContext(oldCategory, newCategory, isTag);
await SendNotificationAsync(NotificationEventType.CategoryChanged, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify category changed");
}
}
private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context)
{
await _dryRunInterceptor.InterceptAsync(SendNotificationInternalAsync, (eventType, context));
}
private async Task SendNotificationInternalAsync((NotificationEventType eventType, NotificationContext context) parameters)
{
var (eventType, context) = parameters;
var providers = await _configurationService.GetProvidersForEventAsync(eventType);
if (!providers.Any())
{
_logger.LogDebug("No providers configured for event type {eventType}", eventType);
return;
}
var tasks = providers.Select(async providerConfig =>
{
try
{
var provider = _providerFactory.CreateProvider(providerConfig);
await provider.SendNotificationAsync(context);
_logger.LogDebug("Notification sent successfully via {providerName}", provider.Name);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send notification via provider {providerName}", providerConfig.Name);
}
});
await Task.WhenAll(tasks);
}
private NotificationContext BuildStrikeNotificationContext(StrikeType strikeType, int strikeCount, NotificationEventType eventType)
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var imageUrl = GetImageFromContext(record, instanceType);
return new NotificationContext
{
EventType = eventType,
Title = $"Strike received with reason: {strikeType}",
Description = record.Title,
Severity = EventSeverity.Warning,
Image = imageUrl,
Data = new Dictionary<string, string>
{
["Strike type"] = strikeType.ToString(),
["Strike count"] = strikeCount.ToString(),
["Hash"] = record.DownloadId.ToLowerInvariant(),
["Instance type"] = instanceType.ToString(),
["Url"] = instanceUrl.ToString(),
}
};
}
private NotificationContext BuildQueueItemDeletedContext(bool removeFromClient, DeleteReason reason)
{
var record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var imageUrl = GetImageFromContext(record, instanceType);
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = $"Deleting item from queue with reason: {reason}",
Description = record.Title,
Severity = EventSeverity.Important,
Image = imageUrl,
Data = new Dictionary<string, string>
{
["Reason"] = reason.ToString(),
["Removed from client?"] = removeFromClient.ToString(),
["Hash"] = record.DownloadId.ToLowerInvariant(),
["Instance type"] = instanceType.ToString(),
["Url"] = instanceUrl.ToString(),
}
};
}
private static NotificationContext BuildDownloadCleanedContext(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason)
{
var downloadName = ContextProvider.Get<string>("downloadName");
var hash = ContextProvider.Get<string>("hash");
return new NotificationContext
{
EventType = NotificationEventType.DownloadCleaned,
Title = $"Cleaned item from download client with reason: {reason}",
Description = downloadName,
Severity = EventSeverity.Important,
Data = new Dictionary<string, string>
{
["Hash"] = hash.ToLowerInvariant(),
["Category"] = categoryName.ToLowerInvariant(),
["Ratio"] = ratio.ToString(CultureInfo.InvariantCulture),
["Seeding hours"] = Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture)
}
};
}
private NotificationContext BuildCategoryChangedContext(string oldCategory, string newCategory, bool isTag)
{
string downloadName = ContextProvider.Get<string>("downloadName");
NotificationContext context = new()
{
EventType = NotificationEventType.CategoryChanged,
Title = isTag ? "Tag added" : "Category changed",
Description = downloadName,
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>
{
["hash"] = ContextProvider.Get<string>("hash").ToLowerInvariant()
}
};
if (isTag)
{
notification.Fields.Add(new() { Title = "Tag", Text = newCategory });
context.Data.Add("Tag", newCategory);
}
else
{
notification.Fields.Add(new() { Title = "Old category", Text = oldCategory });
notification.Fields.Add(new() { Title = "New category", Text = newCategory });
context.Data.Add("Old category", oldCategory);
context.Data.Add("New category", newCategory);
}
await NotifyInternal(notification);
}
private Task NotifyInternal<T>(T message) where T: notnull
{
return _dryRunInterceptor.InterceptAsync(Notify<T>, message);
return context;
}
private Task Notify<T>(T message) where T: notnull
private static NotificationEventType MapStrikeTypeToEventType(StrikeType strikeType)
{
return _messageBus.Publish(message);
return strikeType switch
{
StrikeType.Stalled => NotificationEventType.StalledStrike,
StrikeType.DownloadingMetadata => NotificationEventType.StalledStrike,
StrikeType.FailedImport => NotificationEventType.FailedImportStrike,
StrikeType.SlowSpeed => NotificationEventType.SlowSpeedStrike,
StrikeType.SlowTime => NotificationEventType.SlowTimeStrike,
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
};
}
private Uri? GetImageFromContext(QueueRecord record, InstanceType instanceType)
{
Uri? image = instanceType switch
@@ -172,9 +242,9 @@ public class NotificationPublisher : INotificationPublisher
if (image is null)
{
_logger.LogWarning("no poster found for {title}", record.Title);
_logger.LogWarning("No poster found for {title}", record.Title);
}
return image;
}
}
}

View File

@@ -1,107 +1,85 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Notifications;
namespace Cleanuparr.Infrastructure.Features.Notifications;
public class NotificationService
public sealed class NotificationService
{
private readonly ILogger<NotificationService> _logger;
private readonly INotificationFactory _notificationFactory;
private readonly INotificationConfigurationService _configurationService;
private readonly INotificationProviderFactory _providerFactory;
public NotificationService(ILogger<NotificationService> logger, INotificationFactory notificationFactory)
public NotificationService(
ILogger<NotificationService> logger,
INotificationConfigurationService configurationService,
INotificationProviderFactory providerFactory)
{
_logger = logger;
_notificationFactory = notificationFactory;
_configurationService = configurationService;
_providerFactory = providerFactory;
}
public async Task Notify(FailedImportStrikeNotification notification)
public async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context)
{
foreach (INotificationProvider provider in _notificationFactory.OnFailedImportStrikeEnabled())
try
{
try
var providers = await _configurationService.GetProvidersForEventAsync(eventType);
if (!providers.Any())
{
await provider.OnFailedImportStrike(notification);
_logger.LogDebug("No providers configured for event type {eventType}", eventType);
return;
}
catch (Exception exception)
var tasks = providers.Select(async providerConfig =>
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
try
{
var provider = _providerFactory.CreateProvider(providerConfig);
await provider.SendNotificationAsync(context);
_logger.LogDebug("Notification sent successfully via {providerName}", provider.Name);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send notification via provider {providerName}", providerConfig.Name);
}
});
await Task.WhenAll(tasks);
_logger.LogTrace("Notification sent to {count} providers for event {eventType}", providers.Count, eventType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send notifications for event type {eventType}", eventType);
}
}
public async Task Notify(StalledStrikeNotification notification)
public async Task SendTestNotificationAsync(NotificationProviderDto providerConfig)
{
foreach (INotificationProvider provider in _notificationFactory.OnStalledStrikeEnabled())
NotificationContext testContext = new()
{
try
EventType = NotificationEventType.Test,
Title = "Test Notification from Cleanuparr",
Description = "This is a test notification to verify your configuration is working correctly.",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>
{
await provider.OnStalledStrike(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
["Test time"] = DateTime.UtcNow.ToString("o"),
["Provider type"] = providerConfig.Type.ToString(),
}
};
try
{
var provider = _providerFactory.CreateProvider(providerConfig);
await provider.SendNotificationAsync(testContext);
_logger.LogInformation("Test notification sent successfully via {providerName}", providerConfig.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send test notification via {providerName}", providerConfig.Name);
throw;
}
}
public async Task Notify(SlowStrikeNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnSlowStrikeEnabled())
{
try
{
await provider.OnSlowStrike(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
public async Task Notify(QueueItemDeletedNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnQueueItemDeletedEnabled())
{
try
{
await provider.OnQueueItemDeleted(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
public async Task Notify(DownloadCleanedNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnDownloadCleanedEnabled())
{
try
{
await provider.OnDownloadCleaned(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
public async Task Notify(CategoryChangedNotification notification)
{
foreach (INotificationProvider provider in _notificationFactory.OnCategoryChangedEnabled())
{
try
{
await provider.OnCategoryChanged(notification);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
public interface INtfyProxy
{
Task SendNotification(NtfyPayload payload, NtfyConfig config);
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
public sealed class NtfyException : Exception
{
public NtfyException(string message) : base(message)
{
}
public NtfyException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,24 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
public sealed class NtfyPayload
{
[JsonProperty("topic")]
public string Topic { get; init; } = string.Empty;
[JsonProperty("message")]
public string Message { get; init; } = string.Empty;
[JsonProperty("title")]
public string? Title { get; init; }
[JsonProperty("priority")]
public int? Priority { get; init; }
[JsonProperty("tags")]
public string[]? Tags { get; init; }
[JsonProperty("click")]
public string? Click { get; init; }
}

View File

@@ -0,0 +1,85 @@
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
public sealed class NtfyProvider : NotificationProviderBase<NtfyConfig>
{
private readonly INtfyProxy _proxy;
public NtfyProvider(
string name,
NotificationProviderType type,
NtfyConfig config,
INtfyProxy proxy
) : base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var topics = GetTopics();
var tasks = topics.Select(topic => SendToTopic(topic, context));
await Task.WhenAll(tasks);
}
private async Task SendToTopic(string topic, NotificationContext context)
{
NtfyPayload payload = BuildPayload(topic, context);
await _proxy.SendNotification(payload, Config);
}
private NtfyPayload BuildPayload(string topic, NotificationContext context)
{
int priority = MapSeverityToPriority(context.Severity);
string message = BuildMessage(context);
return new NtfyPayload
{
Topic = topic.Trim(),
Title = context.Title,
Message = message,
Priority = priority,
Tags = Config.Tags.ToArray()
};
}
private string BuildMessage(NotificationContext context)
{
var message = new StringBuilder();
message.AppendLine(context.Description);
if (context.Data.Any())
{
message.AppendLine();
foreach ((string key, string value) in context.Data)
{
message.AppendLine($"{key}: {value}");
}
}
return message.ToString().Trim();
}
private int MapSeverityToPriority(EventSeverity severity)
{
return severity switch
{
EventSeverity.Information => (int)Config.Priority,
EventSeverity.Warning => Math.Max((int)Config.Priority, (int)NtfyPriority.High),
EventSeverity.Important => (int)NtfyPriority.Max,
_ => (int)Config.Priority
};
}
private string[] GetTopics()
{
return Config.Topics
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())
.ToArray();
}
}

View File

@@ -0,0 +1,90 @@
using System.Net.Http.Headers;
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
public sealed class NtfyProxy : INtfyProxy
{
private readonly HttpClient _httpClient;
public NtfyProxy(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(NtfyPayload payload, NtfyConfig config)
{
try
{
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
var parsedUrl = config.Uri!;
using HttpRequestMessage request = new(HttpMethod.Post, parsedUrl);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
// Set authentication headers based on configuration
SetAuthenticationHeaders(request, config);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new NtfyException("Unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 400:
throw new NtfyException("Bad request - invalid topic or payload", exception);
case 401:
throw new NtfyException("Unauthorized - invalid credentials", exception);
case 413:
throw new NtfyException("Payload too large", exception);
case 429:
throw new NtfyException("Rate limited - too many requests", exception);
case 507:
throw new NtfyException("Insufficient storage on server", exception);
default:
throw new NtfyException("Unable to send notification", exception);
}
}
}
private static void SetAuthenticationHeaders(HttpRequestMessage request, NtfyConfig config)
{
switch (config.AuthenticationType)
{
case NtfyAuthenticationType.BasicAuth:
if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password))
{
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Username}:{config.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
break;
case NtfyAuthenticationType.AccessToken:
if (!string.IsNullOrWhiteSpace(config.AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AccessToken);
}
break;
case NtfyAuthenticationType.None:
default:
// No authentication required
break;
}
}
}

View File

@@ -24,20 +24,17 @@ public class HealthCheckBackgroundService : BackgroundService
_logger = logger;
_healthCheckService = healthCheckService;
// Check health every 1 minute by default
_checkInterval = TimeSpan.FromMinutes(1);
_checkInterval = TimeSpan.FromMinutes(5);
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Health check background service started");
try
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogDebug("Performing periodic health check for all clients");
_logger.LogDebug("Performing periodic health check for all download clients");
try
{
@@ -47,17 +44,27 @@ public class HealthCheckBackgroundService : BackgroundService
// Log summary
var healthyCount = results.Count(r => r.Value.IsHealthy);
var unhealthyCount = results.Count - healthyCount;
_logger.LogInformation(
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy clients",
healthyCount,
unhealthyCount);
if (unhealthyCount is 0)
{
_logger.LogDebug(
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy download clients",
healthyCount,
unhealthyCount);
}
else
{
_logger.LogWarning(
"Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy download clients",
healthyCount,
unhealthyCount);
}
// Log detailed information for unhealthy clients
foreach (var result in results.Where(r => !r.Value.IsHealthy))
{
_logger.LogWarning(
"Client {clientId} ({clientName}) is unhealthy: {errorMessage}",
"Download client {clientId} ({clientName}) is unhealthy: {errorMessage}",
result.Key,
result.Value.ClientName,
result.Value.ErrorMessage);

View File

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

View File

@@ -15,11 +15,11 @@ public class AppHub : Hub
private readonly ILogger<AppHub> _logger;
private readonly SignalRLogSink _logSink;
public AppHub(EventsContext context, ILogger<AppHub> logger, SignalRLogSink logSink)
public AppHub(EventsContext context, ILogger<AppHub> logger)
{
_context = context;
_logger = logger;
_logSink = logSink;
_logSink = SignalRLogSink.Instance;
}
/// <summary>

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
using System.IO.Compression;
using Serilog.Sinks.File;
namespace Cleanuparr.Infrastructure.Logging;
// Enhanced from Serilog.Sinks.File.Archive https://github.com/cocowalla/serilog-sinks-file-archive/blob/master/src/Serilog.Sinks.File.Archive/ArchiveHooks.cs
public class ArchiveHooks : FileLifecycleHooks
{
private readonly CompressionLevel _compressionLevel;
private readonly ushort _retainedFileCountLimit;
private readonly TimeSpan? _retainedFileTimeLimit;
public ArchiveHooks(
ushort retainedFileCountLimit,
TimeSpan? retainedFileTimeLimit,
CompressionLevel compressionLevel = CompressionLevel.Fastest
)
{
if (compressionLevel is CompressionLevel.NoCompression)
{
throw new ArgumentException($"{nameof(compressionLevel)} cannot be {CompressionLevel.NoCompression}");
}
if (retainedFileCountLimit is 0 && retainedFileTimeLimit is null)
{
throw new ArgumentException($"At least one of {nameof(retainedFileCountLimit)} or {nameof(retainedFileTimeLimit)} must be set");
}
_retainedFileCountLimit = retainedFileCountLimit;
_retainedFileTimeLimit = retainedFileTimeLimit;
_compressionLevel = compressionLevel;
}
public override void OnFileDeleting(string path)
{
FileInfo originalFileInfo = new FileInfo(path);
string newFilePath = $"{path}.gz";
using (FileStream originalFileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
using (FileStream newFileStream = new FileStream(newFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None))
{
using (GZipStream archiveStream = new GZipStream(newFileStream, _compressionLevel))
{
originalFileStream.CopyTo(archiveStream);
}
}
}
File.SetLastWriteTime(newFilePath, originalFileInfo.LastWriteTime);
File.SetLastWriteTimeUtc(newFilePath, originalFileInfo.LastWriteTimeUtc);
RemoveExcessFiles(Path.GetDirectoryName(path)!);
}
private void RemoveExcessFiles(string folder)
{
string searchPattern = _compressionLevel != CompressionLevel.NoCompression ? "*.gz" : "*.*";
IEnumerable<FileInfo> filesToDeleteQuery = Directory.GetFiles(folder, searchPattern)
.Select((Func<string, FileInfo>)(f => new FileInfo(f)))
.OrderByDescending((Func<FileInfo, FileInfo>)(f => f), LogFileComparer.Default);
if (_retainedFileCountLimit > 0)
{
filesToDeleteQuery = filesToDeleteQuery
.Skip(_retainedFileCountLimit);
}
if (_retainedFileTimeLimit is not null)
{
filesToDeleteQuery = filesToDeleteQuery
.Where(file => file.LastWriteTimeUtc < DateTime.UtcNow - _retainedFileTimeLimit);
}
List<FileInfo> filesToDelete = filesToDeleteQuery.ToList();
foreach (FileInfo fileInfo in filesToDelete)
{
fileInfo.Delete();
}
}
private class LogFileComparer : IComparer<FileInfo>
{
public static readonly IComparer<FileInfo> Default = new LogFileComparer();
public int Compare(FileInfo? x, FileInfo? y)
{
if (x == null && y == null)
{
return 0;
}
if (x == null)
{
return -1;
}
if (y == null || x.LastWriteTimeUtc > y.LastWriteTimeUtc)
{
return 1;
}
return x.LastWriteTimeUtc < y.LastWriteTimeUtc ? -1 : 0;
}
}
}

View File

@@ -1,63 +1,154 @@
using System.IO.Compression;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Cleanuparr.Infrastructure.Logging;
/// <summary>
/// Manages logging configuration and provides dynamic log level control
/// </summary>
public class LoggingConfigManager
public static class LoggingConfigManager
{
private readonly DataContext _dataContext;
private readonly ILogger<LoggingConfigManager> _logger;
private static LoggingLevelSwitch LevelSwitch = new();
public LoggingConfigManager(DataContext dataContext, ILogger<LoggingConfigManager> logger)
{
_dataContext = dataContext;
_logger = logger;
// Load settings from configuration
LoadConfiguration();
}
/// <summary>
/// The level switch used to dynamically control log levels
/// </summary>
public static LoggingLevelSwitch LevelSwitch { get; } = new();
/// <summary>
/// Gets the level switch used to dynamically control log levels
/// Creates a logger configuration for startup before DI is available
/// </summary>
public LoggingLevelSwitch GetLevelSwitch() => LevelSwitch;
/// <returns>Configured LoggerConfiguration</returns>
public static LoggerConfiguration CreateLoggerConfiguration()
{
using var context = DataContext.CreateStaticInstance();
var config = context.GeneralConfigs.AsNoTracking().First();
SetLogLevel(config.Log.Level);
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
// Determine job name padding
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding
List<string> categoryNames = [
InstanceType.Sonarr.ToString(),
InstanceType.Radarr.ToString(),
InstanceType.Lidarr.ToString(),
InstanceType.Readarr.ToString(),
InstanceType.Whisparr.ToString(),
"SYSTEM"
];
int catPadding = categoryNames.Max(x => x.Length) + 2;
// Apply padding values to templates
string consoleTemplate = consoleOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
string fileTemplate = fileOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
LoggerConfiguration logConfig = new LoggerConfiguration()
.MinimumLevel.ControlledBy(LevelSwitch)
.Enrich.FromLogContext()
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
// Create the logs directory
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
if (!Directory.Exists(logsPath))
{
try
{
Directory.CreateDirectory(logsPath);
}
catch (Exception exception)
{
throw new Exception($"Failed to create logs directory | {logsPath}", exception);
}
}
ArchiveHooks? archiveHooks = config.Log.ArchiveEnabled
? new ArchiveHooks(
retainedFileCountLimit: config.Log.ArchiveRetainedCount,
retainedFileTimeLimit: config.Log.ArchiveTimeLimitHours > 0 ? TimeSpan.FromHours(config.Log.ArchiveTimeLimitHours) : null,
compressionLevel: CompressionLevel.SmallestSize
)
: null;
// Add file sink with archive hooks
logConfig.WriteTo.File(
path: Path.Combine(logsPath, "cleanuparr-.txt"),
formatter: new ExpressionTemplate(fileTemplate),
fileSizeLimitBytes: config.Log.RollingSizeMB is 0 ? null : config.Log.RollingSizeMB * 1024L * 1024L,
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: config.Log.RollingSizeMB > 0,
retainedFileCountLimit: config.Log.RetainedFileCount is 0 ? null : config.Log.RetainedFileCount,
retainedFileTimeLimit: config.Log.TimeLimitHours is 0 ? null : TimeSpan.FromHours(config.Log.TimeLimitHours),
hooks: archiveHooks
);
// Add SignalR sink for real-time log updates
logConfig.WriteTo.Sink(SignalRLogSink.Instance);
// Apply standard overrides
logConfig
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
return logConfig;
}
/// <summary>
/// Updates the global log level and persists the change to configuration
/// </summary>
/// <param name="level">The new log level</param>
public void SetLogLevel(LogEventLevel level)
public static void SetLogLevel(LogEventLevel level)
{
_logger.LogCritical("Setting global log level to {level}", level);
// Change the level in the switch
LevelSwitch.MinimumLevel = level;
}
/// <summary>
/// Loads logging settings from configuration
/// Reconfigures the entire logging system with new settings
/// </summary>
private void LoadConfiguration()
/// <param name="config">The new general configuration</param>
public static void ReconfigureLogging(GeneralConfig config)
{
try
{
var config = _dataContext.GeneralConfigs
.AsNoTracking()
.First();
LevelSwitch.MinimumLevel = config.LogLevel;
// Create new logger configuration
var newLoggerConfig = CreateLoggerConfiguration();
// Apply the new configuration to the global logger
Log.Logger = newLoggerConfig.CreateLogger();
// Update the level switch with the new level
LevelSwitch.MinimumLevel = config.Log.Level;
}
catch (Exception ex)
{
// Just log and continue with defaults
_logger.LogError(ex, "Failed to load logging configuration, using defaults");
// Log the error but don't throw to avoid breaking the application
Log.Error(ex, "Failed to reconfigure logger");
}
}
}

View File

@@ -2,7 +2,7 @@ using System.Collections.Concurrent;
using System.Globalization;
using Cleanuparr.Infrastructure.Hubs;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Display;
@@ -14,20 +14,24 @@ namespace Cleanuparr.Infrastructure.Logging;
/// </summary>
public class SignalRLogSink : ILogEventSink
{
private readonly ILogger<SignalRLogSink> _logger;
private readonly ConcurrentQueue<object> _logBuffer;
private readonly int _bufferSize;
private readonly IHubContext<AppHub> _appHubContext;
private readonly MessageTemplateTextFormatter _formatter = new("{Message:l}", CultureInfo.InvariantCulture);
private IHubContext<AppHub>? _appHubContext;
public SignalRLogSink(ILogger<SignalRLogSink> logger, IHubContext<AppHub> appHubContext)
public static SignalRLogSink Instance { get; } = new();
private SignalRLogSink()
{
_appHubContext = appHubContext;
_logger = logger;
_bufferSize = 100;
_logBuffer = new ConcurrentQueue<object>();
}
public void SetAppHubContext(IHubContext<AppHub> appHubContext)
{
_appHubContext = appHubContext ?? throw new ArgumentNullException(nameof(appHubContext), "AppHub context cannot be null");
}
/// <summary>
/// Processes and emits a log event to SignalR clients
/// </summary>
@@ -52,11 +56,14 @@ public class SignalRLogSink : ILogEventSink
AddToBuffer(logData);
// Send to connected clients via the unified hub
_ = _appHubContext.Clients.All.SendAsync("LogReceived", logData);
if (_appHubContext is not null)
{
_ = _appHubContext.Clients.All.SendAsync("LogReceived", logData);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send log event via SignalR");
Log.Logger.Error(ex, "Failed to send log event via SignalR");
}
}

View File

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

View File

@@ -7,9 +7,12 @@ 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;
using Serilog.Events;
namespace Cleanuparr.Persistence;
@@ -36,26 +39,47 @@ public class DataContext : DbContext
public DbSet<ArrInstance> ArrInstances { get; set; }
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
public DbSet<NotificationConfig> NotificationConfigs { get; set; }
public DbSet<NotifiarrConfig> NotifiarrConfigs { get; set; }
public DbSet<AppriseConfig> AppriseConfigs { get; set; }
public DbSet<NtfyConfig> NtfyConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
public DataContext()
{
}
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public static DataContext CreateStaticInstance()
{
var optionsBuilder = new DbContextOptionsBuilder<DataContext>();
SetDbContextOptions(optionsBuilder);
return new DataContext(optionsBuilder.Options);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
SetDbContextOptions(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GeneralConfig>(entity =>
entity.ComplexProperty(e => e.Log, cp =>
{
cp.Property(l => l.Level).HasConversion<LowercaseEnumConverter<LogEventLevel>>();
})
);
modelBuilder.Entity<QueueCleanerConfig>(entity =>
{
entity.ComplexProperty(e => e.FailedImport);
@@ -92,6 +116,42 @@ public class DataContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
// Configure new notification system relationships
modelBuilder.Entity<NotificationConfig>(entity =>
{
entity.Property(e => e.Type).HasConversion(new LowercaseEnumConverter<NotificationProviderType>());
entity.HasOne(p => p.NotifiarrConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<NotifiarrConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.AppriseConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<AppriseConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.NtfyConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<NtfyConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
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())
{
var enumProperties = entityType.ClrType.GetProperties()
@@ -115,4 +175,18 @@ public class DataContext : DbContext
}
}
}
private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
}
}

View File

@@ -12,19 +12,34 @@ namespace Cleanuparr.Persistence;
public class EventsContext : DbContext
{
public DbSet<AppEvent> Events { get; set; }
public EventsContext()
{
}
public EventsContext(DbContextOptions<EventsContext> options) : base(options)
{
}
public static EventsContext CreateStaticInstance()
{
var optionsBuilder = new DbContextOptionsBuilder<EventsContext>();
SetDbContextOptions(optionsBuilder);
return new EventsContext(optionsBuilder.Options);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
SetDbContextOptions(optionsBuilder);
}
public static string GetLikePattern(string input)
{
input = input.Replace("[", "[[]")
.Replace("%", "[%]")
.Replace("_", "[_]");
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "events.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
return $"%{input}%";
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -59,12 +74,17 @@ public class EventsContext : DbContext
}
}
public static string GetLikePattern(string input)
private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder)
{
input = input.Replace("[", "[[]")
.Replace("%", "[%]")
.Replace("_", "[_]");
if (optionsBuilder.IsConfigured)
{
return;
}
return $"%{input}%";
var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "events.db");
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention();
}
}

View File

@@ -0,0 +1,674 @@
// <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("20250816183837_AddAdvancedLoggingSettings")]
partial class AddAdvancedLoggingSettings
{
/// <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.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>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
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>("Tags")
.HasColumnType("TEXT")
.HasColumnName("tags");
b.HasKey("Id")
.HasName("pk_apprise_configs");
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")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
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.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_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.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.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAdvancedLoggingSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "log_archive_enabled",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<ushort>(
name: "log_archive_retained_count",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_archive_time_limit_hours",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_retained_file_count",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_rolling_size_mb",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_time_limit_hours",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.Sql(
"UPDATE general_configs SET log_archive_enabled = 1, log_archive_retained_count = 60, log_archive_time_limit_hours = 720, log_retained_file_count = 5, log_rolling_size_mb = 10, log_time_limit_hours = 24"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "log_archive_enabled",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_archive_retained_count",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_archive_time_limit_hours",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_retained_file_count",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_rolling_size_mb",
table: "general_configs");
migrationBuilder.DropColumn(
name: "log_time_limit_hours",
table: "general_configs");
}
}
}

View File

@@ -0,0 +1,717 @@
// <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("20250830230846_ReworkNotificationSystem")]
partial class ReworkNotificationSystem
{
/// <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.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<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
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.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.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,415 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class ReworkNotificationSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notification_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
type = table.Column<string>(type: "TEXT", nullable: false),
is_enabled = table.Column<bool>(type: "INTEGER", nullable: false),
on_failed_import_strike = table.Column<bool>(type: "INTEGER", nullable: false),
on_stalled_strike = table.Column<bool>(type: "INTEGER", nullable: false),
on_slow_strike = table.Column<bool>(type: "INTEGER", nullable: false),
on_queue_item_deleted = table.Column<bool>(type: "INTEGER", nullable: false),
on_download_cleaned = table.Column<bool>(type: "INTEGER", nullable: false),
on_category_changed = table.Column<bool>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
updated_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_notification_configs", x => x.id);
});
string newGuid = Guid.NewGuid().ToString().ToUpperInvariant();
migrationBuilder.Sql(
$"""
INSERT INTO notification_configs (id, name, type, is_enabled, on_failed_import_strike, on_stalled_strike, on_slow_strike, on_queue_item_deleted, on_download_cleaned, on_category_changed, created_at, updated_at)
SELECT
'{newGuid}' AS id,
'Notifiarr' AS name,
'notifiarr' AS type,
CASE WHEN (on_failed_import_strike = 1 OR on_stalled_strike = 1 OR on_slow_strike = 1 OR on_queue_item_deleted = 1 OR on_download_cleaned = 1 OR on_category_changed = 1) THEN 1 ELSE 0 END AS is_enabled,
on_failed_import_strike,
on_stalled_strike,
on_slow_strike,
on_queue_item_deleted,
on_download_cleaned,
on_category_changed,
datetime('now') AS created_at,
datetime('now') AS updated_at
FROM notifiarr_configs
WHERE
channel_id IS NOT NULL AND channel_id != '' AND api_key IS NOT NULL AND api_key != ''
""");
newGuid = Guid.NewGuid().ToString().ToUpperInvariant();
migrationBuilder.Sql(
$"""
INSERT INTO notification_configs (id, name, type, is_enabled, on_failed_import_strike, on_stalled_strike, on_slow_strike, on_queue_item_deleted, on_download_cleaned, on_category_changed, created_at, updated_at)
SELECT
'{newGuid}' AS id,
'Apprise' AS name,
'apprise' AS type,
CASE WHEN (on_failed_import_strike = 1 OR on_stalled_strike = 1 OR on_slow_strike = 1 OR on_queue_item_deleted = 1 OR on_download_cleaned = 1 OR on_category_changed = 1) THEN 1 ELSE 0 END AS is_enabled,
on_failed_import_strike,
on_stalled_strike,
on_slow_strike,
on_queue_item_deleted,
on_download_cleaned,
on_category_changed,
datetime('now') AS created_at,
datetime('now') AS updated_at
FROM apprise_configs
WHERE
key IS NOT NULL AND key != '' AND full_url IS NOT NULL AND full_url != ''
""");
migrationBuilder.DropColumn(
name: "on_category_changed",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_download_cleaned",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_failed_import_strike",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_queue_item_deleted",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_slow_strike",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_stalled_strike",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "on_category_changed",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_download_cleaned",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_failed_import_strike",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_queue_item_deleted",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_slow_strike",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "on_stalled_strike",
table: "apprise_configs");
migrationBuilder.AlterColumn<string>(
name: "channel_id",
table: "notifiarr_configs",
type: "TEXT",
maxLength: 50,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "api_key",
table: "notifiarr_configs",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AddColumn<Guid>(
name: "notification_config_id",
table: "notifiarr_configs",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.Sql(
"""
UPDATE notifiarr_configs
SET notification_config_id = (
SELECT id FROM notification_configs
WHERE type = 'notifiarr'
LIMIT 1
)
WHERE channel_id IS NOT NULL AND channel_id != '' AND api_key IS NOT NULL AND api_key != ''
""");
migrationBuilder.Sql(
"""
DELETE FROM notifiarr_configs
WHERE NOT EXISTS (
SELECT 1 FROM notification_configs
WHERE type = 'notifiarr'
)
""");
migrationBuilder.AlterColumn<string>(
name: "key",
table: "apprise_configs",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "url",
table: "apprise_configs",
type: "TEXT",
maxLength: 500,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<Guid>(
name: "notification_config_id",
table: "apprise_configs",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.Sql(
"""
UPDATE apprise_configs
SET notification_config_id = (
SELECT id FROM notification_configs
WHERE type = 'apprise'
LIMIT 1
),
url = full_url
WHERE key IS NOT NULL AND key != '' AND full_url IS NOT NULL AND full_url != ''
""");
migrationBuilder.Sql(
"""
DELETE FROM apprise_configs
WHERE NOT EXISTS (
SELECT 1 FROM notification_configs
WHERE type = 'apprise'
)
""");
migrationBuilder.DropColumn(
name: "full_url",
table: "apprise_configs");
migrationBuilder.CreateIndex(
name: "ix_notifiarr_configs_notification_config_id",
table: "notifiarr_configs",
column: "notification_config_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_apprise_configs_notification_config_id",
table: "apprise_configs",
column: "notification_config_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_notification_configs_name",
table: "notification_configs",
column: "name",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_apprise_configs_notification_configs_notification_config_id",
table: "apprise_configs",
column: "notification_config_id",
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "fk_notifiarr_configs_notification_configs_notification_config_id",
table: "notifiarr_configs",
column: "notification_config_id",
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_apprise_configs_notification_configs_notification_config_id",
table: "apprise_configs");
migrationBuilder.DropForeignKey(
name: "fk_notifiarr_configs_notification_configs_notification_config_id",
table: "notifiarr_configs");
migrationBuilder.DropTable(
name: "notification_configs");
migrationBuilder.DropIndex(
name: "ix_notifiarr_configs_notification_config_id",
table: "notifiarr_configs");
migrationBuilder.DropIndex(
name: "ix_apprise_configs_notification_config_id",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "notification_config_id",
table: "notifiarr_configs");
migrationBuilder.DropColumn(
name: "notification_config_id",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "url",
table: "apprise_configs");
migrationBuilder.AlterColumn<string>(
name: "channel_id",
table: "notifiarr_configs",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT",
oldMaxLength: 50);
migrationBuilder.AlterColumn<string>(
name: "api_key",
table: "notifiarr_configs",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT",
oldMaxLength: 255);
migrationBuilder.AddColumn<bool>(
name: "on_category_changed",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_download_cleaned",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_failed_import_strike",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_queue_item_deleted",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_slow_strike",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_stalled_strike",
table: "notifiarr_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AlterColumn<string>(
name: "key",
table: "apprise_configs",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT",
oldMaxLength: 255);
migrationBuilder.AddColumn<string>(
name: "full_url",
table: "apprise_configs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "on_category_changed",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_download_cleaned",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_failed_import_strike",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_queue_item_deleted",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_slow_strike",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "on_stalled_strike",
table: "apprise_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}

View File

@@ -0,0 +1,822 @@
// <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("20250912234118_AddNtfy")]
partial class AddNtfy
{
/// <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.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.Notification.NtfyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("access_token");
b.Property<string>("AuthenticationType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("authentication_type");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Password")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("priority");
b.Property<string>("ServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.PrimitiveCollection<string>("Tags")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags");
b.PrimitiveCollection<string>("Topics")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("topics");
b.Property<string>("Username")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_ntfy_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_ntfy_configs_notification_config_id");
b.ToTable("ntfy_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.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.Configuration.Notification.NtfyConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("NtfyConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
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");
b.Navigation("NtfyConfiguration");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddNtfy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ntfy_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
server_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
topics = table.Column<string>(type: "TEXT", nullable: false),
authentication_type = table.Column<string>(type: "TEXT", nullable: false),
username = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
password = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
access_token = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
priority = table.Column<string>(type: "TEXT", nullable: false),
tags = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_ntfy_configs", x => x.id);
table.ForeignKey(
name: "fk_ntfy_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_ntfy_configs_notification_config_id",
table: "ntfy_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ntfy_configs");
}
}
}

View File

@@ -0,0 +1,816 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
[DbContext(typeof(DataContext))]
[Migration("20250915153159_AddBlacklistSyncSettings")]
partial class AddBlacklistSyncSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_arr_configs");
b.ToTable("arr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_arr_instances");
b.HasIndex("ArrConfigId")
.HasDatabaseName("ix_arr_instances_arr_config_id");
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("BlacklistPath")
.HasColumnType("TEXT")
.HasColumnName("blacklist_path");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.HasKey("Id")
.HasName("pk_blacklist_sync_configs");
b.ToTable("blacklist_sync_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("TimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_time_limit_hours");
});
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("tags");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_apprise_configs_notification_config_id");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is_enabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_notification_configs");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_notification_configs_name");
b.ToTable("notification_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadClientId")
.HasColumnType("TEXT")
.HasColumnName("download_client_id");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("hash");
b.HasKey("Id")
.HasName("pk_blacklist_sync_history");
b.HasIndex("DownloadClientId")
.HasDatabaseName("ix_blacklist_sync_history_download_client_id");
b.HasIndex("Hash")
.HasDatabaseName("ix_blacklist_sync_history_hash");
b.HasIndex("Hash", "DownloadClientId")
.IsUnique()
.HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id");
b.ToTable("blacklist_sync_history", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
.WithMany("Instances")
.HasForeignKey("ArrConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("AppriseConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("NotifiarrConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient")
.WithMany()
.HasForeignKey("DownloadClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id");
b.Navigation("DownloadClient");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -0,0 +1,761 @@
// <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("20250915181529_AddPerJobIgnoredDownloads")]
partial class AddPerJobIgnoredDownloads
{
/// <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.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>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
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.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
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.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
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.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.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddPerJobIgnoredDownloads : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ignored_downloads",
table: "queue_cleaner_configs",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "ignored_downloads",
table: "download_cleaner_configs",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "ignored_downloads",
table: "content_blocker_configs",
type: "TEXT",
nullable: false,
defaultValue: "[]");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ignored_downloads",
table: "queue_cleaner_configs");
migrationBuilder.DropColumn(
name: "ignored_downloads",
table: "download_cleaner_configs");
migrationBuilder.DropColumn(
name: "ignored_downloads",
table: "content_blocker_configs");
}
}
}

View File

@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using System.Collections.Generic;
using Cleanuparr.Persistence;
@@ -79,131 +79,30 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
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>("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.ContentBlocker.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.ContentBlocker.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.ContentBlocker.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.ContentBlocker.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.ContentBlocker.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");
.HasName("pk_blacklist_sync_configs");
b.ToTable("content_blocker_configs", (string)null);
b.ToTable("blacklist_sync_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
@@ -263,6 +162,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
@@ -382,11 +286,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
@@ -395,12 +294,178 @@ namespace Cleanuparr.Persistence.Migrations.Data
.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.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
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")
@@ -408,45 +473,34 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
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<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);
});
@@ -458,13 +512,52 @@ namespace Cleanuparr.Persistence.Migrations.Data
.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");
@@ -489,10 +582,85 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.ToTable("notifiarr_configs", (string)null);
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.Notification.NtfyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("access_token");
b.Property<string>("AuthenticationType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("authentication_type");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Password")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("priority");
b.Property<string>("ServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.PrimitiveCollection<string>("Tags")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("tags");
b.PrimitiveCollection<string>("Topics")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("topics");
b.Property<string>("Username")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_ntfy_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_ntfy_configs_notification_config_id");
b.ToTable("ntfy_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
@@ -511,6 +679,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
@@ -603,6 +776,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")
@@ -627,6 +832,54 @@ namespace Cleanuparr.Persistence.Migrations.Data
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.Notification.NtfyConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("NtfyConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
@@ -636,6 +889,15 @@ namespace Cleanuparr.Persistence.Migrations.Data
{
b.Navigation("Categories");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Navigation("AppriseConfiguration");
b.Navigation("NotifiarrConfiguration");
b.Navigation("NtfyConfiguration");
});
#pragma warning restore 612, 618
}
}

View File

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

View File

@@ -36,6 +36,8 @@ public sealed record DownloadCleanerConfig : IJobConfig
public List<string> UnlinkedCategories { get; set; } = [];
public List<string> IgnoredDownloads { get; set; } = [];
public void Validate()
{
if (!Enabled)

View File

@@ -1,7 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Enums;
using Serilog.Events;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.General;
@@ -25,18 +24,20 @@ public sealed record GeneralConfig : IConfig
public bool SearchEnabled { get; set; } = true;
public ushort SearchDelay { get; set; } = 30;
public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information;
public string EncryptionKey { get; set; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; set; } = [];
public LoggingConfig Log { get; set; } = new();
public void Validate()
{
if (HttpTimeout is 0)
{
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
Log.Validate();
}
}

View File

@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Domain.Exceptions;
using Serilog;
using Serilog.Events;
namespace Cleanuparr.Persistence.Models.Configuration.General;
[ComplexType]
public sealed record LoggingConfig : IConfig
{
public LogEventLevel Level { get; set; } = LogEventLevel.Information;
public ushort RollingSizeMB { get; set; } = 10; // 0 = disabled
public ushort RetainedFileCount { get; set; } = 5; // 0 = unlimited
public ushort TimeLimitHours { get; set; } = 24; // 0 = unlimited
// Archive Configuration
public bool ArchiveEnabled { get; set; } = true;
public ushort ArchiveRetainedCount { get; set; } = 60; // 0 = unlimited
public ushort ArchiveTimeLimitHours { get; set; } = 24 * 30; // 0 = unlimited
public void Validate()
{
if (RollingSizeMB > 100)
{
throw new ValidationException("Log rolling size cannot exceed 100 MB");
}
if (RetainedFileCount > 50)
{
throw new ValidationException("Log retained file count cannot exceed 50");
}
if (TimeLimitHours > 1440) // 24 * 60
{
throw new ValidationException("Log time limit cannot exceed 60 days");
}
if (ArchiveRetainedCount > 100)
{
throw new ValidationException("Log archive retained count cannot exceed 100");
}
if (ArchiveTimeLimitHours > 1440) // 24 * 60
{
throw new ValidationException("Log archive time limit cannot exceed 60 days");
}
if (ArchiveRetainedCount is 0 && ArchiveTimeLimitHours is 0 && ArchiveEnabled)
{
throw new ValidationException("Archiving is enabled, but no retention policy is set. Please set either a retained file count or time limit");
}
}
}

Some files were not shown because too many files have changed in this diff Show More