Add ignored downloads setting per job (#301)

This commit is contained in:
Flaminel
2025-09-15 22:02:03 +03:00
committed by GitHub
parent 6398ef1cc6
commit 736c146f25
24 changed files with 1051 additions and 29 deletions

View File

@@ -1118,6 +1118,7 @@ public class ConfigurationController : ControllerBase
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
// Handle Categories collection separately to avoid EF tracking issues
// Clear existing categories

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

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

@@ -162,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")
@@ -357,6 +362,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.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");
@@ -607,6 +617,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");

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

@@ -31,6 +31,8 @@ public sealed record ContentBlockerConfig : IJobConfig
public BlocklistSettings Readarr { get; set; } = new();
public BlocklistSettings Whisparr { get; set; } = new();
public List<string> IgnoredDownloads { get; set; } = [];
public void Validate()
{

View File

@@ -23,6 +23,8 @@ public sealed record QueueCleanerConfig : IJobConfig
public StalledConfig Stalled { get; set; } = new();
public SlowConfig Slow { get; set; } = new();
public List<string> IgnoredDownloads { get; set; } = [];
public void Validate()
{

View File

@@ -16,6 +16,7 @@ export class DocumentationService {
private readonly fieldMappings: FieldDocumentationMapping = {
'queue-cleaner': {
'enabled': 'enable-queue-cleaner',
'ignoredDownloads': 'ignored-downloads',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
'failedImport.maxStrikes': 'failed-import-max-strikes',
@@ -56,6 +57,7 @@ export class DocumentationService {
},
'download-cleaner': {
'enabled': 'enable-download-cleaner',
'ignoredDownloads': 'ignored-downloads',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
'jobSchedule.every': 'run-schedule',
@@ -73,6 +75,7 @@ export class DocumentationService {
},
'malware-blocker': {
'enabled': 'enable-malware-blocker',
'ignoredDownloads': 'ignored-downloads',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
'jobSchedule.every': 'run-schedule',

View File

@@ -109,6 +109,35 @@
</div>
</div>
<!-- Ignored Downloads Field -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation"></i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="dc-ignoredDownloads"
multiple
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored by the download cleaner</small>
</div>
</div>
<!-- Detailed Settings in Accordion -->
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Seeding Settings -->

View File

@@ -21,7 +21,6 @@ import { ButtonModule } from "primeng/button";
import { InputNumberModule } from "primeng/inputnumber";
import { AccordionModule } from "primeng/accordion";
import { SelectButtonModule } from "primeng/selectbutton";
import { ChipsModule } from "primeng/chips";
import { ToastModule } from "primeng/toast";
import { NotificationService } from "../../core/services/notification.service";
import { SelectModule } from "primeng/select";
@@ -47,7 +46,6 @@ import { DocumentationService } from "../../core/services/documentation.service"
InputNumberModule,
AccordionModule,
SelectButtonModule,
ChipsModule,
ToastModule,
SelectModule,
AutoCompleteModule,
@@ -148,6 +146,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
type: [{ value: ScheduleUnit.Minutes, disabled: true }, [Validators.required]]
}),
categories: this.formBuilder.array([]),
ignoredDownloads: [{ value: [], disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
unlinkedEnabled: [{ value: false, disabled: true }],
unlinkedTargetCategory: [{ value: 'cleanuparr-unlinked', disabled: true }, [Validators.required]],
@@ -290,6 +289,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
useAdvancedScheduling: useAdvanced,
cronExpression: config.cronExpression,
deletePrivate: config.deletePrivate,
ignoredDownloads: config.ignoredDownloads || [],
unlinkedEnabled: config.unlinkedEnabled,
unlinkedTargetCategory: config.unlinkedTargetCategory,
unlinkedUseTag: config.unlinkedUseTag,
@@ -500,11 +500,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
const deletePrivateControl = this.downloadCleanerForm.get('deletePrivate');
const unlinkedEnabledControl = this.downloadCleanerForm.get('unlinkedEnabled');
const useAdvancedSchedulingControl = this.downloadCleanerForm.get('useAdvancedScheduling');
const ignoredDownloadsControl = this.downloadCleanerForm.get('ignoredDownloads');
categoriesControl?.enable();
deletePrivateControl?.enable();
unlinkedEnabledControl?.enable();
useAdvancedSchedulingControl?.enable();
ignoredDownloadsControl?.enable();
// Update unlinked controls based on unlinkedEnabled value
const unlinkedEnabled = unlinkedEnabledControl?.value;
@@ -520,11 +522,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
const deletePrivateControl = this.downloadCleanerForm.get('deletePrivate');
const unlinkedEnabledControl = this.downloadCleanerForm.get('unlinkedEnabled');
const useAdvancedSchedulingControl = this.downloadCleanerForm.get('useAdvancedScheduling');
const ignoredDownloadsControl = this.downloadCleanerForm.get('ignoredDownloads');
categoriesControl?.disable();
deletePrivateControl?.disable();
unlinkedEnabledControl?.disable();
useAdvancedSchedulingControl?.disable();
ignoredDownloadsControl?.disable();
// Always disable unlinked controls when main feature is disabled
this.updateUnlinkedControlsState(false);
@@ -560,6 +564,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
jobSchedule: formValues.jobSchedule,
categories: formValues.categories,
deletePrivate: formValues.deletePrivate,
ignoredDownloads: formValues.ignoredDownloads || [],
unlinkedEnabled: formValues.unlinkedEnabled,
unlinkedTargetCategory: formValues.unlinkedTargetCategory,
unlinkedUseTag: formValues.unlinkedUseTag,

View File

@@ -109,6 +109,36 @@
</div>
</div>
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="mb-ignoredDownloads"
multiple
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>
</div>
<!-- Content Blocker Specific Settings -->
<div class="field-row">
<label class="field-label">

View File

@@ -28,6 +28,8 @@ import { DropdownModule } from "primeng/dropdown";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
import { DocumentationService } from "../../core/services/documentation.service";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
import { AutoCompleteModule } from "primeng/autocomplete";
@Component({
selector: "app-malware-blocker-settings",
@@ -46,6 +48,8 @@ import { DocumentationService } from "../../core/services/documentation.service"
DropdownModule,
LoadingErrorStateComponent,
FluidModule,
MobileAutocompleteComponent,
AutoCompleteModule,
],
providers: [MalwareBlockerConfigStore],
templateUrl: "./malware-blocker-settings.component.html",
@@ -129,6 +133,7 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
every: [{ value: 5, disabled: true }, [Validators.required, Validators.min(1)]],
type: [{ value: ScheduleUnit.Seconds, disabled: true }],
}),
ignoredDownloads: [{ value: [], disabled: true }],
ignorePrivate: [{ value: false, disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
@@ -184,6 +189,7 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
every: 5,
type: ScheduleUnit.Seconds
},
ignoredDownloads: correctedConfig.ignoredDownloads || [],
ignorePrivate: correctedConfig.ignorePrivate,
deletePrivate: correctedConfig.deletePrivate,
deleteKnownMalware: correctedConfig.deleteKnownMalware,
@@ -438,6 +444,10 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
const useAdvancedSchedulingControl = this.malwareBlockerForm.get('useAdvancedScheduling');
useAdvancedSchedulingControl?.enable();
// Enable ignored downloads control
const ignoredDownloadsControl = this.malwareBlockerForm.get('ignoredDownloads');
ignoredDownloadsControl?.enable();
// Enable content blocker specific controls
this.malwareBlockerForm.get("ignorePrivate")?.enable({ onlySelf: true });
this.malwareBlockerForm.get("deleteKnownMalware")?.enable({ onlySelf: true });
@@ -481,6 +491,10 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
const useAdvancedSchedulingControl = this.malwareBlockerForm.get('useAdvancedScheduling');
useAdvancedSchedulingControl?.disable();
// Disable ignored downloads control
const ignoredDownloadsControl = this.malwareBlockerForm.get('ignoredDownloads');
ignoredDownloadsControl?.disable();
// Disable content blocker specific controls
this.malwareBlockerForm.get("ignorePrivate")?.disable({ onlySelf: true });
this.malwareBlockerForm.get("deletePrivate")?.disable({ onlySelf: true });
@@ -528,6 +542,7 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
// If in basic mode, generate cron expression from the schedule
this.malwareBlockerStore.generateCronExpression(formValue.jobSchedule),
jobSchedule: formValue.jobSchedule,
ignoredDownloads: formValue.ignoredDownloads || [],
ignorePrivate: formValue.ignorePrivate || false,
deletePrivate: formValue.deletePrivate || false,
deleteKnownMalware: formValue.deleteKnownMalware || false,

View File

@@ -109,6 +109,36 @@
</div>
</div>
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="qc-ignoredDownloads"
multiple
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>
</div>
<!-- Detailed Settings in Accordion -->
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Failed Import Settings -->

View File

@@ -139,6 +139,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
every: [{ value: 5, disabled: true }, [Validators.required, Validators.min(1)]],
type: [{ value: ScheduleUnit.Minutes, disabled: true }],
}),
ignoredDownloads: [{ value: [], disabled: true }],
// Failed Import settings - nested group
failedImport: this.formBuilder.group({
@@ -203,6 +204,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
every: 5,
type: ScheduleUnit.Minutes
},
ignoredDownloads: correctedConfig.ignoredDownloads || [],
failedImport: correctedConfig.failedImport,
stalled: correctedConfig.stalled,
slow: correctedConfig.slow,
@@ -471,6 +473,10 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
const useAdvancedSchedulingControl = this.queueCleanerForm.get('useAdvancedScheduling');
useAdvancedSchedulingControl?.enable();
// Enable ignored downloads control
const ignoredDownloadsControl = this.queueCleanerForm.get('ignoredDownloads');
ignoredDownloadsControl?.enable();
// Update individual config sections only if they are enabled
const failedImportMaxStrikes = this.queueCleanerForm.get("failedImport.maxStrikes")?.value;
const stalledMaxStrikes = this.queueCleanerForm.get("stalled.maxStrikes")?.value;
@@ -489,6 +495,10 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
const useAdvancedSchedulingControl = this.queueCleanerForm.get('useAdvancedScheduling');
useAdvancedSchedulingControl?.disable();
// Disable ignored downloads control
const ignoredDownloadsControl = this.queueCleanerForm.get('ignoredDownloads');
ignoredDownloadsControl?.disable();
// Save current active accordion state before clearing it
// This will be empty when we collapse all accordions
this.activeAccordionIndices = [];
@@ -607,6 +617,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
useAdvancedScheduling: formValue.useAdvancedScheduling,
cronExpression: cronExpression,
jobSchedule: formValue.jobSchedule,
ignoredDownloads: formValue.ignoredDownloads || [],
failedImport: {
maxStrikes: formValue.failedImport?.maxStrikes || 0,
ignorePrivate: formValue.failedImport?.ignorePrivate || false,

View File

@@ -9,6 +9,7 @@ export interface DownloadCleanerConfig {
jobSchedule: JobSchedule;
categories: CleanCategory[];
deletePrivate: boolean;
ignoredDownloads: string[];
unlinkedEnabled: boolean;
unlinkedTargetCategory: string;
unlinkedUseTag: boolean;
@@ -49,6 +50,7 @@ export const defaultDownloadCleanerConfig: DownloadCleanerConfig = {
},
categories: [],
deletePrivate: false,
ignoredDownloads: [],
unlinkedEnabled: false,
unlinkedTargetCategory: 'cleanuparr-unlinked',
unlinkedUseTag: false,

View File

@@ -34,11 +34,10 @@ export interface MalwareBlockerConfig {
cronExpression: string;
useAdvancedScheduling: boolean;
jobSchedule?: JobSchedule; // UI-only field, not sent to API
ignoredDownloads: string[];
ignorePrivate: boolean;
deletePrivate: boolean;
deleteKnownMalware: boolean;
sonarr: BlocklistSettings;
radarr: BlocklistSettings;
lidarr: BlocklistSettings;

View File

@@ -18,7 +18,6 @@ export interface JobSchedule {
type: ScheduleUnit;
}
// Nested configuration interfaces
export interface FailedImportConfig {
maxStrikes: number;
ignorePrivate: boolean;
@@ -49,28 +48,8 @@ export interface QueueCleanerConfig {
cronExpression: string;
useAdvancedScheduling: boolean;
jobSchedule?: JobSchedule; // UI-only field, not sent to API
// Nested configurations
ignoredDownloads: string[];
failedImport: FailedImportConfig;
stalled: StalledConfig;
slow: SlowConfig;
// Legacy flat properties for backward compatibility
// These will be mapped to/from the nested structure
failedImportMaxStrikes?: number;
failedImportIgnorePrivate?: boolean;
failedImportDeletePrivate?: boolean;
failedImportIgnorePatterns?: string[];
stalledMaxStrikes?: number;
stalledResetStrikesOnProgress?: boolean;
stalledIgnorePrivate?: boolean;
stalledDeletePrivate?: boolean;
downloadingMetadataMaxStrikes?: number;
slowMaxStrikes?: number;
slowResetStrikesOnProgress?: boolean;
slowIgnorePrivate?: boolean;
slowDeletePrivate?: boolean;
slowMinSpeed?: string;
slowMaxTime?: number;
slowIgnoreAboveSize?: string;
}

View File

@@ -64,6 +64,34 @@ Enter a valid Quartz.NET cron expression to control when the Download Cleaner ru
<div className={styles.section}>
<ConfigSection
id="ignored-downloads"
title="Ignored Downloads"
icon="🚫"
>
Downloads matching these patterns will be ignored by Download Cleaner. Patterns can match any of these:
- torrent hash
- qBittorrent tag or category
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- torrent tracker domain
**Examples:**
```
fa800a7d7c443a2c3561d1f8f393c089036dade1
tv-sonarr
qbit-tag
mytracker.com
```
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🌱</span>
Seeding Settings

View File

@@ -32,6 +32,30 @@ When enabled, the Malware Blocker will run according to the configured schedule
</ConfigSection>
<ConfigSection
id="ignored-downloads"
title="Ignored Downloads"
icon="🚫"
>
Downloads matching these patterns will be ignored by Malware Blocker. Patterns can match any of these:
- torrent hash
- qBittorrent tag or category
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- torrent tracker domain
**Examples:**
```
fa800a7d7c443a2c3561d1f8f393c089036dade1
tv-sonarr
qbit-tag
mytracker.com
```
</ConfigSection>
<ConfigSection
id="scheduling-mode"
title="Scheduling Mode"

View File

@@ -59,6 +59,34 @@ Enter a valid Quartz cron expression to control when the Queue Cleaner runs.
<div className={styles.section}>
<ConfigSection
id="ignored-downloads"
title="Ignored Downloads"
icon="🚫"
>
Downloads matching these patterns will be ignored by Queue Cleaner. Patterns can match any of these:
- torrent hash
- qBittorrent tag or category
- Deluge label
- Transmission category (last directory from the save location)
- µTorrent label
- torrent tracker domain
**Examples:**
```
fa800a7d7c443a2c3561d1f8f393c089036dade1
tv-sonarr
qbit-tag
mytracker.com
```
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>❌</span>
Failed Import Settings