mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { getSABnzbdService } from '../integrations/sabnzbd.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { ProwlarrService } from '../integrations/prowlarr.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
@@ -39,20 +41,28 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
},
|
||||
});
|
||||
|
||||
// Get configured download client type
|
||||
// Detect protocol from result and route to appropriate client
|
||||
const isUsenet = ProwlarrService.isNZBResult(torrent);
|
||||
const config = await getConfigService();
|
||||
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
|
||||
const manager = getDownloadClientManager(config);
|
||||
|
||||
const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent');
|
||||
|
||||
if (!clientConfig) {
|
||||
const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)';
|
||||
throw new Error(`No ${protocol} client configured`);
|
||||
}
|
||||
|
||||
let downloadClientId: string;
|
||||
let downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||
|
||||
if (clientType === 'sabnzbd') {
|
||||
if (isUsenet) {
|
||||
// Route to SABnzbd
|
||||
logger.info(`Routing to SABnzbd`);
|
||||
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
|
||||
category: 'readmeabook',
|
||||
category: clientConfig.category || 'readmeabook',
|
||||
priority: 'normal',
|
||||
});
|
||||
downloadClient = 'sabnzbd';
|
||||
@@ -115,7 +125,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
const qbt = await getQBittorrentService();
|
||||
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
|
||||
category: 'readmeabook',
|
||||
category: clientConfig.category || 'readmeabook',
|
||||
tags: ['audiobook'],
|
||||
sequentialDownload: true,
|
||||
paused: false,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Component: Download Client Manager Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
|
||||
* Supports migration from legacy single-client config to multi-client JSON array format.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ConfigurationService } from './config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
import { PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||
|
||||
const logger = RMABLogger.create('DownloadClientManager');
|
||||
|
||||
export interface DownloadClientConfig {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
username?: string; // qBittorrent only
|
||||
password: string; // Password (qBittorrent) or API key (SABnzbd)
|
||||
disableSSLVerify: boolean;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string; // Default: 'readmeabook'
|
||||
}
|
||||
|
||||
type ProtocolType = 'torrent' | 'usenet';
|
||||
|
||||
/**
|
||||
* Download Client Manager
|
||||
*
|
||||
* Provides centralized management of multiple download clients with:
|
||||
* - Protocol-based routing (torrent → qBittorrent, usenet → SABnzbd)
|
||||
* - Auto-migration from legacy single-client config
|
||||
* - Singleton caching with invalidation
|
||||
* - Connection testing
|
||||
*/
|
||||
export class DownloadClientManager {
|
||||
private static instance: DownloadClientManager | null = null;
|
||||
private configService: ConfigurationService;
|
||||
private clientsCache: DownloadClientConfig[] | null = null;
|
||||
private migrationPerformed = false;
|
||||
|
||||
private constructor(configService: ConfigurationService) {
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
static getInstance(configService?: ConfigurationService): DownloadClientManager {
|
||||
if (!DownloadClientManager.instance) {
|
||||
if (!configService) {
|
||||
throw new Error('ConfigurationService required for first initialization');
|
||||
}
|
||||
DownloadClientManager.instance = new DownloadClientManager(configService);
|
||||
}
|
||||
return DownloadClientManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached clients (call after config changes)
|
||||
*/
|
||||
static invalidate(): void {
|
||||
if (DownloadClientManager.instance) {
|
||||
DownloadClientManager.instance.clientsCache = null;
|
||||
DownloadClientManager.instance.migrationPerformed = false;
|
||||
logger.debug('Download client cache invalidated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured download clients
|
||||
*/
|
||||
async getAllClients(): Promise<DownloadClientConfig[]> {
|
||||
if (this.clientsCache) {
|
||||
return this.clientsCache;
|
||||
}
|
||||
|
||||
// Read from database
|
||||
const configValue = await this.configService.get('download_clients');
|
||||
|
||||
if (configValue) {
|
||||
try {
|
||||
const clients = JSON.parse(configValue) as DownloadClientConfig[];
|
||||
this.clientsCache = clients;
|
||||
return clients;
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse download_clients config', { error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for legacy config and migrate
|
||||
if (!this.migrationPerformed) {
|
||||
const migrated = await this.migrateLegacyConfig();
|
||||
this.migrationPerformed = true;
|
||||
if (migrated) {
|
||||
return this.getAllClients(); // Recursive call after migration
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client for specific protocol
|
||||
*/
|
||||
async getClientForProtocol(protocol: ProtocolType): Promise<DownloadClientConfig | null> {
|
||||
const clients = await this.getAllClients();
|
||||
const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
|
||||
|
||||
const client = clients.find(c => c.enabled && c.type === targetType);
|
||||
|
||||
if (!client) {
|
||||
logger.warn(`No enabled ${targetType} client configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if protocol is configured
|
||||
*/
|
||||
async hasClientForProtocol(protocol: ProtocolType): Promise<boolean> {
|
||||
const client = await this.getClientForProtocol(protocol);
|
||||
return client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instantiated client service for protocol
|
||||
*/
|
||||
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
|
||||
const client = await this.getClientForProtocol(protocol);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client.type === 'qbittorrent') {
|
||||
return this.createQBittorrentService(client);
|
||||
} else {
|
||||
return this.createSABnzbdService(client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for a specific client config
|
||||
*/
|
||||
async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (config.type === 'qbittorrent') {
|
||||
const service = this.createQBittorrentService(config);
|
||||
await service.testConnection();
|
||||
return { success: true, message: 'Successfully connected to qBittorrent' };
|
||||
} else {
|
||||
const service = this.createSABnzbdService(config);
|
||||
const version = await service.getVersion();
|
||||
return { success: true, message: `Successfully connected to SABnzbd (v${version})` };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Connection test failed', { type: config.type, error: message });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create qBittorrent service instance
|
||||
*/
|
||||
private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
remotePath: config.remotePath,
|
||||
localPath: config.localPath,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new QBittorrentService(
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password,
|
||||
'/downloads', // defaultSavePath
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SABnzbd service instance
|
||||
*/
|
||||
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
|
||||
return new SABnzbdService(
|
||||
config.url,
|
||||
config.password, // API key stored in password field
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
config.disableSSLVerify
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy single-client config to new multi-client format
|
||||
*/
|
||||
private async migrateLegacyConfig(): Promise<boolean> {
|
||||
logger.info('Checking for legacy download client config...');
|
||||
|
||||
const [
|
||||
clientType,
|
||||
clientUrl,
|
||||
clientUsername,
|
||||
clientPassword,
|
||||
disableSSLVerify,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
category,
|
||||
] = await Promise.all([
|
||||
this.configService.get('download_client_type'),
|
||||
this.configService.get('download_client_url'),
|
||||
this.configService.get('download_client_username'),
|
||||
this.configService.get('download_client_password'),
|
||||
this.configService.get('download_client_disable_ssl_verify'),
|
||||
this.configService.get('download_client_remote_path_mapping_enabled'),
|
||||
this.configService.get('download_client_remote_path'),
|
||||
this.configService.get('download_client_local_path'),
|
||||
this.configService.get('sabnzbd_category'),
|
||||
]);
|
||||
|
||||
// Check if legacy config exists
|
||||
if (!clientType || !clientUrl || !clientPassword) {
|
||||
logger.info('No legacy config found');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Migrating legacy ${clientType} config...`);
|
||||
|
||||
const newClient: DownloadClientConfig = {
|
||||
id: randomUUID(),
|
||||
type: clientType as 'qbittorrent' | 'sabnzbd',
|
||||
name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
|
||||
enabled: true,
|
||||
url: clientUrl,
|
||||
username: clientUsername || undefined,
|
||||
password: clientPassword,
|
||||
disableSSLVerify: disableSSLVerify === 'true',
|
||||
remotePathMappingEnabled: remotePathMappingEnabled === 'true',
|
||||
remotePath: remotePath || undefined,
|
||||
localPath: localPath || undefined,
|
||||
category: category || 'readmeabook',
|
||||
};
|
||||
|
||||
// Save to new format
|
||||
const newConfig = [newClient];
|
||||
await this.configService.setMany([
|
||||
{ key: 'download_clients', value: JSON.stringify(newConfig) },
|
||||
]);
|
||||
|
||||
logger.info('Migration completed successfully', {
|
||||
type: newClient.type,
|
||||
name: newClient.name,
|
||||
id: newClient.id
|
||||
});
|
||||
|
||||
// Update cache
|
||||
this.clientsCache = newConfig;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create singleton instance
|
||||
*/
|
||||
export function getDownloadClientManager(configService?: ConfigurationService): DownloadClientManager {
|
||||
return DownloadClientManager.getInstance(configService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate singleton (call after config changes)
|
||||
*/
|
||||
export function invalidateDownloadClientManager(): void {
|
||||
DownloadClientManager.invalidate();
|
||||
}
|
||||
Reference in New Issue
Block a user