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