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 @@
+
+ + Choose how the patterns are applied to failed imports +
+
+ +
+
- Failed imports containing these patterns will be skipped (e.g. sample) + + {{ queueCleanerForm.get('failedImport.patternMode')?.value === PatternMode.Include ? + 'Only failed imports containing these patterns will be removed and everything else will be skipped' : + 'Failed imports containing these patterns will be skipped and everything else will be removed' + }} +
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. + +Failed Import Messages Example 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