diff --git a/code/backend/Cleanuparr.Domain/Enums/PatternMode.cs b/code/backend/Cleanuparr.Domain/Enums/PatternMode.cs
new file mode 100644
index 00000000..b4ee6b0a
--- /dev/null
+++ b/code/backend/Cleanuparr.Domain/Enums/PatternMode.cs
@@ -0,0 +1,14 @@
+namespace Cleanuparr.Domain.Enums;
+
+public enum PatternMode
+{
+ ///
+ /// Delete all except those that match the patterns
+ ///
+ Exclude,
+
+ ///
+ /// Delete only those that match the patterns
+ ///
+ Include
+}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs
index e8e7fb78..279a798d 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs
@@ -91,9 +91,8 @@ public abstract class ArrClient : IArrClient
if (hasWarn() && (isImportBlocked() || isImportPending() || isImportFailed()) || isFailedLidarr())
{
- if (HasIgnoredPatterns(record))
+ if (!ShouldStrikeFailedImport(queueCleanerConfig, record))
{
- _logger.LogDebug("skip failed import check | contains ignored pattern | {name}", record.Title);
return false;
}
@@ -212,19 +211,14 @@ public abstract class ArrClient : IArrClient
return response;
}
- private static bool HasIgnoredPatterns(QueueRecord record)
+ ///
+ /// Determines whether the failed import record should be skipped
+ ///
+ private bool ShouldStrikeFailedImport(QueueCleanerConfig queueCleanerConfig, QueueRecord record)
{
- var queueCleanerConfig = ContextProvider.Get();
-
- if (queueCleanerConfig.FailedImport.IgnoredPatterns.Count is 0)
- {
- // no patterns are configured
- return false;
- }
-
if (record.StatusMessages?.Count is null or 0)
{
- // no status message found
+ _logger.LogWarning("skip failed import check | no status message found | {name}", record.Title);
return false;
}
@@ -235,10 +229,29 @@ public abstract class ArrClient : IArrClient
.ToList()
.ForEach(x => messages.Add(x));
- return messages.Any(
- m => queueCleanerConfig.FailedImport.IgnoredPatterns.Any(
- p => !string.IsNullOrWhiteSpace(p.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
+ var patterns = queueCleanerConfig.FailedImport.Patterns;
+ var patternMode = queueCleanerConfig.FailedImport.PatternMode;
+
+ var matched = messages.Any(
+ m => patterns.Any(
+ p => !string.IsNullOrWhiteSpace(p?.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
)
);
+
+ if (patternMode is PatternMode.Exclude && matched)
+ {
+ // contains an excluded/ignored pattern -> skip
+ _logger.LogTrace("skip failed import check | excluded pattern matched | {name}", record.Title);
+ return false;
+ }
+
+ if (patternMode is PatternMode.Include && (!matched || patterns.Count is 0))
+ {
+ // does not match any included patterns -> skip
+ _logger.LogTrace("skip failed import check | no included pattern matched | {name}", record.Title);
+ return false;
+ }
+
+ return true;
}
}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs
index 93bd170c..70106743 100644
--- a/code/backend/Cleanuparr.Persistence/DataContext.cs
+++ b/code/backend/Cleanuparr.Persistence/DataContext.cs
@@ -87,9 +87,10 @@ public class DataContext : DbContext
modelBuilder.Entity(entity =>
{
- entity.ComplexProperty(e => e.FailedImport);
- // entity.ComplexProperty(e => e.Stalled);
- // entity.ComplexProperty(e => e.Slow);
+ entity.ComplexProperty(e => e.FailedImport, cp =>
+ {
+ cp.Property(x => x.PatternMode).HasConversion>();
+ });
});
modelBuilder.Entity(entity =>
diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250906183018_AddFailedImportTypeHandling.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250906183018_AddFailedImportTypeHandling.Designer.cs
new file mode 100644
index 00000000..7a2eff3d
--- /dev/null
+++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250906183018_AddFailedImportTypeHandling.Designer.cs
@@ -0,0 +1,751 @@
+//
+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("20250906183018_AddFailedImportTypeHandling")]
+ partial class AddFailedImportTypeHandling
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("FailedImportMaxStrikes")
+ .HasColumnType("INTEGER")
+ .HasColumnName("failed_import_max_strikes");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_arr_configs");
+
+ b.ToTable("arr_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("ApiKey")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("api_key");
+
+ b.Property("ArrConfigId")
+ .HasColumnType("TEXT")
+ .HasColumnName("arr_config_id");
+
+ b.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("enabled");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("url");
+
+ b.HasKey("Id")
+ .HasName("pk_arr_instances");
+
+ b.HasIndex("ArrConfigId")
+ .HasDatabaseName("ix_arr_instances_arr_config_id");
+
+ b.ToTable("arr_instances", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("DownloadCleanerConfigId")
+ .HasColumnType("TEXT")
+ .HasColumnName("download_cleaner_config_id");
+
+ b.Property("MaxRatio")
+ .HasColumnType("REAL")
+ .HasColumnName("max_ratio");
+
+ b.Property("MaxSeedTime")
+ .HasColumnType("REAL")
+ .HasColumnName("max_seed_time");
+
+ b.Property("MinSeedTime")
+ .HasColumnType("REAL")
+ .HasColumnName("min_seed_time");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_clean_categories");
+
+ b.HasIndex("DownloadCleanerConfigId")
+ .HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
+
+ b.ToTable("clean_categories", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("CronExpression")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("cron_expression");
+
+ b.Property("DeletePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("delete_private");
+
+ b.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("enabled");
+
+ b.PrimitiveCollection("UnlinkedCategories")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("unlinked_categories");
+
+ b.Property("UnlinkedEnabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("unlinked_enabled");
+
+ b.Property("UnlinkedIgnoredRootDir")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("unlinked_ignored_root_dir");
+
+ b.Property("UnlinkedTargetCategory")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("unlinked_target_category");
+
+ b.Property("UnlinkedUseTag")
+ .HasColumnType("INTEGER")
+ .HasColumnName("unlinked_use_tag");
+
+ b.Property("UseAdvancedScheduling")
+ .HasColumnType("INTEGER")
+ .HasColumnName("use_advanced_scheduling");
+
+ b.HasKey("Id")
+ .HasName("pk_download_cleaner_configs");
+
+ b.ToTable("download_cleaner_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("enabled");
+
+ b.Property("Host")
+ .HasColumnType("TEXT")
+ .HasColumnName("host");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.Property("Password")
+ .HasColumnType("TEXT")
+ .HasColumnName("password");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("type");
+
+ b.Property("TypeName")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("type_name");
+
+ b.Property("UrlBase")
+ .HasColumnType("TEXT")
+ .HasColumnName("url_base");
+
+ b.Property("Username")
+ .HasColumnType("TEXT")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_download_clients");
+
+ b.ToTable("download_clients", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("DisplaySupportBanner")
+ .HasColumnType("INTEGER")
+ .HasColumnName("display_support_banner");
+
+ b.Property("DryRun")
+ .HasColumnType("INTEGER")
+ .HasColumnName("dry_run");
+
+ b.Property("EncryptionKey")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("encryption_key");
+
+ b.Property("HttpCertificateValidation")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("http_certificate_validation");
+
+ b.Property("HttpMaxRetries")
+ .HasColumnType("INTEGER")
+ .HasColumnName("http_max_retries");
+
+ b.Property("HttpTimeout")
+ .HasColumnType("INTEGER")
+ .HasColumnName("http_timeout");
+
+ b.PrimitiveCollection("IgnoredDownloads")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("ignored_downloads");
+
+ b.Property("SearchDelay")
+ .HasColumnType("INTEGER")
+ .HasColumnName("search_delay");
+
+ b.Property("SearchEnabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("search_enabled");
+
+ b.ComplexProperty>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("ArchiveEnabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("log_archive_enabled");
+
+ b1.Property("ArchiveRetainedCount")
+ .HasColumnType("INTEGER")
+ .HasColumnName("log_archive_retained_count");
+
+ b1.Property("ArchiveTimeLimitHours")
+ .HasColumnType("INTEGER")
+ .HasColumnName("log_archive_time_limit_hours");
+
+ b1.Property("Level")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("log_level");
+
+ b1.Property("RetainedFileCount")
+ .HasColumnType("INTEGER")
+ .HasColumnName("log_retained_file_count");
+
+ b1.Property("RollingSizeMB")
+ .HasColumnType("INTEGER")
+ .HasColumnName("log_rolling_size_mb");
+
+ b1.Property("TimeLimitHours")
+ .HasColumnType("INTEGER")
+ .HasColumnName("log_time_limit_hours");
+ });
+
+ b.HasKey("Id")
+ .HasName("pk_general_configs");
+
+ b.ToTable("general_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("CronExpression")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("cron_expression");
+
+ b.Property("DeleteKnownMalware")
+ .HasColumnType("INTEGER")
+ .HasColumnName("delete_known_malware");
+
+ b.Property("DeletePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("delete_private");
+
+ b.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("enabled");
+
+ b.Property("IgnorePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ignore_private");
+
+ b.Property("UseAdvancedScheduling")
+ .HasColumnType("INTEGER")
+ .HasColumnName("use_advanced_scheduling");
+
+ b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("BlocklistPath")
+ .HasColumnType("TEXT")
+ .HasColumnName("lidarr_blocklist_path");
+
+ b1.Property("BlocklistType")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("lidarr_blocklist_type");
+
+ b1.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("lidarr_enabled");
+ });
+
+ b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("BlocklistPath")
+ .HasColumnType("TEXT")
+ .HasColumnName("radarr_blocklist_path");
+
+ b1.Property("BlocklistType")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("radarr_blocklist_type");
+
+ b1.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("radarr_enabled");
+ });
+
+ b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("BlocklistPath")
+ .HasColumnType("TEXT")
+ .HasColumnName("readarr_blocklist_path");
+
+ b1.Property("BlocklistType")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("readarr_blocklist_type");
+
+ b1.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("readarr_enabled");
+ });
+
+ b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("BlocklistPath")
+ .HasColumnType("TEXT")
+ .HasColumnName("sonarr_blocklist_path");
+
+ b1.Property("BlocklistType")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("sonarr_blocklist_type");
+
+ b1.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("sonarr_enabled");
+ });
+
+ b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("BlocklistPath")
+ .HasColumnType("TEXT")
+ .HasColumnName("whisparr_blocklist_path");
+
+ b1.Property("BlocklistType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("whisparr_blocklist_type");
+
+ b1.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("whisparr_enabled");
+ });
+
+ b.HasKey("Id")
+ .HasName("pk_content_blocker_configs");
+
+ b.ToTable("content_blocker_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT")
+ .HasColumnName("key");
+
+ b.Property("NotificationConfigId")
+ .HasColumnType("TEXT")
+ .HasColumnName("notification_config_id");
+
+ b.Property("Tags")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT")
+ .HasColumnName("tags");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT")
+ .HasColumnName("url");
+
+ b.HasKey("Id")
+ .HasName("pk_apprise_configs");
+
+ b.HasIndex("NotificationConfigId")
+ .IsUnique()
+ .HasDatabaseName("ix_apprise_configs_notification_config_id");
+
+ b.ToTable("apprise_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("ApiKey")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT")
+ .HasColumnName("api_key");
+
+ b.Property("ChannelId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT")
+ .HasColumnName("channel_id");
+
+ b.Property("NotificationConfigId")
+ .HasColumnType("TEXT")
+ .HasColumnName("notification_config_id");
+
+ b.HasKey("Id")
+ .HasName("pk_notifiarr_configs");
+
+ b.HasIndex("NotificationConfigId")
+ .IsUnique()
+ .HasDatabaseName("ix_notifiarr_configs_notification_config_id");
+
+ b.ToTable("notifiarr_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT")
+ .HasColumnName("created_at");
+
+ b.Property("IsEnabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("is_enabled");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.Property("OnCategoryChanged")
+ .HasColumnType("INTEGER")
+ .HasColumnName("on_category_changed");
+
+ b.Property("OnDownloadCleaned")
+ .HasColumnType("INTEGER")
+ .HasColumnName("on_download_cleaned");
+
+ b.Property("OnFailedImportStrike")
+ .HasColumnType("INTEGER")
+ .HasColumnName("on_failed_import_strike");
+
+ b.Property("OnQueueItemDeleted")
+ .HasColumnType("INTEGER")
+ .HasColumnName("on_queue_item_deleted");
+
+ b.Property("OnSlowStrike")
+ .HasColumnType("INTEGER")
+ .HasColumnName("on_slow_strike");
+
+ b.Property("OnStalledStrike")
+ .HasColumnType("INTEGER")
+ .HasColumnName("on_stalled_strike");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_notification_configs");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_notification_configs_name");
+
+ b.ToTable("notification_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("CronExpression")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("cron_expression");
+
+ b.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("enabled");
+
+ b.Property("UseAdvancedScheduling")
+ .HasColumnType("INTEGER")
+ .HasColumnName("use_advanced_scheduling");
+
+ b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("DeletePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("failed_import_delete_private");
+
+ b1.Property("IgnorePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("failed_import_ignore_private");
+
+ b1.Property("MaxStrikes")
+ .HasColumnType("INTEGER")
+ .HasColumnName("failed_import_max_strikes");
+
+ b1.Property("PatternMode")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("failed_import_pattern_mode");
+
+ b1.PrimitiveCollection("Patterns")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("failed_import_patterns");
+ });
+
+ b.ComplexProperty>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("DeletePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("slow_delete_private");
+
+ b1.Property("IgnoreAboveSize")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("slow_ignore_above_size");
+
+ b1.Property("IgnorePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("slow_ignore_private");
+
+ b1.Property("MaxStrikes")
+ .HasColumnType("INTEGER")
+ .HasColumnName("slow_max_strikes");
+
+ b1.Property("MaxTime")
+ .HasColumnType("REAL")
+ .HasColumnName("slow_max_time");
+
+ b1.Property("MinSpeed")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("slow_min_speed");
+
+ b1.Property("ResetStrikesOnProgress")
+ .HasColumnType("INTEGER")
+ .HasColumnName("slow_reset_strikes_on_progress");
+ });
+
+ b.ComplexProperty>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("DeletePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("stalled_delete_private");
+
+ b1.Property("DownloadingMetadataMaxStrikes")
+ .HasColumnType("INTEGER")
+ .HasColumnName("stalled_downloading_metadata_max_strikes");
+
+ b1.Property("IgnorePrivate")
+ .HasColumnType("INTEGER")
+ .HasColumnName("stalled_ignore_private");
+
+ b1.Property("MaxStrikes")
+ .HasColumnType("INTEGER")
+ .HasColumnName("stalled_max_strikes");
+
+ b1.Property("ResetStrikesOnProgress")
+ .HasColumnType("INTEGER")
+ .HasColumnName("stalled_reset_strikes_on_progress");
+ });
+
+ b.HasKey("Id")
+ .HasName("pk_queue_cleaner_configs");
+
+ b.ToTable("queue_cleaner_configs", (string)null);
+ });
+
+ modelBuilder.Entity("Cleanuparr.Persistence.Models.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
+ }
+ }
+}
diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250906183018_AddFailedImportTypeHandling.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250906183018_AddFailedImportTypeHandling.cs
new file mode 100644
index 00000000..446ae22a
--- /dev/null
+++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250906183018_AddFailedImportTypeHandling.cs
@@ -0,0 +1,49 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Cleanuparr.Persistence.Migrations.Data
+{
+ ///
+ public partial class AddFailedImportTypeHandling : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "failed_import_ignored_patterns",
+ table: "queue_cleaner_configs",
+ newName: "failed_import_patterns");
+
+ migrationBuilder.AddColumn(
+ name: "failed_import_pattern_mode",
+ table: "queue_cleaner_configs",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.Sql(
+ """
+ UPDATE queue_cleaner_configs
+ SET failed_import_pattern_mode = CASE
+ WHEN failed_import_max_strikes = 0 AND failed_import_patterns = '[]'
+ THEN 'include'
+ ELSE 'exclude'
+ END;
+ """);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "failed_import_pattern_mode",
+ table: "queue_cleaner_configs");
+
+ migrationBuilder.RenameColumn(
+ name: "failed_import_patterns",
+ table: "queue_cleaner_configs",
+ newName: "failed_import_ignored_patterns");
+ }
+ }
+}
diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs
index 9e370dfa..f0cbe1ee 100644
--- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs
+++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs
@@ -704,14 +704,19 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
- b1.PrimitiveCollection("IgnoredPatterns")
- .IsRequired()
- .HasColumnType("TEXT")
- .HasColumnName("failed_import_ignored_patterns");
-
b1.Property("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
+
+ b1.Property("PatternMode")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("failed_import_pattern_mode");
+
+ b1.PrimitiveCollection("Patterns")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("failed_import_patterns");
});
b.HasKey("Id")
diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs
index ee6073ad..b050aa80 100644
--- a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs
+++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs
@@ -1,4 +1,6 @@
-using System.ComponentModel.DataAnnotations.Schema;
+using System;
+using System.ComponentModel.DataAnnotations.Schema;
+using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
namespace Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
@@ -12,7 +14,9 @@ public sealed record FailedImportConfig
public bool DeletePrivate { get; init; }
- public IReadOnlyList IgnoredPatterns { get; init; } = [];
+ public IReadOnlyList Patterns { get; init; } = [];
+
+ public PatternMode PatternMode { get; init; } = PatternMode.Include;
public void Validate()
{
diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts
index 9f7210bc..0c8620dc 100644
--- a/code/frontend/src/app/core/services/documentation.service.ts
+++ b/code/frontend/src/app/core/services/documentation.service.ts
@@ -22,7 +22,8 @@ export class DocumentationService {
'failedImport.maxStrikes': 'failed-import-max-strikes',
'failedImport.ignorePrivate': 'failed-import-ignore-private',
'failedImport.deletePrivate': 'failed-import-delete-private',
- 'failedImport.ignoredPatterns': 'failed-import-ignored-patterns',
+ 'failedImport.pattern-mode': 'failed-import-pattern-mode',
+ 'failedImport.patterns': 'failed-import-patterns',
'downloadingMetadataMaxStrikes': 'downloading-metadata-max-strikes',
// Stall rule fields
'stallRule.name': 'stall-rule-name',
diff --git a/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html b/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html
index 73570984..257f6c00 100644
--- a/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html
+++ b/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html
@@ -212,20 +212,40 @@
- Ignored Patterns
+ Pattern Mode
+
+
+
+
+
+
+
+ {{ queueCleanerForm.get('failedImport.patternMode')?.value === PatternMode.Include ? 'Included Patterns' : 'Excluded Patterns' }}
diff --git a/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts b/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts
index d6f12273..3eb02609 100644
--- a/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts
+++ b/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts
@@ -9,6 +9,8 @@ import {
ScheduleUnit,
ScheduleOptions
} from "../../shared/models/queue-cleaner-config.model";
+import { PatternMode } from "../../shared/models/queue-cleaner-config.model";
+import { SettingsCardComponent } from "../components/settings-card/settings-card.component";
import { ByteSizeInputComponent } from "../../shared/components/byte-size-input/byte-size-input.component";
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
@@ -112,6 +114,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
{ label: 'Basic', value: false },
{ label: 'Advanced', value: true }
];
+ // Expose PatternMode enum for template comparisons
+ PatternMode = PatternMode;
// Privacy type options for rules
privacyTypeOptions = [
@@ -553,7 +557,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
maxStrikes: [0, [Validators.required, Validators.min(0), Validators.max(5000)]],
ignorePrivate: [{ value: false, disabled: true }],
deletePrivate: [{ value: false, disabled: true }],
- ignoredPatterns: [{ value: [], disabled: true }],
+ patterns: [{ value: [], disabled: true }],
+ patternMode: [{ value: PatternMode.Include, disabled: true }],
}),
downloadingMetadataMaxStrikes: [{ value: 0, disabled: true }, [Validators.required, Validators.min(0), Validators.max(5000)]],
@@ -1015,7 +1020,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
if (enable) {
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.enable(options);
- this.queueCleanerForm.get("failedImport")?.get("ignoredPatterns")?.enable(options);
+ this.queueCleanerForm.get("failedImport")?.get("patterns")?.enable(options);
+ this.queueCleanerForm.get("failedImport")?.get("patternMode")?.enable(options);
// Only enable deletePrivate if ignorePrivate is false
const ignorePrivate = this.queueCleanerForm.get("failedImport.ignorePrivate")?.value || false;
@@ -1029,7 +1035,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
} else {
this.queueCleanerForm.get("failedImport")?.get("ignorePrivate")?.disable(options);
this.queueCleanerForm.get("failedImport")?.get("deletePrivate")?.disable(options);
- this.queueCleanerForm.get("failedImport")?.get("ignoredPatterns")?.disable(options);
+ this.queueCleanerForm.get("failedImport")?.get("patterns")?.disable(options);
+ this.queueCleanerForm.get("failedImport")?.get("patternMode")?.disable(options);
}
}
@@ -1061,7 +1068,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
maxStrikes: formValue.failedImport?.maxStrikes || 0,
ignorePrivate: formValue.failedImport?.ignorePrivate || false,
deletePrivate: formValue.failedImport?.deletePrivate || false,
- ignoredPatterns: formValue.failedImport?.ignoredPatterns || [],
+ patterns: formValue.failedImport?.patterns || [],
+ patternMode: formValue.failedImport?.patternMode || PatternMode.Include,
},
downloadingMetadataMaxStrikes: formValue.downloadingMetadataMaxStrikes || 0,
stallRules: formValue.stallRules || [],
@@ -1126,7 +1134,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
maxStrikes: 0,
ignorePrivate: false,
deletePrivate: false,
- ignoredPatterns: [],
+ patterns: [],
+ patternMode: PatternMode.Include,
},
downloadingMetadataMaxStrikes: 0,
diff --git a/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts b/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts
index f0cffdd1..282a4947 100644
--- a/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts
+++ b/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts
@@ -7,6 +7,11 @@ export enum ScheduleUnit {
Hours = 'Hours'
}
+export enum PatternMode {
+ Exclude = 'Exclude',
+ Include = 'Include'
+}
+
/**
* Valid values for each schedule unit
*/
@@ -25,7 +30,8 @@ export interface FailedImportConfig {
maxStrikes: number;
ignorePrivate: boolean;
deletePrivate: boolean;
- ignoredPatterns: string[];
+ patterns: string[];
+ patternMode?: PatternMode;
}
export interface QueueCleanerConfig {
diff --git a/docs/docs/configuration/queue-cleaner/index.mdx b/docs/docs/configuration/queue-cleaner/index.mdx
index 778621c9..7cb2a90d 100644
--- a/docs/docs/configuration/queue-cleaner/index.mdx
+++ b/docs/docs/configuration/queue-cleaner/index.mdx
@@ -9,6 +9,7 @@ import {
EnhancedWarning,
styles
} from '@site/src/components/documentation';
+import failedImportPatterns from '../../../static/img/failed-import-patterns.png';
# Queue Cleaner
@@ -135,8 +136,20 @@ This setting needs a download client to be configured.
+
+Choose how to handle failed imports based on their patterns:
+- **Exclude**: Remove all failed imports except those containing specified patterns.
+- **Include**: Remove only failed imports containing specified patterns.
+
+
+
+
@@ -145,7 +158,12 @@ This setting needs a download client to be configured.
- `manual import required`
- `recently aired`
-Failed imports containing these patterns in their name will be skipped during cleaning. Useful for avoiding removal of legitimate downloads that may have temporary import issues.
+When pattern mode is set to `Exclude`, failed imports containing these patterns will be skipped. Everything else will be removed.
+When pattern mode is set to `Include`, only failed imports containing these patterns will be removed. Everything else will be skipped.
+
+These patterns can be any substring of the failed import messages. These messsages can be viewed in the queue of your *arr application.
+
+
diff --git a/docs/static/img/failed-import-patterns.png b/docs/static/img/failed-import-patterns.png
new file mode 100644
index 00000000..7687567c
Binary files /dev/null and b/docs/static/img/failed-import-patterns.png differ