Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions fe/src/__tests__/DownloadClientFormModal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
29 changes: 20 additions & 9 deletions fe/src/components/domain/download/DownloadClientFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,22 @@
</Checkbox>
</div>

<div class="form-group" v-if="formData.type === 'transmission'">
<div class="form-group" v-if="showUrlBase">
<label for="urlBase">URL Base</label>
<input
id="urlBase"
v-model="formData.urlBase"
type="text"
placeholder="/transmission/rpc"
:placeholder="getUrlBasePlaceholder()"
/>
<small
<small v-if="formData.type === 'qbittorrent'"
>Path prefix for qBittorrent instances behind a reverse proxy (e.g.
<code>/qbittorrent</code>). 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.</small
>
<small v-else
>RPC path for the Transmission endpoint. Default is <code>/transmission/rpc</code>.
Some seedbox providers use a custom path (e.g. <code>/rpc</code>).</small
>
Expand Down Expand Up @@ -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.'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,37 @@ public static void LogCategoryFiltering(ILogger logger, string? category)
logger.LogInformation("Fetching qBittorrent queue filtered by category: {Category}", category);
}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="client">The download client configuration providing host/port and settings.</param>
/// <returns>The authority (scheme://host:port) with the normalized urlBase prefix appended, if configured.</returns>
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public async Task<DownloadClientSubmissionResult> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public QbittorrentAuthSession(ILogger logger)

public async Task<bool> LoginAsync(HttpClient httpClient, DownloadClientConfiguration client, CancellationToken cancellationToken = default)
{
var baseUrl = DownloadClientUriBuilder.BuildAuthority(client);
var baseUrl = QBittorrentHelpers.BuildBaseUrl(client);

using var loginData = new FormUrlEncodedContent(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public async Task<QueueItem> GetImportItemAsync(
string hash,
CancellationToken ct)
{
var baseUrl = DownloadClientUriBuilder.BuildAuthority(client);
var baseUrl = QBittorrentHelpers.BuildBaseUrl(client);

try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public async Task<bool> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public async Task<List<DownloadClientItem>> GetItemsAsync(DownloadClientConfigur
var items = new List<DownloadClientItem>();
if (client == null) return items;

var baseUrl = DownloadClientUriBuilder.BuildAuthority(client);
var baseUrl = QBittorrentHelpers.BuildBaseUrl(client);
var categoryFilter = QBittorrentHelpers.BuildCategoryParameter(client.Settings, "&");

try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<List<QueueItem>> GetQueueAsync(DownloadClientConfiguration cli
if (client == null) return items;

var isMonitorPoll = ids.Count > 0;
var baseUrl = DownloadClientUriBuilder.BuildAuthority(client);
var baseUrl = QBittorrentHelpers.BuildBaseUrl(client);

try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task<bool> 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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<QbittorrentApiMock>();

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<IDownloadClientGateway>();
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()
{
Expand Down