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
+28 -12
View File
@@ -372,16 +372,20 @@ export class ProwlarrService {
}
/**
* Filter results based on configured download client protocol
* If qBittorrent is configured: only return torrent results
* If SABnzbd is configured: only return NZB results
* Filter results based on configured download client protocols
* If both clients configured: return all results
* If only one client configured: return only matching protocol results
*/
private async filterByProtocol(results: TorrentResult[]): Promise<TorrentResult[]> {
try {
// Get configured download client type
// Get configured download clients
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const { getConfigService } = await import('../services/config.service');
const config = await getConfigService();
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
const manager = getDownloadClientManager(config);
const hasTorrentClient = await manager.hasClientForProtocol('torrent');
const hasUsenetClient = await manager.hasClientForProtocol('usenet');
// Debug: Log protocol distribution
const protocolCounts = results.reduce((acc, r) => {
@@ -403,17 +407,29 @@ export class ProwlarrService {
});
}
if (clientType === 'sabnzbd') {
// Filter for NZB results only
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
logger.info(` Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
return filtered;
} else {
// Filter for torrent results only (default)
// If both clients configured, return all results (best result selected across all protocols)
if (hasTorrentClient && hasUsenetClient) {
logger.info(` Both torrent and usenet clients configured, returning all ${results.length} results`);
return results;
}
// If only torrent client configured, filter for torrent results
if (hasTorrentClient) {
const filtered = results.filter(result => !ProwlarrService.isNZBResult(result));
logger.info(` Filtered ${results.length} results to ${filtered.length} torrent results for qBittorrent`);
return filtered;
}
// If only usenet client configured, filter for NZB results
if (hasUsenetClient) {
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
logger.info(` Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
return filtered;
}
// No clients configured - return empty
logger.warn('No download clients configured, returning empty results');
return [];
} catch (error) {
logger.error('Failed to filter by protocol, returning all results', { error: error instanceof Error ? error.message : String(error) });
return results; // Fallback: return unfiltered if config fails
+34 -54
View File
@@ -997,75 +997,55 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
// Always recreate if config hasn't been loaded successfully
if (!qbittorrentService || !configLoaded) {
try {
// Get configuration from database ONLY (no env var fallback)
// Get configuration from download client manager (uses new multi-client config format)
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
logger.info('[QBittorrent] Loading configuration from database...');
const config = await configService.getMany([
'download_client_url',
'download_client_username',
'download_client_password',
'download_dir',
'download_client_disable_ssl_verify',
'download_client_remote_path_mapping_enabled',
'download_client_remote_path',
'download_client_local_path',
]);
logger.info('[QBittorrent] Loading configuration from download client manager...');
const clientConfig = await manager.getClientForProtocol('torrent');
if (!clientConfig) {
throw new Error('qBittorrent is not configured. Please configure a qBittorrent client in the admin settings.');
}
if (clientConfig.type !== 'qbittorrent') {
throw new Error(`Expected qBittorrent client but found ${clientConfig.type}`);
}
logger.info('[QBittorrent] Config loaded:', {
hasUrl: !!config.download_client_url,
hasUsername: !!config.download_client_username,
hasPassword: !!config.download_client_password,
hasPath: !!config.download_dir,
disableSSLVerify: config.download_client_disable_ssl_verify === 'true',
pathMappingEnabled: config.download_client_remote_path_mapping_enabled === 'true',
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasUsername: !!clientConfig.username,
hasPassword: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
pathMappingEnabled: clientConfig.remotePathMappingEnabled,
});
// Validate all required fields are present (no env var fallback)
const missingFields: string[] = [];
if (!config.download_client_url) {
missingFields.push('qBittorrent URL');
}
if (!config.download_client_username) {
missingFields.push('qBittorrent username');
}
if (!config.download_client_password) {
missingFields.push('qBittorrent password');
}
if (!config.download_dir) {
missingFields.push('Download path');
// Validate required fields
if (!clientConfig.url || !clientConfig.username || !clientConfig.password) {
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
}
if (missingFields.length > 0) {
const errorMsg = `qBittorrent is not fully configured. Missing: ${missingFields.join(', ')}. Please configure qBittorrent in the admin settings.`;
logger.error('Configuration incomplete', { missingFields });
throw new Error(errorMsg);
}
// TypeScript type narrowing: at this point we know all values are non-null
const url = config.download_client_url as string;
const username = config.download_client_username as string;
const password = config.download_client_password as string;
const savePath = config.download_dir as string;
const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
// Get download_dir from main config (not part of client config)
const downloadDir = await configService.get('download_dir') || '/downloads';
// Path mapping configuration
const pathMappingConfig: PathMappingConfig = {
enabled: config.download_client_remote_path_mapping_enabled === 'true',
remotePath: config.download_client_remote_path || '',
localPath: config.download_client_local_path || '',
enabled: clientConfig.remotePathMappingEnabled,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
logger.info('[QBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService(
url,
username,
password,
savePath,
'readmeabook',
disableSSLVerify,
clientConfig.url,
clientConfig.username,
clientConfig.password,
downloadDir,
clientConfig.category || 'readmeabook',
clientConfig.disableSSLVerify,
pathMappingConfig
);
+28 -12
View File
@@ -589,27 +589,43 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
return sabnzbdServiceInstance;
}
// Load configuration from database
// Load configuration from download client manager (uses new multi-client config format)
const { getConfigService } = await import('../services/config.service');
const config = await getConfigService();
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
const url = await config.get('download_client_url');
const apiKey = await config.get('download_client_password'); // Reuse password field for API key
const category = (await config.get('sabnzbd_category')) || 'readmeabook';
const disableSSL = ((await config.get('download_client_disable_ssl_verify')) || 'false') === 'true';
logger.info('Loading configuration from download client manager...');
const clientConfig = await manager.getClientForProtocol('usenet');
if (!url) {
throw new Error('SABnzbd URL not configured. Please configure download client settings.');
if (!clientConfig) {
throw new Error('SABnzbd is not configured. Please configure a SABnzbd client in the admin settings.');
}
if (!apiKey) {
throw new Error('SABnzbd API key not configured. Please configure download client settings.');
if (clientConfig.type !== 'sabnzbd') {
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
}
sabnzbdServiceInstance = new SABnzbdService(url, apiKey, category, disableSSL);
logger.info('Config loaded:', {
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasApiKey: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
});
if (!clientConfig.url || !clientConfig.password) {
throw new Error('SABnzbd is not fully configured. Please check your configuration in admin settings.');
}
sabnzbdServiceInstance = new SABnzbdService(
clientConfig.url,
clientConfig.password, // API key stored in password field
clientConfig.category || 'readmeabook',
clientConfig.disableSSLVerify
);
// Ensure category exists
const downloadDir = await config.get('download_dir');
const downloadDir = await configService.get('download_dir');
await sabnzbdServiceInstance.ensureCategory(downloadDir || undefined);
return sabnzbdServiceInstance;