diff --git a/README.md b/README.md index 0811c7c1f..ee69485fd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Listenarr is a fast, feature-rich, cross-platform audiobook management server. B - [x] **First-class responsive web interface** that works great on any device (phone, tablet, desktop) - [x] **Rich metadata support** with automatic enrichment from Audible and Amazon - [x] **External API integration** for searching across multiple torrent and NZB indexers simultaneously -- [x] **Download management** with support for popular clients (qBittorrent, Transmission, SABnzbd, NZBGet) +- [x] **Download management** with support for popular clients (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet) - [x] **Real-time monitoring** of download progress and status - [x] **Intelligent file organization** with customizable naming patterns - [ ] **Full localization support** Soon™️ @@ -386,6 +386,7 @@ Supported download clients: - **qBittorrent** - Popular torrent client with web UI - **Transmission** - Cross-platform torrent client +- **Deluge** - Torrent client via Deluge Web JSON-RPC - **SABnzbd** - Usenet downloader - **NZBGet** - Efficient usenet client diff --git a/fe/src/components/domain/download/DownloadClientFormModal.vue b/fe/src/components/domain/download/DownloadClientFormModal.vue index baa7df91a..c72b98492 100644 --- a/fe/src/components/domain/download/DownloadClientFormModal.vue +++ b/fe/src/components/domain/download/DownloadClientFormModal.vue @@ -65,6 +65,7 @@ @@ -116,18 +117,22 @@ -
+
- RPC path for the Transmission endpoint. Default is /transmission/rpc. - Some seedbox providers use a custom path (e.g. /rpc). + {{ + formData.type === 'deluge' + ? 'Optional reverse proxy prefix for Deluge Web. The JSON-RPC endpoint will be /json under this path.' + : 'RPC path for the Transmission endpoint. Default is /transmission/rpc. Some seedbox providers use a custom path (e.g. /rpc).' + }}
@@ -404,7 +409,7 @@ const testing = ref(false) const defaultFormData = { name: '', - type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget', + type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'deluge' | 'sabnzbd' | 'nzbget', host: '', port: 8080, username: '', @@ -469,6 +474,7 @@ const getHostPlaceholder = () => { const placeholders: Record = { qbittorrent: 'qbittorrent.tld.com', transmission: 'transmission.tld.com', + deluge: 'deluge.tld.com', sabnzbd: 'sabnzbd.tld.com', nzbget: 'nzbget.tld.com', } @@ -479,6 +485,7 @@ const getPortPlaceholder = () => { const ports: Record = { qbittorrent: 8080, transmission: 9091, + deluge: 8112, sabnzbd: 8080, nzbget: 6789, } @@ -489,6 +496,7 @@ const getPortHelpText = () => { const hints: Record = { transmission: 'RPC port (default: 9091). This is not the web UI port if you changed it separately.', + deluge: 'Deluge Web UI port (default: 8112). The adapter uses Deluge Web JSON-RPC at /json.', qbittorrent: 'Web UI port (default: 8080). Found in qBittorrent → Options → Web UI.', sabnzbd: 'Web interface port (default: 8080). Found in SABnzbd → Config → General.', nzbget: 'Web interface port (default: 6789). Found in NZBGet → Settings → Connection.', @@ -500,6 +508,9 @@ const getCategoryHelp = () => { if (isUsenet.value) { return 'Adding a category specific to Listenarr avoids conflicts with unrelated non-Listenarr downloads. Using a category is optional, but strongly recommended.' } + if (formData.value.type === 'deluge') { + return 'Adding a category specific to Listenarr avoids conflicts with unrelated downloads. Deluge category support requires the Label plugin to be enabled.' + } return 'Adding a category specific to Listenarr avoids conflicts with unrelated downloads.' } @@ -508,6 +519,7 @@ const onTypeChange = () => { const defaultPorts: Record = { qbittorrent: 8080, transmission: 9091, + deluge: 8112, sabnzbd: 8080, nzbget: 6789, } @@ -518,6 +530,8 @@ const onTypeChange = () => { formData.value.password = '' } else { formData.value.apiKey = '' + if (formData.value.type === 'deluge' && !formData.value.category) + formData.value.category = 'listenarr' } } @@ -593,7 +607,8 @@ const testConnection = async () => { ...(formData.value.type === 'sabnzbd' && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), - ...(formData.value.type === 'transmission' && formData.value.urlBase + ...((formData.value.type === 'transmission' || formData.value.type === 'deluge') && + formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}), ...(formData.value.category && { category: formData.value.category }), @@ -653,7 +668,8 @@ const handleSubmit = async () => { ...(formData.value.type === 'sabnzbd' && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), - ...(formData.value.type === 'transmission' && formData.value.urlBase + ...((formData.value.type === 'transmission' || formData.value.type === 'deluge') && + formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}), ...(formData.value.category && { category: formData.value.category }), diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 596251121..884803f5c 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -241,7 +241,7 @@ export interface ApiConfiguration { export interface DownloadClientConfiguration { id: string name: string - type: 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget' + type: 'qbittorrent' | 'transmission' | 'deluge' | 'sabnzbd' | 'nzbget' host: string port: number username: string diff --git a/fe/src/views/settings/DownloadClientsTab.vue b/fe/src/views/settings/DownloadClientsTab.vue index dfa23517c..74fdf9161 100644 --- a/fe/src/views/settings/DownloadClientsTab.vue +++ b/fe/src/views/settings/DownloadClientsTab.vue @@ -33,8 +33,8 @@

- No download clients configured. Add qBittorrent, Transmission, SABnzbd, or NZBGet to - download audiobooks. + No download clients configured. Add qBittorrent, Transmission, Deluge, SABnzbd, or NZBGet + to download audiobooks.

diff --git a/listenarr.application/Downloads/Cleanup/DownloadRemovalWorkflow.cs b/listenarr.application/Downloads/Cleanup/DownloadRemovalWorkflow.cs index ce0363a66..b403e9fb5 100644 --- a/listenarr.application/Downloads/Cleanup/DownloadRemovalWorkflow.cs +++ b/listenarr.application/Downloads/Cleanup/DownloadRemovalWorkflow.cs @@ -196,7 +196,8 @@ private async Task RemoveFromClientAsync(DownloadClientConfiguration clien if (downloadRecord?.Metadata != null) { if ((string.Equals(client.Type, "qbittorrent", StringComparison.OrdinalIgnoreCase) || - string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase)) && + string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase) || + string.Equals(client.Type, "deluge", StringComparison.OrdinalIgnoreCase)) && downloadRecord.Metadata.TryGetValue("TorrentHash", out var hashObj)) { var hash = hashObj?.ToString(); diff --git a/listenarr.application/Downloads/Common/DownloadClientMetadataUpdater.cs b/listenarr.application/Downloads/Common/DownloadClientMetadataUpdater.cs index 7448cbd36..837b8e335 100644 --- a/listenarr.application/Downloads/Common/DownloadClientMetadataUpdater.cs +++ b/listenarr.application/Downloads/Common/DownloadClientMetadataUpdater.cs @@ -29,7 +29,8 @@ public static void ApplyClientSpecificId( download.Metadata["ClientDownloadId"] = clientSpecificId; if (downloadClient.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase) || - downloadClient.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)) + downloadClient.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase) || + downloadClient.Type.Equals("deluge", StringComparison.OrdinalIgnoreCase)) { download.Metadata["TorrentHash"] = clientSpecificId; } diff --git a/listenarr.application/Downloads/Processing/DownloadHashRetrievalService.cs b/listenarr.application/Downloads/Processing/DownloadHashRetrievalService.cs index 905bd34a5..e01f64029 100644 --- a/listenarr.application/Downloads/Processing/DownloadHashRetrievalService.cs +++ b/listenarr.application/Downloads/Processing/DownloadHashRetrievalService.cs @@ -49,22 +49,13 @@ public class DownloadHashRetrievalService public DownloadHashRetrievalService( ILogger logger, IDownloadHistoryRepository historyRepository, - IDownloadClientAdapter qbittorrentAdapter, - IDownloadClientAdapter transmissionAdapter, - IDownloadClientAdapter sabnzbdAdapter, - IDownloadClientAdapter nzbgetAdapter) + IEnumerable adapters) { _logger = logger; _historyRepository = historyRepository; - - // Map adapters by protocol type - _adapters = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["qbittorrent"] = qbittorrentAdapter, - ["transmission"] = transmissionAdapter, - ["sabnzbd"] = sabnzbdAdapter, - ["nzbget"] = nzbgetAdapter - }; + _adapters = adapters + .Where(a => !string.IsNullOrWhiteSpace(a.ClientType)) + .ToDictionary(a => a.ClientType, StringComparer.OrdinalIgnoreCase); } /// diff --git a/listenarr.application/Downloads/Queue/DownloadQueueService.cs b/listenarr.application/Downloads/Queue/DownloadQueueService.cs index 397e487f5..e342701fa 100644 --- a/listenarr.application/Downloads/Queue/DownloadQueueService.cs +++ b/listenarr.application/Downloads/Queue/DownloadQueueService.cs @@ -441,7 +441,8 @@ private async Task PersistDiscoveredClientIdentifiersAsync( allKnownClientItemIds.Add(originalClientId); if (string.Equals(client.Type, "qbittorrent", StringComparison.OrdinalIgnoreCase) || - string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase)) + string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase) || + string.Equals(client.Type, "deluge", StringComparison.OrdinalIgnoreCase)) { var existingTorrentHash = DownloadQueueMetadataMatcher.GetMetadataString(matchedDownload.Metadata, "TorrentHash"); if (!string.Equals(existingTorrentHash, originalClientId, StringComparison.OrdinalIgnoreCase)) diff --git a/listenarr.application/Downloads/Submission/DownloadClientSelector.cs b/listenarr.application/Downloads/Submission/DownloadClientSelector.cs index a8cb24626..c5f09bf53 100644 --- a/listenarr.application/Downloads/Submission/DownloadClientSelector.cs +++ b/listenarr.application/Downloads/Submission/DownloadClientSelector.cs @@ -37,7 +37,8 @@ public class DownloadClientSelector( if (isTorrent) { var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)); + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)) + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("deluge", StringComparison.OrdinalIgnoreCase)); if (client != null) { @@ -45,7 +46,7 @@ public class DownloadClientSelector( } else { - logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients"); + logger.LogWarning("No torrent client (qBittorrent, Transmission, or Deluge) found among enabled clients"); } return client?.Id; diff --git a/listenarr.application/Downloads/Submission/DownloadService.cs b/listenarr.application/Downloads/Submission/DownloadService.cs index 5746081d0..3b8fa8c51 100644 --- a/listenarr.application/Downloads/Submission/DownloadService.cs +++ b/listenarr.application/Downloads/Submission/DownloadService.cs @@ -291,7 +291,7 @@ public async Task SendToDownloadClientAsync( if (downloadClientId == null) { var clientType = isTorrent ? "torrent" : "NZB"; - var neededClients = isTorrent ? "qBittorrent or Transmission" : "SABnzbd or NZBGet"; + var neededClients = isTorrent ? "qBittorrent, Transmission, or Deluge" : "SABnzbd or NZBGet"; throw new Exception($"No suitable download client found for {clientType}. Please configure and enable a {clientType} client ({neededClients}) in Settings."); } diff --git a/listenarr.domain/Downloads/DownloadClientConfiguration.cs b/listenarr.domain/Downloads/DownloadClientConfiguration.cs index 806e6831d..8ac743d15 100644 --- a/listenarr.domain/Downloads/DownloadClientConfiguration.cs +++ b/listenarr.domain/Downloads/DownloadClientConfiguration.cs @@ -6,7 +6,7 @@ public class DownloadClientConfiguration { public string Id { get; set; } = Guid.NewGuid().ToString(); public string Name { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; // "qbittorrent", "transmission", "sabnzbd", "nzbget" + public string Type { get; set; } = string.Empty; // "qbittorrent", "transmission", "deluge", "sabnzbd", "nzbget" public string Host { get; set; } = string.Empty; public int Port { get; set; } public string Username { get; set; } = string.Empty; diff --git a/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs index eb931dfe7..c1f973a35 100644 --- a/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/DownloadClients/DownloadClientRegistrationExtensions.cs @@ -37,6 +37,7 @@ public static IServiceCollection AddDownloadClientHttpClients(this IServiceColle AddAdapterClient(services, DownloadClientTypes.Qbittorrent, useCookies: true, retryPolicy, circuitBreakerPolicy); AddAdapterClient(services, DownloadClientTypes.Transmission, useCookies: false, retryPolicy, circuitBreakerPolicy); + AddAdapterClient(services, DownloadClientTypes.Deluge, useCookies: true, retryPolicy, circuitBreakerPolicy); AddAdapterClient(services, DownloadClientTypes.Sabnzbd, useCookies: false, retryPolicy, circuitBreakerPolicy); AddAdapterClient(services, DownloadClientTypes.Nzbget, useCookies: false, retryPolicy, circuitBreakerPolicy); return services; @@ -53,9 +54,9 @@ public static IServiceCollection AddDownloadClientAdapters( services.TryAddSingleton(TimeProvider.System); services.AddScoped(); services.AddScoped(); - services.AddQbittorrentWorkflows(); services.AddTransmissionWorkflows(); + services.AddDelugeWorkflows(); services.AddSabnzbdWorkflows(); services.AddNzbgetWorkflows(); @@ -74,6 +75,15 @@ public static IServiceCollection AddDownloadClientAdapters( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); + services.AddScoped(sp => new DelugeAdapter( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); services.AddScoped(sp => new SabnzbdAdapter( sp.GetRequiredService(), sp.GetRequiredService(), @@ -264,6 +274,40 @@ private static IServiceCollection AddNzbgetWorkflows(this IServiceCollection ser return services; } + private static IServiceCollection AddDelugeWorkflows(this IServiceCollection services) + { + services.AddScoped(sp => + new DelugeRpcClient( + sp.GetRequiredService(), + DownloadClientTypes.Deluge, + sp.GetRequiredService>())); + services.AddScoped(sp => + new DelugeConnectionTester( + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddScoped(sp => + new DelugeAddWorkflow( + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddScoped(sp => + new DelugeRemovalWorkflow( + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddScoped(sp => + new DelugeQueueFetchWorkflow( + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddScoped(sp => + new DelugeItemFetchWorkflow( + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddScoped(sp => + new DelugeImportItemResolver( + sp.GetRequiredService(), + sp.GetRequiredService>())); + return services; + } + private static void AddAdapterClient( IServiceCollection services, string name, diff --git a/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs b/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs index 685f4598e..044bcb1f5 100644 --- a/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs +++ b/listenarr.infrastructure/DownloadClients/Common/DownloadClientTypes.cs @@ -16,5 +16,6 @@ internal static class DownloadClientTypes public const string Transmission = "transmission"; public const string Sabnzbd = "sabnzbd"; public const string Nzbget = "nzbget"; + public const string Deluge = "deluge"; } } diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeAdapter.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeAdapter.cs new file mode 100644 index 000000000..20c0bdebe --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeAdapter.cs @@ -0,0 +1,148 @@ +/* + * 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. + */ + +using Listenarr.Infrastructure.Torrents; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + /// + /// Deluge JSON-RPC protocol facade. + /// Keep client-specific workflows behind this adapter so every supported + /// download client exposes the same thin IDownloadClientAdapter surface. + /// + public class DelugeAdapter : IDownloadClientAdapter + { + public string ClientId => DownloadClientTypes.Deluge; + public string ClientType => DownloadClientTypes.Deluge; + public DownloadProtocol Protocol => DownloadProtocol.Torrent; + + private readonly DelugeRpcClient _rpcClient; + private readonly DelugeConnectionTester _connectionTester; + private readonly DelugeAddWorkflow _addWorkflow; + private readonly DelugeRemovalWorkflow _removalWorkflow; + private readonly DelugeQueueFetchWorkflow _queueFetchWorkflow; + private readonly DelugeItemFetchWorkflow _itemFetchWorkflow; + private readonly DelugeImportItemResolver _importItemResolver; + private readonly ILogger _logger; + + internal DelugeAdapter( + DelugeRpcClient rpcClient, + DelugeConnectionTester connectionTester, + DelugeAddWorkflow addWorkflow, + DelugeRemovalWorkflow removalWorkflow, + DelugeQueueFetchWorkflow queueFetchWorkflow, + DelugeItemFetchWorkflow itemFetchWorkflow, + DelugeImportItemResolver importItemResolver, + ILogger logger) + { + _rpcClient = rpcClient ?? throw new ArgumentNullException(nameof(rpcClient)); + _connectionTester = connectionTester ?? throw new ArgumentNullException(nameof(connectionTester)); + _addWorkflow = addWorkflow ?? throw new ArgumentNullException(nameof(addWorkflow)); + _removalWorkflow = removalWorkflow ?? throw new ArgumentNullException(nameof(removalWorkflow)); + _queueFetchWorkflow = queueFetchWorkflow ?? throw new ArgumentNullException(nameof(queueFetchWorkflow)); + _itemFetchWorkflow = itemFetchWorkflow ?? throw new ArgumentNullException(nameof(itemFetchWorkflow)); + _importItemResolver = importItemResolver ?? throw new ArgumentNullException(nameof(importItemResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public DelugeAdapter( + IHttpClientFactory httpClientFactory, + ITorrentFileDownloader torrentFileDownloader, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(httpClientFactory); + _ = torrentFileDownloader ?? throw new ArgumentNullException(nameof(torrentFileDownloader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _rpcClient = new DelugeRpcClient(httpClientFactory, ClientType, logger); + _connectionTester = new DelugeConnectionTester(_rpcClient, logger); + _addWorkflow = new DelugeAddWorkflow(_rpcClient, logger); + _removalWorkflow = new DelugeRemovalWorkflow(_rpcClient, logger); + _queueFetchWorkflow = new DelugeQueueFetchWorkflow(_rpcClient, logger); + _itemFetchWorkflow = new DelugeItemFetchWorkflow(_rpcClient, logger); + _importItemResolver = new DelugeImportItemResolver(_rpcClient, logger); + } + + public Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) + => _connectionTester.TestConnectionAsync(client, ct); + + public Task AddAsync( + DownloadClientConfiguration client, + PreparedDownloadSubmission submission, + CancellationToken ct = default) + => _addWorkflow.AddAsync(client, submission, ct); + + public Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) + => _removalWorkflow.RemoveAsync(client, id, deleteFiles, ct); + + public Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default) + => _queueFetchWorkflow.GetQueueAsync(client, ids, ct); + + public Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + => GetQueueAsync(client, [], ct); + + public Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) + { + // Deluge does not expose a dedicated history endpoint via RPC. + return Task.FromResult(new List<(string Id, string Name)>()); + } + + public Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) + => _itemFetchWorkflow.GetItemsAsync(client, ct); + + public Task GetImportItemAsync( + DownloadClientConfiguration client, + DownloadClientItem item, + DownloadClientItem? previousAttempt = null, + CancellationToken ct = default) + => _importItemResolver.GetImportItemAsync(client, item, ct); + + public Task GetImportItemAsync( + DownloadClientConfiguration client, + Download download, + QueueItem queueItem, + QueueItem? previousAttempt = null, + CancellationToken ct = default) + => _importItemResolver.GetImportItemAsync(client, download, queueItem, ct); + + public async Task MarkItemAsImportedAsync( + DownloadClientConfiguration client, + string downloadId, + CancellationToken ct = default) + { + if (client == null || string.IsNullOrEmpty(downloadId)) + { + return false; + } + + var postImportCategory = client.Settings?.TryGetValue("postImportCategory", out var categoryObj) is true + ? categoryObj?.ToString() + : null; + if (string.IsNullOrEmpty(postImportCategory)) + { + _logger.LogDebug("No postImportCategory configured for Deluge client {ClientId}, skipping MarkItemAsImported", client.Id); + return true; + } + + try + { + await _rpcClient.InvokeAsync(client, "label.set_torrent", [downloadId, postImportCategory], ct); + _logger.LogInformation("Marked torrent {Hash} as imported (category: {Category}) in Deluge", downloadId, postImportCategory); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error marking torrent {Hash} as imported in Deluge", downloadId); + return false; + } + } + } +} diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeAddWorkflow.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeAddWorkflow.cs new file mode 100644 index 000000000..57d5b4d4a --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeAddWorkflow.cs @@ -0,0 +1,132 @@ +/* + * 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. + */ + +using System.Text.Json; +using System.Text.RegularExpressions; +using Listenarr.Infrastructure.Torrents; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + internal sealed class DelugeAddWorkflow( + DelugeRpcClient rpcClient, + ILogger logger) + { + public async Task AddAsync( + DownloadClientConfiguration client, + PreparedDownloadSubmission submission, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(client); + if (submission is not PreparedTorrentSubmission torrent) + { + throw new DownloadClientSubmissionException("Deluge requires a prepared torrent submission."); + } + + var options = BuildTorrentOptions(client); + string? id = null; + + if (torrent.TorrentBytes != null && torrent.TorrentBytes.Length > 0) + { + var filename = SanitizeTorrentFileName(torrent.FileName ?? torrent.Title) + ".torrent"; + var res = await rpcClient.InvokeAsync(client, "core.add_torrent_file", [filename, Convert.ToBase64String(torrent.TorrentBytes), options], ct); + id = res.ValueKind == JsonValueKind.String ? res.GetString() : null; + } + else + { + var uri = !string.IsNullOrWhiteSpace(torrent.MagnetUri) + ? DownloadClientUriBuilder.NormalizeMagnetLink(torrent.MagnetUri) + : NormalizeTorrentUrl(torrent.OriginalLocator); + if (string.IsNullOrWhiteSpace(uri)) + { + throw new DownloadClientSubmissionException("No magnet link, torrent URL, or cached torrent file provided."); + } + + if (uri.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase)) + { + var res = await rpcClient.InvokeAsync(client, "core.add_torrent_magnet", [uri, options], ct); + id = res.ValueKind == JsonValueKind.String ? res.GetString() : TryExtractHashFromMagnet(uri); + } + else + { + var tempPath = await rpcClient.InvokeAsync(client, "web.download_torrent_from_url", [uri], ct); + var path = tempPath.GetString(); + if (!string.IsNullOrWhiteSpace(path)) + { + var res = await rpcClient.InvokeAsync(client, "web.add_torrents", [new[] { new { path, options } }], ct); + id = TryExtractAddedId(res); + } + } + } + + var category = GetSetting(client, "category"); + if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(category)) + { + await TrySetLabelAsync(client, id, category, ct); + } + + if (string.IsNullOrWhiteSpace(id)) + { + throw new DownloadClientSubmissionException("Deluge did not return a verified torrent identifier."); + } + + return new DownloadClientSubmissionResult(id, torrent.InfoHash); + } + + private static Dictionary BuildTorrentOptions(DownloadClientConfiguration client) + { + var options = new Dictionary(); + if (!string.IsNullOrWhiteSpace(client.DownloadPath)) + { + options["download_location"] = client.DownloadPath; + } + var addPaused = GetSetting(client, "addPaused") ?? GetSetting(client, "AddPaused"); + if (bool.TryParse(addPaused, out var paused)) + { + options["add_paused"] = paused; + } + return options; + } + + private async Task TrySetLabelAsync( + DownloadClientConfiguration client, + string id, + string label, + CancellationToken ct) + { + try + { + await rpcClient.InvokeAsync(client, "label.set_torrent", [id, label], ct); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Unable to set Deluge label/category. Is the Label plugin enabled?"); + } + } + + private static string? GetSetting(DownloadClientConfiguration c, string key) => + c.Settings != null && c.Settings.TryGetValue(key, out var v) ? v?.ToString() : null; + + private static string SanitizeTorrentFileName(string? title) => + string.Join("_", (title ?? "listenarr").Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries)).Trim(); + + private static string? NormalizeTorrentUrl(string? url) => + string.IsNullOrWhiteSpace(url) ? null : url.Trim(); + + private static string? TryExtractHashFromMagnet(string magnet) + { + var m = Regex.Match(magnet, @"btih:([A-Fa-f0-9]{40})"); + return m.Success ? m.Groups[1].Value.ToLowerInvariant() : null; + } + + private static string? TryExtractAddedId(JsonElement res) => + res.ValueKind == JsonValueKind.Array && res.GetArrayLength() > 0 ? res[0].GetString() : null; + } +} diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeConnectionTester.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeConnectionTester.cs new file mode 100644 index 000000000..db9037e73 --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeConnectionTester.cs @@ -0,0 +1,49 @@ +/* + * 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. + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + internal sealed class DelugeConnectionTester( + DelugeRpcClient rpcClient, + ILogger logger) + { + public async Task<(bool Success, string Message)> TestConnectionAsync( + DownloadClientConfiguration client, + CancellationToken ct = default) + { + try + { + var connected = await rpcClient.InvokeAsync(client, "web.connected", [], ct); + if (connected.ValueKind == JsonValueKind.True) + { + return (true, "Deluge: connected to Web UI and daemon"); + } + return (false, "Deluge: unexpected JSON-RPC response"); + } + catch (UnauthorizedAccessException ex) + { + logger.LogDebug(ex, "Deluge authentication failed for client {ClientId}", LogRedaction.SanitizeText(client?.Id ?? client?.Name ?? client?.Type)); + return (false, "Deluge: authentication failed (check Web UI password)"); + } + catch (TaskCanceledException) + { + return (false, "Deluge: connection timed out"); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Deluge test failed for client {ClientId}", LogRedaction.SanitizeText(client?.Id ?? client?.Name ?? client?.Type)); + return (false, $"Deluge: connection failed ({ex.Message})"); + } + } + } +} diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeImportItemResolver.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeImportItemResolver.cs new file mode 100644 index 000000000..e44328c5d --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeImportItemResolver.cs @@ -0,0 +1,155 @@ +/* + * 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. + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + internal sealed class DelugeImportItemResolver( + DelugeRpcClient rpcClient, + ILogger logger) + { + private static readonly string[] StatusKeys = + [ + "name", "total_size", "total_done", "progress", "download_payload_rate", "eta", "state", + "save_path", "label", "ratio", "num_seeds", "num_peers", "time_added", "files", "message" + ]; + + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + DownloadClientItem item, + CancellationToken ct = default) + { + var torrent = await TryGetTorrentAsync(client, item.DownloadId, ct); + if (torrent == null) + { + return item; + } + + var result = item.Clone(); + var savePath = torrent.Value.TryGetProperty("save_path", out var savePathProp) ? savePathProp.GetString() : null; + var name = torrent.Value.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + + if (!string.IsNullOrEmpty(savePath) && !string.IsNullOrEmpty(name)) + { + result.OutputPath = Path.Combine(savePath, name); + } + + return result; + } + + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + Download download, + QueueItem queueItem, + CancellationToken ct = default) + { + var result = queueItem.Clone(); + var externalId = download.GetExternalId(); + var hash = string.IsNullOrWhiteSpace(externalId) ? queueItem.Id : externalId; + + if (string.IsNullOrEmpty(hash)) + { + return result; + } + + var torrent = await TryGetTorrentAsync(client, hash, ct); + if (torrent == null) + { + return result; + } + + result.Id = hash; + + var savePath = torrent.Value.TryGetProperty("save_path", out var savePathProp) ? savePathProp.GetString() : null; + var name = torrent.Value.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + var state = torrent.Value.TryGetProperty("state", out var stateProp) ? stateProp.GetString() : null; + var progress = torrent.Value.TryGetProperty("progress", out var progressProp) && progressProp.TryGetDouble(out var d) ? d : 0.0; + + if (!string.IsNullOrEmpty(savePath) && !string.IsNullOrEmpty(name)) + { + result.ContentPath = Path.Combine(savePath, name); + } + + if (!string.IsNullOrEmpty(name)) + { + result.Title = name; + } + + if (!string.IsNullOrEmpty(state)) + { + result.Status = MapStatus(state, progress).ToString().ToLowerInvariant(); + } + + if (!string.IsNullOrEmpty(savePath) && torrent.Value.TryGetProperty("files", out var filesProp) && filesProp.ValueKind == JsonValueKind.Array) + { + var filesList = new List(); + foreach (var file in filesProp.EnumerateArray()) + { + if (file.TryGetProperty("path", out var pathProp) && pathProp.ValueKind == JsonValueKind.String) + { + var relPath = pathProp.GetString(); + if (!string.IsNullOrWhiteSpace(relPath)) + { + filesList.Add(Path.Combine(savePath, relPath)); + } + } + } + result.SourceFiles = filesList; + } + + return result; + } + + private static DownloadItemStatus MapStatus(string state, double progress) + { + var normalizedState = (state ?? string.Empty).Trim().ToLowerInvariant(); + var payloadComplete = progress >= 100.0; + + return normalizedState switch + { + "seeding" or "finished" => DownloadItemStatus.Completed, + "paused" when payloadComplete => DownloadItemStatus.Completed, + "paused" => DownloadItemStatus.Paused, + "queued" when payloadComplete => DownloadItemStatus.Completed, + "queued" => DownloadItemStatus.Queued, + "downloading" or "downloading metadata" => DownloadItemStatus.Downloading, + "error" => DownloadItemStatus.Failed, + "checking" => DownloadItemStatus.Checking, + _ => payloadComplete ? DownloadItemStatus.Completed : DownloadItemStatus.Unknown + }; + } + + private async Task TryGetTorrentAsync( + DownloadClientConfiguration client, + string torrentId, + CancellationToken ct) + { + try + { + var res = await rpcClient.InvokeAsync(client, "web.update_ui", [StatusKeys, new Dictionary()], ct); + if (res.TryGetProperty("torrents", out var torrents) && torrents.ValueKind == JsonValueKind.Object) + { + if (torrents.TryGetProperty(torrentId, out var torrent)) + { + return torrent.Clone(); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Error resolving import item for Deluge torrent {TorrentId}", torrentId); + } + + return null; + } + } +} diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeItemFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeItemFetchWorkflow.cs new file mode 100644 index 000000000..7c17cfa8b --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeItemFetchWorkflow.cs @@ -0,0 +1,131 @@ +/* + * 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. + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + internal sealed class DelugeItemFetchWorkflow( + DelugeRpcClient rpcClient, + ILogger logger) + { + private static readonly string[] StatusKeys = + [ + "name", "total_size", "total_done", "progress", "download_payload_rate", "eta", "state", + "save_path", "label", "ratio", "num_seeds", "num_peers", "time_added", "files", "message" + ]; + + public async Task> GetItemsAsync(DownloadClientConfiguration client, CancellationToken ct = default) + { + var list = new List(); + if (client == null) + { + return list; + } + + try + { + var res = await rpcClient.InvokeAsync(client, "web.update_ui", [StatusKeys, new Dictionary()], ct); + var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); + if (!res.TryGetProperty("torrents", out var torrents) || torrents.ValueKind != JsonValueKind.Object) + { + return list; + } + + foreach (var torrent in torrents.EnumerateObject()) + { + var t = torrent.Value; + var label = GetString(t, "label"); + if (!DownloadClientCategoryFilter.Matches(configuredCategory, label)) + { + continue; + } + var total = GetLong(t, "total_size"); + var done = GetLong(t, "total_done"); + list.Add(new DownloadClientItem + { + DownloadId = torrent.Name, + Title = GetString(t, "name"), + Category = label, + TotalSize = total, + RemainingSize = Math.Max(0, total - done), + OutputPath = BuildOutputPath(t), + Status = MapStatus(GetString(t, "state"), GetDouble(t, "progress")), + Message = GetString(t, "message"), + Progress = GetDouble(t, "progress"), + DownloadSpeed = GetDouble(t, "download_payload_rate"), + SeedRatio = GetDouble(t, "ratio"), + Seeders = (int)GetLong(t, "num_seeds"), + Leechers = (int)GetLong(t, "num_peers"), + AddedAt = FromUnix(GetDouble(t, "time_added")), + CanBeRemoved = true, + CanMoveFiles = false, + DownloadClientInfo = DownloadClientItemClientInfo.FromClient( + client.Id, + client.Name, + client.Type, + DownloadProtocol.Torrent, + client.RemoveCompletedDownloads != "none", + false) + }); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to retrieve Deluge items for client {ClientName}", LogRedaction.SanitizeText(client.Name ?? client.Id)); + } + + return list; + } + + private static DownloadItemStatus MapStatus(string state, double progress) + { + var normalizedState = (state ?? string.Empty).Trim().ToLowerInvariant(); + var payloadComplete = progress >= 100.0; + + return normalizedState switch + { + "seeding" or "finished" => DownloadItemStatus.Completed, + "paused" when payloadComplete => DownloadItemStatus.Completed, + "paused" => DownloadItemStatus.Paused, + "queued" when payloadComplete => DownloadItemStatus.Completed, + "queued" => DownloadItemStatus.Queued, + "downloading" or "downloading metadata" => DownloadItemStatus.Downloading, + "error" => DownloadItemStatus.Failed, + "checking" => DownloadItemStatus.Checking, + _ => payloadComplete ? DownloadItemStatus.Completed : DownloadItemStatus.Unknown + }; + } + + private static string BuildOutputPath(JsonElement t) + { + var savePath = GetString(t, "save_path").TrimEnd('/', '\\'); + var name = GetString(t, "name"); + if (string.IsNullOrWhiteSpace(savePath)) + { + return string.Empty; + } + return string.IsNullOrWhiteSpace(name) ? savePath : Path.Combine(savePath, name); + } + + private static string GetString(JsonElement e, string key) => + e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + + private static long GetLong(JsonElement e, string key) => + e.TryGetProperty(key, out var v) && v.TryGetInt64(out var l) ? l : 0; + + private static double GetDouble(JsonElement e, string key) => + e.TryGetProperty(key, out var v) && v.TryGetDouble(out var d) ? d : 0; + + private static DateTime FromUnix(double seconds) => + seconds > 0 ? DateTimeOffset.FromUnixTimeSeconds((long)seconds).UtcDateTime : DateTime.UtcNow; + } +} diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeQueueFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeQueueFetchWorkflow.cs new file mode 100644 index 000000000..f2c6eec54 --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeQueueFetchWorkflow.cs @@ -0,0 +1,154 @@ +/* + * 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. + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + internal sealed class DelugeQueueFetchWorkflow( + DelugeRpcClient rpcClient, + ILogger logger) + { + private static readonly string[] StatusKeys = + [ + "name", "total_size", "total_done", "progress", "download_payload_rate", "eta", "state", + "save_path", "label", "ratio", "num_seeds", "num_peers", "time_added", "files", "message" + ]; + + public async Task> GetQueueAsync(DownloadClientConfiguration client, List ids, CancellationToken ct = default) + { + var items = new List(); + if (client == null) + { + return items; + } + + var isMonitorPoll = ids.Count > 0; + var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); + + try + { + var res = await rpcClient.InvokeAsync(client, "web.update_ui", [StatusKeys, new Dictionary()], ct); + if (!res.TryGetProperty("torrents", out var torrents) || torrents.ValueKind != JsonValueKind.Object) + { + return items; + } + + foreach (var torrent in torrents.EnumerateObject()) + { + var t = torrent.Value; + var label = GetString(t, "label"); + if (!DownloadClientCategoryFilter.Matches(configuredCategory, label)) + { + continue; + } + + var total = GetLong(t, "total_size"); + var done = GetLong(t, "total_done"); + var state = GetString(t, "state"); + var progress = GetDouble(t, "progress"); + + var queueItem = new QueueItem + { + Id = torrent.Name, + Title = GetString(t, "name"), + Status = MapStatus(state, progress).ToString().ToLowerInvariant(), + Progress = progress, + Size = total, + Downloaded = done, + DownloadSpeed = GetDouble(t, "download_payload_rate"), + Eta = GetLong(t, "eta") > 0 ? (int?)GetLong(t, "eta") : null, + DownloadClient = client.Name, + DownloadClientId = client.Id, + DownloadClientType = client.Type, + AddedAt = FromUnix(GetDouble(t, "time_added")), + ErrorMessage = GetString(t, "message"), + Seeders = (int)GetLong(t, "num_seeds"), + Leechers = (int)GetLong(t, "num_peers"), + Ratio = GetDouble(t, "ratio"), + RemotePath = BuildOutputPath(t), + ContentPath = BuildOutputPath(t), + CanRemove = true + }; + + items.Add(queueItem); + } + } + catch (DownloadClientAdapterPollingException) + { + throw; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to retrieve Deluge queue for client {ClientName}", LogRedaction.SanitizeText(client.Name ?? client.Id)); + if (isMonitorPoll) + { + throw new DownloadClientAdapterPollingException("Error polling Deluge queue.", ex); + } + } + + return FilterByIds(items, ids); + } + + private static List FilterByIds(List items, List ids) + { + if (ids.Count == 0) + { + return items; + } + + var idSet = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); + return [.. items.Where(item => !string.IsNullOrWhiteSpace(item.Id) && idSet.Contains(item.Id))]; + } + + private static DownloadItemStatus MapStatus(string state, double progress) + { + var normalizedState = (state ?? string.Empty).Trim().ToLowerInvariant(); + var payloadComplete = progress >= 100.0; + + return normalizedState switch + { + "seeding" or "finished" => DownloadItemStatus.Completed, + "paused" when payloadComplete => DownloadItemStatus.Completed, + "paused" => DownloadItemStatus.Paused, + "queued" when payloadComplete => DownloadItemStatus.Completed, + "queued" => DownloadItemStatus.Queued, + "downloading" or "downloading metadata" => DownloadItemStatus.Downloading, + "error" => DownloadItemStatus.Failed, + "checking" => DownloadItemStatus.Checking, + _ => payloadComplete ? DownloadItemStatus.Completed : DownloadItemStatus.Unknown + }; + } + + private static string BuildOutputPath(JsonElement t) + { + var savePath = GetString(t, "save_path").TrimEnd('/', '\\'); + var name = GetString(t, "name"); + if (string.IsNullOrWhiteSpace(savePath)) + { + return string.Empty; + } + return string.IsNullOrWhiteSpace(name) ? savePath : Path.Combine(savePath, name); + } + + private static string GetString(JsonElement e, string key) => + e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + + private static long GetLong(JsonElement e, string key) => + e.TryGetProperty(key, out var v) && v.TryGetInt64(out var l) ? l : 0; + + private static double GetDouble(JsonElement e, string key) => + e.TryGetProperty(key, out var v) && v.TryGetDouble(out var d) ? d : 0; + + private static DateTime FromUnix(double seconds) => + seconds > 0 ? DateTimeOffset.FromUnixTimeSeconds((long)seconds).UtcDateTime : DateTime.UtcNow; + } +} diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeRemovalWorkflow.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeRemovalWorkflow.cs new file mode 100644 index 000000000..4aaeeea2f --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeRemovalWorkflow.cs @@ -0,0 +1,30 @@ +/* + * 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. + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + internal sealed class DelugeRemovalWorkflow( + DelugeRpcClient rpcClient, + ILogger logger) + { + public async Task RemoveAsync( + DownloadClientConfiguration client, + string id, + bool deleteFiles = false, + CancellationToken ct = default) + { + var res = await rpcClient.InvokeAsync(client, "core.remove_torrent", [id, deleteFiles], ct); + return res.ValueKind == JsonValueKind.True || res.ValueKind == JsonValueKind.Null || res.ValueKind == JsonValueKind.Undefined; + } + } +} diff --git a/listenarr.infrastructure/DownloadClients/Deluge/DelugeRpcClient.cs b/listenarr.infrastructure/DownloadClients/Deluge/DelugeRpcClient.cs new file mode 100644 index 000000000..3473ab469 --- /dev/null +++ b/listenarr.infrastructure/DownloadClients/Deluge/DelugeRpcClient.cs @@ -0,0 +1,141 @@ +/* + * 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. + */ + +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.DownloadClients.Deluge +{ + internal sealed class DelugeRpcClient + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _clientType; + private readonly ILogger _logger; + + public DelugeRpcClient(IHttpClientFactory httpClientFactory, string clientType, ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _clientType = clientType ?? throw new ArgumentNullException(nameof(clientType)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task InvokeAsync( + DownloadClientConfiguration client, + string method, + object[] parameters, + CancellationToken ct) + { + using var http = _httpClientFactory.CreateClient(_clientType); + await AuthenticateAsync(http, client, ct); + await EnsureDaemonConnectedAsync(http, client, ct); + return await SendRequestAsync(http, client, method, parameters, ct); + } + + private async Task AuthenticateAsync(HttpClient http, DownloadClientConfiguration client, CancellationToken ct) + { + var res = await SendRequestAsync(http, client, "auth.login", [client.Password ?? string.Empty], ct); + if (res.ValueKind != JsonValueKind.True) + { + throw new UnauthorizedAccessException("Failed to authenticate with Deluge Web UI"); + } + } + + private async Task EnsureDaemonConnectedAsync(HttpClient http, DownloadClientConfiguration client, CancellationToken ct) + { + var connected = await SendRequestAsync(http, client, "web.connected", [], ct); + if (connected.ValueKind == JsonValueKind.True) + { + return; + } + + var hosts = await SendRequestAsync(http, client, "web.get_hosts", [], ct); + if (hosts.ValueKind != JsonValueKind.Array || hosts.GetArrayLength() == 0) + { + throw new InvalidOperationException("Deluge Web is not connected to a daemon and no daemon hosts are configured"); + } + + string? hostId = null; + foreach (var host in hosts.EnumerateArray()) + { + if (host.ValueKind == JsonValueKind.Array && host.GetArrayLength() > 0) + { + hostId = host[0].GetString(); + if (!string.IsNullOrWhiteSpace(hostId)) + { + break; + } + } + } + + if (string.IsNullOrWhiteSpace(hostId)) + { + throw new InvalidOperationException("Deluge Web returned no daemon host id"); + } + + await SendRequestAsync(http, client, "web.connect", [hostId], ct); + } + + private async Task SendRequestAsync( + HttpClient http, + DownloadClientConfiguration client, + string method, + object[] parameters, + CancellationToken ct) + { + var payload = JsonSerializer.Serialize(new { method, @params = parameters, id = 1 }); + using var content = new ByteArrayContent(Encoding.UTF8.GetBytes(payload)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + var url = BuildBaseUrl(client); + using var response = await http.PostAsync(url, content, ct); + + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + { + throw new UnauthorizedAccessException("Deluge rejected the request"); + } + + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(responseBody); + + if (doc.RootElement.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null) + { + throw new InvalidOperationException($"Deluge JSON-RPC error calling {method}: {error}"); + } + + if (doc.RootElement.TryGetProperty("result", out var result)) + { + return result.Clone(); + } + + return default; + } + + private static string BuildBaseUrl(DownloadClientConfiguration client) + { + var scheme = client.UseSSL ? "https" : "http"; + var host = client.Host.Trim().TrimEnd('/'); + if (host.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || host.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + host = new Uri(host).Authority; + } + var urlBase = GetSetting(client, "urlBase") ?? GetSetting(client, "UrlBase") ?? string.Empty; + urlBase = urlBase.Trim('/'); + return string.IsNullOrWhiteSpace(urlBase) ? $"{scheme}://{host}:{client.Port}/json" : $"{scheme}://{host}:{client.Port}/{urlBase}/json"; + } + + private static string? GetSetting(DownloadClientConfiguration c, string key) => + c.Settings != null && c.Settings.TryGetValue(key, out var v) ? v?.ToString() : null; + } +} diff --git a/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs b/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs index 94f0ad511..960b87f30 100644 --- a/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs +++ b/listenarr.infrastructure/Downloads/Cleanup/MovedDownloadCleanupService.cs @@ -285,7 +285,7 @@ await AddCleanupHistoryAsync( if (!removed && !string.IsNullOrEmpty(torrentHash)) { var torrentClientTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { "qbittorrent", "transmission" }; + { "qbittorrent", "transmission", "deluge" }; var otherTorrentClients = allEnabledClients .Where(c => torrentClientTypes.Contains(c.Type ?? "") && c.Id != download.DownloadClientId) diff --git a/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs b/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs index 6094abf3c..28a1b0637 100644 --- a/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs +++ b/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs @@ -185,10 +185,10 @@ internal async Task MonitorDownloadsAsync(CancellationToken cancellationToken) continue; } - // Respect per-client poll schedules to avoid overloading qbittorrent + // Respect per-client poll schedules to avoid overloading download clients if (_nextClientPoll.TryGetValue(client.Id, out var scheduled) && DateTime.UtcNow < scheduled) { - logger.LogDebug($"Skipping qBittorrent poll for client {client.Id}, next scheduled at {scheduled}"); + logger.LogDebug($"Skipping poll for client {client.Id} ({client.Type}), next scheduled at {scheduled}"); continue; } diff --git a/listenarr.infrastructure/GlobalUsings.cs b/listenarr.infrastructure/GlobalUsings.cs index 69e4c51f6..d6a7eda4a 100644 --- a/listenarr.infrastructure/GlobalUsings.cs +++ b/listenarr.infrastructure/GlobalUsings.cs @@ -99,6 +99,7 @@ global using Listenarr.Infrastructure.Metadata.Providers.OpenLibrary; global using Listenarr.Infrastructure.HostedServices.Search; global using Listenarr.Infrastructure.DownloadClients.Common; +global using Listenarr.Infrastructure.DownloadClients.Deluge; global using Listenarr.Infrastructure.DownloadClients.Nzbget; global using Listenarr.Infrastructure.DownloadClients.Qbittorrent; global using Listenarr.Infrastructure.DownloadClients.Sabnzbd; diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs index 67cb789db..b56b9a2c4 100644 --- a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs @@ -47,7 +47,8 @@ public async Task GetAppropriateDownloadClientAsync(SearchResult searchR if (isTorrent) { var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)); + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)) + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("deluge", StringComparison.OrdinalIgnoreCase)); if (client != null) { @@ -55,7 +56,7 @@ public async Task GetAppropriateDownloadClientAsync(SearchResult searchR } else { - logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients"); + logger.LogWarning("No torrent client (qBittorrent, Transmission, or Deluge) found among enabled clients"); } return client?.Id ?? string.Empty; diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 7a3ba44fe..604ce958c 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -228,6 +228,7 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(_ => new MyAnonamouseApiMock { FailOnUnexpectedCalls = true @@ -250,6 +251,9 @@ private ServiceCollection BuildServices() services.AddHttpClient("qbittorrent") .ConfigurePrimaryHttpMessageHandler(); + services.AddHttpClient("deluge") + .ConfigurePrimaryHttpMessageHandler(); + services.AddHttpClient("DirectDownload"); services.AddHttpClient(DownloadRegistrationExtensions.MyAnonamouseTorrentClientName) diff --git a/tests/Common/MockUtils.cs b/tests/Common/MockUtils.cs index b4ab2cb67..31a352609 100644 --- a/tests/Common/MockUtils.cs +++ b/tests/Common/MockUtils.cs @@ -112,6 +112,14 @@ public static TransmissionAdapter CreateTransmissionAdapter(ServiceProvider prov provider.GetRequiredService>()); } + public static DelugeAdapter CreateDelugeAdapter(ServiceProvider provider) + { + return new DelugeAdapter( + provider.GetRequiredService(), + Mock.Of(), + provider.GetRequiredService>()); + } + public static SabnzbdAdapter CreateSabnzbdAdapter(ServiceProvider provider) { return new SabnzbdAdapter( diff --git a/tests/Features/Application/Downloads/Processing/DownloadHashRetrievalServiceTests.cs b/tests/Features/Application/Downloads/Processing/DownloadHashRetrievalServiceTests.cs index 53224cb89..b71c093d4 100644 --- a/tests/Features/Application/Downloads/Processing/DownloadHashRetrievalServiceTests.cs +++ b/tests/Features/Application/Downloads/Processing/DownloadHashRetrievalServiceTests.cs @@ -44,14 +44,12 @@ public DownloadHashRetrievalServiceTests() // Mock a single adapter for qBittorrent _mockAdapter = new Mock(); _mockAdapter.Setup(a => a.Protocol).Returns(DownloadProtocol.Torrent); + _mockAdapter.Setup(a => a.ClientType).Returns("qbittorrent"); _service = new DownloadHashRetrievalService( _mockLogger.Object, _historyRepository, - _mockAdapter.Object, - _mockAdapter.Object, - _mockAdapter.Object, - _mockAdapter.Object); + [_mockAdapter.Object]); } [Fact] diff --git a/tests/Features/Application/Downloads/Submission/DelugeSelectionAndMetadataTests.cs b/tests/Features/Application/Downloads/Submission/DelugeSelectionAndMetadataTests.cs new file mode 100644 index 000000000..16806a203 --- /dev/null +++ b/tests/Features/Application/Downloads/Submission/DelugeSelectionAndMetadataTests.cs @@ -0,0 +1,44 @@ +using Listenarr.Application.Downloads.Common; +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Downloads.Submission +{ + public class DelugeSelectionAndMetadataTests : BaseTests + { + [Fact] + public async Task GetAppropriateDownloadClientAsync_SelectsDeluge_WhenDelugeIsOnlyEnabledTorrentClient() + { + await _downloadClientConfigurationRepository.SaveAsync(new DownloadClientConfiguration + { + Id = "deluge-client-1", + Name = "Deluge Client", + Type = "deluge", + IsEnabled = true + }); + + var selector = _provider.GetRequiredService(); + var clientId = await selector.GetAppropriateDownloadClientAsync(isTorrent: true); + + Assert.Equal("deluge-client-1", clientId); + } + + [Fact] + public void ApplyClientSpecificId_SetsTorrentHash_WhenClientTypeIsDeluge() + { + var download = new Download(); + var delugeClient = new DownloadClientConfiguration + { + Id = "deluge-1", + Name = "Deluge", + Type = "deluge" + }; + + DownloadClientMetadataUpdater.ApplyClientSpecificId(download, delugeClient, "HASH1234567890"); + + Assert.NotNull(download.Metadata); + Assert.Equal("HASH1234567890", download.Metadata["ClientDownloadId"]); + Assert.Equal("HASH1234567890", download.Metadata["TorrentHash"]); + } + } +} diff --git a/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs index 7a7221b18..c21231fd1 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientWorkflowRegistrationTests.cs @@ -33,6 +33,7 @@ public void AllDownloadClientAdapters_ResolveFromDi() Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Transmission); Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Sabnzbd); Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Nzbget); + Assert.Contains(adapters, a => a.ClientType == DownloadClientTypes.Deluge); } [Fact] @@ -50,6 +51,14 @@ public void DownloadClientWorkflows_ResolveFromDi() _provider.GetRequiredService(); _provider.GetRequiredService(); + _provider.GetRequiredService(); + _provider.GetRequiredService(); + _provider.GetRequiredService(); + _provider.GetRequiredService(); + _provider.GetRequiredService(); + _provider.GetRequiredService(); + _provider.GetRequiredService(); + _provider.GetRequiredService(); _provider.GetRequiredService(); _provider.GetRequiredService(); @@ -90,6 +99,13 @@ public void SharedProtocolHelpers_UseScopedLifetime() firstScope.ServiceProvider.GetRequiredService(), secondScope.ServiceProvider.GetRequiredService()); + Assert.Same( + firstScope.ServiceProvider.GetRequiredService(), + firstScope.ServiceProvider.GetRequiredService()); + Assert.NotSame( + firstScope.ServiceProvider.GetRequiredService(), + secondScope.ServiceProvider.GetRequiredService()); + Assert.Same( firstScope.ServiceProvider.GetRequiredService(), firstScope.ServiceProvider.GetRequiredService()); diff --git a/tests/Features/Infrastructure/DownloadClients/Deluge/DelugeAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Deluge/DelugeAdapterTests.cs new file mode 100644 index 000000000..c7bfa19c9 --- /dev/null +++ b/tests/Features/Infrastructure/DownloadClients/Deluge/DelugeAdapterTests.cs @@ -0,0 +1,233 @@ +/* + * 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. + */ + +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; +using Listenarr.Tests.Mocks.Api; +using Listenarr.Infrastructure.Torrents; +using Listenarr.Domain.Downloads.Exceptions; +using Microsoft.Extensions.DependencyInjection; + +namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Deluge +{ + [Trait("Name", "DelugeAdapterTests")] + [Trait("Category", "DownloadClientAdapter")] + [Trait("Third-Party", "Deluge")] + public class DelugeAdapterTests : BaseTests + { + private DownloadClientConfiguration _client = null!; + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + _client = await _downloadClientConfigurationRepository.SaveAsync(new DownloadClientConfigurationBuilder() + .WithId("deluge-1") + .WithName("Deluge") + .WithType("deluge") + .WithHost("localhost") + .WithPort(8112) + .WithPassword("deluge") + .Build()); + } + + [Theory] + [InlineData("Seeding", 100.0, DownloadItemStatus.Completed)] + [InlineData("Paused", 100.0, DownloadItemStatus.Completed)] + [InlineData("Queued", 100.0, DownloadItemStatus.Completed)] + [InlineData("Downloading", 42.5, DownloadItemStatus.Downloading)] + [InlineData("Downloading Metadata", 0.0, DownloadItemStatus.Downloading)] + [InlineData("Checking", 100.0, DownloadItemStatus.Checking)] + [InlineData("Error", 0.0, DownloadItemStatus.Failed)] + public async Task GetItemsAsync_MapsDelugeStatesToNormalizedStatuses(string state, double progress, DownloadItemStatus expectedStatus) + { + var mock = _provider.GetRequiredService(); + mock.UpdateUiResponseOverride = BuildUpdateUiResponse(state, progress, "listenarr"); + + var adapter = MockUtils.CreateDelugeAdapter(_provider); + var items = await adapter.GetItemsAsync(_client, CancellationToken.None); + + Assert.Single(items); + Assert.Equal(expectedStatus, items[0].Status); + Assert.Equal("ABCDEF1234567890", items[0].DownloadId); + Assert.Equal("Book.m4b", items[0].Title); + Assert.Equal("/downloads/Book.m4b", items[0].OutputPath); + } + + [Fact] + public async Task GetItemsAsync_FiltersByConfiguredCategory() + { + var mock = _provider.GetRequiredService(); + mock.UpdateUiResponseOverride = """ + { + "id": 1, + "result": { + "torrents": { + "HASH1": { + "name": "Book One", + "total_size": 100, + "total_done": 100, + "progress": 100.0, + "download_payload_rate": 0, + "eta": 0, + "state": "Seeding", + "save_path": "/downloads", + "label": "listenarr", + "ratio": 1.0, + "num_seeds": 1, + "num_peers": 0, + "time_added": 1700000000, + "message": "" + }, + "HASH2": { + "name": "Movie One", + "total_size": 100, + "total_done": 100, + "progress": 100.0, + "download_payload_rate": 0, + "eta": 0, + "state": "Seeding", + "save_path": "/downloads", + "label": "movies", + "ratio": 1.0, + "num_seeds": 1, + "num_peers": 0, + "time_added": 1700000000, + "message": "" + } + } + }, + "error": null + } + """; + + var client = await _downloadClientConfigurationRepository.SaveAsync(new DownloadClientConfigurationBuilder() + .WithId("deluge-category-test") + .WithName("Deluge") + .WithType("deluge") + .WithHost("localhost") + .WithPort(8112) + .WithPassword("deluge") + .WithSettings("category", "listenarr") + .Build()); + + var adapter = MockUtils.CreateDelugeAdapter(_provider); + var items = await adapter.GetItemsAsync(client, CancellationToken.None); + + Assert.Single(items); + Assert.Equal("HASH1", items[0].DownloadId); + Assert.Equal("listenarr", items[0].Category); + } + + [Fact] + public async Task TestConnectionAsync_AuthenticatesAndRequiresDaemonConnection() + { + var mock = _provider.GetRequiredService(); + mock.UpdateUiResponseOverride = BuildUpdateUiResponse("Seeding", 100.0, "listenarr"); + + var adapter = MockUtils.CreateDelugeAdapter(_provider); + var (success, message) = await adapter.TestConnectionAsync(_client, CancellationToken.None); + + Assert.True(success); + Assert.Contains("connected to Web UI and daemon", message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetImportItemAsync_MatchesQueueItemByExternalClientId() + { + var mock = _provider.GetRequiredService(); + mock.UpdateUiResponseOverride = BuildUpdateUiResponse("Seeding", 100.0, "listenarr"); + + var download = new Download + { + Id = "listenarr-download-1", + DownloadClientId = _client.Id + }; + download.SetExternalId("ABCDEF1234567890"); + + var fallback = new QueueItem + { + Id = "listenarr-download-1", + Title = "Fallback" + }; + + var adapter = MockUtils.CreateDelugeAdapter(_provider); + var item = await adapter.GetImportItemAsync(_client, download, fallback, null, CancellationToken.None); + + Assert.Equal("ABCDEF1234567890", item.Id); + Assert.Equal("Book.m4b", item.Title); + Assert.Equal("completed", item.Status); + } + + [Fact] + public async Task GetQueueAsync_WithTrackedIds_ResolvesNormally() + { + var mock = _provider.GetRequiredService(); + mock.UpdateUiResponseOverride = BuildUpdateUiResponse("Seeding", 100.0, "listenarr"); + + var adapter = MockUtils.CreateDelugeAdapter(_provider); + var items = await adapter.GetQueueAsync(_client, ["ABCDEF1234567890"], CancellationToken.None); + + Assert.Single(items); + Assert.Equal("ABCDEF1234567890", items[0].Id); + Assert.Equal("completed", items[0].Status); + } + + [Fact] + public async Task GetQueueAsync_WithIds_ThrowsPollingException_OnQueueRequestFailure() + { + var mock = _provider.GetRequiredService(); + mock.FailRequests = true; + + var adapter = MockUtils.CreateDelugeAdapter(_provider); + await Assert.ThrowsAsync( + () => adapter.GetQueueAsync(_client, ["ABCDEF1234567890"])); + } + + [Fact] + public async Task GetQueueAsync_WithoutIds_ReturnsEmpty_OnQueueRequestFailure() + { + var mock = _provider.GetRequiredService(); + mock.FailRequests = true; + + var adapter = MockUtils.CreateDelugeAdapter(_provider); + var items = await adapter.GetQueueAsync(_client); + + Assert.Empty(items); + } + + private static string BuildUpdateUiResponse(string state, double progress, string label) + => $$""" + { + "id": 1, + "result": { + "torrents": { + "ABCDEF1234567890": { + "name": "Book.m4b", + "total_size": 100, + "total_done": {{(long)Math.Round(progress)}}, + "progress": {{progress.ToString(System.Globalization.CultureInfo.InvariantCulture)}}, + "download_payload_rate": 25, + "eta": 60, + "state": "{{state}}", + "save_path": "/downloads", + "label": "{{label}}", + "ratio": 1.0, + "num_seeds": 1, + "num_peers": 0, + "time_added": 1700000000, + "message": "" + } + } + }, + "error": null + } + """; + } +} diff --git a/tests/GlobalUsings.cs b/tests/GlobalUsings.cs index 7bd033450..24d524024 100644 --- a/tests/GlobalUsings.cs +++ b/tests/GlobalUsings.cs @@ -101,6 +101,7 @@ global using Listenarr.Infrastructure.DownloadClients.Qbittorrent; global using Listenarr.Infrastructure.DownloadClients.Sabnzbd; global using Listenarr.Infrastructure.DownloadClients.Transmission; +global using Listenarr.Infrastructure.DownloadClients.Deluge; global using Listenarr.Infrastructure.ActivityHistory.Persistence; global using Listenarr.Infrastructure.ActivityHistory.Services; global using Listenarr.Infrastructure.Search.Providers.InternetArchive; diff --git a/tests/Mocks/Api/DelugeApiMock.cs b/tests/Mocks/Api/DelugeApiMock.cs new file mode 100644 index 000000000..0be331722 --- /dev/null +++ b/tests/Mocks/Api/DelugeApiMock.cs @@ -0,0 +1,127 @@ +using System.Net; +using System.Text.Json; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Mocks.Api +{ + public class DelugeApiMock : BaseApiMock + { + public bool Authenticated { get; set; } = false; + public bool DaemonConnected { get; set; } = true; + public string? UpdateUiResponseOverride { get; set; } + public bool FailRequests { get; set; } = false; + + public DelugeApiMock() + { + AddRoute("json", HandleRpc, HttpMethod.Post); + } + + private async Task HandleRpc(HttpRequestMessage request, CancellationToken ct) + { + if (FailRequests) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + } + + var body = await request.Content!.ReadAsStringAsync(ct); + using var document = JsonDocument.Parse(body); + if (document.RootElement.TryGetProperty("method", out var methodProp)) + { + var method = methodProp.GetString(); + string responseBody = method switch + { + "auth.login" => """ + { + "id": 1, + "result": true, + "error": null + } + """, + "web.connected" => $$""" + { + "id": 1, + "result": {{DaemonConnected.ToString().ToLowerInvariant()}}, + "error": null + } + """, + "web.get_hosts" => """ + { + "id": 1, + "result": [["host_id_1", "localhost", 58846, "connected"]], + "error": null + } + """, + "web.connect" => """ + { + "id": 1, + "result": true, + "error": null + } + """, + "core.add_torrent_file" => """ + { + "id": 1, + "result": "ABCDEF1234567890", + "error": null + } + """, + "core.add_torrent_magnet" => """ + { + "id": 1, + "result": "ABCDEF1234567890", + "error": null + } + """, + "web.download_torrent_from_url" => """ + { + "id": 1, + "result": "/tmp/torrent.torrent", + "error": null + } + """, + "web.add_torrents" => """ + { + "id": 1, + "result": ["ABCDEF1234567890"], + "error": null + } + """, + "label.set_torrent" => """ + { + "id": 1, + "result": true, + "error": null + } + """, + "core.remove_torrent" => """ + { + "id": 1, + "result": true, + "error": null + } + """, + "web.update_ui" => UpdateUiResponseOverride ?? """ + { + "id": 1, + "result": { + "torrents": {} + }, + "error": null + } + """, + _ => """ + { + "id": 1, + "result": null, + "error": null + } + """ + }; + + return MockUtils.GetCannedResponse(responseBody); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + } +}