Add support for qBittorrent API key (#832)

This commit is contained in:
Flaminel authored and GitHub committed 2026-10-12 16:22:46 +03:00
1 parent 85e956dd92
commit 705957759e
19 files changed
+770 -9

No files matched your search

@@ -50,4 +50,5 @@ export const NEW_FEATURES: NewFeature[] = [
{ id: 'change-username' },
{ id: 'force-import' },
{ id: 'usenet-queue-cleaner' },
{ id: 'qbit-api-key' },
];
@@ -83,8 +83,9 @@
}
@if (showApiKeyField()) {
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [formField]="clientForm.apiKey"
[hint]="apiKeyHint"
[hint]="apiKeyHint()"
[error]="clientForm.apiKey().errors()[0]?.message"
[featureId]="apiKeyFeatureId()"
helpKey="download-client:apiKey" />
}
<app-input label="URL Base" placeholder="/api/v2" [formField]="clientForm.urlBase"
@@ -42,7 +42,9 @@ const TYPES: DownloadClientTypesResponse = {
types: [
{
typeName: DownloadClientTypeName.qBittorrent,
authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password],
authFields: [
DownloadClientAuthField.Username, DownloadClientAuthField.Password, DownloadClientAuthField.ApiKey,
],
capabilities: [DownloadClientCapability.SeedingCleanup, DownloadClientCapability.TagFiltering],
},
{
@@ -253,6 +255,10 @@ describe('DownloadClientsComponent', () => {
expect(fieldLabels(fixture)).not.toContain('Username');
expect(fieldLabels(fixture)).not.toContain('Password');
expect(fieldLabels(fixture)).toContain('API Key');
component.clientForm.apiKey().value.set('sab-key');
fixture.detectChanges();
expect(hintFor(fixture, 'API Key')).toBe('API key from SABnzbd > Config > General');
});
it('blocks saving a SABnzbd client with an empty API key and shows the error', () => {
@@ -315,11 +321,112 @@ describe('DownloadClientsComponent', () => {
component.openAddModal();
fixture.detectChanges();
expect(component.clientModel().typeName).toBe(DownloadClientTypeName.qBittorrent);
chooseClientType(fixture, 'Deluge');
expect(component.clientModel().typeName).toBe(DownloadClientTypeName.Deluge);
expect(component.showApiKeyField()).toBe(false);
expect(fieldLabels(fixture)).not.toContain('API Key');
});
it('shows the API key field for qBittorrent alongside username and password', () => {
const { fixture, component } = setup();
component.openAddModal();
fixture.detectChanges();
expect(component.clientModel().typeName).toBe(DownloadClientTypeName.qBittorrent);
expect(component.showUsernameField()).toBe(true);
expect(component.showPasswordField()).toBe(true);
expect(component.showApiKeyField()).toBe(true);
expect(fieldLabels(fixture)).toContain('API Key');
expect(hintFor(fixture, 'API Key'))
.toBe('qBittorrent 5.2+: Options > WebUI > API key. Leave username and password empty.');
});
it('blocks saving a qBittorrent client with both an API key and a password', () => {
const { fixture, component, api } = setup();
component.openAddModal();
component.clientForm.name().value.set('qbit box');
component.clientForm.host().value.set('http://localhost:8080');
component.clientForm.password().value.set('secret');
component.clientForm.apiKey().value.set('qbit-key');
fixture.detectChanges();
expect(component.hasModalErrors()).toBe(true);
expect(component.clientForm.apiKey().errors()[0].message)
.toBe('Use either an API key or a username and password, not both');
component.saveClient();
expect(api.create).not.toHaveBeenCalled();
});
it('saves a qBittorrent client with only an API key', () => {
const { fixture, component, api } = setup();
component.openAddModal();
component.clientForm.name().value.set('qbit box');
component.clientForm.host().value.set('http://localhost:8080');
component.clientForm.apiKey().value.set('qbit-key');
fixture.detectChanges();
expect(component.hasModalErrors()).toBe(false);
component.saveClient();
fixture.detectChanges();
expect(api.create).toHaveBeenCalledWith(
expect.objectContaining({
typeName: DownloadClientTypeName.qBittorrent,
apiKey: 'qbit-key',
}),
);
});
it('blocks saving a qBittorrent client with both an API key and a username', () => {
const { fixture, component, api } = setup();
component.openAddModal();
component.clientForm.name().value.set('qbit box');
component.clientForm.host().value.set('http://localhost:8080');
component.clientForm.username().value.set('admin');
component.clientForm.apiKey().value.set('qbit-key');
fixture.detectChanges();
expect(component.hasModalErrors()).toBe(true);
expect(component.clientForm.apiKey().errors()[0].message)
.toBe('Use either an API key or a username and password, not both');
component.saveClient();
expect(api.create).not.toHaveBeenCalled();
});
it('edits a qBittorrent client with a null username and an API key placeholder without showing the exclusive error', () => {
const qbitApiKeyOnly: ClientConfig = {
...QBIT,
username: null as unknown as string,
password: undefined,
apiKey: 'qbit-placeholder-key',
};
const { fixture, component, api } = setup(createApi({ clients: [qbitApiKeyOnly] }));
component.openEditModal(qbitApiKeyOnly);
fixture.detectChanges();
expect(component.hasModalErrors()).toBe(false);
component.clientForm.name().value.set('qbit box renamed');
fixture.detectChanges();
component.saveClient();
fixture.detectChanges();
expect(api.update).toHaveBeenCalledWith(
qbitApiKeyOnly.id,
expect.objectContaining({ name: 'qbit box renamed', apiKey: 'qbit-placeholder-key' }),
);
});
it('saves a SABnzbd client with the Usenet client type', () => {
const { fixture, component, api } = setup();
@@ -1,6 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core';
import { forkJoin } from 'rxjs';
import { form, required, disabled, FormField } from '@angular/forms/signals';
import { form, required, disabled, validate, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -114,6 +114,15 @@ export class DownloadClientsComponent implements HasPendingChanges {
when: ({ valueOf }) => valueOf(p.typeName) === DownloadClientTypeName.Sabnzbd,
message: 'API key is required',
});
validate(p.apiKey, () => {
const m = this.clientModel();
const fields = this.authFieldsByType()[m.typeName] ?? [];
const supportsBoth = fields.includes('apiKey') && (fields.includes('username') || fields.includes('password'));
const has = (v: string | null | undefined): boolean => !!v && v.trim() !== '';
return supportsBoth && has(m.apiKey) && (has(m.username) || has(m.password))
? { kind: 'exclusive', message: 'Use either an API key or a username and password, not both' }
: undefined;
});
disabled(p.typeName, () => this.editingClient() !== null);
});
@@ -133,7 +142,15 @@ export class DownloadClientsComponent implements HasPendingChanges {
readonly showApiKeyField = computed(() =>
(this.authFieldsByType()[this.clientModel().typeName] ?? []).includes('apiKey'));
readonly apiKeyHint = 'API key from SABnzbd > Config > General';
readonly apiKeyHint = computed(() => {
if (this.clientModel().typeName === DownloadClientTypeName.qBittorrent) {
return 'qBittorrent 5.2+: Options > WebUI > API key. Leave username and password empty.';
}
return 'API key from SABnzbd > Config > General';
});
readonly apiKeyFeatureId = computed(() =>
this.clientModel().typeName === DownloadClientTypeName.qBittorrent ? 'qbit-api-key' : undefined);
readonly usernameHint = computed(() => {
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
@@ -204,7 +221,7 @@ export class DownloadClientsComponent implements HasPendingChanges {
name: client.name,
typeName: client.typeName,
host: client.host,
username: client.username,
username: client.username ?? '',
password: client.password ?? '',
apiKey: client.apiKey ?? '',
urlBase: client.urlBase,