Add option to remove only specified failed import message patterns (#297)

This commit is contained in:
Flaminel authored and GitHub committed 2025-10-22 23:45:05 +03:00
1 parent ebb166a7b9
commit efbf60dcdd
13 files changed
+936 -42

No files matched your search

@@ -0,0 +1,14 @@
namespace Cleanuparr.Domain.Enums;
public enum PatternMode
{
/// <summary>
/// Delete all except those that match the patterns
/// </summary>
Exclude,
/// <summary>
/// Delete only those that match the patterns
/// </summary>
Include
}
@@ -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)
/// <summary>
/// Determines whether the failed import record should be skipped
/// </summary>
private bool ShouldStrikeFailedImport(QueueCleanerConfig queueCleanerConfig, QueueRecord record)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
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;
}
}
@@ -87,9 +87,10 @@ public class DataContext : DbContext
modelBuilder.Entity<QueueCleanerConfig>(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<LowercaseEnumConverter<PatternMode>>();
});
});
modelBuilder.Entity<ContentBlockerConfig>(entity =>
@@ -0,0 +1,751 @@
// <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("20250906183018_AddFailedImportTypeHandling")]
partial class AddFailedImportTypeHandling
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_arr_configs");
b.ToTable("arr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_arr_instances");
b.HasIndex("ArrConfigId")
.HasDatabaseName("ix_arr_instances_arr_config_id");
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("TimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_time_limit_hours");
});
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("tags");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_apprise_configs_notification_config_id");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_notifiarr_configs_notification_config_id");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is_enabled");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_notification_configs");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_notification_configs_name");
b.ToTable("notification_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b1.Property<string>("PatternMode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_pattern_mode");
b1.PrimitiveCollection<string>("Patterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_patterns");
});
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
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddFailedImportTypeHandling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "failed_import_ignored_patterns",
table: "queue_cleaner_configs",
newName: "failed_import_patterns");
migrationBuilder.AddColumn<string>(
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;
""");
}
/// <inheritdoc />
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");
}
}
}
@@ -704,14 +704,19 @@ namespace Cleanuparr.Persistence.Migrations.Data
.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");
b1.Property<string>("PatternMode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_pattern_mode");
b1.PrimitiveCollection<string>("Patterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_patterns");
});
b.HasKey("Id")
@@ -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<string> IgnoredPatterns { get; init; } = [];
public IReadOnlyList<string> Patterns { get; init; } = [];
public PatternMode PatternMode { get; init; } = PatternMode.Include;
public void Validate()
{
@@ -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',
@@ -212,20 +212,40 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignoredPatterns')"
(click)="openFieldDocs('failedImport.pattern-mode')"
title="Click for documentation"></i>
Ignored Patterns
Pattern Mode
</label>
<div class="field-input">
<p-selectButton
formControlName="patternMode"
[options]="[{ label: PatternMode.Include, value: PatternMode.Include }, { label: PatternMode.Exclude, value: PatternMode.Exclude }]"
optionLabel="label"
optionValue="value"
[allowEmpty]="false"
[multiple]="false"
></p-selectButton>
<small class="form-helper-text">Choose how the patterns are applied to failed imports</small>
</div>
</div>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.patterns')"
title="Click for documentation"></i>
{{ queueCleanerForm.get('failedImport.patternMode')?.value === PatternMode.Include ? 'Included Patterns' : 'Excluded Patterns' }}
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredPatterns"
formControlName="patterns"
placeholder="Add pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredPatterns"
formControlName="patterns"
multiple
fluid
[typeahead]="false"
@@ -233,9 +253,12 @@
class="desktop-only"
>
</p-autocomplete>
<small class="form-helper-text"
>Failed imports containing these patterns will be skipped (e.g. <code>sample</code>)</small
>
<small class="form-helper-text">
{{ 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'
}}
</small>
</div>
</div>
</p-accordion-content>
@@ -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,
@@ -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 {
@@ -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.
</ConfigSection>
<ConfigSection
id="failed-import-ignored-patterns"
title="Failed Import Ignored Patterns"
id="failed-import-pattern-mode"
title="Failed Import Pattern Mode"
icon="🎭"
>
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.
</ConfigSection>
<ConfigSection
id="failed-import-patterns"
title="Failed Import Patterns"
icon="📝"
>
@@ -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.
<img src={failedImportPatterns} alt="Failed Import Messages Example" style={{ borderRadius: '8px' }} />
</ConfigSection>
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB