using System.Collections.Concurrent; using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.HealthCheck; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Files; using Cleanuparr.Infrastructure.Features.MalwareBlocker; using Cleanuparr.Infrastructure.Http; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.DownloadClient; /// /// Base download client implementation, shared by every protocol. /// Torrent-only behaviour (seeding, hardlinks, slow/stall rules) lives in . /// public abstract class DownloadService : IQueueCheckCapable, IFileBlockingCapable, IOrphanClaimsCapable { protected readonly ILogger _logger; protected readonly IFilenameEvaluator _filenameEvaluator; protected readonly IDryRunInterceptor _dryRunInterceptor; protected readonly IEventPublisher _eventPublisher; protected readonly IBlocklistProvider _blocklistProvider; protected readonly HttpClient _httpClient; protected readonly DownloadClientConfig _downloadClientConfig; protected readonly TimeProvider _timeProvider; protected DownloadService( ILogger logger, IFilenameEvaluator filenameEvaluator, IDryRunInterceptor dryRunInterceptor, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, TimeProvider timeProvider ) { _logger = logger; _filenameEvaluator = filenameEvaluator; _dryRunInterceptor = dryRunInterceptor; _eventPublisher = eventPublisher; _blocklistProvider = blocklistProvider; _downloadClientConfig = downloadClientConfig; _httpClient = httpClientProvider.CreateClient(downloadClientConfig); _timeProvider = timeProvider; } public DownloadClientConfig ClientConfig => _downloadClientConfig; protected void SetDownloadClientContext() { ContextProvider.SetDownloadClient(_downloadClientConfig); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { } public abstract Task LoginAsync(); public abstract Task HealthCheckAsync(); /// public abstract Task ShouldRemoveFromArrQueueAsync(string downloadId, IReadOnlyList ignoredDownloads); /// public abstract Task> GetAllDownloadsLite(); /// public abstract Task> GetClaimedPathsAsync(IReadOnlyList downloads); /// /// Rejects a torrent list that lost any row the client reported. /// /// Rows the client sent. /// Rows that survived parsing and filtering. /// At least one reported row was unusable. protected void ThrowIfTorrentListCollapsed(int reportedCount, int usableCount) { if (reportedCount == usableCount) { return; } throw new InvalidOperationException($"{_downloadClientConfig.Name} reported {reportedCount} torrents, only {usableCount} usable"); } protected async Task> BuildClaimedPathsAsync( IReadOnlyList downloads, Func>> resolveRelativeFilePaths) { HashSet claimed = new(StringComparer.OrdinalIgnoreCase); foreach (IDownloadItem torrent in downloads) { IReadOnlyCollection relativeFilePaths; try { relativeFilePaths = await resolveRelativeFilePaths(torrent); } catch (Exception ex) { _logger.LogDebug(ex, "failed to resolve files, falling back to name | {name}", torrent.Name); relativeFilePaths = []; } foreach (string path in BuildClaimedPaths(torrent, relativeFilePaths)) { claimed.Add(path); } } return claimed.ToList(); } /// /// The top-level entries a download occupies. /// private IReadOnlyList BuildClaimedPaths(IDownloadItem torrent, IReadOnlyCollection relativeFilePaths) { List claimed = []; if (string.IsNullOrEmpty(torrent.SavePath)) { return claimed; } claimed.Add(RemapAndTrim(torrent.SavePath)); IReadOnlyCollection sources = relativeFilePaths; if (sources.Count == 0 && !string.IsNullOrEmpty(torrent.Name)) { sources = [torrent.Name]; } foreach (string relativePath in sources) { string firstSegment = FirstSegment(relativePath); if (!string.IsNullOrEmpty(firstSegment)) { claimed.Add(RemapAndTrim(Path.Combine(torrent.SavePath, firstSegment))); } } return claimed; } private static string FirstSegment(string relativePath) { string[] parts = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); return parts.Length > 0 ? parts[0] : string.Empty; } protected string RemapAndTrim(string path) => PathHelper .NormalizeAndRemap(path, _downloadClientConfig.DownloadDirectorySource, _downloadClientConfig.DownloadDirectoryTarget) .TrimEnd(Path.DirectorySeparatorChar); /// /// Counts unwanted files and collects the indices to block. /// Don't yield a file the client excludes from the count, such as one with no index. /// Deluge and rTorrent validate the bare filename but log the relative path. /// With on, the first unwanted file sets DeleteImmediately and ends the scan. /// protected (List UnwantedIndices, long TotalFiles, long TotalUnwantedFiles, bool DeleteImmediately) ScanFilesForBlocking( IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> files, BlocklistType blocklistType, ConcurrentBag patterns, ConcurrentBag regexes, bool deleteIfAnyFileBlocked) { List unwantedIndices = []; long totalFiles = 0; long totalUnwantedFiles = 0; foreach ((int index, string validationName, string logName, FileBlockAction action) in files) { totalFiles++; if (action is FileBlockAction.AlreadySkipped) { _logger.LogTrace("File is already skipped | {File}", logName); totalUnwantedFiles++; continue; } if (_filenameEvaluator.IsValid(validationName, blocklistType, patterns, regexes)) { _logger.LogTrace("File is valid | {File}", logName); continue; } _logger.LogInformation("unwanted file found | {File}", logName); totalUnwantedFiles++; if (deleteIfAnyFileBlocked) { return (unwantedIndices, totalFiles, totalUnwantedFiles, true); } unwantedIndices.Add(index); } return (unwantedIndices, totalFiles, totalUnwantedFiles, false); } /// /// Scans the files, sets the removal verdict on and marks the unwanted files as skipped. /// /// Marks the given file indices as skipped in the client. protected async Task ApplyFileBlockingAsync( BlockFilesResult result, string name, IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> files, bool deleteIfAnyFileBlocked, Func, Task> markFilesAsSkipped) { InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); (List unwantedIndices, long totalFiles, long totalUnwantedFiles, bool deleteImmediately) = ScanFilesForBlocking(files, blocklistType, patterns, regexes, deleteIfAnyFileBlocked); if (deleteImmediately) { _logger.LogDebug("at least one file is blocked for {Name}", name); result.ShouldRemove = true; result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; return; } if (unwantedIndices.Count is 0) { _logger.LogDebug("No unwanted files found for {Name}", name); return; } if (totalUnwantedFiles == totalFiles) { _logger.LogDebug("All files are blocked for {Name}", name); result.ShouldRemove = true; result.DeleteReason = DeleteReason.AllFilesBlocked; } _logger.LogDebug("Marking {Count} unwanted files as skipped for {Name}", unwantedIndices.Count, name); await _dryRunInterceptor.InterceptAsync(() => MarkFilesAsSkipped(name, unwantedIndices, markFilesAsSkipped)); } /// /// A failed priority update leaves the verdict in place. /// private async Task MarkFilesAsSkipped(string name, List unwantedIndices, Func, Task> markFilesAsSkipped) { try { await markFilesAsSkipped(unwantedIndices); } catch (Exception exception) { _logger.LogError(exception, "Failed to mark files as skipped | {Name}", name); } } /// public abstract Task BlockUnwantedFilesAsync(string downloadId, IReadOnlyList ignoredDownloads); /// /// Deletes the specified download from the download client. /// Each client implementation handles the deletion according to its API requirements. /// /// The download item to delete /// Whether to delete the source files along with the download public abstract Task DeleteDownload(IDownloadItem item, bool deleteSourceFiles); protected bool TryDeleteFiles(string path, bool failOnNotFound) { if (string.IsNullOrEmpty(path)) { _logger.LogTrace("File path is null or empty"); if (failOnNotFound) { return false; } return true; } if (Directory.Exists(path)) { try { Directory.Delete(path, true); return true; } catch (Exception ex) { _logger.LogError(ex, "Failed to delete directory: {path}", path); return false; } } if (File.Exists(path)) { try { File.Delete(path); return true; } catch (Exception ex) { _logger.LogError(ex, "Failed to delete file: {path}", path); return false; } } _logger.LogTrace("File path to delete not found: {path}", path); if (failOnNotFound) { return false; } return true; } }