using System.Collections.Concurrent; using System.Text.RegularExpressions; using Common.Attributes; using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadCleaner; using Common.Configuration.DownloadClient; using Common.Configuration.QueueCleaner; using Common.Exceptions; using Common.Helpers; using Domain.Enums; using Infrastructure.Extensions; using Infrastructure.Interceptors; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.Context; using Infrastructure.Verticals.Files; using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using QBittorrent.Client; namespace Infrastructure.Verticals.DownloadClient.QBittorrent; public class QBitService : DownloadService, IQBitService { private readonly QBitConfig _config; private readonly QBittorrentClient _client; public QBitService( ILogger logger, IHttpClientFactory httpClientFactory, IOptions config, IOptions queueCleanerConfig, IOptions contentBlockerConfig, IOptions downloadCleanerConfig, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor, IHardLinkFileService hardLinkFileService ) : base( logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService ) { _config = config.Value; _config.Validate(); UriBuilder uriBuilder = new(_config.Url); uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase) ? uriBuilder.Path : $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/')}"; _client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), uriBuilder.Uri); } public override async Task LoginAsync() { if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password)) { return; } await _client.LoginAsync(_config.Username, _config.Password); } /// public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) { StalledResult result = new(); TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) .FirstOrDefault(); if (download is null) { _logger.LogDebug("failed to find torrent {hash} in the download client", hash); return result; } IReadOnlyList trackers = await GetTrackersAsync(hash); if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true)) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); return result; } TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); if (torrentProperties is null) { _logger.LogDebug("failed to find torrent properties {hash} in the download client", hash); return result; } result.IsPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) && bool.TryParse(dictValue?.ToString(), out bool boolValue) && boolValue; IReadOnlyList? files = await _client.GetTorrentContentsAsync(hash); if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) { result.ShouldRemove = true; // if all files were blocked by qBittorrent if (download is { CompletionOn: not null, Downloaded: null or 0 }) { result.DeleteReason = DeleteReason.AllFilesSkippedByQBit; return result; } // remove if all files are unwanted result.DeleteReason = DeleteReason.AllFilesSkipped; return result; } // remove if download is stuck (result.ShouldRemove, result.DeleteReason) = await IsItemStuckAndShouldRemove(download, result.IsPrivate); return result; } /// public override async Task BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType, ConcurrentBag patterns, ConcurrentBag regexes, IReadOnlyList ignoredDownloads ) { TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) .FirstOrDefault(); BlockFilesResult result = new(); if (download is null) { _logger.LogDebug("failed to find torrent {hash} in the download client", hash); return result; } IReadOnlyList trackers = await GetTrackersAsync(hash); if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true)) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); return result; } TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); if (torrentProperties is null) { _logger.LogDebug("failed to find torrent properties {hash} in the download client", hash); return result; } bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) && bool.TryParse(dictValue?.ToString(), out bool boolValue) && boolValue; result.IsPrivate = isPrivate; if (_contentBlockerConfig.IgnorePrivate && isPrivate) { // ignore private trackers _logger.LogDebug("skip files check | download is private | {name}", download.Name); return result; } IReadOnlyList? files = await _client.GetTorrentContentsAsync(hash); if (files is null) { return result; } List unwantedFiles = []; long totalFiles = 0; long totalUnwantedFiles = 0; foreach (TorrentContent file in files) { if (!file.Index.HasValue) { continue; } totalFiles++; if (file.Priority is TorrentContentPriority.Skip) { totalUnwantedFiles++; continue; } if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes)) { continue; } _logger.LogInformation("unwanted file found | {file}", file.Name); unwantedFiles.Add(file.Index.Value); totalUnwantedFiles++; } if (unwantedFiles.Count is 0) { return result; } if (totalUnwantedFiles == totalFiles) { // Skip marking files as unwanted. The download will be removed completely. result.ShouldRemove = true; return result; } foreach (int fileIndex in unwantedFiles) { await _dryRunInterceptor.InterceptAsync(SkipFile, hash, fileIndex); } return result; } /// public override async Task?> GetSeedingDownloads() => (await _client.GetTorrentListAsync(new() { Filter = TorrentListFilter.Seeding })) ?.Where(x => !string.IsNullOrEmpty(x.Hash)) .Cast() .ToList(); /// public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => downloads ?.Cast() .Where(x => !string.IsNullOrEmpty(x.Hash)) .Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .Cast() .ToList(); /// public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => downloads ?.Cast() .Where(x => !string.IsNullOrEmpty(x.Hash)) .Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .Cast() .ToList(); /// public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, IReadOnlyList ignoredDownloads) { if (downloads?.Count is null or 0) { return; } foreach (TorrentInfo download in downloads) { if (string.IsNullOrEmpty(download.Hash)) { continue; } if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) { _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); continue; } IReadOnlyList trackers = await GetTrackersAsync(download.Hash); if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)))) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); continue; } CleanCategory? category = categoriesToClean .FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase)); if (category is null) { continue; } if (!_downloadCleanerConfig.DeletePrivate) { TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash); if (torrentProperties is null) { _logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name); return; } bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) && bool.TryParse(dictValue?.ToString(), out bool boolValue) && boolValue; if (isPrivate) { _logger.LogDebug("skip | download is private | {name}", download.Name); continue; } } ContextProvider.Set("downloadName", download.Name); ContextProvider.Set("hash", download.Hash); SeedingCheckResult result = ShouldCleanDownload(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, category); if (!result.ShouldClean) { continue; } await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash); _logger.LogInformation( "download cleaned | {reason} reached | {name}", result.Reason is CleanReason.MaxRatioReached ? "MAX_RATIO & MIN_SEED_TIME" : "MAX_SEED_TIME", download.Name ); await _notifier.NotifyDownloadCleaned(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, category.Name, result.Reason); } } public override async Task CreateCategoryAsync(string name) { IReadOnlyDictionary? existingCategories = await _client.GetCategoriesAsync(); if (existingCategories.Any(x => x.Value.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) { return; } await _client.AddCategoryAsync(name); } public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) { if (downloads?.Count is null or 0) { return; } if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)) { _hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir); } foreach (TorrentInfo download in downloads) { if (string.IsNullOrEmpty(download.Hash)) { continue; } if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) { _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); continue; } IReadOnlyList trackers = await GetTrackersAsync(download.Hash); if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)))) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); continue; } IReadOnlyList? files = await _client.GetTorrentContentsAsync(download.Hash); if (files is null) { _logger.LogDebug("failed to find files for {name}", download.Name); continue; } ContextProvider.Set("downloadName", download.Name); ContextProvider.Set("hash", download.Hash); bool hasHardlinks = false; foreach (TorrentContent file in files) { if (!file.Index.HasValue) { _logger.LogDebug("skip | file index is null for {name}", download.Name); hasHardlinks = true; break; } string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/'])); long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)); if (hardlinkCount < 0) { _logger.LogDebug("skip | could not get file properties | {file}", filePath); hasHardlinks = true; break; } if (hardlinkCount > 0) { hasHardlinks = true; break; } } if (hasHardlinks) { _logger.LogDebug("skip | download has hardlinks | {name}", download.Name); continue; } await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory); _logger.LogInformation("category changed for {name}", download.Name); await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory); download.Category = _downloadCleanerConfig.UnlinkedTargetCategory; } } /// [DryRunSafeguard] public override async Task DeleteDownload(string hash) { await _client.DeleteAsync(hash, deleteDownloadedData: true); } [DryRunSafeguard] protected virtual async Task SkipFile(string hash, int fileIndex) { await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip); } [DryRunSafeguard] protected virtual async Task ChangeCategory(string hash, string newCategory) { await _client.SetTorrentCategoryAsync([hash], newCategory); } public override void Dispose() { _client.Dispose(); } private async Task<(bool, DeleteReason)> IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate) { if (_queueCleanerConfig.StalledMaxStrikes is 0) { return (false, default); } if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate) { // ignore private trackers _logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name); return (false, default); } if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata and not TorrentState.ForcedFetchingMetadata) { // ignore other states return (false, default); } ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0); if (torrent.State is TorrentState.StalledDownload) { return (await StrikeAndCheckLimit(torrent.Hash, torrent.Name, StrikeType.Stalled), DeleteReason.Stalled); } return (await StrikeAndCheckLimit(torrent.Hash, torrent.Name, StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata); } private async Task> GetTrackersAsync(string hash) { return (await _client.GetTorrentTrackersAsync(hash)) .Where(x => !x.Url.ToString().Contains("**")) .ToList(); } }