using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; /// /// Per-download-client configuration for the orphaned files scanner. /// public sealed record OrphanedFilesConfig : IConfig { /// /// Unique identifier for this config row. /// [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; set; } = Guid.NewGuid(); /// /// Owning download client identifier. /// public Guid DownloadClientConfigId { get; set; } /// /// Navigation back to the owning download client. /// public DownloadClientConfig DownloadClientConfig { get; set; } = null!; /// /// Whether the orphaned files scanner is enabled for this client. /// public bool Enabled { get; set; } /// /// Absolute paths to scan for orphaned files. Each top-level entry is /// checked against the client's active torrents. /// public List ScanDirectories { get; set; } = []; /// /// Destination directory where orphaned entries are moved. /// [Required] public string OrphanedDirectory { get; set; } = string.Empty; /// /// Glob patterns that exclude entries from being treated as orphaned /// (e.g. "*.nfo", ".DS_Store"). /// public List ExcludePatterns { get; set; } = []; /// /// Minimum age in hours an entry must have before it can be considered /// orphaned. Protects in-flight downloads that the client has not yet /// registered as a torrent. Set to 0 to disable the age check. /// [Range(0, int.MaxValue)] public int MinFileAgeHours { get; set; } = 24; /// /// If set, entries in older than this many /// hours are permanently deleted. Null leaves them indefinitely. /// [Range(1, int.MaxValue)] public int? PurgeAfterHours { get; set; } /// /// Self-validation with no cross-client checks. /// public void Validate() => Validate([], []); /// /// Validates this config and ensures its scan/orphaned paths do not /// overlap with any sibling client's orphaned-files config or another /// client's download directory target. /// public void Validate( IReadOnlyList siblings, IReadOnlyList? otherDownloadClients = null) { otherDownloadClients ??= []; if (!Enabled) { return; } if (ScanDirectories.Count == 0) { throw new ValidationException("At least one scan directory is required when orphaned files cleanup is enabled for this client"); } if (string.IsNullOrWhiteSpace(OrphanedDirectory)) { throw new ValidationException("Orphaned directory is required when orphaned files cleanup is enabled for this client"); } foreach (var scanDir in ScanDirectories) { var normalized = NormalizePath(scanDir); foreach (var sibling in siblings) { foreach (var otherScanDir in sibling.ScanDirectories) { CheckOverlap(normalized, NormalizePath(otherScanDir), "scan directory", "another client's scan directory"); } if (!string.IsNullOrWhiteSpace(sibling.OrphanedDirectory)) { CheckOverlap(normalized, NormalizePath(sibling.OrphanedDirectory), "scan directory", "another client's orphaned directory"); } } foreach (var otherClient in otherDownloadClients) { if (!string.IsNullOrWhiteSpace(otherClient.DownloadDirectoryTarget)) { CheckOverlap(normalized, NormalizePath(otherClient.DownloadDirectoryTarget), "scan directory", $"another client's download directory ({otherClient.Name})"); } } } var normalizedOrphaned = NormalizePath(OrphanedDirectory); var sep = Path.DirectorySeparatorChar.ToString(); foreach (var scanDir in ScanDirectories) { var normalizedScan = NormalizePath(scanDir); if (string.Equals(normalizedScan, normalizedOrphaned, StringComparison.OrdinalIgnoreCase)) { throw new ValidationException( $"Orphaned directory '{normalizedOrphaned}' cannot equal scan directory '{normalizedScan}'."); } if (normalizedScan.StartsWith(normalizedOrphaned + sep, StringComparison.OrdinalIgnoreCase)) { throw new ValidationException( $"Orphaned directory '{normalizedOrphaned}' cannot be an ancestor of scan directory '{normalizedScan}'."); } } foreach (var sibling in siblings) { foreach (var otherScanDir in sibling.ScanDirectories) { CheckOverlap(normalizedOrphaned, NormalizePath(otherScanDir), "orphaned directory", "another client's scan directory"); } if (!string.IsNullOrWhiteSpace(sibling.OrphanedDirectory)) { CheckOverlap(normalizedOrphaned, NormalizePath(sibling.OrphanedDirectory), "orphaned directory", "another client's orphaned directory"); } } foreach (var otherClient in otherDownloadClients) { if (!string.IsNullOrWhiteSpace(otherClient.DownloadDirectoryTarget)) { CheckOverlap(normalizedOrphaned, NormalizePath(otherClient.DownloadDirectoryTarget), "orphaned directory", $"another client's download directory ({otherClient.Name})"); } } } private static void CheckOverlap(string a, string b, string aLabel, string bLabel) { var sep = Path.DirectorySeparatorChar.ToString(); if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase) || a.StartsWith(b + sep, StringComparison.OrdinalIgnoreCase) || b.StartsWith(a + sep, StringComparison.OrdinalIgnoreCase)) { throw new ValidationException( $"Path overlap detected: {aLabel} '{a}' overlaps with {bLabel} '{b}'. Scan directories and orphaned directories must not overlap across clients."); } } private static string NormalizePath(string path) => string.Join(Path.DirectorySeparatorChar, path.Split(['\\', '/'])) .TrimEnd(Path.DirectorySeparatorChar); }