diff --git a/Directory.Packages.props b/Directory.Packages.props index c55448c6d..2adae1151 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,8 @@ true true 10.0.7 + true + $(NoWarn);1591 @@ -26,7 +28,7 @@ - + diff --git a/listenarr.api/Controllers/ManualImportController.cs b/listenarr.api/Controllers/ManualImportController.cs index 37dccd6b9..c0f3acbc1 100644 --- a/listenarr.api/Controllers/ManualImportController.cs +++ b/listenarr.api/Controllers/ManualImportController.cs @@ -572,7 +572,7 @@ private async Task GenerateManualImportPathAsync(Audiobook audiobook, Au relativePath = string.IsNullOrWhiteSpace(folderRelative) ? fileRelative - : CombineWithOptionalBase(folderRelative, fileRelative); + : FileUtils.CombineWithOptionalBase(folderRelative, fileRelative); } if ((string.IsNullOrWhiteSpace(folderPattern) || isCustomBasePath) @@ -591,33 +591,7 @@ private async Task GenerateManualImportPathAsync(Audiobook audiobook, Au return string.IsNullOrWhiteSpace(basePath) ? relativePath - : CombineWithOptionalBase(basePath, relativePath); - } - - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; + : FileUtils.CombineWithOptionalBase(basePath, relativePath); } private static List BuildOrderedItems(IEnumerable items) @@ -751,7 +725,7 @@ private async Task ImportCompanionFilesAsync( continue; } - var destinationPath = CombineWithOptionalBase(destinationRoot, relativePath); + var destinationPath = FileUtils.CombineWithOptionalBase(destinationRoot, relativePath); var success = await _fileMover.PerformActionOn(request.Action, companionFile, destinationPath); if (success) diff --git a/listenarr.application/Audiobooks/RenameService.cs b/listenarr.application/Audiobooks/RenameService.cs index 3791266d8..2fad18bbe 100644 --- a/listenarr.application/Audiobooks/RenameService.cs +++ b/listenarr.application/Audiobooks/RenameService.cs @@ -424,14 +424,14 @@ private string BuildExpectedPath(Audiobook audiobook, PreviewFileEntry file, App var folderRelative = _fileNamingService.ApplyNamingPattern(folderPattern, variables, false); var fileRelative = _fileNamingService.ApplyNamingPattern(effectiveFilePattern, variables, !PatternAllowsSubfolders(effectiveFilePattern)); if (isMultiFile && !patternHasNumberTokens) fileRelative = FileUtils.AppendSequenceSuffix(fileRelative, file.SequenceNumber); - relativePath = string.IsNullOrWhiteSpace(folderRelative) ? fileRelative : CombineWithOptionalBase(folderRelative, fileRelative); + relativePath = string.IsNullOrWhiteSpace(folderRelative) ? fileRelative : FileUtils.CombineWithOptionalBase(folderRelative, fileRelative); } if ((string.IsNullOrWhiteSpace(folderPattern) || isCustomBasePath) && isMultiFile && !patternHasNumberTokens) relativePath = FileUtils.AppendSequenceSuffix(relativePath, file.SequenceNumber); if (!relativePath.EndsWith(file.Extension, StringComparison.OrdinalIgnoreCase)) relativePath += file.Extension; - return string.IsNullOrWhiteSpace(basePath) ? NormalizePath(relativePath) : NormalizePath(CombineWithOptionalBase(basePath, relativePath)); + return string.IsNullOrWhiteSpace(basePath) ? NormalizePath(relativePath) : NormalizePath(FileUtils.CombineWithOptionalBase(basePath, relativePath)); } private static Dictionary BuildNamingVariables(Audiobook audiobook, string? folderPattern, string? filePattern, int sequenceNumber, bool isMultiFile) @@ -527,14 +527,6 @@ private static string ComputeCommonBasePath(IEnumerable paths) return string.IsNullOrWhiteSpace(common) ? string.Empty : NormalizePath(common); } - private static string CombineWithOptionalBase(string basePath, string relativePath) - { - var safeRelative = relativePath ?? string.Empty; - if (string.IsNullOrWhiteSpace(basePath)) return safeRelative; - if (Path.IsPathRooted(safeRelative)) return safeRelative; - return Path.Join(basePath, safeRelative); - } - private static string CombineRelativePath(string basePath, string relativePath) { var safeRelative = relativePath ?? string.Empty; diff --git a/listenarr.application/Common/FileNamingService.cs b/listenarr.application/Common/FileNamingService.cs index 2fd97f5e1..4a2a92b6a 100644 --- a/listenarr.application/Common/FileNamingService.cs +++ b/listenarr.application/Common/FileNamingService.cs @@ -20,6 +20,7 @@ using System.Text; using System.Text.RegularExpressions; using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Listenarr.Domain.Models.Configurations; using Microsoft.Extensions.Logging; @@ -137,7 +138,7 @@ public async Task GenerateFilePathAsync( relativePath = string.IsNullOrWhiteSpace(folderRelative) ? fileRelative - : CombineWithOptionalBase(folderRelative, fileRelative); + : FileUtils.CombineWithOptionalBase(folderRelative, fileRelative); } // Ensure it has the correct extension @@ -149,7 +150,7 @@ public async Task GenerateFilePathAsync( // Combine with the provided output path var fullPath = string.IsNullOrWhiteSpace(outputPath) ? relativePath - : CombineWithOptionalBase(outputPath, relativePath); + : FileUtils.CombineWithOptionalBase(outputPath, relativePath); fullPath = EnsurePathWithinLimits(fullPath); @@ -559,32 +560,6 @@ public string EnsurePathWithinLimits(string fullPath) return result; } - - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } } } diff --git a/listenarr.application/Downloads/DownloadClientGateway.cs b/listenarr.application/Downloads/DownloadClientGateway.cs index d4b18367a..f48adff71 100644 --- a/listenarr.application/Downloads/DownloadClientGateway.cs +++ b/listenarr.application/Downloads/DownloadClientGateway.cs @@ -16,6 +16,8 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Mapping; using Listenarr.Application.Security; using Listenarr.Domain.Common; using Listenarr.Domain.Models; @@ -32,31 +34,21 @@ namespace Listenarr.Application.Downloads public class DownloadClientGateway( IRemotePathMappingService remotePathMappingService, IDownloadClientAdapterFactory factory, + IDownloadRepository downloadRepository, ILogger logger) : IDownloadClientGateway { internal IDownloadClientAdapter ResolveAdapter(DownloadClientConfiguration client) { - if (client == null) - { - throw new ArgumentNullException(nameof(client)); - } + ArgumentNullException.ThrowIfNull(client); - var attemptedKeys = new List { client.Id, client.Type }; - foreach (var key in attemptedKeys) + if (!string.IsNullOrWhiteSpace(client.Type)) { - if (string.IsNullOrWhiteSpace(key)) - { - continue; - } - try { - return factory.GetByIdOrType(key); + return factory.GetByType(client.Type); } catch (InvalidOperationException) { - // Try the next key. - continue; } } @@ -90,21 +82,13 @@ public Task RemoveAsync(DownloadClientConfiguration client, string id, boo public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) { - var adapter = ResolveAdapter(client); - var results = await adapter.GetQueueAsync(client, ct); - - List translatedResults = []; - foreach (QueueItem result in results) - { - translatedResults.Add(await TranslateQueueItemPathsAsync(client, result)); - } - return translatedResults; - } + var downloads = await downloadRepository.GetByClientAsync(client.Id); + var ids = GetExternalIds(downloads); - public Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) - { var adapter = ResolveAdapter(client); - return adapter.GetRecentHistoryAsync(client, limit, ct); + var items = await adapter.GetQueueAsync(client, ids, ct); + var tasks = items.Select(item => TranslateQueueItemPathsAsync(client, item)); + return [.. await Task.WhenAll(tasks)]; } public async Task MarkItemAsImportedAsync(DownloadClientConfiguration client, Download download, CancellationToken ct = default) @@ -131,6 +115,48 @@ public async Task GetQueueItemAsync( return await TranslateQueueItemPathsAsync(client, item); } + public async Task> FetchDownloadsAsync(DownloadClientConfiguration client, List downloads, CancellationToken ct = default) + { + var ids = GetExternalIds(downloads); + + var adapter = ResolveAdapter(client); + var items = await adapter.GetQueueAsync(client, ids!, ct); + var tasks = items.Select(item => TranslateQueueItemPathsAsync(client, item)); + items = [.. await Task.WhenAll(tasks)]; + + foreach (QueueItem item in items) + { + var download = downloads.FirstOrDefault(d => d.GetExternalId() == item.Id); + if (download == null) + { + continue; + } + + logger.LogDebug($"Found matching qBittorrent torrent for {download.Id}: {item.Title} (Hash: {item.Id}, Status: {item.Status}, Progress: {item.Progress:P2}, LocalPath: {item.LocalPath}, ContentPath: {item.ContentPath})"); + + // DIAGNOSTIC: Log detailed completion check values + logger.LogInformation($"Completion diagnostic for {download.Id}: Progress={item.Progress:F4} (>= 1.0? {item.Progress >= 1.0}), AmountLeft={item.Size - item.Downloaded} (== 0? {item.Size - item.Downloaded <= 0}), Status={item.Status}"); + + download = QueueItemConverter.UpdateFromQueueItem(download, item); + } + + return downloads; + } + + /// + /// Give the list of external IDs from a list of download + /// + /// + /// + private List GetExternalIds(List downloads) + { + return downloads + .Select(d => d.GetExternalId()) + .Where(id => id != null) + .ToHashSet() + .ToList()!; + } + /// /// Handles path mapping of queue item /// Make sure all path are localy accessible after processing and @@ -201,16 +227,5 @@ private async Task TranslateQueueItemPathsAsync(DownloadClientConfigu return item; } - - public async Task> FetchDownloadsAsync(DownloadClientConfiguration client, List downloads, CancellationToken ct = default) - { - var adapter = ResolveAdapter(client); - downloads = await adapter.FetchDownloadsAsync(client, downloads, ct); - foreach (Download download in downloads) - { - download.DownloadPath = await remotePathMappingService.TranslatePathAsync(client, download.DownloadPath); - } - return downloads; - } } } diff --git a/listenarr.application/Downloads/DownloadHashRetrievalService.cs b/listenarr.application/Downloads/DownloadHashRetrievalService.cs deleted file mode 100644 index b621ef99e..000000000 --- a/listenarr.application/Downloads/DownloadHashRetrievalService.cs +++ /dev/null @@ -1,244 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -using Listenarr.Application.Interfaces; -using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; -using Microsoft.Extensions.Logging; - -namespace Listenarr.Application.Downloads -{ - /// - /// Stage 4: Hash Retrieval Service with exponential backoff - /// - /// Problem: When a torrent/NZB is sent to a download client, the hash/ID isn't - /// immediately available. The client needs time to process the file. - /// - /// Solution: Retry with exponential backoff: - /// - 1st retry: 2 seconds after grab - /// - 2nd retry: 4 seconds (2^2) - /// - 3rd retry: 8 seconds (2^3) - /// - 4th retry: 16 seconds (2^4) - /// - 5th retry: 30 seconds (capped at 30s max) - /// - Maximum 10 retries over 60 seconds total - /// - public class DownloadHashRetrievalService - { - private readonly ILogger _logger; - private readonly IDownloadHistoryRepository _historyRepository; - private readonly Dictionary _adapters; - - // Retry configuration with exponential backoff - private const int MaxRetries = 10; - private const int MaxBackoffSeconds = 30; - private const int BaseBackoffSeconds = 2; - - public DownloadHashRetrievalService( - ILogger logger, - IDownloadHistoryRepository historyRepository, - IDownloadClientAdapter qbittorrentAdapter, - IDownloadClientAdapter transmissionAdapter, - IDownloadClientAdapter sabnzbdAdapter, - IDownloadClientAdapter nzbgetAdapter) - { - _logger = logger; - _historyRepository = historyRepository; - - // Map adapters by protocol type - _adapters = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["qbittorrent"] = qbittorrentAdapter, - ["transmission"] = transmissionAdapter, - ["sabnzbd"] = sabnzbdAdapter, - ["nzbget"] = nzbgetAdapter - }; - } - - /// - /// Attempt to retrieve download hash/ID from client for recently grabbed downloads - /// Returns the DownloadId (hash) if found, null otherwise - /// - public async Task TryRetrieveHashAsync( - DownloadClientItemQuery query, - DownloadClientConfiguration client, - CancellationToken ct = default) - { - if (query == null || client == null) - { - return null; - } - - // Check if we've exceeded retry limits - if (query.RetryCount >= MaxRetries) - { - _logger.LogWarning( - "Hash retrieval exceeded max retries ({MaxRetries}) for download: Title={Title}, Client={Client}", - MaxRetries, query.Title, client.Name); - return null; - } - - // Check if we're within the retry window (60 seconds from grab) - var elapsed = DateTime.UtcNow - query.AddedDate; - if (elapsed.TotalSeconds > 60) - { - _logger.LogWarning( - "Hash retrieval timeout (60s) exceeded for download: Title={Title}, Elapsed={Elapsed:F1}s", - query.Title, elapsed.TotalSeconds); - return null; - } - - // Calculate exponential backoff delay - var backoffSeconds = Math.Min( - MaxBackoffSeconds, - BaseBackoffSeconds * Math.Pow(2, query.RetryCount)); - - // Check if enough time has passed since last retry - if (query.LastRetry.HasValue) - { - var timeSinceLastRetry = DateTime.UtcNow - query.LastRetry.Value; - if (timeSinceLastRetry.TotalSeconds < backoffSeconds) - { - _logger.LogDebug( - "Skipping hash retrieval - backoff not elapsed. Title={Title}, Retry={RetryCount}, NextIn={NextIn:F1}s", - query.Title, query.RetryCount, backoffSeconds - timeSinceLastRetry.TotalSeconds); - return null; - } - } - - // Get the appropriate adapter - if (!_adapters.TryGetValue(client.Type, out var adapter)) - { - _logger.LogWarning("No adapter found for client type: {ClientType}", client.Type); - return null; - } - - // Skip hash retrieval for disabled clients - if (!client.IsEnabled) - { - _logger.LogDebug("Skipping hash retrieval for disabled client {ClientName}", client.Name); - return null; - } - - try - { - _logger.LogInformation( - "Attempting hash retrieval (retry {RetryCount}/{MaxRetries}) for: Title={Title}, Client={Client}, Backoff={Backoff:F1}s", - query.RetryCount + 1, MaxRetries, query.Title, client.Name, backoffSeconds); - - // Get all items from the client - var items = await adapter.GetItemsAsync(client, ct); - - // Try to find our download by title/name - var match = items.FirstOrDefault(item => - string.Equals(item.Title, query.Title, StringComparison.OrdinalIgnoreCase) || - TitleUtils.AreTitlesSimilarWithLevenstein(item.Title, query.Title)); - - if (match != null && !string.IsNullOrEmpty(match.DownloadId)) - { - _logger.LogInformation( - "✅ Hash retrieval successful (retry {RetryCount}): DownloadId={DownloadId}, Title={Title}, Match={MatchTitle}", - query.RetryCount + 1, match.DownloadId, query.Title, match.Title); - - // Record successful retrieval in history - await _historyRepository.AddAsync(new DownloadHistory - { - DownloadId = match.DownloadId, - EventType = DownloadHistoryEventType.Grabbed, - Status = match.Status, - EventDate = DateTime.UtcNow, - AudiobookId = query.AudiobookId, - DownloadClient = client.Name, - DownloadClientId = client.Id, - Protocol = query.Protocol, - Title = match.Title, - OutputPath = match.OutputPath, - Data = new Dictionary - { - ["HashRetrievalAttempt"] = query.RetryCount + 1, - ["HashRetrievalElapsedSeconds"] = elapsed.TotalSeconds, - ["OriginalTitle"] = query.Title - } - }, ct); - - return match.DownloadId; - } - - _logger.LogDebug( - "Hash not found yet (retry {RetryCount}/{MaxRetries}): Title={Title}, ItemsChecked={ItemCount}", - query.RetryCount + 1, MaxRetries, query.Title, items.Count); - - return null; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, - "Error during hash retrieval (retry {RetryCount}): Title={Title}, Client={Client}", - query.RetryCount + 1, query.Title, client.Name); - return null; - } - } - - /// - /// Get downloads that need hash retrieval (grabbed but no DownloadId yet) - /// - public async Task> GetPendingHashRetrievalsAsync(CancellationToken ct = default) - { - // Get all pending imports (grabbed but not imported) - var pendingImports = await _historyRepository.GetPendingImportsAsync(ct); - - var queries = new List(); - - foreach (var history in pendingImports.Where(h => - // Only process if we don't have a valid DownloadId yet - // (or if the DownloadId looks like a temporary placeholder) - string.IsNullOrEmpty(h.DownloadId) || - h.DownloadId.StartsWith("temp-") || - h.DownloadId.Length < 10)) - { - // Calculate retry count from history events - var allEvents = await _historyRepository.GetByDownloadIdAsync(history.DownloadId, ct); - var retryCount = allEvents.Count(e => - e.EventType == DownloadHistoryEventType.Grabbed && - e.Data != null && - e.Data.ContainsKey("HashRetrievalAttempt")); - - var lastRetry = allEvents - .Where(e => e.EventType == DownloadHistoryEventType.Grabbed) - .OrderByDescending(e => e.EventDate) - .FirstOrDefault()?.EventDate; - - queries.Add(new DownloadClientItemQuery - { - DownloadId = history.DownloadId, - Title = history.Title, - AudiobookId = history.AudiobookId, - AddedDate = history.EventDate, - DownloadClient = history.DownloadClient, - DownloadClientId = history.DownloadClientId, - Protocol = history.Protocol, - RetryCount = retryCount, - LastRetry = lastRetry - }); - } - - return queries; - } - } -} - diff --git a/listenarr.application/Downloads/DownloadImportService.cs b/listenarr.application/Downloads/DownloadImportService.cs index 7b3386e6a..a47942bf5 100644 --- a/listenarr.application/Downloads/DownloadImportService.cs +++ b/listenarr.application/Downloads/DownloadImportService.cs @@ -149,7 +149,7 @@ public async Task> ImportDownloadFilesAsync(Audiobook audiobo relativePath = Path.GetFileName(file); } - var destination = CombineWithOptionalBase(audiobook.BasePath, relativePath); + var destination = FileUtils.CombineWithOptionalBase(audiobook.BasePath, relativePath); if (!await fileMover.PerformActionOn(completedFileAction, file, destination)) { @@ -231,7 +231,7 @@ public async Task> ImportDownloadFilesAsync(Audiobook audiobo var folderRelative = fileNamingService.ApplyNamingPattern(folderPattern, variablesForFile, treatAsFilename: false); if (string.IsNullOrEmpty(audiobook.BasePath) && !string.IsNullOrWhiteSpace(folderRelative)) { - destDirForFile = CombineWithOptionalBase(destDirForFile, folderRelative); + destDirForFile = FileUtils.CombineWithOptionalBase(destDirForFile, folderRelative); } var baseFilePattern = isMultiFileBatch ? settings.MultiFileNamingPattern : settings.FileNamingPattern; @@ -269,7 +269,7 @@ public async Task> ImportDownloadFilesAsync(Audiobook audiobo } } - var destination = CombineWithOptionalBase(destDirForFile, filename); + var destination = FileUtils.CombineWithOptionalBase(destDirForFile, filename); if (!await fileMover.PerformActionOn(completedFileAction, file, destination)) { @@ -418,32 +418,6 @@ private static string NonNarratorAuthorCandidate(string? candidate, string? narr return trimmedCandidate; } - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - // Local helpers - aligned with DownloadService helper behavior private static string DetermineQualityFromMetadata(AudioMetadata? metadata, string path) { diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index 331461028..082723d49 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -1770,10 +1770,17 @@ private async Task RemoveFromClientAsync(DownloadClientConfiguration clien public async Task UpdateAsync(Download download) { + var previousStatus = DownloadStatus.Queued; + var previous = await downloadRepository.GetByIdAsync(download.Id); + if (previous != null) + { + previousStatus = previous.Status; + } + await downloadRepository.UpdateAsync(download); - switch (previous.Status, download.Status) + switch (previousStatus, download.Status) { case var (old, next) when old == next: return; diff --git a/listenarr.application/Downloads/DownloadValidationPipeline.cs b/listenarr.application/Downloads/DownloadValidationPipeline.cs deleted file mode 100644 index 0fccc89ce..000000000 --- a/listenarr.application/Downloads/DownloadValidationPipeline.cs +++ /dev/null @@ -1,352 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; -using Microsoft.Extensions.Logging; - -namespace Listenarr.Application.Downloads -{ - /// - /// Stage 6: Three-phase validation pipeline for processing completed downloads - /// - /// Phase 1 - Check: Validate download is complete and ready - /// - Verify files exist on disk - /// - Check file integrity (size, format) - /// - Validate download client reports completion - /// - Ensure no partial downloads - /// - /// Phase 2 - Import: Process and move files - /// - Extract archives if needed - /// - Apply file naming patterns - /// - Move/copy to destination folder - /// - Update database records - /// - /// Phase 3 - Verify: Confirm import success - /// - Verify files at destination - /// - Validate database consistency - /// - Mark download as imported - /// - Cleanup source files (optional) - /// - public class DownloadValidationPipeline - { - private readonly ILogger _logger; - private readonly DownloadStateMachine _stateMachine; - private readonly IDownloadHistoryRepository _historyRepository; - - public DownloadValidationPipeline( - ILogger logger, - DownloadStateMachine stateMachine, - IDownloadHistoryRepository historyRepository) - { - _logger = logger; - _stateMachine = stateMachine; - _historyRepository = historyRepository; - } - - /// - /// Execute the complete validation pipeline - /// Returns true if all phases succeed, false otherwise - /// - public async Task ExecutePipelineAsync( - DownloadClientItem download, - Guid? audiobookId = null, - CancellationToken ct = default) - { - var result = new ValidationResult - { - DownloadId = download.DownloadId, - StartedAt = DateTime.UtcNow - }; - - try - { - // Phase 1: Check - _logger.LogInformation("Pipeline Phase 1/3: Checking download {DownloadId}", download.DownloadId); - var checkResult = await CheckPhaseAsync(download, ct); - result.CheckPhase = checkResult; - - if (!checkResult.Success) - { - _logger.LogWarning("Check phase failed for {DownloadId}: {Reason}", download.DownloadId, checkResult.ErrorMessage); - result.CompletedAt = DateTime.UtcNow; - return result; - } - - // Phase 2: Import - _logger.LogInformation("Pipeline Phase 2/3: Importing download {DownloadId}", download.DownloadId); - var importResult = await ImportPhaseAsync(download, audiobookId, ct); - result.ImportPhase = importResult; - - if (!importResult.Success) - { - _logger.LogWarning("Import phase failed for {DownloadId}: {Reason}", download.DownloadId, importResult.ErrorMessage); - result.CompletedAt = DateTime.UtcNow; - return result; - } - - // Phase 3: Verify - _logger.LogInformation("Pipeline Phase 3/3: Verifying download {DownloadId}", download.DownloadId); - var verifyResult = await VerifyPhaseAsync(download, importResult.ImportedPath, ct); - result.VerifyPhase = verifyResult; - - if (!verifyResult.Success) - { - _logger.LogWarning("Verify phase failed for {DownloadId}: {Reason}", download.DownloadId, verifyResult.ErrorMessage); - } - - result.CompletedAt = DateTime.UtcNow; - result.Success = verifyResult.Success; - - if (result.Success) - { - _logger.LogInformation("✅ Pipeline completed successfully for {DownloadId} in {Duration:F1}s", - download.DownloadId, (result.CompletedAt.Value - result.StartedAt).TotalSeconds); - - // Mark as imported in history - await _historyRepository.MarkAsImportedAsync(download.DownloadId, ct); - } - - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Pipeline execution failed for {DownloadId}", download.DownloadId); - result.CompletedAt = DateTime.UtcNow; - result.Success = false; - result.GlobalError = ex.Message; - return result; - } - } - - /// - /// Phase 1: Check - Validate download is complete and ready - /// - private async Task CheckPhaseAsync(DownloadClientItem download, CancellationToken ct) - { - var result = new PhaseResult { PhaseName = "Check" }; - - try - { - // Check 1: Download must be in Completed status - if (download.Status != DownloadItemStatus.Completed) - { - result.ErrorMessage = $"Download not completed (Status: {download.Status})"; - return result; - } - - // Check 2: Must have output path - if (string.IsNullOrEmpty(download.OutputPath)) - { - result.ErrorMessage = "Output path is empty"; - return result; - } - - // Check 3: Output path must exist - if (!Directory.Exists(download.OutputPath) && !File.Exists(download.OutputPath)) - { - result.ErrorMessage = $"Output path does not exist: {download.OutputPath}"; - return result; - } - - // Check 4: Must have valid DownloadId - if (string.IsNullOrEmpty(download.DownloadId) || download.DownloadId.StartsWith("temp-")) - { - result.ErrorMessage = "Invalid or temporary DownloadId"; - return result; - } - - // Check 5: Size must be greater than zero - if (download.TotalSize <= 0) - { - result.ErrorMessage = "Download size is zero or negative"; - return result; - } - - // Record check phase success - await _stateMachine.TransitionAsync( - download.DownloadId, - DownloadItemStatus.Completed, - DownloadItemStatus.Completed, - DownloadHistoryEventType.Checking, - downloadClient: download.DownloadClientInfo.Name, - downloadClientId: download.DownloadClientInfo.Id, - protocol: download.DownloadClientInfo.Protocol, - title: download.Title, - outputPath: download.OutputPath, - metadata: new Dictionary - { - ["Phase"] = "Check", - ["OutputPath"] = download.OutputPath, - ["TotalSize"] = download.TotalSize - }, - ct: ct); - - result.Success = true; - _logger.LogDebug("Check phase passed for {DownloadId}", download.DownloadId); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error in check phase for {DownloadId}", download.DownloadId); - result.ErrorMessage = ex.Message; - return result; - } - } - - /// - /// Phase 2: Import - Process and move files - /// - private async Task ImportPhaseAsync(DownloadClientItem download, Guid? audiobookId, CancellationToken ct) - { - var result = new ImportPhaseResult { PhaseName = "Import" }; - - try - { - // For now, we'll use the output path as-is - // In a full implementation, this would: - // - Extract archives - // - Apply naming patterns - // - Move to final destination - // - Update database - - result.ImportedPath = download.OutputPath; - - // Record import phase - await _stateMachine.TransitionAsync( - download.DownloadId, - DownloadItemStatus.Completed, - DownloadItemStatus.Completed, - DownloadHistoryEventType.Imported, - audiobookId: audiobookId, - downloadClient: download.DownloadClientInfo.Name, - downloadClientId: download.DownloadClientInfo.Id, - protocol: download.DownloadClientInfo.Protocol, - title: download.Title, - outputPath: result.ImportedPath, - metadata: new Dictionary - { - ["Phase"] = "Import", - ["ImportedPath"] = result.ImportedPath - }, - ct: ct); - - result.Success = true; - _logger.LogDebug("Import phase passed for {DownloadId}", download.DownloadId); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error in import phase for {DownloadId}", download.DownloadId); - result.ErrorMessage = ex.Message; - return result; - } - } - - /// - /// Phase 3: Verify - Confirm import success - /// - private async Task VerifyPhaseAsync(DownloadClientItem download, string? importedPath, CancellationToken ct) - { - var result = new PhaseResult { PhaseName = "Verify" }; - - try - { - // Verify 1: Imported path must not be empty - if (string.IsNullOrEmpty(importedPath)) - { - result.ErrorMessage = "Imported path is empty"; - return result; - } - - // Verify 2: Imported path must still exist - if (!Directory.Exists(importedPath) && !File.Exists(importedPath)) - { - result.ErrorMessage = $"Imported path does not exist: {importedPath}"; - return result; - } - - // Record verify phase - await _stateMachine.TransitionAsync( - download.DownloadId, - DownloadItemStatus.Completed, - DownloadItemStatus.Completed, - DownloadHistoryEventType.Imported, // Use Imported event for final success - downloadClient: download.DownloadClientInfo.Name, - downloadClientId: download.DownloadClientInfo.Id, - protocol: download.DownloadClientInfo.Protocol, - title: download.Title, - outputPath: importedPath, - metadata: new Dictionary - { - ["Phase"] = "Verify", - ["VerifiedPath"] = importedPath, - ["PipelineComplete"] = true - }, - ct: ct); - - result.Success = true; - _logger.LogDebug("Verify phase passed for {DownloadId}", download.DownloadId); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error in verify phase for {DownloadId}", download.DownloadId); - result.ErrorMessage = ex.Message; - return result; - } - } - } - - /// - /// Result of the complete validation pipeline - /// - public class ValidationResult - { - public string DownloadId { get; set; } = string.Empty; - public DateTime StartedAt { get; set; } - public DateTime? CompletedAt { get; set; } - public bool Success { get; set; } - public string? GlobalError { get; set; } - - public PhaseResult? CheckPhase { get; set; } - public ImportPhaseResult? ImportPhase { get; set; } - public PhaseResult? VerifyPhase { get; set; } - - public TimeSpan Duration => (CompletedAt ?? DateTime.UtcNow) - StartedAt; - } - - /// - /// Result of a single pipeline phase - /// - public class PhaseResult - { - public string PhaseName { get; set; } = string.Empty; - public bool Success { get; set; } - public string? ErrorMessage { get; set; } - } - - /// - /// Result of the import phase (includes imported path) - /// - public class ImportPhaseResult : PhaseResult - { - public string? ImportedPath { get; set; } - } -} - diff --git a/listenarr.application/Interfaces/IDownloadClientAdapter.cs b/listenarr.application/Interfaces/IDownloadClientAdapter.cs index 8bf3c8813..590c96dc1 100644 --- a/listenarr.application/Interfaces/IDownloadClientAdapter.cs +++ b/listenarr.application/Interfaces/IDownloadClientAdapter.cs @@ -22,13 +22,20 @@ namespace Listenarr.Application.Interfaces /// /// Encapsulates all download-client specific operations. Implement an adapter per client to keep /// protocol details isolated from the orchestration layer. - /// Follows IDownloadClient pattern for consistency. + /// Regarding QueueItem: + /// - Progress is the source of truth for completion and range from 0 to 100 by convention + /// - SourceFiles is the source of truth for downloaded files, if it cannot be determinerd, + /// ContentPath should be used instead (as a path either being a directory or a single file), + /// gateway will transform that as a SourceFiles list + /// - Only remote path should be returned, gateway handles the local path mapping + /// - The adapter must define an external ID used to link listenarr downloads 1-to-1 with + /// download client entries/items /// public interface IDownloadClientAdapter { - string ClientId { get; } string ClientType { get; } - DownloadProtocol Protocol { get; } + + List Protocols { get; } Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default); @@ -44,33 +51,13 @@ public interface IDownloadClientAdapter Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default); /// - /// Give a list of ongoing download as queue items, each of them should respect the same constraint as for GetImportItemAsync - /// - Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default); - - /// - /// Returns normalized DownloadClientItem list - /// This is the preferred method going forward - /// - Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default); - - Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default); - - /// - /// Resolves the actual import item for a completed download. - /// Called just before import to ensure the most accurate path and metadata. - /// Some clients (like qBittorrent) require additional queries to determine final paths. + /// Given a list of IDs, fetch updates from the given client /// /// Download client configuration - /// The download client item to resolve - /// Previous import attempt for retry scenarios (can be null) - /// Cancellation token - /// Updated item with resolved OutputPath, or original if unable to determine - Task GetImportItemAsync( - DownloadClientConfiguration client, - DownloadClientItem item, - DownloadClientItem? previousAttempt = null, - CancellationToken ct = default); + /// List of IDs to get updates from + /// + /// List of updated values for the given IDs + Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default); /// /// Retrieves the information about a given download as a queue item @@ -96,15 +83,5 @@ Task GetImportItemAsync( /// True if the operation succeeded or was a no-op Task MarkItemAsImportedAsync(DownloadClientConfiguration client, string id, CancellationToken ct = default) => Task.FromResult(true); // Default no-op - - /// - /// Given a list of downloads, fetch updates from the given client - /// This only returns updated values, it does not persists them! - /// - /// Download client configuration - /// List of downloads to be updated - /// - /// - Task> FetchDownloadsAsync(DownloadClientConfiguration client, List downloads, CancellationToken cancellationToken = default); } } diff --git a/listenarr.application/Interfaces/IDownloadClientAdapterFactory.cs b/listenarr.application/Interfaces/IDownloadClientAdapterFactory.cs index aa0b4c01b..2bc3c1141 100644 --- a/listenarr.application/Interfaces/IDownloadClientAdapterFactory.cs +++ b/listenarr.application/Interfaces/IDownloadClientAdapterFactory.cs @@ -1,7 +1,30 @@ +using Listenarr.Domain.Models; + namespace Listenarr.Application.Interfaces { public interface IDownloadClientAdapterFactory { - IDownloadClientAdapter GetByIdOrType(string id); + /// + /// Retrieve a download client adapter by client type/software + /// + /// Actual name of the download client (qBittorent, Transmission, Slskd, ...) + /// Download client adapter to use for the given type + /// Exception thrown when no adapter is defined for the given type + IDownloadClientAdapter GetByType(string type); + + /// + /// Retrieve a download client adapter by protocol + /// + /// Protocol it supports (Torrent, Usenet, Soulseek, ...) + /// Available download clients + /// Exception thrown when no adapter is defined for the given protocol + List GetByProtocol(DownloadProtocol protocol); + + /// + /// Retrieve the list of client type that are compatible with the given protocol + /// + /// Protocol we want to check + /// A list of client type that supports the given protocol + List GetClientTypeSupportingProtocol(DownloadProtocol protocol); } } diff --git a/listenarr.application/Interfaces/IDownloadClientGateway.cs b/listenarr.application/Interfaces/IDownloadClientGateway.cs index 4f93c506d..704c7e768 100644 --- a/listenarr.application/Interfaces/IDownloadClientGateway.cs +++ b/listenarr.application/Interfaces/IDownloadClientGateway.cs @@ -32,10 +32,14 @@ public interface IDownloadClientGateway Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default); + /// + /// Given a download client, fetch all ongoing downloads in it + /// + /// + /// + /// List of ongoing items in the download client related to listenarr downloads Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default); - Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default); - Task GetQueueItemAsync( DownloadClientConfiguration client, Download download, @@ -44,6 +48,13 @@ Task GetQueueItemAsync( Task MarkItemAsImportedAsync(DownloadClientConfiguration client, Download download, CancellationToken ct = default); + /// + /// Given a list of download, update theit data based on reported download client informations + /// + /// + /// List of downloads to update + /// + /// Same list with updated values Task> FetchDownloadsAsync(DownloadClientConfiguration client, List downloads, CancellationToken cancellationToken = default); } } diff --git a/listenarr.application/Interfaces/IDownloadPushService.cs b/listenarr.application/Interfaces/IDownloadPushService.cs index ded143422..bf0d1a6a6 100644 --- a/listenarr.application/Interfaces/IDownloadPushService.cs +++ b/listenarr.application/Interfaces/IDownloadPushService.cs @@ -17,7 +17,6 @@ public interface IDownloadPushService /// /// Same as HandlePushAsync with a list of downloads /// - /// public Task HandlePushAsync(List downloads, CancellationToken cancellationToken = default); } } diff --git a/listenarr.application/Interfaces/IRemotePathMappingService.cs b/listenarr.application/Interfaces/IRemotePathMappingService.cs index ccf4762d8..73b67d762 100644 --- a/listenarr.application/Interfaces/IRemotePathMappingService.cs +++ b/listenarr.application/Interfaces/IRemotePathMappingService.cs @@ -60,7 +60,7 @@ public interface IRemotePathMappingService /// Translate a remote path from a download client to a local path for Listenarr. /// Finds the best matching path mapping for the given client and applies it. /// - /// The ID of the download client reporting the path + /// Download client reporting the path /// The path as reported by the download client /// The translated local path, or the original path if no mapping matches Task TranslatePathAsync(DownloadClientConfiguration client, string remotePath); diff --git a/listenarr.infrastructure/Adapters/AdapterUtils.cs b/listenarr.application/Mapping/QueueItemConverter.cs similarity index 67% rename from listenarr.infrastructure/Adapters/AdapterUtils.cs rename to listenarr.application/Mapping/QueueItemConverter.cs index 797a4a4ba..8c800347b 100644 --- a/listenarr.infrastructure/Adapters/AdapterUtils.cs +++ b/listenarr.application/Mapping/QueueItemConverter.cs @@ -1,9 +1,52 @@ using Listenarr.Domain.Models; -namespace Listenarr.Infrastructure.Adapters +namespace Listenarr.Application.Mapping { - public class AdapterUtils + public class QueueItemConverter { + public static Download UpdateFromQueueItem(Download download, QueueItem item) + { + if (!string.IsNullOrEmpty(item.LocalPath)) + { + download.DownloadPath = item.LocalPath; + } + + if (!string.IsNullOrEmpty(item.ContentPath)) + { + download.SetMetadata("ClientContentPath", item.ContentPath); + } + + download.SetMetadata("CanBeRemoved", item.CanRemove); + + var amountLeft = item.Size - item.Downloaded; + download = MapDownloadProgress(download, item.Progress, amountLeft, item.Status); + + // Skip finalization/progress logic for downloads that are already + // being processed, awaiting import, or fully imported. Re-entering + // finalization for these would cause duplicate notifications and + // potentially import the wrong files a second time. + if (download.Status == DownloadStatus.Moved || + download.Status == DownloadStatus.Processing || + download.Status == DownloadStatus.ImportPending) + { + return download; + } + + var normalizedState = (item.Status ?? string.Empty).ToLowerInvariant(); + if (normalizedState == "error" || normalizedState == "missingfiles") + { + download.Failed($"qBittorrent state: {item.Status}"); + return download; + } + + if (item.Progress >= 100) + { + download.Completed(); + } + + return download; + } + /// /// Used for old adapter implementation /// Returns a download updated with the given values @@ -13,7 +56,7 @@ public class AdapterUtils /// /// /// - public static Download MapDownloadProgress(Download download, double progress, long amountLeft, string clientState) + private static Download MapDownloadProgress(Download download, double progress, long amountLeft, string clientState) { var normalizedState = (clientState ?? string.Empty).ToLowerInvariant(); @@ -51,12 +94,9 @@ public static Download MapDownloadProgress(Download download, double progress, l _ => DownloadStatus.Queued }; - // Calculate downloaded size from progress and total size - long downloadedSize = download.TotalSize > 0 ? (long)(download.TotalSize * progress / 100) : 0; - // Update download record download.Progress = (decimal)progress; - download.DownloadedSize = downloadedSize; + download.DownloadedSize = download.TotalSize - amountLeft; download.Metadata ??= new Dictionary(); download.Metadata!["ClientState"] = clientState ?? "Unknown"; download.Metadata!["AmountLeft"] = amountLeft; diff --git a/listenarr.domain/Models/Download.cs b/listenarr.domain/Models/Download.cs index 00cea6d80..5a9f499a2 100644 --- a/listenarr.domain/Models/Download.cs +++ b/listenarr.domain/Models/Download.cs @@ -62,6 +62,11 @@ public DownloadStatus Status get; set; } = DownloadStatus.Queued; + + /// + /// Progress of the download. + /// From 0 to 100 by convention + /// public decimal Progress { get; @@ -79,7 +84,7 @@ public decimal Progress public DateTime? CompletedAt { get; set; } public string? ErrorMessage { get; set; } public string DownloadClientId { get; set; } = string.Empty; - public Dictionary Metadata { get; set; } = new(); + public Dictionary Metadata { get; set; } = []; /// /// Tracks why a download is ImportBlocked (error code for categorization) @@ -111,6 +116,11 @@ public decimal Progress /// public int? HistoryId { get; set; } + public void SetMetadata(string key, object value) + { + Metadata[key] = value; + } + public string? GetMetadataString(string key) { if (!Metadata.TryGetValue(key, out var value) || value == null) diff --git a/listenarr.domain/Models/SearchResult.cs b/listenarr.domain/Models/SearchResult.cs index 3e9731d44..fe3658526 100644 --- a/listenarr.domain/Models/SearchResult.cs +++ b/listenarr.domain/Models/SearchResult.cs @@ -206,7 +206,6 @@ public class IndexerResultDto public string? Language { get; set; } } - /// /// /// Response wrapper for search operations that can contain different types of results /// diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index 65c7f253d..ce3c774af 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -26,18 +26,14 @@ using Listenarr.Application.Security; using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Adapters { public class NzbgetAdapter : IDownloadClientAdapter { - public string ClientId => "nzbget"; public string ClientType => "nzbget"; - public DownloadProtocol Protocol => DownloadProtocol.Usenet; - - private static readonly HashSet InvalidFileNameChars = new(Path.GetInvalidFileNameChars()); + public List Protocols => [DownloadProtocol.Usenet]; private readonly IHttpClientFactory _httpClientFactory; private readonly INzbUrlResolver _nzbUrlResolver; @@ -397,7 +393,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i } } - public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + public async Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default) { var items = new List(); if (client == null) return items; @@ -431,6 +427,11 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli } var queueItem = MapGroup(client, structElement); + if (!ids.Any(id => id == queueItem.Id)) + { + continue; + } + items.Add(queueItem); } } @@ -452,258 +453,6 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli return items; } - public async Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) - { - var history = new List<(string Id, string Name)>(); - if (client == null) return history; - - try - { - var historyResult = await CallXmlRpcAsync(client, "history", false); - var arrayData = historyResult.Element("array")?.Element("data"); - - if (arrayData == null) - { - return history; - } - - var count = 0; - foreach (var valueElement in arrayData.Elements("value")) - { - if (count >= limit) break; - - var structElement = valueElement.Element("struct"); - if (structElement != null) - { - var members = structElement.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty - ); - - var entryId = members.GetValueOrDefault("ID", string.Empty); - var entryName = members.GetValueOrDefault("NZBName", string.Empty); - - if (!string.IsNullOrEmpty(entryId) && !string.IsNullOrEmpty(entryName)) - { - history.Add((entryId, entryName)); - count++; - } - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch NZBGet history for client {ClientName}", LogRedaction.SanitizeText(client.Name ?? client.Id)); - } - - return history; - } - - /// - /// Get all downloads as standardized DownloadClientItem objects - /// - public async Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) - { - var items = new List(); - if (client == null) return items; - - var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - - try - { - var listResult = await CallXmlRpcAsync(client, "listgroups"); - var arrayData = listResult.Element("array")?.Element("data"); - - if (arrayData == null) - { - return items; - } - - foreach (var valueElement in arrayData.Elements("value")) - { - try - { - var structElement = valueElement.Element("struct"); - if (structElement != null) - { - var groupCategory = structElement.Elements("member") - .FirstOrDefault(m => string.Equals(m.Element("name")?.Value, "Category", StringComparison.Ordinal))? - .Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty; - - if (!DownloadClientCategoryFilter.Matches(configuredCategory, groupCategory)) - { - continue; - } - - var downloadClientItem = await MapGroupToDownloadClientItemAsync(client, structElement); - items.Add(downloadClientItem); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to map NZBGet queue item (non-fatal)"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to retrieve NZBGet items for client {ClientName}", LogRedaction.SanitizeText(client.Name ?? client.Id)); - } - - return items; - } - - /// - /// Get import item from DownloadClientItem - /// - public async Task GetImportItemAsync( - DownloadClientConfiguration client, - DownloadClientItem item, - DownloadClientItem? previousAttempt = null, - CancellationToken ct = default) - { - // Clone to avoid mutating the original - var result = item.Clone(); - - // If OutputPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.OutputPath)) - { - return result; - } - - try - { - // Query NZBGet history for the download - var historyResult = await CallXmlRpcAsync(client, "history", false); - var arrayData = historyResult.Element("array")?.Element("data"); - - if (arrayData == null) - { - _logger.LogWarning("Invalid NZBGet history response format"); - return result; - } - - // Find matching history entry by ID - foreach (var members in arrayData.Elements("value") - .Select(valueElement => valueElement.Element("struct")) - .Where(structElement => structElement != null) - .Select(structElement => structElement!.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty))) - { - var entryId = members.GetValueOrDefault("ID", string.Empty); - if (!string.Equals(entryId, item.DownloadId, StringComparison.OrdinalIgnoreCase)) continue; - - // Extract destination directory - var destDir = members.GetValueOrDefault("DestDir", string.Empty); - if (string.IsNullOrEmpty(destDir)) - { - _logger.LogWarning("No DestDir found for NZBGet download {Id}", item.DownloadId); - return result; - } - - result.OutputPath = destDir; - - _logger.LogDebug( - "Resolved NZBGet content path for {Id}: {ContentPath}", - item.DownloadId, - destDir); - - return result; - } - - _logger.LogWarning("Download {Id} not found in NZBGet history", item.DownloadId); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for NZBGet download {Id}", item.DownloadId); - return result; - } - } - - private async Task MapGroupToDownloadClientItemAsync(DownloadClientConfiguration client, XElement structElement) - { - var members = (IReadOnlyDictionary)structElement.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty - ); - - var id = members.GetValueOrDefault("GroupID", null) - ?? members.GetValueOrDefault("LastID", null) - ?? Guid.NewGuid().ToString("N"); - - var title = members.GetValueOrDefault("NZBName", string.Empty); - var statusRaw = members.GetValueOrDefault("Status", string.Empty); - var category = members.GetValueOrDefault("Category", string.Empty); - var sizeMb = double.TryParse(members.GetValueOrDefault("FileSizeMB", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var sm) ? sm : 0d; - var remainingMb = double.TryParse(members.GetValueOrDefault("RemainingSizeMB", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var rm) ? rm : 0d; - var downloadRate = double.TryParse(members.GetValueOrDefault("DownloadRate", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var dr) ? dr : 0d; - var destDir = members.GetValueOrDefault("DestDir", string.Empty); - - var sizeBytes = Convert.ToInt64(Math.Max(0, sizeMb) * 1024 * 1024); - var remainingBytes = Convert.ToInt64(Math.Max(0, remainingMb) * 1024 * 1024); - - TimeSpan? remainingTime = null; - if (downloadRate > 0 && remainingMb > 0) - { - var remainingBytesExact = remainingMb * 1024 * 1024; - var etaSeconds = (int)Math.Max(0, remainingBytesExact / downloadRate); - remainingTime = TimeSpan.FromSeconds(etaSeconds); - } - - // Map NZBGet status to DownloadItemStatus. - // NZBGet can emit suffixed states (e.g. SUCCESS/HEALTH, FAILURE/HEALTH). - var normalizedStatus = (statusRaw ?? "QUEUED").ToUpperInvariant(); - var status = normalizedStatus switch - { - "QUEUED" => DownloadItemStatus.Queued, - "DOWNLOADING" => DownloadItemStatus.Downloading, - "PAUSED" => DownloadItemStatus.Paused, - "FETCHING" => DownloadItemStatus.Downloading, - "SCANNING" => DownloadItemStatus.Downloading, - "PP_QUEUED" => DownloadItemStatus.Downloading, - "PP_PROCESSING" => DownloadItemStatus.Downloading, - _ when normalizedStatus.StartsWith("SUCCESS", StringComparison.Ordinal) => DownloadItemStatus.Completed, - _ when normalizedStatus.StartsWith("FAILURE", StringComparison.Ordinal) || normalizedStatus.StartsWith("FAILED", StringComparison.Ordinal) => DownloadItemStatus.Failed, - _ => DownloadItemStatus.Queued - }; - - // For NZBGet, construct OutputPath from destDir + title - var contentPath = !string.IsNullOrEmpty(destDir) && !string.IsNullOrEmpty(title) - ? CombineWithOptionalBase(destDir, title) - : (destDir ?? string.Empty); - var localContentPath = contentPath ?? string.Empty; - - var progress = sizeMb > 0 ? Math.Clamp((sizeMb - remainingMb) / sizeMb * 100, 0, 100) : 0; - - return new DownloadClientItem - { - DownloadId = id.ToUpperInvariant(), - Title = title ?? string.Empty, - Category = category ?? string.Empty, - Status = status, - TotalSize = sizeBytes, - RemainingSize = remainingBytes, - RemainingTime = remainingTime, - OutputPath = localContentPath ?? string.Empty, - Message = statusRaw ?? "QUEUED", - Progress = progress, - DownloadSpeed = downloadRate, - CanBeRemoved = true, - CanMoveFiles = status == DownloadItemStatus.Completed, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "nzbget", - protocol: DownloadProtocol.Usenet, - removeCompletedDownloads: client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && - (removeVal is bool boolVal && boolVal), - hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString()) - ) - }; - } - private QueueItem MapGroup(DownloadClientConfiguration client, XElement structElement) { var members = (IReadOnlyDictionary)structElement.Elements("member").ToDictionary( @@ -711,10 +460,7 @@ private QueueItem MapGroup(DownloadClientConfiguration client, XElement structEl m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty ); - var id = members.GetValueOrDefault("GroupID", null) - ?? members.GetValueOrDefault("LastID", null) - ?? Guid.NewGuid().ToString("N"); - + var id = members.GetValueOrDefault("GroupID", null) ?? members.GetValueOrDefault("LastID", null) ?? members.GetValueOrDefault("NZBID", string.Empty); var title = members.GetValueOrDefault("NZBName", string.Empty); var statusRaw = members.GetValueOrDefault("Status", string.Empty); var category = members.GetValueOrDefault("Category", string.Empty); @@ -753,7 +499,7 @@ _ when normalizedStatus.StartsWith("FAILURE", StringComparison.Ordinal) || norma // For NZBGet, construct ContentPath from destDir + title var contentPath = !string.IsNullOrEmpty(destDir) && !string.IsNullOrEmpty(title) - ? CombineWithOptionalBase(destDir, title) + ? FileUtils.CombineWithOptionalBase(destDir, title) : destDir; var localContentPath = contentPath; @@ -761,7 +507,7 @@ _ when normalizedStatus.StartsWith("FAILURE", StringComparison.Ordinal) || norma return new QueueItem { - Id = id, + Id = id ?? string.Empty, Title = title ?? string.Empty, Quality = category ?? string.Empty, Status = status, @@ -1091,140 +837,5 @@ public async Task GetImportItemAsync( return result; } } - - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - - public async Task> FetchDownloadsAsync( - DownloadClientConfiguration client, - List downloads, - CancellationToken cancellationToken) - { - _logger.LogDebug("Polling NZBGet client {ClientName}", client.Name); - try - { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/jsonrpc"); - - using var http = _httpClientFactory.CreateClient(ClientType); - - // Add basic auth if credentials provided - if (!string.IsNullOrEmpty(client.Username)) - { - var authBytes = Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}"); - var authHeader = Convert.ToBase64String(authBytes); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authHeader); - } - - // Get active downloads from status for progress updates - var statusRequest = new - { - method = "status", - id = 2 - }; - - var statusJsonContent = JsonSerializer.Serialize(statusRequest); - using var statusHttpContent = new StringContent(statusJsonContent, Encoding.UTF8, "application/json"); - - using var statusResponse = await http.PostAsync(baseUrl, statusHttpContent, cancellationToken); - - if (statusResponse.IsSuccessStatusCode) - { - var statusJson = await statusResponse.Content.ReadAsStringAsync(cancellationToken); - var statusDoc = JsonDocument.Parse(statusJson); - - if (statusDoc.RootElement.TryGetProperty("result", out var statusResult)) - { - // Get queue for active downloads - var queueRequest = new - { - method = "listgroups", - id = 3 - }; - - var queueJsonContent = JsonSerializer.Serialize(queueRequest); - using var queueHttpContent = new StringContent(queueJsonContent, Encoding.UTF8, "application/json"); - - using var queueResponse = await http.PostAsync(baseUrl, queueHttpContent, cancellationToken); - - if (queueResponse.IsSuccessStatusCode) - { - var queueJson = await queueResponse.Content.ReadAsStringAsync(cancellationToken); - var queueDoc = JsonDocument.Parse(queueJson); - - if (queueDoc.RootElement.TryGetProperty("result", out var queueResult) && queueResult.ValueKind == JsonValueKind.Array) - { - foreach (var group in queueResult.EnumerateArray()) - { - try - { - var nzbId = group.TryGetProperty("NZBID", out var nzbIdProp) ? nzbIdProp.GetInt32() : 0; - var nzbName = group.TryGetProperty("NZBName", out var nameProp) ? nameProp.GetString() ?? "" : ""; - var status = group.TryGetProperty("Status", out var statusProp) ? statusProp.GetString() ?? "" : ""; - var fileSizeMB = group.TryGetProperty("FileSizeMB", out var sizeProp) ? sizeProp.GetString() ?? "" : ""; - var remainingSizeMB = group.TryGetProperty("RemainingSizeMB", out var remainingSizeProp) ? remainingSizeProp.GetString() ?? "" : ""; - // Find matching download by NZB ID - var matchingDownload = downloads.FirstOrDefault(dl => - { - var clientItemId = dl.GetExternalId(); - return !string.IsNullOrEmpty(clientItemId) && - clientItemId.Equals(nzbId.ToString(), StringComparison.OrdinalIgnoreCase); - }); - - if (matchingDownload == null && !string.IsNullOrEmpty(nzbName)) - { - matchingDownload = downloads.FirstOrDefault(dl => TitleUtils.AreTitlesSimilar(dl.Title, nzbName)); - } - - if (matchingDownload != null && - double.TryParse(fileSizeMB, out var totalMB) && - double.TryParse(remainingSizeMB, out var remainingMB)) - { - var progress = totalMB > 0 ? (totalMB - remainingMB) / totalMB : 0.0; - var amountLeft = (long)(remainingMB * 1024 * 1024); // Convert MB to bytes - - AdapterUtils.MapDownloadProgress(matchingDownload, progress, amountLeft, status); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error updating NZBGet queue progress for group"); - } - } - } - } - } - } - - return downloads; - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - throw new DownloadClientAdapterPollingException($"Error polling NZBGet client {client.Id}", exception); - } - } } } - diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 84f6ca4fe..6b19c98a5 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -33,9 +33,8 @@ namespace Listenarr.Infrastructure.Adapters /// public class QbittorrentAdapter : IDownloadClientAdapter { - public string ClientId => "qbittorrent"; public string ClientType => "qbittorrent"; - public DownloadProtocol Protocol => DownloadProtocol.Torrent; + public List Protocols => [DownloadProtocol.Torrent]; private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; @@ -664,7 +663,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i } } - public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + public async Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default) { var items = new List(); if (client == null) return items; @@ -705,26 +704,74 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli return items; } - // Limit fields returned to reduce memory usage - var fields = "name,progress,size,downloaded,dlspeed,eta,state,hash,added_on,num_seeds,num_leechs,ratio,save_path"; + // Fetch qBittorrent global preferences for seed limit evaluation (Sonarr parity) + bool qbtGlobalMaxRatioEnabled = false; + float qbtGlobalMaxRatio = -1f; + bool qbtGlobalMaxSeedingTimeEnabled = false; + long qbtGlobalMaxSeedingTime = -1; + bool qbtRemoveCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && + client.RemoveCompletedDownloads != "none"; + try + { + using var prefsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/app/preferences", ct); + if (prefsResp.IsSuccessStatusCode) + { + var prefsJson = await prefsResp.Content.ReadAsStringAsync(ct); + if (!string.IsNullOrWhiteSpace(prefsJson)) + { + var prefs = JsonSerializer.Deserialize>(prefsJson); + if (prefs != null) + { + qbtGlobalMaxRatioEnabled = prefs.TryGetValue("max_ratio_enabled", out var mre) && mre.GetBoolean(); + qbtGlobalMaxRatio = prefs.TryGetValue("max_ratio", out var mr) ? (float)mr.GetDouble() : -1f; + qbtGlobalMaxSeedingTimeEnabled = prefs.TryGetValue("max_seeding_time_enabled", out var mste) && mste.GetBoolean(); + qbtGlobalMaxSeedingTime = prefs.TryGetValue("max_seeding_time", out var mst) ? mst.GetInt64() : -1; + } + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to fetch qBittorrent preferences for seed limit evaluation"); + } - // Build category filter parameter if configured - var categoryFilter = QBittorrentHelpers.BuildCategoryParameter(client.Settings, "&"); + // If we have tracked hashes, chunk them into batches to avoid very large queries and to allow + // slight delays between requests to prevent overwhelming qBittorrent. + List> torrents = []; + if (ids.Count != 0) + { + const int batchSize = 100; // safe default batch size + _logger.LogDebug($"Querying qBittorrent for specific hashes (total={ids.Count}), using batches of {batchSize}"); - // Extract category for logging - var category = client.Settings?.TryGetValue("category", out var categoryObj) is true - ? categoryObj?.ToString() - : null; - QBittorrentHelpers.LogCategoryFiltering(_logger, category); + var batches = Enumerable.Range(0, (ids.Count + batchSize - 1) / batchSize) + .Select(i => ids.Skip(i * batchSize).Take(batchSize).ToList()) + .ToList(); - using var torrentsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/info?fields={Uri.EscapeDataString(fields)}{categoryFilter}", ct); - if (!torrentsResp.IsSuccessStatusCode) return items; + foreach (var batch in batches) + { + // Limit fields returned to reduce memory usage + var fields = "name,progress,size,downloaded,dlspeed,eta,state,hash,added_on,num_seeds,num_leechs,ratio,save_path,content_path,amount_left,category,seeding_time,ratio_limit,seeding_time_limit"; + var hashesParam = Uri.EscapeDataString(string.Join("|", batch)); + var query = $"?hashes={hashesParam}&fields={Uri.EscapeDataString(fields)}"; - var json = await torrentsResp.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(json)) return items; + using var torrentsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", ct); + if (!torrentsResp.IsSuccessStatusCode) + { + var errorContent = await torrentsResp.Content.ReadAsStringAsync(ct); + throw new DownloadClientAdapterPollingException($"Failed to fetch torrent batch from qBittorrent for {client.Name} (batch size={batch.Count}, URL={baseUrl}/api/v2/torrents/info{query}, StatusCode={torrentsResp.StatusCode}, Response={errorContent})"); + } - var torrents = JsonSerializer.Deserialize>>(json); - if (torrents == null) return items; + var json = await torrentsResp.Content.ReadAsStringAsync(ct); + var batchedTorrents = JsonSerializer.Deserialize>>(json); + if (batchedTorrents != null) + { + torrents.AddRange(batchedTorrents); + } + + // Small delay between batches to avoid hammering the client + await Task.Delay(150, ct); + } + } foreach (var torrent in torrents) { @@ -739,8 +786,24 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli var addedOn = torrent.TryGetValue("added_on", out var addedOnEl) ? addedOnEl.GetInt64() : 0; var numSeeds = torrent.TryGetValue("num_seeds", out var numSeedsEl) ? (int?)numSeedsEl.GetInt32() : null; var numLeechs = torrent.TryGetValue("num_leechs", out var numLeechsEl) ? (int?)numLeechsEl.GetInt32() : null; - var ratio = torrent.TryGetValue("ratio", out var ratioEl) ? (double?)ratioEl.GetDouble() : null; + var ratio = torrent.TryGetValue("ratio", out var ratioEl) ? (double?)ratioEl.GetDouble() ?? 0.0 : 0.0; var savePath = torrent.TryGetValue("save_path", out var savePathEl) ? savePathEl.GetString() ?? string.Empty : string.Empty; + var contentPath = torrent.TryGetValue("content_path", out var contentPathElement) ? contentPathElement.GetString() ?? "" : ""; + var amountLeft = torrent.TryGetValue("amount_left", out var amountLeftElement) ? amountLeftElement.GetInt64() : 0L; + var category = torrent.TryGetValue("category", out var categoryElement) ? categoryElement.GetString() ?? "" : ""; + var seedingTime = torrent.TryGetValue("seeding_time", out var seedingTimeElement) ? seedingTimeElement.GetInt64() : (long?)null; + var ratioLimit = torrent.TryGetValue("ratio_limit", out var ratioLimitElement) ? (float)ratioLimitElement.GetDouble() : -2f; + var seedingTimeLimit = torrent.TryGetValue("seeding_time_limit", out var seedingTimeLimitElement) ? seedingTimeLimitElement.GetInt64() : -2L; + + // Sonarr parity: compute CanMoveFiles/CanBeRemoved per-torrent + var isStopped = state is "pausedUP" or "stoppedUP"; + var seedLimitReached = QBitHasReachedSeedLimit( + ratio, ratioLimit, seedingTime, seedingTimeLimit, + qbtGlobalMaxRatioEnabled, qbtGlobalMaxRatio, + qbtGlobalMaxSeedingTimeEnabled, qbtGlobalMaxSeedingTime); + var canBeRemoved = qbtRemoveCompletedDownloads && seedLimitReached; + + QBittorrentHelpers.LogCategoryFiltering(_logger, category); List> files = []; using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); @@ -750,9 +813,6 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli files = JsonSerializer.Deserialize>>(filesJson) ?? []; } - var localPath = savePath; - var outputPath = ResolveTorrentContentPath(savePath, files); - var status = state switch { "downloading" => "downloading", @@ -794,17 +854,16 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli Eta = eta >= 8640000 ? null : eta, DownloadClient = client.Name, DownloadClientId = client.Id, - DownloadClientType = "qbittorrent", + DownloadClientType = ClientType, AddedAt = DateTimeOffset.FromUnixTimeSeconds(addedOn).DateTime, Seeders = numSeeds, Leechers = numLeechs, Ratio = ratio, CanPause = status == "downloading" || status == "queued", - CanRemove = true, + CanRemove = canBeRemoved, RemotePath = savePath, - LocalPath = localPath, SourceFiles = BuildTorrentSourceFiles(savePath, files), - ContentPath = outputPath + ContentPath = ResolveTorrentContentPath(savePath, files) }); } } @@ -816,378 +875,6 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli return items; } - public Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) - { - return Task.FromResult(new List<(string Id, string Name)>()); - } - - /// - /// Get all downloads as standardized DownloadClientItem objects - /// - public async Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) - { - var items = new List(); - if (client == null) return items; - - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - var categoryFilter = QBittorrentHelpers.BuildCategoryParameter(client.Settings, "&"); - - try - { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); - - using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); - if (loginResp.StatusCode == HttpStatusCode.Forbidden) - { - using var testResp = await httpClient.GetAsync($"{baseUrl}/api/v2/app/version", ct); - if (!testResp.IsSuccessStatusCode) - { - _logger.LogWarning("qBittorrent authentication appears to be enabled and credentials are invalid for client {ClientId}", LogRedaction.SanitizeText(client.Id)); - return items; - } - } - else if (!loginResp.IsSuccessStatusCode) - { - _logger.LogWarning("qBittorrent login failed with status {Status} for client {ClientId}", loginResp.StatusCode, LogRedaction.SanitizeText(client.Id)); - return items; - } - - // Fetch qBittorrent global preferences for seed limit evaluation (Sonarr parity) - bool globalMaxRatioEnabled = false; - float globalMaxRatio = -1f; - bool globalMaxSeedingTimeEnabled = false; - long globalMaxSeedingTime = -1; - try - { - using var prefsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/app/preferences", ct); - if (prefsResp.IsSuccessStatusCode) - { - var prefsJson = await prefsResp.Content.ReadAsStringAsync(ct); - if (!string.IsNullOrWhiteSpace(prefsJson)) - { - var prefs = JsonSerializer.Deserialize>(prefsJson); - if (prefs != null) - { - globalMaxRatioEnabled = prefs.TryGetValue("max_ratio_enabled", out var mre) && mre.GetBoolean(); - globalMaxRatio = prefs.TryGetValue("max_ratio", out var mr) ? (float)mr.GetDouble() : -1f; - globalMaxSeedingTimeEnabled = prefs.TryGetValue("max_seeding_time_enabled", out var mste) && mste.GetBoolean(); - globalMaxSeedingTime = prefs.TryGetValue("max_seeding_time", out var mst) ? mst.GetInt64() : -1; - } - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch qBittorrent preferences for seed limit evaluation, will use conservative defaults"); - } - - // Resolve removeCompletedDownloads setting once for all torrents - var removeCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && - client.RemoveCompletedDownloads != "none"; - - // Limit fields returned to reduce memory usage - var fields = "name,progress,size,downloaded,dlspeed,eta,state,hash,added_on,num_seeds,num_leechs,ratio,save_path,category,content_path,ratio_limit,seeding_time_limit,seeding_time"; - using var torrentsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/info?fields={Uri.EscapeDataString(fields)}{categoryFilter}", ct); - if (!torrentsResp.IsSuccessStatusCode) return items; - - var json = await torrentsResp.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(json)) return items; - - var torrents = JsonSerializer.Deserialize>>(json); - if (torrents == null) return items; - - foreach (var torrent in torrents) - { - var name = torrent.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty; - var progress = torrent.TryGetValue("progress", out var progressEl) ? progressEl.GetDouble() * 100 : 0; - var size = torrent.TryGetValue("size", out var sizeEl) ? sizeEl.GetInt64() : 0; - var downloaded = torrent.TryGetValue("downloaded", out var downloadedEl) ? downloadedEl.GetInt64() : 0; - var dlspeed = torrent.TryGetValue("dlspeed", out var dlspeedEl) ? dlspeedEl.GetDouble() : 0; - var eta = torrent.TryGetValue("eta", out var etaEl) ? (int?)etaEl.GetInt32() : null; - var state = torrent.TryGetValue("state", out var stateEl) ? stateEl.GetString() ?? "unknown" : "unknown"; - var hash = torrent.TryGetValue("hash", out var hashEl) ? hashEl.GetString() ?? string.Empty : string.Empty; - var numSeeds = torrent.TryGetValue("num_seeds", out var numSeedsEl) ? (int?)numSeedsEl.GetInt32() : null; - var numLeechs = torrent.TryGetValue("num_leechs", out var numLeechsEl) ? (int?)numLeechsEl.GetInt32() : null; - var ratio = torrent.TryGetValue("ratio", out var ratioEl) ? (double?)ratioEl.GetDouble() : null; - // Per-torrent seed limit overrides (-1 = use global, -2 = use global, >=0 = per-torrent limit) - var ratioLimit = torrent.TryGetValue("ratio_limit", out var ratioLimitEl) ? (float)ratioLimitEl.GetDouble() : -2f; - var seedingTimeLimit = torrent.TryGetValue("seeding_time_limit", out var stlEl) ? stlEl.GetInt64() : -2L; - var seedingTime = torrent.TryGetValue("seeding_time", out var seedTimeEl) ? (long?)seedTimeEl.GetInt64() : null; - var savePath = torrent.TryGetValue("save_path", out var savePathEl) ? savePathEl.GetString() ?? string.Empty : string.Empty; - var category = torrent.TryGetValue("category", out var categoryEl) ? categoryEl.GetString() ?? string.Empty : string.Empty; - var contentPath = torrent.TryGetValue("content_path", out var contentPathEl) ? contentPathEl.GetString() ?? string.Empty : string.Empty; - - // ✅ Map qBittorrent status to DownloadItemStatus enum - var status = state switch - { - "downloading" => DownloadItemStatus.Downloading, - "metaDL" => DownloadItemStatus.Downloading, - "forcedDL" => DownloadItemStatus.Downloading, - "forcedMetaDL" => DownloadItemStatus.Downloading, - "stalledDL" => DownloadItemStatus.Downloading, - "checkingDL" => DownloadItemStatus.Downloading, - "stoppedDL" => DownloadItemStatus.Paused, - "stoppedUP" => DownloadItemStatus.Paused, - "queuedDL" => DownloadItemStatus.Queued, - "queuedUP" => DownloadItemStatus.Queued, - "uploading" => DownloadItemStatus.Downloading, // Still seeding after completion - "stalledUP" => DownloadItemStatus.Downloading, - "checkingUP" => DownloadItemStatus.Downloading, - "forcedUP" => DownloadItemStatus.Downloading, - "checkingResumeData" => DownloadItemStatus.Downloading, - "moving" => DownloadItemStatus.Downloading, - "error" => DownloadItemStatus.Failed, - "missingFiles" => DownloadItemStatus.Failed, - _ => DownloadItemStatus.Warning - }; - - // If completed, override status - if (progress >= 100.0 && (status == DownloadItemStatus.Downloading || state == "uploading" || state == "stalledUP" || state == "checkingUP" || state == "forcedUP" || state == "stoppedUP")) - { - status = DownloadItemStatus.Completed; - } - - var localPath = savePath; - - var outputPath = localPath; - - TimeSpan? remainingTime = eta.HasValue && eta.Value < 8640000 ? TimeSpan.FromSeconds(eta.Value) : null; - - // qBittorrent can remove completed torrents while still seeding; file moves - // still require the torrent to be stopped so we don't break the payload. - var isStopped = state is "pausedUP" or "stoppedUP"; - var seedLimitReached = HasReachedSeedLimit( - ratio ?? 0, ratioLimit, seedingTime, seedingTimeLimit, - globalMaxRatioEnabled, globalMaxRatio, - globalMaxSeedingTimeEnabled, globalMaxSeedingTime); - var canBeRemoved = removeCompletedDownloads && seedLimitReached; - var canMoveFiles = canBeRemoved && isStopped; - - items.Add(new DownloadClientItem - { - DownloadId = hash.ToUpperInvariant(), // ✅ Uppercase SHA1 hash (standard format) - Title = name, - Category = category, - Status = status, - TotalSize = size, - RemainingSize = size - downloaded, - RemainingTime = remainingTime, - SeedRatio = ratio, - OutputPath = outputPath, - Message = state, - Progress = progress, - DownloadSpeed = dlspeed, - Seeders = numSeeds ?? 0, - Leechers = numLeechs ?? 0, - CanBeRemoved = canBeRemoved, - CanMoveFiles = canMoveFiles, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "qbittorrent", - protocol: DownloadProtocol.Torrent, - removeCompletedDownloads: removeCompletedDownloads, - hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString()) - ) - }); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error getting qBittorrent items - client may be unreachable"); - } - - return items; - } - - /// - /// Determines whether a qBittorrent torrent has reached its seed limit (ratio or time). - /// Mirrors Sonarr's HasReachedSeedLimit logic for qBittorrent. - /// - /// Current torrent ratio - /// Per-torrent ratio limit (-2 = use global, -1 = no limit, >=0 = per-torrent) - /// Torrent seeding time in seconds (null if unknown) - /// Per-torrent seeding time limit in minutes (-2 = use global, -1 = no limit, >=0 = per-torrent) - /// Whether global max ratio is enabled in qBit preferences - /// Global max ratio from qBit preferences - /// Whether global max seeding time is enabled in qBit preferences - /// Global max seeding time from qBit preferences (in minutes) - private static bool HasReachedSeedLimit( - double ratio, - float ratioLimit, - long? seedingTime, - long seedingTimeLimit, - bool globalMaxRatioEnabled, - float globalMaxRatio, - bool globalMaxSeedingTimeEnabled, - long globalMaxSeedingTime) - { - var hasEffectiveRatioLimit = - ratioLimit >= 0 || - (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio > 0); - var hasEffectiveSeedingTimeLimit = - seedingTimeLimit >= 0 || - (seedingTimeLimit <= -2 && globalMaxSeedingTimeEnabled && globalMaxSeedingTime > 0); - - if (!hasEffectiveRatioLimit && !hasEffectiveSeedingTimeLimit) - { - return true; - } - - // Check ratio limit (per-torrent override takes precedence) - if (ratioLimit >= 0 && ratioLimit - ratio <= 0.001) - { - // Per-torrent ratio limit set - return true; - } - - if (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio - ratio <= 0.001) - { - // Use global ratio limit (-2 means inherit global) - return true; - } - - // Check seeding time limit (per-torrent override takes precedence) - if (seedingTimeLimit >= 0 && - seedingTime is long currentSeedingTime && - currentSeedingTime >= seedingTimeLimit * 60) - { - // Per-torrent seeding time limit set (in minutes, convert to seconds for comparison) - return true; - } - - if (seedingTimeLimit <= -2 && - globalMaxSeedingTimeEnabled && - seedingTime is long inheritedSeedingTime && - inheritedSeedingTime >= globalMaxSeedingTime * 60) - { - // Use global seeding time limit (in minutes, convert to seconds) - return true; - } - - return false; - } - - /// - /// Get import item from DownloadClientItem - /// - public async Task GetImportItemAsync( - DownloadClientConfiguration client, - DownloadClientItem item, - DownloadClientItem? previousAttempt = null, - CancellationToken ct = default) - { - // Clone to avoid modifying original - var result = item.Clone(); - - // If OutputPath is already set, use it directly - if (!string.IsNullOrEmpty(result.OutputPath)) - { - _logger.LogDebug("Using existing OutputPath for import: {Path}", result.OutputPath); - return result; - } - - // Otherwise, resolve path from qBittorrent API - var hash = result.DownloadId.ToLowerInvariant(); - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - - try - { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - - // Login - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); - - using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); - if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode != HttpStatusCode.Forbidden) - { - _logger.LogWarning("qBittorrent login failed for import resolution"); - return result; - } - - // Query files API to determine base folder - using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); - if (!filesResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query torrent files for hash {Hash}", hash); - return result; - } - - var filesJson = await filesResp.Content.ReadAsStringAsync(ct); - var files = JsonSerializer.Deserialize>>(filesJson); - - if (files == null || !files.Any()) - { - _logger.LogDebug("No files found for torrent {Hash}", hash); - return result; - } - - // Get torrent properties to find save_path - using var propsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/properties?hash={hash}", ct); - if (!propsResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query torrent properties for hash {Hash}", hash); - return result; - } - - var propsJson = await propsResp.Content.ReadAsStringAsync(ct); - var props = JsonSerializer.Deserialize>(propsJson); - var savePath = props?.TryGetValue("save_path", out var savePathEl) is true - ? savePathEl.GetString() ?? string.Empty - : string.Empty; - - if (string.IsNullOrEmpty(savePath)) - { - _logger.LogWarning("No save_path found for torrent {Hash}", hash); - return result; - } - - var outputPath = ResolveTorrentContentPath(savePath, files); - if (string.IsNullOrEmpty(outputPath)) - { - _logger.LogWarning("Unable to resolve content path from torrent files for hash {Hash}", hash); - return result; - } - - // Apply remote path mapping - result.OutputPath = outputPath; - - _logger.LogInformation("Resolved import path for {Hash}: {Path}", hash, result.OutputPath); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error resolving import item for torrent {Hash}", hash); - } - - return result; - } - /// /// LEGACY: Resolves the actual import item for a completed download. /// Matches GetImportItem pattern. @@ -1316,32 +1003,6 @@ public async Task GetImportItemAsync( return result; } - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - private static List BuildTorrentSourceFiles( string savePath, List> files) @@ -1354,7 +1015,7 @@ private static List BuildTorrentSourceFiles( return files .Select(file => file.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) .Where(name => !string.IsNullOrWhiteSpace(name)) - .Select(name => CombineWithOptionalBase(savePath, name.Replace('/', Path.DirectorySeparatorChar))) + .Select(name => FileUtils.CombineWithOptionalBase(savePath, name.Replace('/', Path.DirectorySeparatorChar))) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } @@ -1400,8 +1061,8 @@ internal static string ResolveTorrentContentPath( if (fileNames.Count == 1) { return hasNestedPath - ? CombineWithOptionalBase(savePath, firstParts[0]) - : CombineWithOptionalBase(savePath, firstFile); + ? FileUtils.CombineWithOptionalBase(savePath, firstParts[0]) + : FileUtils.CombineWithOptionalBase(savePath, firstFile); } if (!hasNestedPath) @@ -1417,365 +1078,10 @@ internal static string ResolveTorrentContentPath( }); return allShareTopLevel - ? CombineWithOptionalBase(savePath, topLevel) + ? FileUtils.CombineWithOptionalBase(savePath, topLevel) : savePath; } - public async Task> FetchDownloadsAsync( - DownloadClientConfiguration client, - List downloads, - CancellationToken cancellationToken) - { - _logger.LogDebug("Polling qBittorrent client {ClientName}", client.Name); - try - { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - _logger.LogInformation("Polling qBittorrent client {ClientName} at {BaseUrl}", client.Name, baseUrl); - - // Create an HttpClient with its own CookieContainer so the qBittorrent - // SID cookie from login is stored and sent with subsequent requests. - // The factory "DownloadClient" has UseCookies=false which breaks qBit auth. - var cookieJar = new System.Net.CookieContainer(); - using var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = System.Net.DecompressionMethods.All - }; - using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - - // Login - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); - using var loginResp = await http.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, cancellationToken); - if (!loginResp.IsSuccessStatusCode) - { - var loginError = await loginResp.Content.ReadAsStringAsync(cancellationToken); - throw new DownloadClientAdapterPollingException($"qBittorrent login failed for client {client.Name} at {baseUrl} - StatusCode={loginResp.StatusCode}, Response={loginError}"); - } - _logger.LogDebug("qBittorrent login successful for client {ClientName}", client.Name); - - // Fetch qBittorrent global preferences for seed limit evaluation (Sonarr parity) - bool qbtGlobalMaxRatioEnabled = false; - float qbtGlobalMaxRatio = -1f; - bool qbtGlobalMaxSeedingTimeEnabled = false; - long qbtGlobalMaxSeedingTime = -1; - bool qbtRemoveCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && - client.RemoveCompletedDownloads != "none"; - try - { - using var prefsResp = await http.GetAsync($"{baseUrl}/api/v2/app/preferences", cancellationToken); - if (prefsResp.IsSuccessStatusCode) - { - var prefsJson = await prefsResp.Content.ReadAsStringAsync(cancellationToken); - if (!string.IsNullOrWhiteSpace(prefsJson)) - { - var prefs = System.Text.Json.JsonSerializer.Deserialize>(prefsJson); - if (prefs != null) - { - qbtGlobalMaxRatioEnabled = prefs.TryGetValue("max_ratio_enabled", out var mre) && mre.GetBoolean(); - qbtGlobalMaxRatio = prefs.TryGetValue("max_ratio", out var mr) ? (float)mr.GetDouble() : -1f; - qbtGlobalMaxSeedingTimeEnabled = prefs.TryGetValue("max_seeding_time_enabled", out var mste) && mste.GetBoolean(); - qbtGlobalMaxSeedingTime = prefs.TryGetValue("max_seeding_time", out var mst) ? mst.GetInt64() : -1; - } - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch qBittorrent preferences for seed limit evaluation"); - } - - // Request all necessary fields from torrents/info to avoid additional API calls per torrent - // This single call replaces the need for individual /properties calls per download - var fields = "hash,name,save_path,content_path,progress,amount_left,state,size,category,completion_on,seeding_time,ratio,ratio_limit,seeding_time_limit"; - - // Prefer querying only the hashes we are tracking (if available) to avoid fetching all torrents - var trackedHashes = downloads - .Select(d => d.Metadata != null && d.Metadata.TryGetValue("TorrentHash", out var h) ? h?.ToString() : null) - .Where(h => !string.IsNullOrEmpty(h)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - // If we have tracked hashes, chunk them into batches to avoid very large queries and to allow - // slight delays between requests to prevent overwhelming qBittorrent. - List> allTorrents = new(); - - if (trackedHashes.Any()) - { - const int batchSize = 100; // safe default batch size - _logger.LogDebug("Querying qBittorrent for specific hashes (total={Count}), using batches of {BatchSize}", trackedHashes.Count, batchSize); - - var batches = Enumerable.Range(0, (trackedHashes.Count + batchSize - 1) / batchSize) - .Select(i => trackedHashes.Skip(i * batchSize).Take(batchSize).ToList()) - .ToList(); - - foreach (var batch in batches) - { - var hashesParam = Uri.EscapeDataString(string.Join("|", batch)); - var query = $"?hashes={hashesParam}&fields={Uri.EscapeDataString(fields)}"; - - using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); - if (!torrentsResp.IsSuccessStatusCode) - { - var errorContent = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - throw new DownloadClientAdapterPollingException($"Failed to fetch torrent batch from qBittorrent for {client.Name} (batch size={batch.Count}, URL={baseUrl}/api/v2/torrents/info{query}, StatusCode={torrentsResp.StatusCode}, Response={errorContent})"); - } - - var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - var torrents = System.Text.Json.JsonSerializer.Deserialize>>(json); - if (torrents != null) - { - allTorrents.AddRange(torrents); - } - - // Small delay between batches to avoid hammering the client - await Task.Delay(150, cancellationToken); - } - } - else - { - var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - if (!string.IsNullOrWhiteSpace(configuredCategory)) - { - var cat = Uri.EscapeDataString(configuredCategory); - var query = $"?category={cat}&fields={Uri.EscapeDataString(fields)}"; - _logger.LogDebug("Querying qBittorrent by category: {Category}", configuredCategory); - - using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); - if (!torrentsResp.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"Failed to fetch torrents from qBittorrent for {client.Name}"); - } - - var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - var torrents = JsonSerializer.Deserialize>>(json); - if (torrents == null) return []; - - allTorrents.AddRange(torrents); - } - else - { - // Default: fetch a limited set of recent torrents - var query = $"?fields={Uri.EscapeDataString(fields)}"; - using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); - if (!torrentsResp.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"Failed to fetch torrents from qBittorrent for {client.Name}"); - } - - var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - var torrents = JsonSerializer.Deserialize>>(json); - if (torrents == null) return []; - - allTorrents.AddRange(torrents); - } - } - - // Build comprehensive lookup with all torrent info we need from single API call - var torrentLookup = new List<(string Hash, string Name, string SavePath, string ContentPath, double Progress, long AmountLeft, string State, long Size, string Category, long? SeedingTime, double Ratio, float RatioLimit, long SeedingTimeLimit, bool CanMoveFiles, bool CanBeRemoved)>(); - foreach (var t in allTorrents) - { - var hash = t.TryGetValue("hash", out var hashElement) ? hashElement.GetString() ?? "" : ""; - var name = t.TryGetValue("name", out var nameElement) ? nameElement.GetString() ?? "" : ""; - var savePath = t.TryGetValue("save_path", out var savePathElement) ? savePathElement.GetString() ?? "" : ""; - var contentPath = t.TryGetValue("content_path", out var contentPathElement) ? contentPathElement.GetString() ?? "" : ""; - var progress = t.TryGetValue("progress", out var progressElement) ? progressElement.GetDouble() : 0.0; - var amountLeft = t.TryGetValue("amount_left", out var amountLeftElement) ? amountLeftElement.GetInt64() : 0L; - var state = t.TryGetValue("state", out var stateElement) ? stateElement.GetString() ?? "" : ""; - var size = t.TryGetValue("size", out var sizeElement) ? sizeElement.GetInt64() : 0L; - var category = t.TryGetValue("category", out var categoryElement) ? categoryElement.GetString() ?? "" : ""; - var seedingTime = t.TryGetValue("seeding_time", out var seedingTimeElement) ? seedingTimeElement.GetInt64() : (long?)null; - var tRatio = t.TryGetValue("ratio", out var ratioElement) ? ratioElement.GetDouble() : 0.0; - var tRatioLimit = t.TryGetValue("ratio_limit", out var ratioLimitElement) ? (float)ratioLimitElement.GetDouble() : -2f; - var tSeedingTimeLimit = t.TryGetValue("seeding_time_limit", out var seedingTimeLimitElement) ? seedingTimeLimitElement.GetInt64() : -2L; - - // Sonarr parity: compute CanMoveFiles/CanBeRemoved per-torrent - var tIsStopped = state is "pausedUP" or "stoppedUP"; - var tSeedLimitReached = QBitHasReachedSeedLimit( - tRatio, tRatioLimit, seedingTime, tSeedingTimeLimit, - qbtGlobalMaxRatioEnabled, qbtGlobalMaxRatio, - qbtGlobalMaxSeedingTimeEnabled, qbtGlobalMaxSeedingTime); - var tCanBeRemoved = qbtRemoveCompletedDownloads && tSeedLimitReached; - var tCanMoveFiles = tCanBeRemoved && tIsStopped; - - torrentLookup.Add((hash, name, savePath, contentPath, progress, amountLeft, state, size, category, seedingTime, tRatio, tRatioLimit, tSeedingTimeLimit, tCanMoveFiles, tCanBeRemoved)); - } - - - _logger.LogDebug("Found {TorrentCount} torrents in qBittorrent for client {ClientName}", torrentLookup.Count, client.Name); - - // Log all torrents for diagnostics - foreach (var t in torrentLookup.Take(10)) - { - _logger.LogDebug("qBittorrent torrent: Name={Name}, Hash={Hash}, Progress={Progress:P2}, State={State}, Size={Size}", - t.Name, t.Hash, t.Progress, t.State, t.Size); - } - - // For each DB download associated with this client, try to find matching torrent - _logger.LogInformation("Checking {DownloadCount} downloads against qBittorrent torrents for client {ClientName}", - downloads.Count, client.Name); - - foreach (var dl in downloads) - { - try - { - _logger.LogDebug("Looking for qBittorrent match for download {DownloadId}: {Title}", dl.Id, dl.Title); - - // Try hash-based matching first (most reliable for qBittorrent) - var matched = (Hash: "", Name: "", SavePath: "", ContentPath: "", Progress: 0.0, AmountLeft: 0L, State: "", Size: 0L, Category: "", SeedingTime: (long?)null, Ratio: 0.0, RatioLimit: -2f, SeedingTimeLimit: -2L, CanMoveFiles: false, CanBeRemoved: false); - - // Check if we have a stored torrent hash for this download - if (dl.Metadata != null && dl.Metadata.TryGetValue("TorrentHash", out var hashObj)) - { - var storedHash = hashObj?.ToString(); - if (!string.IsNullOrEmpty(storedHash)) - { - matched = torrentLookup.FirstOrDefault(t => - string.Equals(t.Hash, storedHash, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogDebug("Found qBittorrent torrent by hash match: {Hash} for download {DownloadId}", storedHash, dl.Id); - } - } - } - - // Fallback to deterministic matching if hash matching failed. - // Following Sonarr's pattern: only match on exact identifiers - // (name or content path), never on fuzzy title similarity. - // Fuzzy matching caused cross-contamination (e.g. importing - // "Mr. Mercedes" files into "One Hundred Years of Solitude"). - if (string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogInformation("Hash matching failed for download {DownloadId}, trying exact name/path matching", dl.Id); - - // 1. Exact torrent name == download title - matched = torrentLookup.FirstOrDefault(t => - string.Equals(t.Name, dl.Title, StringComparison.OrdinalIgnoreCase)); - - // 2. Exact normalized title match (strip brackets/quality tags only) - if (string.IsNullOrEmpty(matched.Hash)) - { - var dlNorm = TitleUtils.NormalizeTitle(dl.Title); - matched = torrentLookup.FirstOrDefault(t => - string.Equals(TitleUtils.NormalizeTitle(t.Name), dlNorm, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogInformation("Normalized title match: '{DbTitle}' <-> '{TorrentTitle}'", dl.Title, matched.Name); - } - } - - // 3. Exact content path match - if (string.IsNullOrEmpty(matched.Hash) && !string.IsNullOrEmpty(dl.DownloadPath)) - { - var dlPathNorm = Path.GetFullPath(dl.DownloadPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - matched = torrentLookup.FirstOrDefault(t => - { - if (string.IsNullOrEmpty(t.ContentPath)) return false; - var contentNorm = Path.GetFullPath(t.ContentPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.Equals(dlPathNorm, contentNorm, StringComparison.OrdinalIgnoreCase); - }); - } - } - - if (string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogWarning("No matching qBittorrent torrent found for download {DownloadId}: {Title}", dl.Id, dl.Title); - continue; - } - - _logger.LogDebug("Found matching qBittorrent torrent for {DownloadId}: {TorrentName} (Hash: {Hash}, State: {State}, Progress: {Progress:P2}, SavePath: {SavePath}, ContentPath: {ContentPath})", - dl.Id, matched.Name, matched.Hash, matched.State, matched.Progress, matched.SavePath, matched.ContentPath); - - // DIAGNOSTIC: Log detailed completion check values - _logger.LogInformation("Completion diagnostic for {DownloadId}: Progress={Progress:F4} (>= 1.0? {ProgressCheck}), AmountLeft={AmountLeft} (== 0? {AmountCheck}), State={State}", - dl.Id, matched.Progress, matched.Progress >= 1.0, matched.AmountLeft, matched.AmountLeft == 0, matched.State); - - if (!string.IsNullOrEmpty(matched.SavePath) && dl.DownloadPath != matched.SavePath) - { - dl.DownloadPath = matched.SavePath; - } - - if (dl.Metadata == null) dl.Metadata = new Dictionary(); - - if (!string.IsNullOrEmpty(matched.ContentPath)) - { - dl.Metadata["ClientContentPath"] = matched.ContentPath; - } - - if (matched.SeedingTime.HasValue) - { - dl.Metadata["SeedingTimeSeconds"] = matched.SeedingTime.Value; - } - - dl.Metadata["CanMoveFiles"] = matched.CanMoveFiles; - dl.Metadata["CanBeRemoved"] = matched.CanBeRemoved; - - AdapterUtils.MapDownloadProgress(dl, matched.Progress * 100, matched.AmountLeft, matched.State); - - // Skip finalization/progress logic for downloads that are already - // being processed, awaiting import, or fully imported. Re-entering - // finalization for these would cause duplicate notifications and - // potentially import the wrong files a second time. - if (dl.Status == DownloadStatus.Moved || - dl.Status == DownloadStatus.Processing || - dl.Status == DownloadStatus.ImportPending) - { - _logger.LogDebug("Skipping finalization for {Status} download {DownloadId}", dl.Status, dl.Id); - continue; - } - - var normalizedState = (matched.State ?? string.Empty).ToLowerInvariant(); - if (normalizedState == "error" || normalizedState == "missingfiles") - { - dl.Failed($"qBittorrent state: {matched.State}"); - continue; - } - - // Lenient completion detection for qBittorrent - // A torrent is complete when progress >= 100% OR amount left is 0 - // The stability window below ensures we don't immediately import a torrent - // that just hit 100% - we wait for the configured delay period - var isComplete = matched.Progress >= 1.0 || matched.AmountLeft == 0; - - _logger.LogDebug("Completion check for {DownloadId}: IsComplete={IsComplete}, Progress={Progress:P2}, AmountLeft={AmountLeft}, State={State}", - dl.Id, isComplete, matched.Progress, matched.AmountLeft, matched.State); - - if (isComplete) - { - // Determine the best path to use for file discovery - // Priority: content_path (actual file/folder) > save_path + name (torrent root) > save_path (download directory) - var completionPath = !string.IsNullOrEmpty(matched.ContentPath) - ? matched.ContentPath - : (!string.IsNullOrEmpty(matched.SavePath) && !string.IsNullOrEmpty(matched.Name) - ? CombineWithOptionalBase(matched.SavePath, matched.Name) - : matched.SavePath); - - _logger.LogInformation("Download {DownloadId} observed as complete candidate (qBittorrent). Torrent: {TorrentName}, Path: {Path}. Waiting for stability window.", - dl.Id, matched.Name, completionPath); - - dl.Completed(); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error processing download {DownloadId} while polling qBittorrent", dl.Id); - } - } - - return downloads; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - throw new DownloadClientAdapterPollingException($"Error polling qBittorrent client {client.Name}"); - } - } - /// /// Determines whether a qBittorrent torrent has reached its seed limit. /// Used by the qBittorrent poller to compute CanMoveFiles/CanBeRemoved per-torrent. diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index b41b929e5..1a307428c 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using System.Globalization; using System.Net; using System.Text.Json; using Listenarr.Application.Downloads; @@ -22,16 +23,14 @@ using Listenarr.Application.Security; using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Adapters { public class SabnzbdAdapter : IDownloadClientAdapter { - public string ClientId => "sabnzbd"; public string ClientType => "sabnzbd"; - public DownloadProtocol Protocol => DownloadProtocol.Usenet; + public List Protocols => [DownloadProtocol.Usenet]; private readonly IHttpClientFactory _httpFactory; private readonly INzbUrlResolver _nzbUrlResolver; @@ -308,7 +307,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i } } - public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + public async Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default) { var items = new List(); if (client == null) return items; @@ -367,7 +366,7 @@ double ParseNumericValue(JsonElement element) if (element.ValueKind == JsonValueKind.String) { var str = element.GetString() ?? "0"; - if (double.TryParse(str, out var value)) + if (double.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) return value; } return 0; @@ -420,10 +419,15 @@ double ParseNumericValue(JsonElement element) // For SABnzbd, construct ContentPath from download path + filename var contentPath = !string.IsNullOrEmpty(remotePath) && !string.IsNullOrEmpty(filename) - ? CombineWithOptionalBase(remotePath, filename) + ? FileUtils.CombineWithOptionalBase(remotePath, filename) : remotePath; var localContentPath = contentPath; + if (!ids.Any(id => id == nzoId)) + { + continue; + } + items.Add(new QueueItem { Id = nzoId, @@ -506,6 +510,11 @@ double ParseNumericValue(JsonElement element) completedAt = DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime; } + if (!ids.Any(id => id == nzoId)) + { + continue; + } + items.Add(new QueueItem { Id = nzoId, @@ -553,306 +562,6 @@ double ParseNumericValue(JsonElement element) return items; } - public async Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) - { - var result = new List<(string Id, string Name)>(); - if (client == null) return result; - - try - { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - if (string.IsNullOrEmpty(apiKey)) return result; - - var historyUrl = $"{baseUrl}?mode=history&output=json&limit={limit}&apikey={Uri.EscapeDataString(apiKey)}"; - var http = _httpFactory.CreateClient(ClientType); - var historyResp = await http.GetAsync(historyUrl, ct); - if (!historyResp.IsSuccessStatusCode) return result; - - var historyText = await historyResp.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(historyText)) return result; - - var doc = JsonDocument.Parse(historyText); - if (doc.RootElement.TryGetProperty("history", out var history) && history.TryGetProperty("slots", out var slots) && slots.ValueKind == JsonValueKind.Array) - { - foreach (var slot in slots.EnumerateArray()) - { - var nzoId = slot.TryGetProperty("nzo_id", out var nzo) ? nzo.GetString() ?? string.Empty : string.Empty; - var name = slot.TryGetProperty("name", out var nm) ? nm.GetString() ?? string.Empty : string.Empty; - result.Add((nzoId, name)); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to fetch SABnzbd history (non-fatal)"); - } - - return result; - } - - /// - /// Get all downloads as standardized DownloadClientItem objects - /// - public async Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) - { - var items = new List(); - if (client == null) return items; - - var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - - try - { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - if (string.IsNullOrEmpty(apiKey)) - { - _logger.LogWarning("SABnzbd API key not configured for client {ClientName}", LogRedaction.SanitizeText(client.Name)); - return items; - } - - var requestUrl = $"{baseUrl}?mode=queue&output=json&apikey={Uri.EscapeDataString(apiKey)}"; - var http = _httpFactory.CreateClient(ClientType); - var response = await http.GetAsync(requestUrl, ct); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("SABnzbd queue request failed with status {Status}", response.StatusCode); - return items; - } - - var jsonContent = await response.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(jsonContent)) - { - _logger.LogWarning("SABnzbd returned empty response for client {ClientName}", LogRedaction.SanitizeText(client.Name)); - return items; - } - - var doc = JsonDocument.Parse(jsonContent); - if (!doc.RootElement.TryGetProperty("queue", out var queue)) return items; - if (!queue.TryGetProperty("slots", out var slots) || slots.ValueKind != JsonValueKind.Array) return items; - - var queueSpeed = 0.0; - if (queue.TryGetProperty("speed", out var speedProp)) - { - var speedStr = speedProp.GetString() ?? "0"; - queueSpeed = ParseSABnzbdSpeed(speedStr); - } - - foreach (var slot in slots.EnumerateArray()) - { - try - { - var nzoId = slot.TryGetProperty("nzo_id", out var id) ? id.GetString() ?? "" : ""; - var filename = slot.TryGetProperty("filename", out var fn) ? fn.GetString() ?? "Unknown" : "Unknown"; - var status = slot.TryGetProperty("status", out var st) ? st.GetString() ?? "Unknown" : "Unknown"; - - double ParseNumericValue(JsonElement element) - { - if (element.ValueKind == JsonValueKind.Number) - return element.GetDouble(); - if (element.ValueKind == JsonValueKind.String) - { - var str = element.GetString() ?? "0"; - if (double.TryParse(str, out var value)) - return value; - } - return 0; - } - - var sizeMB = slot.TryGetProperty("mb", out var mb) ? ParseNumericValue(mb) : 0; - var mbLeft = slot.TryGetProperty("mbleft", out var left) ? ParseNumericValue(left) : 0; - var percentage = slot.TryGetProperty("percentage", out var pct) ? ParseNumericValue(pct) : 0; - - var timeLeft = slot.TryGetProperty("timeleft", out var time) ? time.GetString() ?? "0:00:00" : "0:00:00"; - var category = slot.TryGetProperty("cat", out var cat) ? cat.GetString() ?? "" : ""; - - if (!DownloadClientCategoryFilter.Matches(configuredCategory, category)) - { - continue; - } - - int etaSeconds = 0; - if (!string.IsNullOrEmpty(timeLeft) && timeLeft != "0:00:00") - { - etaSeconds = ParseSABnzbdTimeLeft(timeLeft); - } - - var sizeBytes = (long)(sizeMB * 1024 * 1024); - var remainingBytes = (long)(mbLeft * 1024 * 1024); - - // Map SABnzbd status to DownloadItemStatus - var mappedStatus = status.ToLower() switch - { - "downloading" => DownloadItemStatus.Downloading, - "queued" => DownloadItemStatus.Queued, - "paused" => DownloadItemStatus.Paused, - "checking" => DownloadItemStatus.Downloading, - "extracting" => DownloadItemStatus.Downloading, - "moving" => DownloadItemStatus.Downloading, - "completed" => DownloadItemStatus.Completed, - "failed" => DownloadItemStatus.Failed, - _ => DownloadItemStatus.Queued - }; - - var remotePath = client.DownloadPath ?? ""; - var contentPath = !string.IsNullOrEmpty(remotePath) && !string.IsNullOrEmpty(filename) - ? CombineWithOptionalBase(remotePath, filename) - : remotePath; - var localContentPath = contentPath; - - TimeSpan? remainingTime = etaSeconds > 0 ? TimeSpan.FromSeconds(etaSeconds) : null; - - items.Add(new DownloadClientItem - { - DownloadId = nzoId.ToUpperInvariant(), // SABnzbd uses nzo_id as unique identifier - Title = filename, - Category = category, - Status = mappedStatus, - TotalSize = sizeBytes, - RemainingSize = remainingBytes, - RemainingTime = remainingTime, - OutputPath = localContentPath, - Message = status, - Progress = percentage, - DownloadSpeed = queueSpeed, // SABnzbd provides global speed - CanBeRemoved = true, - CanMoveFiles = mappedStatus == DownloadItemStatus.Completed, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "sabnzbd", - protocol: DownloadProtocol.Usenet, - removeCompletedDownloads: client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && - (removeVal is bool boolVal && boolVal), - hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString()) - ) - }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error parsing SABnzbd queue item"); - } - } - - _logger.LogInformation("Retrieved {Count} items from SABnzbd queue", items.Count); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error getting SABnzbd items"); - } - - return items; - } - - /// - /// Get import item from DownloadClientItem - /// - public async Task GetImportItemAsync( - DownloadClientConfiguration client, - DownloadClientItem item, - DownloadClientItem? previousAttempt = null, - CancellationToken ct = default) - { - // Clone to avoid mutating the original - var result = item.Clone(); - - // If OutputPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.OutputPath)) - { - var localPath = result.OutputPath; - if (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) - { - result.OutputPath = localPath; - return result; - } - } - - try - { - // Query SABnzbd history for the download - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) - { - _logger.LogWarning("SABnzbd API key not configured for client {ClientId}", client.Id); - return result; - } - - // Query history with nzo_id filter - var historyUrl = $"{baseUrl}?mode=history&output=json&apikey={Uri.EscapeDataString(apiKey)}"; - var http = _httpFactory.CreateClient(ClientType); - var historyResp = await http.GetAsync(historyUrl, ct); - - if (!historyResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query SABnzbd history for download {NzoId}", item.DownloadId); - return result; - } - - var historyText = await historyResp.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(historyText)) - { - return result; - } - - var doc = JsonDocument.Parse(historyText); - if (!doc.RootElement.TryGetProperty("history", out var history) || - !history.TryGetProperty("slots", out var slots) || - slots.ValueKind != JsonValueKind.Array) - { - _logger.LogWarning("Invalid SABnzbd history response format"); - return result; - } - - // Find matching history entry (case-insensitive comparison) - foreach (var slot in slots.EnumerateArray()) - { - var nzoId = slot.TryGetProperty("nzo_id", out var nzo) ? nzo.GetString() ?? string.Empty : string.Empty; - if (!string.Equals(nzoId, item.DownloadId, StringComparison.OrdinalIgnoreCase)) continue; - - // Extract storage path - var storage = slot.TryGetProperty("storage", out var storageProp) ? storageProp.GetString() : null; - if (string.IsNullOrEmpty(storage)) - { - _logger.LogWarning("No storage path found for SABnzbd download {NzoId}", item.DownloadId); - return result; - } - - // Apply path mapping - var localContentPath = storage; - result.OutputPath = localContentPath; - - _logger.LogDebug( - "Resolved SABnzbd content path for {NzoId}: {ContentPath}", - item.DownloadId, - localContentPath); - - return result; - } - - _logger.LogWarning("Download {NzoId} not found in SABnzbd history", item.DownloadId); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for SABnzbd download {NzoId}", item.DownloadId); - return result; - } - } - private int ParseSABnzbdTimeLeft(string timeLeft) { try @@ -1012,276 +721,6 @@ public async Task GetImportItemAsync( return result; } } - - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - - public async Task> FetchDownloadsAsync( - DownloadClientConfiguration client, - List downloads, - CancellationToken cancellationToken) - { - _logger.LogDebug("Polling SABnzbd client {ClientName}", client.Name); - try - { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - - using var http = _httpFactory.CreateClient(ClientType); - - // Get API key from settings - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) - { - throw new DownloadClientAdapterPollingException($"SABnzbd API key not configured for client {client.Id}"); - } - - // Poll SABnzbd queue for active downloads progress updates - var queueUrl = $"{baseUrl}?mode=queue&output=json&apikey={Uri.EscapeDataString(apiKey)}"; - // Redacted queue URL for safe diagnostics - _logger.LogDebug("SABnzbd poll queue URL (redacted): {Url}", LogRedaction.RedactText(queueUrl, LogRedaction.GetSensitiveValuesFromEnvironment().Concat([apiKey]))); - using var queueResponse = await http.GetAsync(queueUrl, cancellationToken); - - if (queueResponse.IsSuccessStatusCode) - { - var queueJson = await queueResponse.Content.ReadAsStringAsync(cancellationToken); - var queueDoc = JsonDocument.Parse(queueJson); - - if (queueDoc.RootElement.TryGetProperty("queue", out var queue) && - queue.TryGetProperty("slots", out var queueSlots) && - queueSlots.ValueKind == JsonValueKind.Array) - { - foreach (Download download in downloads) - { - var clientDownloadId = download.GetExternalId(); - - foreach (var slot in queueSlots.EnumerateArray()) - { - try - { - var nzoId = slot.TryGetProperty("nzo_id", out var nzoIdProp) ? nzoIdProp.GetString() ?? "" : ""; - if (!string.IsNullOrEmpty(clientDownloadId) && !string.Equals(nzoId, clientDownloadId, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var filename = slot.TryGetProperty("filename", out var filenameProp) ? filenameProp.GetString() ?? "" : ""; - if (!TitleUtils.AreTitlesSimilar(download.Title, filename)) - { - continue; - } - - // SABnzbd sometimes returns numeric values as numbers or strings. - // Be defensive and accept either JSON number or JSON string. - double GetDoubleValue(System.Text.Json.JsonElement el) - { - try - { - if (el.ValueKind == System.Text.Json.JsonValueKind.Number) - return el.GetDouble(); - - if (el.ValueKind == System.Text.Json.JsonValueKind.String) - { - var s = el.GetString(); - if (double.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)) - return v; - } - } - catch (Exception caughtEx_10) when (caughtEx_10 is not OperationCanceledException && caughtEx_10 is not OutOfMemoryException && caughtEx_10 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - return 0.0; - } - - var percentage = slot.TryGetProperty("percentage", out var percentageProp) ? GetDoubleValue(percentageProp) : 0.0; - var mbleft = slot.TryGetProperty("mbleft", out var mbleftProp) ? GetDoubleValue(mbleftProp) : 0.0; - var status = slot.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; - - // Calculate progress and update - // percentage is provided by SABnzbd as a percent (e.g. 50.0). Our UpdateDownloadProgressAsync - // expects a percentage in the 0..100 range. Use the percentage directly. - var progressPercent = percentage; // 0..100 - - // Convert sizes from MB -> bytes - var amountLeft = (long)(mbleft * 1024 * 1024); - - // Update progress using percent and amountLeft (UpdateDownloadProgressAsync uses percent->downloaded size calculation when TotalSize is set) - AdapterUtils.MapDownloadProgress(download, progressPercent, amountLeft, status); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error updating SABnzbd queue progress for slot"); - } - } - } - } - } - - // Get completed downloads (history) - limit to recent items - var historyUrl = $"{baseUrl}?mode=history&limit=100&output=json&apikey={Uri.EscapeDataString(apiKey)}"; - // Redacted history URL for safe diagnostics - _logger.LogDebug("SABnzbd history URL (redacted): {Url}", LogRedaction.RedactText(historyUrl, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { apiKey }))); - using var historyResponse = await http.GetAsync(historyUrl, cancellationToken); - - if (!historyResponse.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"Failed to fetch SABnzbd history for {client.Id}: {historyResponse.StatusCode}"); - } - - var historyJson = await historyResponse.Content.ReadAsStringAsync(cancellationToken); - var historyDoc = System.Text.Json.JsonDocument.Parse(historyJson); - - if (!historyDoc.RootElement.TryGetProperty("history", out var history) || - !history.TryGetProperty("slots", out var slots) || - slots.ValueKind != System.Text.Json.JsonValueKind.Array) - { - throw new DownloadClientAdapterPollingException($"No history data found for SABnzbd client {client.Id}"); - } - - // Build a lookup of completed items for faster matching - // Include nzo_id when available so we can match downloads by ID as well - var completedItems = new List<(string Name, string Status, string Path, DateTime CompletedTime, string NzoId)>(); - var failedItems = new List<(string Name, string Status, string Path, DateTime CompletedTime, string NzoId, string Error)>(); - - foreach (var slot in slots.EnumerateArray()) - { - var name = slot.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : ""; - var status = slot.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; - var path = slot.TryGetProperty("storage", out var pathProp) ? pathProp.GetString() ?? "" : ""; - var nzoId = slot.TryGetProperty("nzo_id", out var nzoIdProp) ? nzoIdProp.GetString() ?? "" : ""; - - // Parse completion time - var completedTime = DateTime.MinValue; - if (slot.TryGetProperty("completed", out var completedProp)) - { - var completedTimestamp = completedProp.GetInt64(); - completedTime = DateTimeOffset.FromUnixTimeSeconds(completedTimestamp).DateTime; - } - - if (!string.IsNullOrEmpty(name) && - (status.Equals("Completed", StringComparison.OrdinalIgnoreCase) || - status.Equals("Complete", StringComparison.OrdinalIgnoreCase))) - { - _logger.LogInformation("SABnzbd history slot parsed: nzo_id={NzoId}, name={Name}, status={Status}, path={Path}, completed={Completed}", nzoId, LogRedaction.SanitizeText(name), LogRedaction.SanitizeText(status), LogRedaction.SanitizeFilePath(path), completedTime); - - completedItems.Add((name, status, path, completedTime, nzoId)); - } - else if (!string.IsNullOrEmpty(name) && status.Equals("Failed", StringComparison.OrdinalIgnoreCase)) - { - var failMessage = slot.TryGetProperty("fail_message", out var failProp) - ? failProp.GetString() ?? string.Empty - : status; - - failedItems.Add((name, status, path, completedTime, nzoId, failMessage)); - } - } - - _logger.LogDebug("Found {CompletedCount} completed items in SABnzbd history for client {ClientName}", - completedItems.Count, client.Name); - - // Check each download against completed items - foreach (var dl in downloads) - { - // Skip downloads that are already being processed, awaiting import, - // or fully imported to avoid duplicate finalization/notifications. - if (dl.Status == DownloadStatus.Moved || - dl.Status == DownloadStatus.Processing || - dl.Status == DownloadStatus.ImportPending) - continue; - - try - { - var failedMatch = failedItems.FirstOrDefault(item => - (!string.IsNullOrEmpty(item.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && - string.Equals(item.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) || - string.Equals(item.Name, dl.Title, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrEmpty(dl.Title) && item.Name.Contains(dl.Title, StringComparison.OrdinalIgnoreCase)) - ); - - if (!string.IsNullOrEmpty(failedMatch.Name)) - { - continue; - } - - // Find matching active download by NZO ID - var matchingItem = completedItems.FirstOrDefault(item => - // Match by NZO ID (strongest) or fall back to name/title matching - (!string.IsNullOrEmpty(item.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && - string.Equals(item.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) || - string.Equals(item.Name, dl.Title, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrEmpty(dl.Title) && item.Name.Contains(dl.Title, StringComparison.OrdinalIgnoreCase)) - ); - - if (!string.IsNullOrEmpty(matchingItem.Name)) - { - AdapterUtils.MapDownloadProgress(dl, 100.0, 0, "success"); - - // Record match type metrics - try - { - if (!string.IsNullOrEmpty(matchingItem.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && string.Equals(matchingItem.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) - { - _appMetricsService.Increment("sabnzbd.history.match.nzo"); - } - else if (!string.IsNullOrEmpty(matchingItem.Name) && string.Equals(matchingItem.Name, dl.Title, StringComparison.OrdinalIgnoreCase)) - { - _appMetricsService.Increment("sabnzbd.history.match.title_exact"); - } - else - { - _appMetricsService.Increment("sabnzbd.history.match.title_contains"); - } - } - catch (Exception caughtEx_11) when (caughtEx_11 is not OperationCanceledException && caughtEx_11 is not OutOfMemoryException && caughtEx_11 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - _logger.LogInformation("Found completed SABnzbd download: {DownloadTitle} -> {CompletedName} at {Path}", - dl.Title, matchingItem.Name, matchingItem.Path); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error processing download {DownloadId} while polling SABnzbd", dl.Id); - } - } - - return downloads; - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - throw new DownloadClientAdapterPollingException($"Error polling SABnzbd client {client.Id}"); - } - } } } diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 2ea6fb76d..7d3949b41 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -26,16 +26,14 @@ using Listenarr.Application.Security; using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Adapters { public class TransmissionAdapter : IDownloadClientAdapter { - public string ClientId => "transmission"; public string ClientType => "transmission"; - public DownloadProtocol Protocol => DownloadProtocol.Torrent; + public List Protocols => [DownloadProtocol.Torrent]; private readonly IHttpClientFactory _httpClientFactory; private readonly ITorrentFileDownloader _torrentFileDownloader; @@ -327,79 +325,13 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i } } - public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + public async Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default) { var items = new List(); if (client == null) return items; var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - // Use old format for compatibility with Transmission < 4.1.0 - var payload = new - { - method = "torrent-get", - arguments = new - { - fields = new[] - { - "id", "hashString", "name", "percentDone", "status", "totalSize", "rateDownload", "rateUpload", - "leftUntilDone", "eta", "downloadDir", "addedDate", "uploadedEver", "uploadRatio", "labels" - } - }, - tag = 3 - }; - - try - { - var response = await InvokeRpcAsync(client, payload, ct); - if (!response.TryGetProperty("arguments", out var args) || !args.TryGetProperty("torrents", out var torrents) || torrents.ValueKind != JsonValueKind.Array) - { - return items; - } - - foreach (var torrent in torrents.EnumerateArray()) - { - try - { - var labels = ExtractLabels(torrent); - if (!DownloadClientCategoryFilter.MatchesAny(configuredCategory, labels)) - { - continue; - } - - var queueItem = await MapTorrentAsync(client, torrent, ct); - items.Add(queueItem); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to map Transmission torrent entry (non-fatal)"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to retrieve Transmission queue for client {ClientName}", LogRedaction.SanitizeText(client.Name ?? client.Id)); - } - - return items; - } - - public Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) - { - // Transmission does not expose a dedicated history endpoint via RPC. - return Task.FromResult(new List<(string Id, string Name)>()); - } - - /// - /// Get all downloads as standardized DownloadClientItem objects - /// - public async Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) - { - var items = new List(); - if (client == null) return items; - - var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - // Fetch session-level seed config for Sonarr-parity seed limit evaluation bool sessionSeedRatioLimited = false; double sessionSeedRatioLimit = 0; @@ -422,8 +354,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur _logger.LogDebug(ex, "Failed to fetch Transmission session config for seed limit evaluation, will use conservative defaults"); } - var sessionConfig = (sessionSeedRatioLimited, sessionSeedRatioLimit, sessionIdleSeedingLimitEnabled, sessionIdleSeedingLimit); - + // Use old format for compatibility with Transmission < 4.1.0 var payload = new { method = "torrent-get", @@ -431,7 +362,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur { fields = new[] { - "id", "hashString", "name", "percentDone", "status", "totalSize", "rateDownload", "rateUpload", + "id", "hashString", "name", "percentDone", "isFinished", "status", "totalSize", "rateDownload", "rateUpload", "leftUntilDone", "eta", "downloadDir", "addedDate", "uploadedEver", "uploadRatio", "labels", "seedRatioMode", "seedRatioLimit", "seedIdleMode", "seedIdleLimit", "secondsSeeding" } @@ -457,8 +388,13 @@ public async Task> GetItemsAsync(DownloadClientConfigur continue; } - var downloadClientItem = await MapToDownloadClientItemAsync(client, torrent, sessionConfig, ct); - items.Add(downloadClientItem); + var queueItem = await MapTorrentAsync(client, torrent, sessionSeedRatioLimited, sessionSeedRatioLimit, sessionIdleSeedingLimitEnabled, sessionIdleSeedingLimit, ct); + if (!ids.Any(id => id == queueItem.Id)) + { + continue; + } + + items.Add(queueItem); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -468,96 +404,12 @@ public async Task> GetItemsAsync(DownloadClientConfigur } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogWarning(ex, "Failed to retrieve Transmission items for client {ClientName}", LogRedaction.SanitizeText(client.Name ?? client.Id)); + _logger.LogWarning(ex, "Failed to retrieve Transmission queue for client {ClientName}", LogRedaction.SanitizeText(client.Name ?? client.Id)); } return items; } - /// - /// Get import item from DownloadClientItem - /// - public async Task GetImportItemAsync( - DownloadClientConfiguration client, - DownloadClientItem item, - DownloadClientItem? previousAttempt = null, - CancellationToken ct = default) - { - // Clone to avoid mutating the original - var result = item.Clone(); - - // If OutputPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.OutputPath)) - { - var localPath = result.OutputPath; - if (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) - { - result.OutputPath = localPath; - return result; - } - } - - // Query Transmission for the torrent details - var payload = new - { - method = "torrent-get", - arguments = new - { - ids = ParseTransmissionIds(item.DownloadId), - fields = new[] { "id", "name", "downloadDir" } - }, - tag = 5 - }; - - try - { - var response = await InvokeRpcAsync(client, payload, ct); - if (!response.TryGetProperty("arguments", out var args) || - !args.TryGetProperty("torrents", out var torrents) || - torrents.ValueKind != JsonValueKind.Array) - { - _logger.LogWarning("Failed to query Transmission for torrent {TorrentId}", item.DownloadId); - return result; - } - - var torrent = torrents.EnumerateArray().FirstOrDefault(); - if (torrent.ValueKind == JsonValueKind.Undefined) - { - _logger.LogWarning("Torrent {TorrentId} not found in Transmission", item.DownloadId); - return result; - } - - var downloadDir = torrent.TryGetProperty("downloadDir", out var dirProp) ? dirProp.GetString() : null; - var name = torrent.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; - - if (string.IsNullOrEmpty(downloadDir) || string.IsNullOrEmpty(name)) - { - _logger.LogWarning("Missing downloadDir or name for torrent {TorrentId}", item.DownloadId); - return result; - } - - // Transmission stores files as: downloadDir/name. - var contentPath = FileUtils.CombineWithOptionalBase(downloadDir, name); - - // Apply path mapping - // FIXME: Path mapping should be the responsability of the download processors - var localContentPath = contentPath; - result.OutputPath = localContentPath; - - _logger.LogDebug( - "Resolved Transmission content path for {TorrentId}: {ContentPath}", - item.DownloadId, - localContentPath); - - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for Transmission torrent {TorrentId}", item.DownloadId); - return result; - } - } - /// /// LEGACY: Resolves the actual import item for a completed download. /// Queries Transmission API for downloadDir and builds the content path. @@ -682,7 +534,14 @@ private static List BuildTransmissionSourceFiles(string? downloadDir, Js return sourceFiles; } - private async Task MapTorrentAsync(DownloadClientConfiguration client, JsonElement torrent, CancellationToken ct) + private async Task MapTorrentAsync( + DownloadClientConfiguration client, + JsonElement torrent, + bool sessionSeedRatioLimited, + double sessionSeedRatioLimit, + bool sessionIdleSeedingLimitEnabled, + int sessionIdleSeedingLimit, + CancellationToken ct) { // Try snake_case (JSON-RPC 2.0 / Transmission 4.1+) first, fall back to camelCase for backwards compatibility var id = torrent.TryGetProperty("hash_string", out var hashProp) || torrent.TryGetProperty("hashString", out hashProp) @@ -710,6 +569,18 @@ private async Task MapTorrentAsync(DownloadClientConfiguration client var uploadRatio = (torrent.TryGetProperty("upload_ratio", out var ratioProp) || torrent.TryGetProperty("uploadRatio", out ratioProp)) ? ratioProp.GetDouble() : 0d; + // Seed limit fields for Sonarr-parity seed limit evaluation + var seedRatioMode = (torrent.TryGetProperty("seed_ratio_mode", out var srmProp) || torrent.TryGetProperty("seedRatioMode", out srmProp)) + ? srmProp.GetInt32() : 0; + var seedRatioLimit = (torrent.TryGetProperty("seed_ratio_limit", out var srlProp) || torrent.TryGetProperty("seedRatioLimit", out srlProp)) + ? srlProp.GetDouble() : 0d; + var seedIdleMode = (torrent.TryGetProperty("seed_idle_mode", out var simProp) || torrent.TryGetProperty("seedIdleMode", out simProp)) + ? simProp.GetInt32() : 0; + var seedIdleLimit = (torrent.TryGetProperty("seed_idle_limit", out var silProp) || torrent.TryGetProperty("seedIdleLimit", out silProp)) + ? silProp.GetInt32() : 0; + var secondsSeeding = (torrent.TryGetProperty("seconds_seeding", out var ssProp) || torrent.TryGetProperty("secondsSeeding", out ssProp)) + ? ssProp.GetInt64() : 0L; + var downloaded = Math.Max(0, totalSize - leftUntilDone); var status = statusCode switch @@ -735,7 +606,6 @@ private async Task MapTorrentAsync(DownloadClientConfiguration client _logger.LogDebug("After completion check: hash={Hash}, finalStatus={Status}", id, status); - string? localPath = downloadDir; var addedAt = addedDate > 0 ? DateTimeOffset.FromUnixTimeSeconds(addedDate).UtcDateTime : DateTime.UtcNow; // For Transmission, construct ContentPath from downloadDir + name @@ -745,6 +615,23 @@ private async Task MapTorrentAsync(DownloadClientConfiguration client var localContentPath = contentPath; var primaryLabel = ExtractLabels(torrent).FirstOrDefault() ?? string.Empty; + // Sonarr parity: CanBeRemoved = removeCompletedDownloads && HasReachedSeedLimit + // CanMoveFiles = CanBeRemoved && status == Stopped (statusCode 0) + // This prevents removing torrents before seed goals are met and prevents + // moving files from active seeders (which breaks the torrent). + var removeCompletedDownloads = client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && + (removeVal is bool boolVal && boolVal); + var isStopped = statusCode == 0; // TR_STATUS_STOPPED + var isSeeding = statusCode == 6; // TR_STATUS_SEED + var seedLimitReached = HasReachedSeedLimit( + isStopped, isSeeding, uploadRatio, + seedRatioMode, seedRatioLimit, + seedIdleMode, seedIdleLimit, secondsSeeding, + sessionSeedRatioLimited, sessionSeedRatioLimit, + sessionIdleSeedingLimitEnabled, sessionIdleSeedingLimit + ); + var canBeRemoved = removeCompletedDownloads && seedLimitReached; + var queueItem = new QueueItem { Id = id, @@ -762,185 +649,14 @@ private async Task MapTorrentAsync(DownloadClientConfiguration client AddedAt = addedAt, Ratio = uploadRatio, CanPause = status is "downloading" or "queued", - CanRemove = true, + CanRemove = canBeRemoved, RemotePath = downloadDir, - LocalPath = localPath, ContentPath = localContentPath }; return queueItem; } - private async Task MapToDownloadClientItemAsync( - DownloadClientConfiguration client, - JsonElement torrent, - (bool SeedRatioLimited, double SeedRatioLimit, bool IdleSeedingLimitEnabled, int IdleSeedingLimit) sessionConfig, - CancellationToken ct) - { - // Try snake_case (JSON-RPC 2.0 / Transmission 4.1+) first, fall back to camelCase for backwards compatibility - var hash = torrent.TryGetProperty("hash_string", out var hashProp) || torrent.TryGetProperty("hashString", out hashProp) - ? hashProp.GetString() ?? string.Empty : string.Empty; - var numericId = torrent.TryGetProperty("id", out var numericIdProp) ? numericIdProp.GetInt32() : 0; - var name = torrent.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty; - var percentDone = (torrent.TryGetProperty("percent_done", out var percentProp) || torrent.TryGetProperty("percentDone", out percentProp)) - ? percentProp.GetDouble() * 100 : 0d; - var totalSize = (torrent.TryGetProperty("total_size", out var sizeProp) || torrent.TryGetProperty("totalSize", out sizeProp)) - ? sizeProp.GetInt64() : 0L; - var leftUntilDone = (torrent.TryGetProperty("left_until_done", out var leftProp) || torrent.TryGetProperty("leftUntilDone", out leftProp)) - ? leftProp.GetInt64() : 0L; - var rateDownload = (torrent.TryGetProperty("rate_download", out var rateProp) || torrent.TryGetProperty("rateDownload", out rateProp)) - ? rateProp.GetDouble() : 0d; - var eta = torrent.TryGetProperty("eta", out var etaProp) ? etaProp.GetInt32() : -1; - var downloadDir = (torrent.TryGetProperty("download_dir", out var dirProp) || torrent.TryGetProperty("downloadDir", out dirProp)) - ? dirProp.GetString() ?? string.Empty : string.Empty; - var statusCode = torrent.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; - var uploadRatio = (torrent.TryGetProperty("upload_ratio", out var ratioProp) || torrent.TryGetProperty("uploadRatio", out ratioProp)) - ? ratioProp.GetDouble() : 0d; - - // Seed limit fields for Sonarr-parity seed limit evaluation - var seedRatioMode = (torrent.TryGetProperty("seed_ratio_mode", out var srmProp) || torrent.TryGetProperty("seedRatioMode", out srmProp)) - ? srmProp.GetInt32() : 0; - var seedRatioLimit = (torrent.TryGetProperty("seed_ratio_limit", out var srlProp) || torrent.TryGetProperty("seedRatioLimit", out srlProp)) - ? srlProp.GetDouble() : 0d; - var seedIdleMode = (torrent.TryGetProperty("seed_idle_mode", out var simProp) || torrent.TryGetProperty("seedIdleMode", out simProp)) - ? simProp.GetInt32() : 0; - var seedIdleLimit = (torrent.TryGetProperty("seed_idle_limit", out var silProp) || torrent.TryGetProperty("seedIdleLimit", out silProp)) - ? silProp.GetInt32() : 0; - var secondsSeeding = (torrent.TryGetProperty("seconds_seeding", out var ssProp) || torrent.TryGetProperty("secondsSeeding", out ssProp)) - ? ssProp.GetInt64() : 0L; - - // Map Transmission status codes to DownloadItemStatus - var status = statusCode switch - { - 0 => DownloadItemStatus.Paused, // Stopped - 1 => DownloadItemStatus.Queued, // Check waiting - 2 => DownloadItemStatus.Downloading, // Checking - 3 => DownloadItemStatus.Queued, // Download waiting - 4 => DownloadItemStatus.Downloading, // Downloading - 5 => DownloadItemStatus.Queued, // Seed waiting - 6 => DownloadItemStatus.Downloading, // Seeding - _ => DownloadItemStatus.Warning - }; - - if (percentDone >= 100.0 && (statusCode is 0 or 3 or 5 or 6)) - { - status = DownloadItemStatus.Completed; - } - - // For Transmission, construct OutputPath from downloadDir + name - var contentPath = !string.IsNullOrEmpty(downloadDir) && !string.IsNullOrEmpty(name) - ? FileUtils.CombineWithOptionalBase(downloadDir, name) - : downloadDir; - var localContentPath = contentPath; - var primaryLabel = ExtractLabels(torrent).FirstOrDefault() ?? string.Empty; - - TimeSpan? remainingTime = eta >= 0 ? TimeSpan.FromSeconds(eta) : null; - - // ✅ Use hash as DownloadId if available, otherwise fall back to numeric ID - var downloadId = !string.IsNullOrEmpty(hash) ? hash.ToUpperInvariant() : numericId.ToString(CultureInfo.InvariantCulture); - - // Sonarr parity: CanBeRemoved = removeCompletedDownloads && HasReachedSeedLimit - // CanMoveFiles = CanBeRemoved && status == Stopped (statusCode 0) - // This prevents removing torrents before seed goals are met and prevents - // moving files from active seeders (which breaks the torrent). - var removeCompletedDownloads = client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && - (removeVal is bool boolVal && boolVal); - var isStopped = statusCode == 0; // TR_STATUS_STOPPED - var isSeeding = statusCode == 6; // TR_STATUS_SEED - var seedLimitReached = HasReachedSeedLimit( - isStopped, isSeeding, uploadRatio, - seedRatioMode, seedRatioLimit, - seedIdleMode, seedIdleLimit, secondsSeeding, - sessionConfig); - var canBeRemoved = removeCompletedDownloads && seedLimitReached; - var canMoveFiles = canBeRemoved && isStopped; - - return new DownloadClientItem - { - DownloadId = downloadId, - Title = name, - Category = primaryLabel, - Status = status, - TotalSize = totalSize, - RemainingSize = leftUntilDone, - RemainingTime = remainingTime, - SeedRatio = uploadRatio, - OutputPath = localContentPath, - Message = $"Status code: {statusCode}", - Progress = percentDone, - DownloadSpeed = rateDownload, - CanBeRemoved = canBeRemoved, - CanMoveFiles = canMoveFiles, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "transmission", - protocol: DownloadProtocol.Torrent, - removeCompletedDownloads: removeCompletedDownloads, - hasPostImportCategory: false // Transmission doesn't support post-import categories - ) - }; - } - - /// - /// Determines whether a Transmission torrent has reached its seed limit (ratio or idle time). - /// Mirrors Sonarr's HasReachedSeedLimit logic for Transmission. - /// - private static bool HasReachedSeedLimit( - bool isStopped, - bool isSeeding, - double ratio, - int seedRatioMode, - double seedRatioLimit, - int seedIdleMode, - int seedIdleLimit, - long secondsSeeding, - (bool SeedRatioLimited, double SeedRatioLimit, bool IdleSeedingLimitEnabled, int IdleSeedingLimit) sessionConfig) - { - var hasEffectiveRatioLimit = - (seedRatioMode == 1 && seedRatioLimit > 0) || - (seedRatioMode == 0 && sessionConfig.SeedRatioLimited && sessionConfig.SeedRatioLimit > 0); - var hasEffectiveIdleLimit = - (seedIdleMode == 1 && seedIdleLimit > 0) || - (seedIdleMode == 0 && sessionConfig.IdleSeedingLimitEnabled && sessionConfig.IdleSeedingLimit > 0); - - // With no effective seed constraints configured, honor the cleanup policy - // immediately instead of reporting the torrent as non-removable forever. - if (!hasEffectiveRatioLimit && !hasEffectiveIdleLimit) - { - return true; - } - - // seedRatioMode: 0 = global, 1 = per-torrent, 2 = unlimited - if (seedRatioMode == 1 && isStopped && ratio >= seedRatioLimit) - { - // Per-torrent ratio limit - return true; - } - - if (seedRatioMode == 0 && isStopped && sessionConfig.SeedRatioLimited && ratio >= sessionConfig.SeedRatioLimit) - { - // Use global ratio limit - return true; - } - - // seedIdleMode: 0 = global, 1 = per-torrent, 2 = unlimited - // Transmission uses idle limit as a seeding time limit when set per-torrent - if (seedIdleMode == 1 && (isStopped || isSeeding) && secondsSeeding > seedIdleLimit * 60) - { - // Per-torrent idle/seed time limit (in minutes) - return true; - } - - if (seedIdleMode == 0 && isStopped && sessionConfig.IdleSeedingLimitEnabled) - { - // The global idle limit is a real idle limit, if configured then 'Stopped' is enough - return true; - } - - return false; - } - private static List ExtractLabels(JsonElement torrent) { var labels = new List(); @@ -1268,288 +984,11 @@ or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect return null; } - public async Task> FetchDownloadsAsync( - DownloadClientConfiguration client, - List downloads, - CancellationToken cancellationToken) - { - _logger.LogInformation("Polling Transmission client {ClientName} for {Count} downloads", client.Name, downloads.Count); - try - { - var rpcPath = "/transmission/rpc"; - if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) - { - var custom = urlBaseObj?.ToString()?.Trim(); - if (!string.IsNullOrEmpty(custom)) - { - rpcPath = custom.StartsWith('/') ? custom : "/" + custom; - } - } - var baseUrl = DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); - using var http = _httpClientFactory.CreateClient(ClientType); - - // Resolve removeCompletedDownloads for CanMoveFiles/CanBeRemoved evaluation - bool txRemoveCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && - client.RemoveCompletedDownloads != "none"; - - // Prepare RPC payload for torrent-get (includes seed limit fields for Sonarr parity) - var rpc = new - { - method = "torrent-get", - arguments = new - { - fields = new[] { "id", "hashString", "name", "percentDone", "leftUntilDone", "isFinished", "status", "downloadDir", - "uploadRatio", "seedRatioMode", "seedRatioLimit", "seedIdleMode", "seedIdleLimit", "secondsSeeding" } - }, - tag = 4 - }; - - var serializedPayload = System.Text.Json.JsonSerializer.Serialize(rpc, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - string? sessionId = null; - - _logger.LogDebug("PollTransmission RPC request to {BaseUrl}", baseUrl); - - // Transmission CSRF protection: first request gets 409 with session-id, retry with that session-id - // This mirrors TransmissionAdapter.InvokeRpcAsync pattern - for (var attempt = 0; attempt < 2; attempt++) - { - using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl) - { - Content = new StringContent(serializedPayload, System.Text.Encoding.UTF8, "application/json") - }; - - // Add session-id header if we have one (from previous 409 retry) - if (!string.IsNullOrEmpty(sessionId)) - { - request.Headers.Add("X-Transmission-Session-Id", sessionId); - _logger.LogDebug("PollTransmission using X-Transmission-Session-Id: {SessionId}", sessionId); - } - - // Add Basic auth header if configured - if (!string.IsNullOrWhiteSpace(client.Username)) - { - var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); - } - - var resp = await http.SendAsync(request, cancellationToken); - var respText = await resp.Content.ReadAsStringAsync(cancellationToken); - - // Handle 409 Conflict (CSRF session-id flow) - if (resp.StatusCode == System.Net.HttpStatusCode.Conflict && attempt == 0) - { - if (resp.Headers.TryGetValues("X-Transmission-Session-Id", out var values)) - { - sessionId = values.FirstOrDefault(); - _logger.LogDebug("PollTransmission received 409 Conflict, retrying with session-id: {SessionId}", sessionId); - continue; // Retry with session-id - } - } - - // Check for success - _logger.LogInformation("PollTransmission HTTP response: {StatusCode}", resp.StatusCode); - if (!resp.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: non-success HTTP status {resp.StatusCode} from {baseUrl} for client {client.Id}"); - } - - // Process successful response - _logger.LogDebug("PollTransmission response text length: {Length}", respText?.Length ?? 0); - if (string.IsNullOrWhiteSpace(respText)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: empty response content for client {client.Id}"); - } - - // Parse response and continue with torrent processing - JsonElement doc; - try - { - doc = JsonSerializer.Deserialize(respText)!; - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission failed to parse JSON response for client {client.Id}", exception); - } - - if (!doc.TryGetProperty("arguments", out var args)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: missing 'arguments' in response for client {client.Id}"); - } - if (!args.TryGetProperty("torrents", out var torrents)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: missing 'torrents' in 'arguments' for client {client.Id}"); - } - if (torrents.ValueKind != JsonValueKind.Array) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: 'torrents' not an array (Kind={torrents.ValueKind}) for client {client.Id}"); - } - _logger.LogInformation("PollTransmission found {Count} torrents in response", torrents.GetArrayLength()); - - // Fetch session config for seed limit evaluation (Sonarr parity) - bool txSessionSeedRatioLimited = false; - double txSessionSeedRatioLimit = 0; - bool txSessionIdleSeedingLimitEnabled = false; - int txSessionIdleSeedingLimit = 0; - try - { - var sessionPayload = System.Text.Json.JsonSerializer.Serialize(new { method = "session-get", arguments = new { }, tag = 99 }, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - using var sessionReq = new HttpRequestMessage(HttpMethod.Post, baseUrl) - { - Content = new StringContent(sessionPayload, System.Text.Encoding.UTF8, "application/json") - }; - if (!string.IsNullOrEmpty(sessionId)) - sessionReq.Headers.Add("X-Transmission-Session-Id", sessionId); - if (!string.IsNullOrWhiteSpace(client.Username)) - { - var creds = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); - sessionReq.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", creds); - } - using var sessionResp = await http.SendAsync(sessionReq, cancellationToken); - if (sessionResp.IsSuccessStatusCode) - { - var sessionText = await sessionResp.Content.ReadAsStringAsync(cancellationToken); - var sessionDoc = System.Text.Json.JsonSerializer.Deserialize(sessionText); - if (sessionDoc.TryGetProperty("arguments", out var sessionArgs)) - { - txSessionSeedRatioLimited = (sessionArgs.TryGetProperty("seedRatioLimited", out var srl) || sessionArgs.TryGetProperty("seed_ratio_limited", out srl)) && srl.GetBoolean(); - txSessionSeedRatioLimit = (sessionArgs.TryGetProperty("seedRatioLimit", out var srlv) || sessionArgs.TryGetProperty("seed_ratio_limit", out srlv)) ? srlv.GetDouble() : 0; - txSessionIdleSeedingLimitEnabled = (sessionArgs.TryGetProperty("idle-seeding-limit-enabled", out var isle) || sessionArgs.TryGetProperty("idle_seeding_limit_enabled", out isle)) && isle.GetBoolean(); - txSessionIdleSeedingLimit = (sessionArgs.TryGetProperty("idle-seeding-limit", out var isl) || sessionArgs.TryGetProperty("idle_seeding_limit", out isl)) ? isl.GetInt32() : 0; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch Transmission session config for seed limit evaluation"); - } - - // Process torrents (continue with existing logic below) - foreach (var dl in downloads) - { - try - { - // Attempt to match by hashString (preferred) or name - var matching = torrents.EnumerateArray().FirstOrDefault(t => - { - // First try matching by hash (most reliable) - if (dl.Metadata != null && dl.Metadata.TryGetValue("TorrentHash", out var hashObj)) - { - var downloadHash = hashObj?.ToString() ?? string.Empty; - if (!string.IsNullOrEmpty(downloadHash)) - { - var hash = t.TryGetProperty("hashString", out var h) ? h.GetString() ?? string.Empty : string.Empty; - if (string.Equals(hash, downloadHash, StringComparison.OrdinalIgnoreCase)) - return true; - } - } - - // Fallback to exact name or normalized title match only. - // No fuzzy/path-based matching to avoid cross-contamination. - var name = t.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty; - if (string.Equals(name, dl.Title, StringComparison.OrdinalIgnoreCase)) - return true; - if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(dl.Title) && - string.Equals(TitleUtils.NormalizeTitle(name), TitleUtils.NormalizeTitle(dl.Title), StringComparison.OrdinalIgnoreCase)) - return true; - return false; - }); - - if (matching.ValueKind == System.Text.Json.JsonValueKind.Undefined) - { - _logger.LogDebug("Could not find matching torrent for download {DownloadId} ({Title}) in Transmission", dl.Id, dl.Title); - continue; - } - - _logger.LogDebug("Matched download {DownloadId} to Transmission torrent", dl.Id); - - var percent = matching.TryGetProperty("percentDone", out var p) ? p.GetDouble() : 0.0; - var left = matching.TryGetProperty("leftUntilDone", out var l) ? l.GetInt64() : 0L; - var statusCode = matching.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; - - // Map Transmission status code to status string (same as TransmissionAdapter) - var status = statusCode switch - { - 0 => "paused", // TR_STATUS_STOPPED - 1 => "queued", // TR_STATUS_CHECK_WAIT - 2 => "downloading", // TR_STATUS_CHECK - 3 => "queued", // TR_STATUS_DOWNLOAD_WAIT - 4 => "downloading", // TR_STATUS_DOWNLOAD - 5 => "queued", // TR_STATUS_SEED_WAIT - 6 => "seeding", // TR_STATUS_SEED - 7 => "failed", // TR_STATUS_ISOLATED - _ => "unknown" - }; - - AdapterUtils.MapDownloadProgress(dl, percent * 100, left, status); - - // Compute and persist CanMoveFiles/CanBeRemoved (Sonarr parity) - try - { - var txUploadRatio = (matching.TryGetProperty("uploadRatio", out var txRatP) || matching.TryGetProperty("upload_ratio", out txRatP)) ? txRatP.GetDouble() : 0d; - var txSeedRatioMode = (matching.TryGetProperty("seedRatioMode", out var txSrmP) || matching.TryGetProperty("seed_ratio_mode", out txSrmP)) ? txSrmP.GetInt32() : 0; - var txSeedRatioLimit = (matching.TryGetProperty("seedRatioLimit", out var txSrlP) || matching.TryGetProperty("seed_ratio_limit", out txSrlP)) ? txSrlP.GetDouble() : 0d; - var txSeedIdleMode = (matching.TryGetProperty("seedIdleMode", out var txSimP) || matching.TryGetProperty("seed_idle_mode", out txSimP)) ? txSimP.GetInt32() : 0; - var txSeedIdleLimit = (matching.TryGetProperty("seedIdleLimit", out var txSilP) || matching.TryGetProperty("seed_idle_limit", out txSilP)) ? txSilP.GetInt32() : 0; - var txSecondsSeeding = (matching.TryGetProperty("secondsSeeding", out var txSsP) || matching.TryGetProperty("seconds_seeding", out txSsP)) ? txSsP.GetInt64() : 0L; - - var txIsStopped = statusCode == 0; - var txIsSeeding = statusCode == 6; - var txSeedLimitReached = TransmissionHasReachedSeedLimit( - txIsStopped, txIsSeeding, txUploadRatio, - txSeedRatioMode, txSeedRatioLimit, - txSeedIdleMode, txSeedIdleLimit, txSecondsSeeding, - txSessionSeedRatioLimited, txSessionSeedRatioLimit, - txSessionIdleSeedingLimitEnabled, txSessionIdleSeedingLimit); - var txCanBeRemoved = txRemoveCompletedDownloads && txSeedLimitReached; - var txCanMoveFiles = txCanBeRemoved && txIsStopped; - - if (dl.Metadata == null) dl.Metadata = new Dictionary(); - dl.Metadata["CanMoveFiles"] = txCanMoveFiles; - dl.Metadata["CanBeRemoved"] = txCanBeRemoved; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to persist CanMoveFiles/CanBeRemoved for Transmission download {DownloadId}", dl.Id); - } - - // Skip finalization/progress logic for downloads that are already - // being processed, awaiting import, or fully imported. - if (dl.Status == DownloadStatus.Moved || - dl.Status == DownloadStatus.Processing || - dl.Status == DownloadStatus.ImportPending) - { - _logger.LogDebug("Skipping finalization for {Status} download {DownloadId}", dl.Status, dl.Id); - continue; - } - - // Check for completion using same logic as TransmissionAdapter - var isComplete = percent >= 1.0 && (status == "seeding" || status == "queued" || status == "paused"); - _logger.LogInformation("PollTransmission download {DownloadId}: percent={Percent}, status={Status}, isComplete={IsComplete}", dl.Id, percent, status, isComplete); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error processing download {DownloadId} while polling Transmission", dl.Id); - } - } - - return downloads; - } - - // If we reach here, session-id flow failed after retries - throw new DownloadClientAdapterPollingException($"PollTransmission failed to establish session after retries for client {client.Id}"); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error polling Transmission client {ClientName}", client.Name); - throw new DownloadClientAdapterPollingException($"Error polling Transmission client {client.Id}"); - } - } - /// /// Determines whether a Transmission torrent has reached its seed limit. /// Mirrors Sonarr's HasReachedSeedLimit logic for Transmission. /// - private static bool TransmissionHasReachedSeedLimit( + private static bool HasReachedSeedLimit( bool isStopped, bool isSeeding, double ratio, diff --git a/listenarr.infrastructure/Factories/DownloadClientAdapterFactory.cs b/listenarr.infrastructure/Factories/DownloadClientAdapterFactory.cs index 99ef2a4c1..7637fc62b 100644 --- a/listenarr.infrastructure/Factories/DownloadClientAdapterFactory.cs +++ b/listenarr.infrastructure/Factories/DownloadClientAdapterFactory.cs @@ -16,38 +16,75 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; namespace Listenarr.Infrastructure.Factories { public class DownloadClientAdapterFactory : IDownloadClientAdapterFactory { - private readonly Dictionary _byId; - private readonly Dictionary _byType; + private readonly Dictionary> _byType; + private readonly Dictionary> _byProtocol; public DownloadClientAdapterFactory(IEnumerable adapters) { - var list = adapters?.ToList() ?? new List(); - _byId = list - .Where(a => !string.IsNullOrWhiteSpace(a.ClientId)) - .GroupBy(a => a.ClientId!, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); - - _byType = list + adapters = adapters?.ToList() ?? []; + _byType = adapters .Where(a => !string.IsNullOrWhiteSpace(a.ClientType)) .GroupBy(a => a.ClientType!, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + _byProtocol = adapters + .SelectMany(a => a.Protocols.Select(p => new { Protocol = p, Adapter = a })) + .GroupBy(x => x.Protocol) + .ToDictionary( + g => g.Key, + g => g.Select(x => x.Adapter).ToList() + ); + + // Ensure one client type is always one adapter + var duplicatedAdapters = _byType + .Where(pair => pair.Value.Count > 1) + .Select(pair => pair.Key); + if (duplicatedAdapters.Count() > 0) + { + var duplicatedAdaptersString = string.Join(", ", duplicatedAdapters); + throw new ArgumentException($"Multiple adapters found for the following client types: {duplicatedAdaptersString}. Each type must have only one adapter."); + } } - public IDownloadClientAdapter GetByIdOrType(string id) + public IDownloadClientAdapter GetByType(string type) { - if (string.IsNullOrWhiteSpace(id)) + if (string.IsNullOrWhiteSpace(type)) { throw new InvalidOperationException("Adapter key not provided."); } - if (_byId.TryGetValue(id, out var byId)) return byId; - if (_byType.TryGetValue(id, out var byType)) return byType; - throw new InvalidOperationException($"No adapter registered for key '{id}'."); + if (!_byType.TryGetValue(type, out var adapters)) + { + throw new InvalidOperationException($"No adapter of type '{type}' registered."); + } + + if (adapters.Count > 1) + { + throw new InvalidOperationException($"Multiple adapters of type '{type}' registered: Each client type can only have one adapter."); + } + + return adapters.First(); + } + + public List GetByProtocol(DownloadProtocol protocol) + { + if (!_byProtocol.TryGetValue(protocol, out var adapters)) + { + throw new InvalidOperationException($"No adapter implementing '{protocol}' registered."); + } + + return adapters; + } + + public List GetClientTypeSupportingProtocol(DownloadProtocol protocol) + { + return [.. GetByProtocol(protocol).Select(a => a.ClientType)]; } } } diff --git a/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs b/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs index 774428e50..7bc78803e 100644 --- a/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs +++ b/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs @@ -20,6 +20,7 @@ using Listenarr.Application.Mapping; using Listenarr.Application.Notification; using Listenarr.Application.Security; +using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; @@ -139,7 +140,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) foreach (var entry in entries) { var rel = Path.GetRelativePath(source, entry); - var destPath = CombineWithOptionalBase(copyDest, rel); + var destPath = FileUtils.CombineWithOptionalBase(copyDest, rel); if (Directory.Exists(entry)) { @@ -236,11 +237,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var fullImagePath = Path.IsPathRooted(imageUrl) ? Path.GetFullPath(imageUrl) - : Path.GetFullPath(CombineWithOptionalBase(source, imageUrl)); + : Path.GetFullPath(FileUtils.CombineWithOptionalBase(source, imageUrl)); if (fullImagePath.StartsWith(source, StringComparison.OrdinalIgnoreCase)) { var rel = Path.GetRelativePath(source, fullImagePath); - var newImagePath = Path.GetFullPath(CombineWithOptionalBase(target, rel)); + var newImagePath = Path.GetFullPath(FileUtils.CombineWithOptionalBase(target, rel)); // Only update if the new file actually exists after move if (System.IO.File.Exists(newImagePath)) @@ -270,12 +271,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var fullFilePath = Path.IsPathRooted(audiobook.FilePath) ? Path.GetFullPath(audiobook.FilePath) - : Path.GetFullPath(CombineWithOptionalBase(source, audiobook.FilePath)); + : Path.GetFullPath(FileUtils.CombineWithOptionalBase(source, audiobook.FilePath)); if (fullFilePath.StartsWith(source, StringComparison.OrdinalIgnoreCase)) { var rel = Path.GetRelativePath(source, fullFilePath); - var newFilePath = Path.GetFullPath(CombineWithOptionalBase(target, rel)); + var newFilePath = Path.GetFullPath(FileUtils.CombineWithOptionalBase(target, rel)); // Only update if the new file actually exists after move if (File.Exists(newFilePath)) @@ -503,32 +504,6 @@ await toastService.PublishToastAsync( logger.LogError(ex, "Unhandled error in MoveBackgroundService channel loop"); } } - - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } } } diff --git a/listenarr.infrastructure/Persistence/Configurations/AudiobookConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/AudiobookConfiguration.cs index a8441618a..8847d4fb9 100644 --- a/listenarr.infrastructure/Persistence/Configurations/AudiobookConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/AudiobookConfiguration.cs @@ -26,7 +26,7 @@ namespace Listenarr.Infrastructure.Persistence.Configurations { /// /// EF mapping for Audiobook entity extracted from ListenArrDbContext. - /// Keeps conversions / comparers and relationships colocated for easier testing & reuse. + /// Keeps conversions / comparers and relationships colocated for easier testing and reuse. /// public class AudiobookConfiguration : IEntityTypeConfiguration { diff --git a/listenarr.infrastructure/Persistence/Migrations/20260204000000_AddFolderNamingPatternToApplicationSettings.cs b/listenarr.infrastructure/Persistence/Migrations/20260204000000_AddFolderNamingPatternToApplicationSettings.cs index 2c30b66dc..e82fcbd81 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260204000000_AddFolderNamingPatternToApplicationSettings.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260204000000_AddFolderNamingPatternToApplicationSettings.cs @@ -24,7 +24,6 @@ namespace Listenarr.Infrastructure.Persistence.Migrations { [DbContext(typeof(ListenArrDbContext))] [Migration("20260204000000_AddFolderNamingPatternToApplicationSettings")] - /// public partial class AddFolderNamingPatternToApplicationSettings : Migration { /// diff --git a/listenarr.infrastructure/Persistence/Migrations/20260206000000_AddMultiFileNamingPatternToApplicationSettings.cs b/listenarr.infrastructure/Persistence/Migrations/20260206000000_AddMultiFileNamingPatternToApplicationSettings.cs index 4ee1ca8eb..f9b2566f8 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260206000000_AddMultiFileNamingPatternToApplicationSettings.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260206000000_AddMultiFileNamingPatternToApplicationSettings.cs @@ -24,7 +24,6 @@ namespace Listenarr.Infrastructure.Persistence.Migrations { [DbContext(typeof(ListenArrDbContext))] [Migration("20260206000000_AddMultiFileNamingPatternToApplicationSettings")] - /// public partial class AddMultiFileNamingPatternToApplicationSettings : Migration { /// diff --git a/listenarr.infrastructure/Persistence/Migrations/20260213000000_AddFailedDownloadHandlingToApplicationSettings.cs b/listenarr.infrastructure/Persistence/Migrations/20260213000000_AddFailedDownloadHandlingToApplicationSettings.cs index f2ddfcea9..87c1f6e81 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260213000000_AddFailedDownloadHandlingToApplicationSettings.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260213000000_AddFailedDownloadHandlingToApplicationSettings.cs @@ -24,7 +24,6 @@ namespace Listenarr.Infrastructure.Persistence.Migrations { [DbContext(typeof(ListenArrDbContext))] [Migration("20260213000000_AddFailedDownloadHandlingToApplicationSettings")] - /// public partial class AddFailedDownloadHandlingToApplicationSettings : Migration { /// diff --git a/tests/Builders/DownloadBuilder.cs b/tests/Builders/DownloadBuilder.cs index aa09486ea..387e6587c 100644 --- a/tests/Builders/DownloadBuilder.cs +++ b/tests/Builders/DownloadBuilder.cs @@ -117,7 +117,7 @@ public DownloadBuilder WithDownloading(decimal value) return this; } - public DownloadBuilder WithClientDownloadId(string value) + public DownloadBuilder WithExternalId(string value) { _download.SetExternalId(value); return this; diff --git a/tests/Builders/QueueItemBuilder.cs b/tests/Builders/QueueItemBuilder.cs index 537c450fb..ed6612a2f 100644 --- a/tests/Builders/QueueItemBuilder.cs +++ b/tests/Builders/QueueItemBuilder.cs @@ -8,9 +8,16 @@ public class QueueItemBuilder public QueueItemBuilder() { + _item.Id = "1"; _item.Progress = 0; } + public QueueItemBuilder WithId(string value) + { + _item.Id = value; + return this; + } + public QueueItemBuilder WithRemotePath(string value) { _item.RemotePath = value; @@ -32,6 +39,18 @@ public QueueItemBuilder WithContentPath(string value) return this; } + public QueueItemBuilder WithProgress(double value) + { + _item.Progress = value; + return this; + } + + public QueueItemBuilder WithStatus(string value) + { + _item.Status = value; + return this; + } + public QueueItem Build() { return _item; diff --git a/tests/Common/MockUtils.cs b/tests/Common/MockUtils.cs index 31780a0c2..09e99235c 100644 --- a/tests/Common/MockUtils.cs +++ b/tests/Common/MockUtils.cs @@ -25,6 +25,7 @@ public abstract class MockUtils /// Returns a 200 HTTP reply with the given content /// /// Content to use as a response body + /// /// HttpResponseMessage with status 200 and body public static HttpResponseMessage GetCannedResponse(string content, string mediaType = "application/json") { diff --git a/tests/Features/Api/Services/Adapters/AdapterImportPathResolutionTests.cs b/tests/Features/Api/Services/Adapters/AdapterImportPathResolutionTests.cs index 4d7b36112..a3ce912f9 100644 --- a/tests/Features/Api/Services/Adapters/AdapterImportPathResolutionTests.cs +++ b/tests/Features/Api/Services/Adapters/AdapterImportPathResolutionTests.cs @@ -97,24 +97,6 @@ await _remotePathMappingRepository.SaveAsync(new RemotePathMappingBuilder() { NzbgetApiMock.MULTI_FILE_NZBGET, FileUtils.GetAbsolutePath("nzbget", "completed", "Book Folder") } }; - [Theory] - [Trait("Third-Party", "Transmission")] - [Trait("Method", "GetImportItemAsync")] - [MemberData(nameof(TransmissionGetImportItemAsyncCases))] - public async Task Transmission_GetImportItemAsync_ResolvesPath(int downloadId, string expectedPath) - { - var item = new DownloadClientItem - { - DownloadId = downloadId.ToString(), - OutputPath = string.Empty - }; - - var adapter = MockUtils.CreateTransmissionAdapter(_provider); - var resolved = await adapter.GetImportItemAsync(_transmissionClient, item); - - Assert.Equal(expectedPath, resolved.OutputPath); - } - [Fact] [Trait("Third-Party", "Transmission")] [Trait("Method", "GetImportItemAsync")] @@ -142,41 +124,5 @@ public async Task Transmission_LegacyGetImportItemAsync_PopulatesClientReportedS }, resolved.SourceFiles); } - - [Theory] - [Trait("Third-Party", "Sabnzbd")] - [Trait("Method", "GetImportItemAsync")] - [MemberData(nameof(SabnzbdGetImportItemAsyncCases))] - public async Task Sabnzbd_GetImportItemAsync_ResolvesPath(string downloadId, string expectedPath) - { - var item = new DownloadClientItem - { - DownloadId = downloadId, - OutputPath = string.Empty - }; - - var adapter = MockUtils.CreateSabnzbdAdapter(_provider); - var resolved = await adapter.GetImportItemAsync(_sabnzbdClient, item); - - Assert.Equal(expectedPath, resolved.OutputPath); - } - - [Theory] - [Trait("Third-Party", "Nzbget")] - [Trait("Method", "GetImportItemAsync")] - [MemberData(nameof(NzbgetGetImportItemAsyncCases))] - public async Task Nzbget_GetImportItemAsync_ResolvesPath(string downloadId, string expectedPath) - { - var item = new DownloadClientItem - { - DownloadId = downloadId, - OutputPath = string.Empty - }; - - var adapter = MockUtils.CreateNzbgetAdapter(_provider); - var resolved = await adapter.GetImportItemAsync(_nzbgetClient, item); - - Assert.Equal(expectedPath, resolved.OutputPath); - } } } diff --git a/tests/Features/Api/Services/Adapters/NzbgetAdapterTests.cs b/tests/Features/Api/Services/Adapters/NzbgetAdapterTests.cs index 49f29b0d0..dcbc3076d 100644 --- a/tests/Features/Api/Services/Adapters/NzbgetAdapterTests.cs +++ b/tests/Features/Api/Services/Adapters/NzbgetAdapterTests.cs @@ -149,7 +149,7 @@ public async Task GetQueueAsync_NormalizesHostWithSchemeAndPath() Password = "secret" }; - var queue = await adapter.GetQueueAsync(client); + var queue = await adapter.GetQueueAsync(client, ["fake"]); Assert.NotNull(queue); Assert.Empty(queue); diff --git a/tests/Features/Api/Services/Adapters/SabnzbdAdapterTests.cs b/tests/Features/Api/Services/Adapters/SabnzbdAdapterTests.cs index 5a589fac4..5f522a35a 100644 --- a/tests/Features/Api/Services/Adapters/SabnzbdAdapterTests.cs +++ b/tests/Features/Api/Services/Adapters/SabnzbdAdapterTests.cs @@ -119,7 +119,7 @@ public async Task PollSABnzbd_Mapping_StripsNumericSuffix_AndFinalizesDownload() .WithDownloadClientConfiguration(_client) .WithDownloading(0) .WithPath(source) - .WithClientDownloadId(SabnzbdApiMock.COMPLETED_FILE_SABNZBD) + .WithExternalId(SabnzbdApiMock.COMPLETED_FILE_SABNZBD) .Build()); var monitor = _provider.GetRequiredService(); @@ -159,7 +159,7 @@ public async Task PollSABnzbd_SchedulesRetry_AndFinalizes_WhenFileArrives() .WithDownloadClientConfiguration(_client) .WithAudiobook(audiobook) .WithPath(sourceDirectory) - .WithClientDownloadId(SabnzbdApiMock.COMPLETED_FILE_SABNZBD) + .WithExternalId(SabnzbdApiMock.COMPLETED_FILE_SABNZBD) .Build()); var downloadProcessingJobService = _provider.GetRequiredService(); diff --git a/tests/Features/Api/Services/Adapters/UsenetAdapterFilteringTests.cs b/tests/Features/Api/Services/Adapters/UsenetAdapterFilteringTests.cs index edfe601b8..adeec6aa8 100644 --- a/tests/Features/Api/Services/Adapters/UsenetAdapterFilteringTests.cs +++ b/tests/Features/Api/Services/Adapters/UsenetAdapterFilteringTests.cs @@ -134,14 +134,10 @@ public async Task Sabnzbd_GetQueueAndItems_FilterByConfiguredCategory() } }; - var queue = await adapter.GetQueueAsync(client, CancellationToken.None); - var items = await adapter.GetItemsAsync(client, CancellationToken.None); + var queue = await adapter.GetQueueAsync(client, ["SABnzbd_nzo_1", "SABnzbd_nzo_2"], CancellationToken.None); Assert.Single(queue); Assert.Equal("Book One", queue[0].Title); - - Assert.Single(items); - Assert.Equal("Book One", items[0].Title); } [Fact] @@ -184,14 +180,10 @@ public async Task Nzbget_GetQueueAndItems_FilterByConfiguredCategory() } }; - var queue = await adapter.GetQueueAsync(client, CancellationToken.None); - var items = await adapter.GetItemsAsync(client, CancellationToken.None); + var queue = await adapter.GetQueueAsync(client, ["101", "202"], CancellationToken.None); Assert.Single(queue); Assert.Equal("Book One", queue[0].Title); - - Assert.Single(items); - Assert.Equal("Book One", items[0].Title); } private static string BuildNzbGetListGroupsResponse(params (string Id, string Name, string Category, string Status)[] groups) diff --git a/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs b/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs index 3629ff48c..112958181 100644 --- a/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs +++ b/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs @@ -140,89 +140,13 @@ public async Task Transmission_GetQueue_FiltersByConfiguredCategory() } }; - var queue = await adapter.GetQueueAsync(client, CancellationToken.None); + var queue = await adapter.GetQueueAsync(client, ["HASH1", "HASH2"], CancellationToken.None); Assert.Single(queue); Assert.Equal("Book One", queue[0].Title); Assert.Equal("audiobooks", queue[0].Quality); } - [Fact] - [Trait("Scenario", "TransmissionItemCategoryFilter")] - public async Task Transmission_GetItems_FiltersByConfiguredCategory() - { - const string body = """ - { - "result":"success", - "arguments":{ - "torrents":[ - { - "id":1, - "hashString":"HASH1", - "name":"Book One", - "percentDone":0.5, - "status":4, - "totalSize":1000, - "leftUntilDone":500, - "rateDownload":25, - "eta":60, - "downloadDir":"/downloads", - "addedDate":1700000000, - "uploadRatio":0.1, - "labels":["audiobooks"] - }, - { - "id":2, - "hashString":"HASH2", - "name":"Movie One", - "percentDone":0.6, - "status":4, - "totalSize":1000, - "leftUntilDone":400, - "rateDownload":20, - "eta":50, - "downloadDir":"/downloads", - "addedDate":1700000000, - "uploadRatio":0.1, - "labels":["movies"] - } - ] - } - } - """; - using var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - var handler = new DelegatingHandlerMock((_, _) => - { - return Task.FromResult(response); - }); - - using var httpClient = new HttpClient(handler); - var httpFactory = new Mock(); - httpFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); - - var adapter = new TransmissionAdapter(httpFactory.Object, Mock.Of(), NullLogger.Instance); - var client = new DownloadClientConfiguration - { - Id = "tr-1", - Name = "Transmission", - Type = "transmission", - Host = "localhost", - Port = 9091, - Settings = new Dictionary - { - ["category"] = "audiobooks" - } - }; - - var items = await adapter.GetItemsAsync(client, CancellationToken.None); - - Assert.Single(items); - Assert.Equal("Book One", items[0].Title); - } - [Fact] [Trait("Scenario", "QbittorrentCategoryParameterConsistency")] public void QBittorrent_CategoryParameter_IsAvailableForQueueAndItemSurfaces() diff --git a/tests/Features/Api/Services/DownloadHashRetrievalServiceTests.cs b/tests/Features/Api/Services/DownloadHashRetrievalServiceTests.cs deleted file mode 100644 index 89951ba84..000000000 --- a/tests/Features/Api/Services/DownloadHashRetrievalServiceTests.cs +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; -using Listenarr.Infrastructure.Persistence; -using Listenarr.Infrastructure.Persistence.Repositories; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; - -namespace Listenarr.Tests.Features.Api.Services -{ - /// - /// Tests for DownloadHashRetrievalService - Stage 4 exponential backoff retry logic - /// - public class DownloadHashRetrievalServiceTests : IDisposable - { - private readonly ListenArrDbContext _context; - private readonly DownloadHistoryRepository _historyRepository; - private readonly Mock> _mockLogger; - private readonly Mock _mockAdapter; - private readonly DownloadHashRetrievalService _service; - - public DownloadHashRetrievalServiceTests() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - - _context = new ListenArrDbContext(options); - _historyRepository = new DownloadHistoryRepository(_context); - _mockLogger = new Mock>(); - - // Mock a single adapter for qBittorrent - _mockAdapter = new Mock(); - _mockAdapter.Setup(a => a.Protocol).Returns(DownloadProtocol.Torrent); - - _service = new DownloadHashRetrievalService( - _mockLogger.Object, - _historyRepository, - _mockAdapter.Object, - _mockAdapter.Object, - _mockAdapter.Object, - _mockAdapter.Object); - } - - [Fact] - public async Task TryRetrieveHashAsync_ReturnsNull_WhenMaxRetriesExceeded() - { - // Arrange - var query = new DownloadClientItemQuery - { - Title = "Test Audiobook", - AddedDate = DateTime.UtcNow, - RetryCount = 10, // Max retries - DownloadClient = "qBittorrent", - DownloadClientId = "qbit-1", - Protocol = DownloadProtocol.Torrent - }; - - var client = new DownloadClientConfiguration - { - Id = "qbit-1", - Name = "qBittorrent", - Type = "qbittorrent", - Host = "localhost", - Port = 8080 - }; - - // Act - var result = await _service.TryRetrieveHashAsync(query, client); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task TryRetrieveHashAsync_ReturnsNull_WhenTimeoutExceeded() - { - // Arrange - var query = new DownloadClientItemQuery - { - Title = "Test Audiobook", - AddedDate = DateTime.UtcNow.AddSeconds(-65), // 65 seconds ago (over 60s limit) - RetryCount = 3, - DownloadClient = "qBittorrent", - DownloadClientId = "qbit-1", - Protocol = DownloadProtocol.Torrent - }; - - var client = new DownloadClientConfiguration - { - Id = "qbit-1", - Name = "qBittorrent", - Type = "qbittorrent", - Host = "localhost", - Port = 8080 - }; - - // Act - var result = await _service.TryRetrieveHashAsync(query, client); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task TryRetrieveHashAsync_ReturnsNull_WhenBackoffNotElapsed() - { - // Arrange - var query = new DownloadClientItemQuery - { - Title = "Test Audiobook", - AddedDate = DateTime.UtcNow.AddSeconds(-10), - RetryCount = 3, // 8 second backoff - LastRetry = DateTime.UtcNow.AddSeconds(-5), // Only 5 seconds ago - DownloadClient = "qBittorrent", - DownloadClientId = "qbit-1", - Protocol = DownloadProtocol.Torrent - }; - - var client = new DownloadClientConfiguration - { - Id = "qbit-1", - Name = "qBittorrent", - Type = "qbittorrent", - Host = "localhost", - Port = 8080 - }; - - // Act - var result = await _service.TryRetrieveHashAsync(query, client); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task TryRetrieveHashAsync_ReturnsHash_WhenMatchFound() - { - // Arrange - var query = new DownloadClientItemQuery - { - Title = "Test Audiobook", - AddedDate = DateTime.UtcNow.AddSeconds(-5), - RetryCount = 0, - AudiobookId = Guid.NewGuid(), - DownloadClient = "qBittorrent", - DownloadClientId = "qbit-1", - Protocol = DownloadProtocol.Torrent - }; - - var client = new DownloadClientConfiguration - { - Id = "qbit-1", - Name = "qBittorrent", - Type = "qbittorrent", - Host = "localhost", - Port = 8080 - }; - - var expectedHash = "ABC123DEF456"; - var mockItems = new List - { - new DownloadClientItem - { - DownloadId = expectedHash, - Title = "Test Audiobook", - Status = DownloadItemStatus.Downloading - } - }; - - _mockAdapter - .Setup(a => a.GetItemsAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(mockItems); - - // Act - var result = await _service.TryRetrieveHashAsync(query, client); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedHash, result); - - // Verify history was recorded - var history = await _historyRepository.GetByDownloadIdAsync(expectedHash); - Assert.Single(history); - Assert.Equal(DownloadHistoryEventType.Grabbed, history[0].EventType); - } - - [Fact] - public async Task TryRetrieveHashAsync_ReturnsNull_WhenNoMatchFound() - { - // Arrange - var query = new DownloadClientItemQuery - { - Title = "Test Audiobook", - AddedDate = DateTime.UtcNow.AddSeconds(-5), - RetryCount = 0, - DownloadClient = "qBittorrent", - DownloadClientId = "qbit-1", - Protocol = DownloadProtocol.Torrent - }; - - var client = new DownloadClientConfiguration - { - Id = "qbit-1", - Name = "qBittorrent", - Type = "qbittorrent", - Host = "localhost", - Port = 8080 - }; - - var mockItems = new List - { - new DownloadClientItem - { - DownloadId = "DIFFERENT123", - Title = "Different Audiobook", - Status = DownloadItemStatus.Downloading - } - }; - - _mockAdapter - .Setup(a => a.GetItemsAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(mockItems); - - // Act - var result = await _service.TryRetrieveHashAsync(query, client); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetPendingHashRetrievalsAsync_ReturnsOnlyItemsWithoutValidHash() - { - // Arrange - // Add history with temp DownloadId (needs hash retrieval) - await _historyRepository.AddAsync(new DownloadHistory - { - DownloadId = "temp-123", - EventType = DownloadHistoryEventType.Grabbed, - Status = DownloadItemStatus.Queued, - EventDate = DateTime.UtcNow.AddSeconds(-10), - AudiobookId = Guid.NewGuid(), - DownloadClient = "qBittorrent", - DownloadClientId = "qbit-1", - Protocol = DownloadProtocol.Torrent, - Title = "Pending Audiobook", - WasImported = false - }); - - // Add history with valid DownloadId (skip) - await _historyRepository.AddAsync(new DownloadHistory - { - DownloadId = "ABC123DEF456789", - EventType = DownloadHistoryEventType.Grabbed, - Status = DownloadItemStatus.Queued, - EventDate = DateTime.UtcNow.AddSeconds(-5), - AudiobookId = Guid.NewGuid(), - DownloadClient = "qBittorrent", - DownloadClientId = "qbit-1", - Protocol = DownloadProtocol.Torrent, - Title = "Valid Audiobook", - WasImported = false - }); - - // Act - var result = await _service.GetPendingHashRetrievalsAsync(); - - // Assert - Assert.Single(result); - Assert.Equal("temp-123", result[0].DownloadId); - Assert.Equal("Pending Audiobook", result[0].Title); - } - - [Fact] - public async Task ExponentialBackoff_IncreasesCorrectly() - { - // Test that backoff times increase exponentially: 2s, 4s, 8s, 16s, 30s (capped) - // This is a conceptual test - actual backoff logic is in TryRetrieveHashAsync - - var expectedBackoffs = new[] { 2, 4, 8, 16, 30, 30, 30, 30, 30, 30 }; // Last 6 capped at 30s - - for (int retry = 0; retry < 10; retry++) - { - var backoff = Math.Min(30, 2 * Math.Pow(2, retry)); - Assert.Equal(expectedBackoffs[retry], (int)backoff); - } - } - - public void Dispose() - { - _context?.Dispose(); - } - } -} diff --git a/tests/Features/Api/Services/DownloadValidationPipelineTests.cs b/tests/Features/Api/Services/DownloadValidationPipelineTests.cs deleted file mode 100644 index 6ded29061..000000000 --- a/tests/Features/Api/Services/DownloadValidationPipelineTests.cs +++ /dev/null @@ -1,417 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -using Listenarr.Application.Downloads; -using Listenarr.Domain.Models; -using Listenarr.Infrastructure.Persistence; -using Listenarr.Infrastructure.Persistence.Repositories; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; - -namespace Listenarr.Tests.Features.Api.Services -{ - /// - /// Tests for DownloadValidationPipeline - Stage 6 three-phase validation - /// - public class DownloadValidationPipelineTests : IDisposable - { - private readonly ListenArrDbContext _context; - private readonly DownloadHistoryRepository _historyRepository; - private readonly DownloadStateMachine _stateMachine; - private readonly Mock> _mockLogger; - private readonly Mock> _mockStateMachineLogger; - private readonly DownloadValidationPipeline _pipeline; - private readonly string _testDirectory; - - public DownloadValidationPipelineTests() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - - _context = new ListenArrDbContext(options); - _historyRepository = new DownloadHistoryRepository(_context); - _mockStateMachineLogger = new Mock>(); - _stateMachine = new DownloadStateMachine(_mockStateMachineLogger.Object, _historyRepository); - _mockLogger = new Mock>(); - - _pipeline = new DownloadValidationPipeline(_mockLogger.Object, _stateMachine, _historyRepository); - - // Create test directory - _testDirectory = Path.Join(Path.GetTempPath(), "listenarr_pipeline_test_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_testDirectory); - } - - [Fact] - public async Task ExecutePipeline_SucceedsWithValidDownload() - { - // Arrange - var testFile = Path.Join(_testDirectory, "test.m4b"); - File.WriteAllText(testFile, "test content"); - - var download = new DownloadClientItem - { - DownloadId = "VALID123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = testFile, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Id = "qbit-1", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.True(result.Success); - Assert.NotNull(result.CheckPhase); - Assert.True(result.CheckPhase.Success); - Assert.NotNull(result.ImportPhase); - Assert.True(result.ImportPhase.Success); - Assert.NotNull(result.VerifyPhase); - Assert.True(result.VerifyPhase.Success); - Assert.NotNull(result.CompletedAt); - - // Verify history was recorded - var history = await _historyRepository.GetByDownloadIdAsync("VALID123"); - Assert.NotEmpty(history); - - // Verify marked as imported - var wasImported = await _historyRepository.WasImportedAsync("VALID123"); - Assert.True(wasImported); - } - - [Fact] - public async Task CheckPhase_FailsWhenStatusNotCompleted() - { - // Arrange - var download = new DownloadClientItem - { - DownloadId = "NOTCOMPLETE123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Downloading, // Not completed - OutputPath = _testDirectory, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.CheckPhase); - Assert.False(result.CheckPhase.Success); - Assert.Contains("not completed", result.CheckPhase.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.Null(result.ImportPhase); // Should not reach import phase - Assert.Null(result.VerifyPhase); // Should not reach verify phase - } - - [Fact] - public async Task CheckPhase_FailsWhenOutputPathEmpty() - { - // Arrange - var download = new DownloadClientItem - { - DownloadId = "NOPATH123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = string.Empty, // Empty path - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.CheckPhase); - Assert.False(result.CheckPhase.Success); - Assert.Contains("empty", result.CheckPhase.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CheckPhase_FailsWhenOutputPathDoesNotExist() - { - // Arrange - var nonExistentPath = Path.Join(_testDirectory, "nonexistent", "file.m4b"); - - var download = new DownloadClientItem - { - DownloadId = "BADPATH123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = nonExistentPath, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.CheckPhase); - Assert.False(result.CheckPhase.Success); - Assert.Contains("does not exist", result.CheckPhase.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CheckPhase_FailsWhenDownloadIdTemporary() - { - // Arrange - var testFile = Path.Join(_testDirectory, "test2.m4b"); - File.WriteAllText(testFile, "test"); - - var download = new DownloadClientItem - { - DownloadId = "temp-12345", // Temporary ID - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = testFile, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.CheckPhase); - Assert.False(result.CheckPhase.Success); - Assert.Contains("Invalid or temporary", result.CheckPhase.ErrorMessage); - } - - [Fact] - public async Task CheckPhase_FailsWhenSizeZero() - { - // Arrange - var testFile = Path.Join(_testDirectory, "test3.m4b"); - File.WriteAllText(testFile, "test"); - - var download = new DownloadClientItem - { - DownloadId = "ZEROSIZE123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = testFile, - TotalSize = 0, // Zero size - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.CheckPhase); - Assert.False(result.CheckPhase.Success); - Assert.Contains("zero or negative", result.CheckPhase.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Pipeline_RecordsAllPhases() - { - // Arrange - var testFile = Path.Join(_testDirectory, "test4.m4b"); - File.WriteAllText(testFile, "test content"); - - var download = new DownloadClientItem - { - DownloadId = "PHASES123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = testFile, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Id = "qbit-1", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - await _pipeline.ExecutePipelineAsync(download); - - // Assert - Verify all phases were recorded in history - var history = await _historyRepository.GetByDownloadIdAsync("PHASES123"); - Assert.True(history.Count >= 3); // At least 3 phase records - - // Check for phase metadata - var checkEvent = history.FirstOrDefault(h => - h.Data != null && h.Data.TryGetValue("Phase", out var phaseObj) && phaseObj?.ToString() == "Check"); - Assert.NotNull(checkEvent); - - var importEvent = history.FirstOrDefault(h => - h.Data != null && h.Data.TryGetValue("Phase", out var phaseObj) && phaseObj?.ToString() == "Import"); - Assert.NotNull(importEvent); - - var verifyEvent = history.FirstOrDefault(h => - h.Data != null && h.Data.TryGetValue("Phase", out var phaseObj) && phaseObj?.ToString() == "Verify"); - Assert.NotNull(verifyEvent); - } - - [Fact] - public async Task Pipeline_IncludesAudiobookId() - { - // Arrange - var testFile = Path.Join(_testDirectory, "test5.m4b"); - File.WriteAllText(testFile, "test"); - - var audiobookId = Guid.NewGuid(); - var download = new DownloadClientItem - { - DownloadId = "AUDIOBOOK123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = testFile, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Id = "qbit-1", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - await _pipeline.ExecutePipelineAsync(download, audiobookId); - - // Assert - var history = await _historyRepository.GetByAudiobookIdAsync(audiobookId); - Assert.NotEmpty(history); - } - - [Fact] - public async Task Pipeline_CalculatesDuration() - { - // Arrange - var testFile = Path.Join(_testDirectory, "test6.m4b"); - File.WriteAllText(testFile, "test"); - - var download = new DownloadClientItem - { - DownloadId = "DURATION123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = testFile, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.True(result.Duration.TotalSeconds >= 0); - Assert.True(result.Duration.TotalSeconds < 10); // Should complete quickly - } - - [Fact] - public async Task ImportPhase_SetsImportedPath() - { - // Arrange - var testFile = Path.Join(_testDirectory, "test7.m4b"); - File.WriteAllText(testFile, "test"); - - var download = new DownloadClientItem - { - DownloadId = "IMPORTPATH123", - Title = "Test Audiobook", - Status = DownloadItemStatus.Completed, - OutputPath = testFile, - TotalSize = 1024, - DownloadClientInfo = new DownloadClientItemClientInfo - { - Name = "qBittorrent", - Type = "qBittorrent", - Protocol = DownloadProtocol.Torrent - } - }; - - // Act - var result = await _pipeline.ExecutePipelineAsync(download); - - // Assert - Assert.NotNull(result.ImportPhase); - Assert.NotNull(result.ImportPhase.ImportedPath); - Assert.Equal(testFile, result.ImportPhase.ImportedPath); - } - - public void Dispose() - { - _context?.Dispose(); - - // Cleanup test directory - if (Directory.Exists(_testDirectory)) - { - try - { - Directory.Delete(_testDirectory, true); - } - catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException) - { - // Ignore cleanup errors - } - } - } - } -} diff --git a/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs b/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs index 42cfbe971..f40ee8904 100644 --- a/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs +++ b/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs @@ -87,15 +87,19 @@ public async Task TestConnectionAsync() [Fact] [Trait("Method", "FetchDownloadsAsync")] - [Trait("Scenario", "Check updated downloads a path mapped correctly")] + [Trait("Scenario", "Check updated downloads path mapped correctly")] public async Task FetchDownloadsAsync() { - var downloads = await downloadClientGateway.FetchDownloadsAsync(client, []); + var newDownload = await _downloadRepository.AddAsync(new DownloadBuilder() + .WithExternalId("1") + .Build()); + + var downloads = await downloadClientGateway.FetchDownloadsAsync(client, [newDownload]); Assert.NotEmpty(downloads); - foreach (Download download in downloads) - { - Assert.StartsWith(localPath, download.DownloadPath); - } + Assert.Single(downloads); + var download = downloads.First(); + Assert.NotNull(download); + Assert.StartsWith(localPath, download.DownloadPath); } [Fact] diff --git a/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs b/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs index f1e52f9c6..116a0a009 100644 --- a/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs +++ b/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs @@ -84,11 +84,22 @@ public async Task MonitorDownloadsAsync_CompletedStaysCompleted() public async Task MonitorDownloadsAsync_DownloadingBecomesCompleted() { var download = await _downloadRepository.AddAsync(new DownloadBuilder() - .WithDownloading(50) + .WithDownloading(0) + .WithExternalId("1") .WithDownloadClientConfiguration(client) .Build()); await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); + downloadMonitorService.ScheduleNextClientPoll(client, -100); + await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); + downloadMonitorService.ScheduleNextClientPoll(client, -100); + await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); + downloadMonitorService.ScheduleNextClientPoll(client, -100); + await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); + downloadMonitorService.ScheduleNextClientPoll(client, -100); + await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); + downloadMonitorService.ScheduleNextClientPoll(client, -100); + await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); download = await _downloadRepository.GetByIdAsync(download.Id); Assert.NotNull(download); @@ -128,7 +139,8 @@ public async Task MonitorDownloadsAsync_DownloadingBecomesCompleted() public async Task MonitorDownloadsAsync_RespectSchedulingInterval() { var download = await _downloadRepository.AddAsync(new DownloadBuilder() - .WithDownloading(50) + .WithDownloading(0) + .WithExternalId("1") .WithDownloadClientConfiguration(client) .Build()); @@ -137,7 +149,7 @@ public async Task MonitorDownloadsAsync_RespectSchedulingInterval() download = await _downloadRepository.GetByIdAsync(download.Id); Assert.NotNull(download); Assert.Equal(DownloadStatus.Downloading, download.Status); - Assert.True(download.Progress == 60); + Assert.True(download.Progress == 10); await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); await downloadMonitorService.MonitorDownloadsAsync(CancellationToken.None); @@ -146,7 +158,7 @@ public async Task MonitorDownloadsAsync_RespectSchedulingInterval() download = await _downloadRepository.GetByIdAsync(download.Id); Assert.NotNull(download); Assert.Equal(DownloadStatus.Downloading, download.Status); - Assert.True(download.Progress == 60); + Assert.True(download.Progress == 10); var downloadProcessingJobService = _provider.GetRequiredService(); var job = await downloadProcessingJobService.GetNextJobAsync(); @@ -158,15 +170,18 @@ public async Task MonitorDownloadsAsync_RespectSchedulingInterval() public async Task MonitorDownloadsAsync_DisabledClientDownload_DoesNotUpdate() { await _downloadRepository.AddAsync(new DownloadBuilder() - .WithDownloading(50) + .WithDownloading(0) + .WithExternalId("DISABLED_1") .WithDownloadClientConfiguration(disabledClient) .Build()); await _downloadRepository.AddAsync(new DownloadBuilder() - .WithDownloading(50) + .WithDownloading(0) + .WithExternalId("DISABLED_2") .WithDownloadClientConfiguration(disabledClient) .Build()); await _downloadRepository.AddAsync(new DownloadBuilder() - .WithDownloading(50) + .WithDownloading(0) + .WithExternalId("1") .WithDownloadClientConfiguration(client) .Build()); @@ -179,11 +194,11 @@ await _downloadRepository.AddAsync(new DownloadBuilder() { if (download.DownloadClientId == client.Id) { - Assert.NotEqual(50, download.Progress); + Assert.Equal(10, download.Progress); } else { - Assert.Equal(50, download.Progress); + Assert.Equal(0, download.Progress); Assert.Equal(DownloadStatus.Downloading, download.Status); } } diff --git a/tests/Mocks/DownloadClientAdapterMock.cs b/tests/Mocks/DownloadClientAdapterMock.cs index 6817e644e..7eebb7fd7 100644 --- a/tests/Mocks/DownloadClientAdapterMock.cs +++ b/tests/Mocks/DownloadClientAdapterMock.cs @@ -1,36 +1,27 @@ using Listenarr.Application.Interfaces; -using Listenarr.Application.Interfaces.Repositories; using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Listenarr.Tests.Builders; namespace Listenarr.Tests.Mocks { - public class DownloadCLientAdapterMock( - IDownloadRepository downloadRepository) : IDownloadClientAdapter + public class DownloadCLientAdapterMock : IDownloadClientAdapter { public static readonly string RemotePath = FileUtils.GetAbsolutePath("downloads", "complete", "audiobooks"); - public string ClientId => "mock"; public string ClientType => "mock"; - public DownloadProtocol Protocol => DownloadProtocol.Torrent; + public List Protocols => [ + DownloadProtocol.Torrent, + DownloadProtocol.Usenet + ]; + public QueueItem QueueItemMock { get; set; } = null; + private int CurrentProgress = 0; public async Task AddAsync(DownloadClientConfiguration client, SearchResult result, CancellationToken ct = default) { - var download = await downloadRepository.AddAsync(new DownloadBuilder() - .WithDownloadClientConfiguration(client) - .WithPath(RemotePath) - .Build()); - - // FIXME: Currently, the IDownloadClientAdapter returns specific client ID's, this should change to return uniformized Download instead - return download.Id; - } - - public Task GetImportItemAsync(DownloadClientConfiguration client, DownloadClientItem item, DownloadClientItem? previousAttempt = null, CancellationToken ct = default) - { - throw new NotImplementedException(); + return Guid.NewGuid().ToString(); } public async Task GetImportItemAsync(DownloadClientConfiguration client, Download download, QueueItem queueItem, QueueItem? previousAttempt = null, CancellationToken ct = default) @@ -50,38 +41,40 @@ public async Task GetImportItemAsync(DownloadClientConfiguration clie .Build(); } - public Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + public async Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default) { var path1 = FileUtils.GetAbsolutePath(RemotePath, "random title"); var path2 = FileUtils.GetAbsolutePath(RemotePath, "random title two"); - List result = [ + // Simulate progress + CurrentProgress += 10; + + List results = [ new QueueItemBuilder() + .WithId("1") .WithRemotePath(path1) + .WithContentPath(path1) .WithSourceFile(Path.Join(path1, "file1.mp3")) .WithSourceFile(Path.Join(path1, "file2.mp3")) .WithSourceFile(Path.Join(path1, "file3.mp3")) + .WithProgress(CurrentProgress) + .WithStatus(CurrentProgress >= 100 ? "completed" : "downloading") .Build(), new QueueItemBuilder() + .WithId("2") .WithRemotePath(path2) + .WithContentPath(path2) .WithSourceFile(Path.Join(path2, "file1.mp3")) .WithSourceFile(Path.Join(path2, "file10.mp3")) .WithSourceFile(Path.Join(path2, "file5.mp3")) .WithSourceFile(Path.Join(path2, "file.nfo")) .WithSourceFile(Path.Join(path2, "helloworld.txt")) + .WithProgress(CurrentProgress) + .WithStatus(CurrentProgress >= 100 ? "completed" : "downloading") .Build() ]; - return result; - } - public Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) - { - throw new NotImplementedException(); + return results; } public Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) @@ -93,43 +86,5 @@ public Task RemoveAsync(DownloadClientConfiguration client, string id, boo { return (true, "mock"); } - - public async Task> FetchDownloadsAsync(DownloadClientConfiguration client, List downloads, CancellationToken cancellationToken = default) - { - // When no downloads are given, this mock returns hardcoded data - if (downloads.Count <= 0) - { - var path1 = FileUtils.GetAbsolutePath(RemotePath, "random title"); - var path2 = FileUtils.GetAbsolutePath(RemotePath, "random title two"); - - List results = [ - new DownloadBuilder() - .WithPath(path1) - .Build(), - new DownloadBuilder() - .WithPath(path2) - .Build() - ]; - - foreach (Download download in results) - { - await downloadRepository.AddAsync(download); - } - - return results; - } - - // Otherwise, simulate progress on given downloads - foreach (Download download in downloads) - { - download.Progress += 10; - if (download.Progress >= 100) - { - download.Completed(); - } - } - - return downloads; - } } }