combine arr configs #1

This commit is contained in:
Flaminel
2025-06-15 21:15:50 +03:00
parent 62eee94497
commit bf37668dcb
74 changed files with 486 additions and 703 deletions

View File

@@ -1,4 +1,4 @@
using Common.Configuration.Arr;
using Data.Models.Configuration.Arr;
namespace Data.Models.Arr;

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Common.Configuration;
using Data.Enums;
namespace Data.Models.Configuration.Arr;
public class ArrConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public required InstanceType Type { get; set; }
public bool Enabled { get; set; }
public short FailedImportMaxStrikes { get; set; } = -1;
public List<ArrInstance> Instances { get; set; } = [];
public void Validate()
{
}
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Common.Attributes;
namespace Data.Models.Configuration.Arr;
public sealed class ArrInstance
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ArrConfigId { get; set; }
public ArrConfig ArrConfig { get; set; }
public required string Name { get; set; }
public required Uri Url { get; set; }
[SensitiveData]
public required string ApiKey { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace Data.Models.Configuration.Arr;
public enum SonarrSearchType
{
Episode,
Season,
Series
}

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Common.Configuration;
using ValidationException = Common.Exceptions.ValidationException;
namespace Data.Models.Configuration.DownloadCleaner;
public sealed record CleanCategory : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
public required string Name { get; init; }
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public required double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public required double MinSeedTime { get; init; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public required double MaxSeedTime { get; init; } = -1;
public void Validate()
{
if (string.IsNullOrEmpty(Name.Trim()))
{
throw new ValidationException("Category name can not be empty");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException("Both max ratio and max seed time are disabled");
}
if (MinSeedTime < 0)
{
throw new ValidationException("Min seed time can not be negative");
}
}
}

View File

@@ -0,0 +1,85 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Common.Configuration;
using ValidationException = Common.Exceptions.ValidationException;
namespace Data.Models.Configuration.DownloadCleaner;
public sealed record DownloadCleanerConfig : IJobConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0 0 * * * ?";
/// <summary>
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule
/// </summary>
public bool UseAdvancedScheduling { get; init; }
public List<CleanCategory> Categories { get; init; } = [];
public bool DeletePrivate { get; init; }
/// <summary>
/// Indicates whether unlinked download handling is enabled
/// </summary>
public bool UnlinkedEnabled { get; init; } = false;
public string UnlinkedTargetCategory { get; init; } = "cleanuparr-unlinked";
public bool UnlinkedUseTag { get; init; }
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
public List<string> UnlinkedCategories { get; init; } = [];
public void Validate()
{
if (!Enabled)
{
return;
}
if (Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
{
throw new ValidationException("duplicated clean categories found");
}
Categories.ForEach(x => x.Validate());
// Only validate unlinked settings if unlinked handling is enabled
if (!UnlinkedEnabled)
{
return;
}
if (string.IsNullOrEmpty(UnlinkedTargetCategory))
{
throw new ValidationException("unlinked target category is required");
}
if (UnlinkedCategories?.Count is null or 0)
{
throw new ValidationException("no unlinked categories configured");
}
if (UnlinkedCategories.Contains(UnlinkedTargetCategory))
{
throw new ValidationException($"The unlinked target category should not be present in unlinked categories");
}
if (UnlinkedCategories.Any(string.IsNullOrEmpty))
{
throw new ValidationException("empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
{
throw new ValidationException($"{UnlinkedIgnoredRootDir} root directory does not exist");
}
}
}

View File

@@ -0,0 +1,85 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Common.Attributes;
using Common.Enums;
using Common.Exceptions;
namespace Common.Configuration;
/// <summary>
/// Configuration for a specific download client
/// </summary>
[Table("download_clients")]
public sealed record DownloadClientConfig
{
/// <summary>
/// Unique identifier for this client
/// </summary>
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// Whether this client is enabled
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Friendly name for this client
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Type name of download client
/// </summary>
public required DownloadClientTypeName TypeName { get; init; }
/// <summary>
/// Type of download client
/// </summary>
public required DownloadClientType Type { get; init; }
/// <summary>
/// Host address for the download client
/// </summary>
public Uri? Host { get; init; }
/// <summary>
/// Username for authentication
/// </summary>
[SensitiveData]
public string? Username { get; init; }
/// <summary>
/// Password for authentication
/// </summary>
[SensitiveData]
public string? Password { get; init; }
/// <summary>
/// The base URL path component, used by clients like Transmission and Deluge
/// </summary>
public string? UrlBase { get; init; }
/// <summary>
/// The computed full URL for the client
/// </summary>
[NotMapped]
[JsonIgnore]
public Uri Url => new($"{Host?.ToString().TrimEnd('/')}/{UrlBase.TrimStart('/').TrimEnd('/')}");
/// <summary>
/// Validates the configuration
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new ValidationException($"Client name cannot be empty for client ID: {Id}");
}
if (Host is null && TypeName is not DownloadClientTypeName.Usenet)
{
throw new ValidationException($"Host cannot be empty for client ID: {Id}");
}
}
}

View File

@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Common.Configuration;
using Common.Enums;
using Serilog.Events;
using ValidationException = Common.Exceptions.ValidationException;
namespace Data.Models.Configuration.General;
public sealed record GeneralConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
public bool DryRun { get; set; }
public ushort HttpMaxRetries { get; set; }
public ushort HttpTimeout { get; set; } = 100;
public CertificateValidationType HttpCertificateValidation { get; set; } = CertificateValidationType.Enabled;
public bool SearchEnabled { get; set; } = true;
public ushort SearchDelay { get; set; } = 30;
public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information;
public string EncryptionKey { get; set; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; set; } = [];
public void Validate()
{
if (HttpTimeout is 0)
{
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Common.Configuration;
public interface IConfig
{
void Validate();
}

View File

@@ -0,0 +1,13 @@
namespace Common.Configuration;
public interface IJobConfig : IConfig
{
bool Enabled { get; init; }
string CronExpression { get; init; }
/// <summary>
/// Indicates whether to use the CronExpression directly (true) or convert from JobSchedule (false)
/// </summary>
bool UseAdvancedScheduling { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace Data.Models.Configuration.Notification;
public sealed record AppriseConfig : NotificationConfig
{
public Uri? Url { get; init; }
public string? Key { get; init; }
public override bool IsValid()
{
if (Url is null)
{
return false;
}
if (string.IsNullOrEmpty(Key?.Trim()))
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,23 @@
namespace Data.Models.Configuration.Notification;
public sealed record NotifiarrConfig : NotificationConfig
{
public string? ApiKey { get; init; }
public string? ChannelId { get; init; }
public override bool IsValid()
{
if (string.IsNullOrEmpty(ApiKey?.Trim()))
{
return false;
}
if (string.IsNullOrEmpty(ChannelId?.Trim()))
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.Models.Configuration.Notification;
public abstract record NotificationConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
public bool IsEnabled =>
OnFailedImportStrike ||
OnStalledStrike ||
OnSlowStrike ||
OnQueueItemDeleted ||
OnDownloadCleaned ||
OnCategoryChanged;
public abstract bool IsValid();
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.Models.Configuration.QueueCleaner;
/// <summary>
/// Settings for a blocklist
/// </summary>
[ComplexType]
public sealed record BlocklistSettings
{
public BlocklistType BlocklistType { get; init; }
public string? BlocklistPath { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace Data.Models.Configuration.QueueCleaner;
public enum BlocklistType
{
Blacklist,
Whitelist
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.Models.Configuration.QueueCleaner;
[ComplexType]
public sealed record ContentBlockerConfig
{
public bool Enabled { get; init; }
public bool IgnorePrivate { get; init; }
public bool DeletePrivate { get; init; }
public BlocklistSettings Sonarr { get; init; } = new();
public BlocklistSettings Radarr { get; init; } = new();
public BlocklistSettings Lidarr { get; init; } = new();
public void Validate()
{
}
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
using Common.Exceptions;
namespace Data.Models.Configuration.QueueCleaner;
[ComplexType]
public sealed record FailedImportConfig
{
public ushort MaxStrikes { get; init; }
public bool IgnorePrivate { get; init; }
public bool DeletePrivate { get; init; }
public IReadOnlyList<string> IgnoredPatterns { get; init; } = [];
public void Validate()
{
if (MaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for failed imports max strikes must be 3");
}
}
}

View File

@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Common.Configuration;
namespace Data.Models.Configuration.QueueCleaner;
public sealed record QueueCleanerConfig : IJobConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0 0/5 * * * ?";
/// <summary>
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule
/// </summary>
public bool UseAdvancedScheduling { get; init; } = false;
public FailedImportConfig FailedImport { get; init; } = new();
public StalledConfig Stalled { get; init; } = new();
public SlowConfig Slow { get; init; } = new();
public ContentBlockerConfig ContentBlocker { get; init; } = new();
public void Validate()
{
FailedImport.Validate();
Stalled.Validate();
Slow.Validate();
ContentBlocker.Validate();
}
}

View File

@@ -0,0 +1,65 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Common.CustomDataTypes;
using Common.Exceptions;
namespace Data.Models.Configuration.QueueCleaner;
[ComplexType]
public sealed record SlowConfig
{
public ushort MaxStrikes { get; init; }
public bool ResetStrikesOnProgress { get; init; }
public bool IgnorePrivate { get; init; }
public bool DeletePrivate { get; init; }
public string MinSpeed { get; init; } = string.Empty;
[JsonIgnore]
public ByteSize MinSpeedByteSize => string.IsNullOrEmpty(MinSpeed) ? new ByteSize(0) : ByteSize.Parse(MinSpeed);
public double MaxTime { get; init; }
public string IgnoreAboveSize { get; init; } = string.Empty;
[JsonIgnore]
public ByteSize? IgnoreAboveSizeByteSize => string.IsNullOrEmpty(IgnoreAboveSize) ? null : ByteSize.Parse(IgnoreAboveSize);
public void Validate()
{
if (MaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for slow max strikes must be 3");
}
if (MaxStrikes > 0)
{
bool isSpeedSet = !string.IsNullOrEmpty(MinSpeed);
if (isSpeedSet && ByteSize.TryParse(MinSpeed, out _) is false)
{
throw new ValidationException("invalid value for slow min speed");
}
if (MaxTime < 0)
{
throw new ValidationException("invalid value for slow max time");
}
if (!isSpeedSet && MaxTime is 0)
{
throw new ValidationException("either slow min speed or slow max time must be set");
}
bool isIgnoreAboveSizeSet = !string.IsNullOrEmpty(IgnoreAboveSize);
if (isIgnoreAboveSizeSet && ByteSize.TryParse(IgnoreAboveSize, out _) is false)
{
throw new ValidationException($"invalid value for slow ignore above size: {IgnoreAboveSize}");
}
}
}
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations.Schema;
using Common.Exceptions;
namespace Data.Models.Configuration.QueueCleaner;
[ComplexType]
public sealed record StalledConfig
{
public ushort MaxStrikes { get; init; }
public bool ResetStrikesOnProgress { get; init; }
public bool IgnorePrivate { get; init; }
public bool DeletePrivate { get; init; }
public ushort DownloadingMetadataMaxStrikes { get; init; }
public void Validate()
{
if (MaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for stalled max strikes must be 3");
}
if (DownloadingMetadataMaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for downloading metadata max strikes must be 3");
}
}
}

View File

@@ -1,4 +1,4 @@
using Common.Configuration.Arr;
using Data.Models.Configuration.Arr;
namespace Data.Models.Sonarr;