Add multi-download-client support and UI management

Implements support for configuring both qBittorrent and SABnzbd simultaneously, including migration from legacy config, protocol-aware routing, and protocol filtering. Adds new CRUD API routes for download clients, new UI management components, and updates setup and settings flows to use the new multi-client architecture. Updates documentation to describe the new structure and usage.
This commit is contained in:
kikootwo
2026-01-29 09:21:33 -05:00
parent 3290ebbc9d
commit 2cda6decbe
26 changed files with 3452 additions and 924 deletions
+33 -7
View File
@@ -25,6 +25,13 @@ const configMock = vi.hoisted(() => ({
getMany: vi.fn(),
}));
// Mock for DownloadClientManager
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
getAllClients: vi.fn(),
hasClientForProtocol: vi.fn(),
}));
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
@@ -34,16 +41,27 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
invalidateDownloadClientManager: vi.fn(),
}));
describe('ProwlarrService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
axiosMock.get.mockReset();
configMock.get.mockReset();
downloadClientManagerMock.getClientForProtocol.mockReset();
downloadClientManagerMock.getAllClients.mockReset();
downloadClientManagerMock.hasClientForProtocol.mockReset();
});
it('filters results for SABnzbd (usenet)', async () => {
configMock.get.mockResolvedValue('sabnzbd');
// Mock: Only SABnzbd is configured (usenet only)
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
return protocol === 'usenet';
});
clientMock.get.mockResolvedValue({
data: [
{
@@ -76,7 +94,10 @@ describe('ProwlarrService', () => {
});
it('throws when search fails', async () => {
configMock.get.mockResolvedValue('qbittorrent');
// Mock: qBittorrent is configured (torrent only)
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
return protocol === 'torrent';
});
clientMock.get.mockRejectedValue(new Error('bad search'));
const service = new ProwlarrService('http://prowlarr', 'key');
@@ -85,7 +106,10 @@ describe('ProwlarrService', () => {
});
it('filters results for qBittorrent (torrent)', async () => {
configMock.get.mockResolvedValue('qbittorrent');
// Mock: Only qBittorrent is configured (torrent only)
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
return protocol === 'torrent';
});
clientMock.get.mockResolvedValue({
data: [
{
@@ -178,7 +202,10 @@ describe('ProwlarrService', () => {
});
it('applies category, indexer, and seeder filters', async () => {
configMock.get.mockResolvedValue('qbittorrent');
// Mock: Only qBittorrent is configured (torrent only)
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
return protocol === 'torrent';
});
clientMock.get.mockResolvedValue({
data: [
{
@@ -223,9 +250,8 @@ describe('ProwlarrService', () => {
});
it('returns unfiltered results when protocol filtering fails', async () => {
configMock.get
.mockResolvedValueOnce('qbittorrent')
.mockRejectedValueOnce(new Error('config fail'));
// Mock: hasClientForProtocol throws an error
downloadClientManagerMock.hasClientForProtocol.mockRejectedValue(new Error('config fail'));
clientMock.get.mockResolvedValue({
data: [
+47 -22
View File
@@ -21,6 +21,14 @@ const axiosMock = vi.hoisted(() => ({
const parseTorrentMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({
getMany: vi.fn(),
get: vi.fn(),
}));
// Mock for DownloadClientManager
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
getAllClients: vi.fn(),
hasClientForProtocol: vi.fn(),
}));
vi.mock('axios', () => ({
@@ -33,7 +41,12 @@ vi.mock('parse-torrent', () => ({
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
getConfigService: vi.fn(async () => configServiceMock),
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
invalidateDownloadClientManager: vi.fn(),
}));
describe('QBittorrentService', () => {
@@ -45,6 +58,10 @@ describe('QBittorrentService', () => {
axiosMock.post.mockReset();
parseTorrentMock.mockReset();
configServiceMock.getMany.mockReset();
configServiceMock.get.mockReset();
downloadClientManagerMock.getClientForProtocol.mockReset();
downloadClientManagerMock.getAllClients.mockReset();
downloadClientManagerMock.hasClientForProtocol.mockReset();
invalidateQBittorrentService();
});
@@ -586,25 +603,26 @@ describe('QBittorrentService', () => {
});
it('throws when qBittorrent configuration is incomplete', async () => {
configServiceMock.getMany.mockResolvedValue({
download_client_url: null,
download_client_username: null,
download_client_password: null,
download_dir: null,
download_client_disable_ssl_verify: 'false',
});
// Mock: no qBittorrent client configured
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not fully configured');
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not configured');
});
it('returns a cached instance after successful initialization', async () => {
configServiceMock.getMany.mockResolvedValue({
download_client_url: 'http://qb',
download_client_username: 'user',
download_client_password: 'pass',
download_dir: '/downloads',
download_client_disable_ssl_verify: 'false',
// Mock: qBittorrent client configured
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
url: 'http://qb',
username: 'user',
password: 'pass',
disableSSLVerify: false,
remotePathMappingEnabled: false,
});
configServiceMock.get.mockResolvedValue('/downloads');
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
@@ -612,19 +630,26 @@ describe('QBittorrentService', () => {
const second = await getQBittorrentService();
expect(first).toBe(second);
expect(configServiceMock.getMany).toHaveBeenCalledTimes(1);
// Should only call getClientForProtocol once (cached after first call)
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledTimes(1);
testConnectionSpy.mockRestore();
});
it('throws when connection test fails during service creation', async () => {
configServiceMock.getMany.mockResolvedValue({
download_client_url: 'http://qb',
download_client_username: 'user',
download_client_password: 'pass',
download_dir: '/downloads',
download_client_disable_ssl_verify: 'false',
// Mock: qBittorrent client configured
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
url: 'http://qb',
username: 'user',
password: 'pass',
disableSSLVerify: false,
remotePathMappingEnabled: false,
});
configServiceMock.get.mockResolvedValue('/downloads');
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
+28 -16
View File
@@ -18,13 +18,25 @@ const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
// Mock for DownloadClientManager
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
getAllClients: vi.fn(),
hasClientForProtocol: vi.fn(),
}));
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
getConfigService: vi.fn(async () => configServiceMock),
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
invalidateDownloadClientManager: vi.fn(),
}));
describe('SABnzbdService', () => {
@@ -32,6 +44,9 @@ describe('SABnzbdService', () => {
vi.clearAllMocks();
clientMock.get.mockReset();
configServiceMock.get.mockReset();
downloadClientManagerMock.getClientForProtocol.mockReset();
downloadClientManagerMock.getAllClients.mockReset();
downloadClientManagerMock.hasClientForProtocol.mockReset();
invalidateSABnzbdService();
});
@@ -456,22 +471,19 @@ describe('SABnzbdService', () => {
});
it('creates a singleton service from config', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
switch (key) {
case 'download_client_url':
return 'http://sab';
case 'download_client_password':
return 'api-key';
case 'sabnzbd_category':
return 'books';
case 'download_client_disable_ssl_verify':
return 'false';
case 'download_dir':
return '/downloads';
default:
return null;
}
// Mock: SABnzbd client configured via DownloadClientManager
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
url: 'http://sab',
password: 'api-key', // API key stored in password field
disableSSLVerify: false,
remotePathMappingEnabled: false,
category: 'books',
});
configServiceMock.get.mockResolvedValue('/downloads');
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();