From d7d7f9243868b08a1d68db96dd4f6a3216e06f93 Mon Sep 17 00:00:00 2001 From: schmitzkr <105251385+schmitzkr@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:36:47 -0700 Subject: [PATCH] fix(qbittorrent): add URL Base support for reverse-proxied instances qBittorrent's download client only had Host/Port, so it always connected at the root path and couldn't reach instances served behind a path-prefixed reverse proxy (Transmission already had this field). Unlike Transmission's urlBase, which replaces the whole RPC path to match Transmission's own configurable --rpc-url-base daemon setting, qBittorrent has no equivalent server-side base path setting (upstream qbittorrent/qBittorrent#21471 and #23467 are both unmerged), so this prepends urlBase as a plain prefix before the fixed /api/v2/... routes instead of replacing them. Closes #690. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/DownloadClientFormModal.spec.ts | 43 +++++++++++++++++++ .../download/DownloadClientFormModal.vue | 29 +++++++++---- .../Qbittorrent/QBittorrentHelpers.cs | 32 ++++++++++++++ .../Qbittorrent/QbittorrentAddWorkflow.cs | 2 +- .../Qbittorrent/QbittorrentAuthSession.cs | 2 +- .../QbittorrentConnectionTester.cs | 2 +- .../QbittorrentImportItemResolver.cs | 2 +- .../QbittorrentImportMarkerWorkflow.cs | 2 +- .../QbittorrentItemFetchWorkflow.cs | 2 +- .../QbittorrentQueueFetchWorkflow.cs | 2 +- .../Qbittorrent/QbittorrentRemovalWorkflow.cs | 2 +- .../Qbittorrent/QbittorrentAdapterTests.cs | 29 +++++++++++++ 12 files changed, 132 insertions(+), 17 deletions(-) diff --git a/fe/src/__tests__/DownloadClientFormModal.spec.ts b/fe/src/__tests__/DownloadClientFormModal.spec.ts index 17beed61d..23320dabd 100644 --- a/fe/src/__tests__/DownloadClientFormModal.spec.ts +++ b/fe/src/__tests__/DownloadClientFormModal.spec.ts @@ -172,4 +172,47 @@ describe('DownloadClientFormModal', () => { expect(calledWith.password).toBe('') expect(calledWith.id).toBe('4') }) + + it('renders URL Base field for qbittorrent and includes it in the test payload when set', async () => { + const api = await import('@/services/api') + ;(api.testDownloadClient as unknown) = vi.fn(async (config: unknown) => ({ + success: true, + message: 'ok', + client: config, + })) + + const wrapper = mount(DownloadClientFormModal, { + global: { plugins: [createPinia()] }, + props: { visible: true, editingClient: null }, + }) + + await wrapper.setProps({ + editingClient: { + id: '5', + name: 'qbt', + type: 'qbittorrent', + host: 'qbittorrent.local', + port: 8080, + isEnabled: true, + useSSL: false, + downloadPath: '', + username: '', + password: '', + settings: {}, + }, + }) + await wrapper.vm.$nextTick() + + const urlBaseInput = wrapper.find('input[id="urlBase"]') + expect(urlBaseInput.exists()).toBe(true) + + await urlBaseInput.setValue('/qbittorrent') + + const testButton = wrapper.find('button.btn-info') + await testButton.trigger('click') + + expect(api.testDownloadClient as unknown).toHaveBeenCalled() + const calledWith = (api.testDownloadClient as unknown).mock.calls[0][0] + expect(calledWith.settings.urlBase).toBe('/qbittorrent') + }) }) diff --git a/fe/src/components/domain/download/DownloadClientFormModal.vue b/fe/src/components/domain/download/DownloadClientFormModal.vue index baa7df91a..8b9d1ffa1 100644 --- a/fe/src/components/domain/download/DownloadClientFormModal.vue +++ b/fe/src/components/domain/download/DownloadClientFormModal.vue @@ -116,15 +116,22 @@ -
+
- Path prefix for qBittorrent instances behind a reverse proxy (e.g. + /qbittorrent). Must match the prefix your reverse proxy strips before + forwarding to qBittorrent's own root-relative API - qBittorrent itself has no + built-in base path setting. Leave blank if qBittorrent is reachable directly at the + host/port above. + RPC path for the Transmission endpoint. Default is /transmission/rpc. Some seedbox providers use a custom path (e.g. /rpc). @@ -496,6 +503,14 @@ const getPortHelpText = () => { return hints[formData.value.type] || 'Port the download client is listening on.' } +const showUrlBase = computed(() => { + return formData.value.type === 'transmission' || formData.value.type === 'qbittorrent' +}) + +const getUrlBasePlaceholder = () => { + return formData.value.type === 'qbittorrent' ? '/qbittorrent' : '/transmission/rpc' +} + 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.' @@ -593,9 +608,7 @@ const testConnection = async () => { ...(formData.value.type === 'sabnzbd' && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), - ...(formData.value.type === 'transmission' && formData.value.urlBase - ? { urlBase: formData.value.urlBase } - : {}), + ...(showUrlBase.value && formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}), ...(formData.value.category && { category: formData.value.category }), ...(formData.value.tags && { tags: formData.value.tags }), recentPriority: formData.value.recentPriority, @@ -653,9 +666,7 @@ const handleSubmit = async () => { ...(formData.value.type === 'sabnzbd' && formData.value.apiKey ? { apiKey: formData.value.apiKey } : {}), - ...(formData.value.type === 'transmission' && formData.value.urlBase - ? { urlBase: formData.value.urlBase } - : {}), + ...(showUrlBase.value && formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}), ...(formData.value.category && { category: formData.value.category }), ...(formData.value.tags && { tags: formData.value.tags }), recentPriority: formData.value.recentPriority, diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs index 2ee964c35..f7410d402 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QBittorrentHelpers.cs @@ -69,5 +69,37 @@ public static void LogCategoryFiltering(ILogger logger, string? category) logger.LogInformation("Fetching qBittorrent queue filtered by category: {Category}", category); } } + + /// + /// Builds the base URL used for every qBittorrent WebAPI call, including an optional + /// "urlBase" path prefix from client settings (e.g. "/qbittorrent") for instances that + /// sit behind a reverse proxy at a path prefix. + /// Unlike Transmission's urlBase (which replaces the whole RPC path to match Transmission's + /// own configurable --rpc-url-base setting), qBittorrent has no equivalent server-side base + /// path setting, so this is a plain prefix prepended before the fixed "/api/v2/..." routes - + /// it must match whatever prefix the reverse proxy strips before forwarding to qBittorrent. + /// + /// The download client configuration providing host/port and settings. + /// The authority (scheme://host:port) with the normalized urlBase prefix appended, if configured. + public static string BuildBaseUrl(DownloadClientConfiguration client) + { + var authority = DownloadClientUriBuilder.BuildAuthority(client); + var prefix = ResolveUrlBasePrefix(client); + return prefix.Length == 0 ? authority : authority + prefix; + } + + private static string ResolveUrlBasePrefix(DownloadClientConfiguration client) + { + if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) + { + var trimmed = urlBaseObj?.ToString()?.Trim().TrimEnd('/'); + if (!string.IsNullOrEmpty(trimmed)) + { + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } + } + + return string.Empty; + } } } diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs index 1739841a0..8e1b5a4b8 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAddWorkflow.cs @@ -28,7 +28,7 @@ public async Task AddAsync( throw new DownloadClientSubmissionException("qBittorrent requires a prepared torrent submission."); } - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); using var httpClient = httpClientFactory.CreateClient(clientType); try diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs index 19e986d69..b818fb68d 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentAuthSession.cs @@ -24,7 +24,7 @@ public QbittorrentAuthSession(ILogger logger) public async Task LoginAsync(HttpClient httpClient, DownloadClientConfiguration client, CancellationToken cancellationToken = default) { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); using var loginData = new FormUrlEncodedContent( [ diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs index da741b586..ca8b3d306 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentConnectionTester.cs @@ -37,7 +37,7 @@ public QbittorrentConnectionTester(IHttpClientFactory httpClientFactory, ILogger { try { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); using var http = _httpClientFactory.CreateClient(_clientType); using var resp = await http.GetAsync($"{baseUrl}/api/v2/app/version", ct); diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs index 3fcb32df1..1251a7e00 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportItemResolver.cs @@ -118,7 +118,7 @@ public async Task GetImportItemAsync( string hash, CancellationToken ct) { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs index abf1cb2e8..56acc4bd8 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportMarkerWorkflow.cs @@ -33,7 +33,7 @@ public async Task MarkItemAsImportedAsync(DownloadClientConfiguration clie return true; // No-op is success } - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { using var httpClient = httpClientFactory.CreateClient(clientType); diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs index 45a624f1d..b6f48113d 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentItemFetchWorkflow.cs @@ -26,7 +26,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur var items = new List(); if (client == null) return items; - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); var categoryFilter = QBittorrentHelpers.BuildCategoryParameter(client.Settings, "&"); try diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs index bd3c62a0a..b5a9b427b 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentQueueFetchWorkflow.cs @@ -24,7 +24,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli if (client == null) return items; var isMonitorPoll = ids.Count > 0; - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs index 3d2691d5c..24a333a60 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentRemovalWorkflow.cs @@ -34,7 +34,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i ArgumentNullException.ThrowIfNull(client); if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + var baseUrl = QBittorrentHelpers.BuildBaseUrl(client); try { diff --git a/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs index 038abe508..998b62f53 100644 --- a/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Qbittorrent/QbittorrentAdapterTests.cs @@ -98,6 +98,35 @@ public async Task TestConnection_NormalizesHostWithSchemeAndPath() Assert.Equal("/api/v2/app/version", uri.AbsolutePath); } + [Fact] + public async Task TestConnection_WithUrlBase_PrefixesApiPath() + { + var mock = _provider.GetRequiredService(); + + var client = await _downloadClientConfigurationRepository.SaveAsync(new DownloadClientConfigurationBuilder() + .WithHost("192.168.50.111") + .WithPort(8080) + .WithoutSsl() + .WithType("qbittorrent") + .WithUsername("admin") + .WithPassword("admin") + .WithUrlBase("/qbittorrent") + .Build()); + + var adapter = _provider.GetRequiredService(); + var (success, message) = await adapter.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("Successfully connected to qBittorrent", message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(mock.GetLastRequest()); + + var uri = mock.GetLastRequest().RequestUri; + // Unlike Transmission's urlBase (which replaces the whole RPC path to match Transmission's + // own --rpc-url-base setting), qBittorrent has no equivalent server-side base path setting, + // so urlBase is a plain prefix: the fixed "/api/v2/..." routes must still follow it. + Assert.Equal("/qbittorrent/api/v2/app/version", uri.AbsolutePath); + } + [Fact] public async Task AddAsync_WhenMagnetAndTorrentUrlAreProvided_UsesVerifiedMagnetHashWithoutDownloading() {