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;
- }
}
}