mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
d70f6c9957
Introduce Deluge download client service and tests, remove obsolete rdtclient service, and update qbittorrent integration/tests and download-client interfaces/manager. Large UI refactor for admin pages: Jobs and Logs were redesigned to be responsive (mobile card views + desktop tables), improved headers, dialogs, controls, and better status/detail rendering. Also updated DownloadClient components (card, management, modal), organize-files processor, audible-series integration, and related unit tests to align with integration changes. Minor UX and accessibility tweaks, cron handling/validation adjustments, and a few formatting/cleanup fixes throughout.
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
/**
|
|
* Component: Download Client Manager Service
|
|
* Documentation: documentation/phase3/download-clients.md
|
|
*
|
|
* Manages multiple download clients (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet) with protocol-based routing.
|
|
* Supports migration from legacy single-client config to multi-client JSON array format.
|
|
*/
|
|
|
|
import { randomUUID } from 'crypto';
|
|
import path from 'path';
|
|
import { ConfigurationService } from './config.service';
|
|
import { getEncryptionService } from './encryption.service';
|
|
import { isEncryptedFormat } from './credential-migration.service';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
|
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
|
import { NZBGetService } from '@/lib/integrations/nzbget.service';
|
|
import { TransmissionService } from '@/lib/integrations/transmission.service';
|
|
import { DelugeService } from '@/lib/integrations/deluge.service';
|
|
import { PathMappingConfig } from '@/lib/utils/path-mapper';
|
|
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
|
|
|
const logger = RMABLogger.create('DownloadClientManager');
|
|
|
|
export interface DownloadClientConfig {
|
|
id: string;
|
|
type: DownloadClientType;
|
|
name: string;
|
|
enabled: boolean;
|
|
url: string;
|
|
username?: string; // qBittorrent/Transmission/NZBGet only
|
|
password: string; // Password (qBittorrent/Transmission/NZBGet) or API key (SABnzbd)
|
|
disableSSLVerify: boolean;
|
|
remotePathMappingEnabled: boolean;
|
|
remotePath?: string;
|
|
localPath?: string;
|
|
category?: string; // Default: 'readmeabook'
|
|
customPath?: string; // Relative sub-path appended to download_dir
|
|
postImportCategory?: string; // Category to assign after import (torrent clients only)
|
|
}
|
|
|
|
|
|
/**
|
|
* 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 serviceCache: Map<string, IDownloadClient> = new Map();
|
|
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.serviceCache.clear();
|
|
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[];
|
|
|
|
// Decrypt passwords if they're in encrypted format
|
|
const encryptionService = getEncryptionService();
|
|
const decryptedClients = clients.map(client => {
|
|
if (client.password && isEncryptedFormat(client.password)) {
|
|
try {
|
|
return {
|
|
...client,
|
|
password: encryptionService.decrypt(client.password),
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Failed to decrypt password for client ${client.name}`, { error });
|
|
return client;
|
|
}
|
|
}
|
|
return client;
|
|
});
|
|
|
|
this.clientsCache = decryptedClients;
|
|
return decryptedClients;
|
|
} 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.
|
|
* Uses CLIENT_PROTOCOL_MAP so any client type matching the protocol is found
|
|
* (e.g. both qBittorrent and Transmission can serve the 'torrent' protocol).
|
|
*/
|
|
async getClientForProtocol(protocol: ProtocolType): Promise<DownloadClientConfig | null> {
|
|
const clients = await this.getAllClients();
|
|
|
|
const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol);
|
|
|
|
if (!client) {
|
|
logger.warn(`No enabled ${protocol} 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.
|
|
* Returns the unified IDownloadClient interface for protocol-agnostic usage.
|
|
*/
|
|
async getClientServiceForProtocol(protocol: ProtocolType): Promise<IDownloadClient | null> {
|
|
const client = await this.getClientForProtocol(protocol);
|
|
|
|
if (!client) {
|
|
return null;
|
|
}
|
|
|
|
return this.getOrCreateService(client);
|
|
}
|
|
|
|
/**
|
|
* Factory: create a new IDownloadClient from config.
|
|
* This is the single place where client type maps to a concrete class.
|
|
* Add new client types (e.g. Transmission, NZBGet) here.
|
|
*/
|
|
private async createService(config: DownloadClientConfig): Promise<IDownloadClient> {
|
|
const baseDir = await this.configService.get('download_dir') || '/downloads';
|
|
const downloadDir = config.customPath
|
|
? path.join(baseDir, config.customPath)
|
|
: baseDir;
|
|
|
|
switch (config.type) {
|
|
case 'qbittorrent':
|
|
return this.createQBittorrentService(config, downloadDir);
|
|
case 'sabnzbd':
|
|
return this.createSABnzbdService(config, downloadDir);
|
|
case 'nzbget':
|
|
return this.createNZBGetService(config, downloadDir);
|
|
case 'transmission':
|
|
return this.createTransmissionService(config, downloadDir);
|
|
case 'deluge':
|
|
return this.createDelugeService(config, downloadDir);
|
|
default:
|
|
throw new Error(`Unsupported download client type: ${config.type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a cached service instance or create a new one.
|
|
* Caches by client config ID to preserve session state (e.g. qBittorrent SID cookie).
|
|
*/
|
|
private async getOrCreateService(config: DownloadClientConfig): Promise<IDownloadClient> {
|
|
const cached = this.serviceCache.get(config.id);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const service = await this.createService(config);
|
|
this.serviceCache.set(config.id, service);
|
|
return service;
|
|
}
|
|
|
|
/**
|
|
* Create an IDownloadClient instance from a config object.
|
|
* Uses cached instances when available to preserve session state.
|
|
*/
|
|
async createClientFromConfig(config: DownloadClientConfig): Promise<IDownloadClient> {
|
|
return this.getOrCreateService(config);
|
|
}
|
|
|
|
/**
|
|
* Test connection for a specific client config.
|
|
* Uses the unified IDownloadClient.testConnection() method.
|
|
*/
|
|
async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
|
|
try {
|
|
// Always create a fresh instance for connection testing (don't use cache)
|
|
const service = await this.createService(config);
|
|
const result = await service.testConnection();
|
|
|
|
if (result.success) {
|
|
const versionSuffix = result.version ? ` (v${result.version})` : '';
|
|
return { success: true, message: `Successfully connected to ${config.name}${versionSuffix}` };
|
|
}
|
|
|
|
return { success: false, message: result.message || 'Connection failed' };
|
|
} 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, downloadDir: string): 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 || '', // Optional for IP whitelist auth
|
|
downloadDir,
|
|
config.category || 'readmeabook',
|
|
config.disableSSLVerify,
|
|
pathMapping
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create SABnzbd service instance
|
|
*/
|
|
private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService {
|
|
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
|
? {
|
|
enabled: true,
|
|
remotePath: config.remotePath,
|
|
localPath: config.localPath,
|
|
}
|
|
: undefined;
|
|
|
|
return new SABnzbdService(
|
|
config.url,
|
|
config.password, // API key stored in password field
|
|
config.category || 'readmeabook',
|
|
downloadDir,
|
|
config.disableSSLVerify,
|
|
pathMapping
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create NZBGet service instance
|
|
*/
|
|
private createNZBGetService(config: DownloadClientConfig, downloadDir: string): NZBGetService {
|
|
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
|
? {
|
|
enabled: true,
|
|
remotePath: config.remotePath,
|
|
localPath: config.localPath,
|
|
}
|
|
: undefined;
|
|
|
|
return new NZBGetService(
|
|
config.url,
|
|
config.username || '',
|
|
config.password,
|
|
config.category || 'readmeabook',
|
|
downloadDir,
|
|
config.disableSSLVerify,
|
|
pathMapping
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create Transmission service instance
|
|
*/
|
|
private createTransmissionService(config: DownloadClientConfig, downloadDir: string): TransmissionService {
|
|
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
|
? {
|
|
enabled: true,
|
|
remotePath: config.remotePath,
|
|
localPath: config.localPath,
|
|
}
|
|
: undefined;
|
|
|
|
return new TransmissionService(
|
|
config.url,
|
|
config.username || '',
|
|
config.password || '',
|
|
downloadDir,
|
|
config.category || 'readmeabook',
|
|
config.disableSSLVerify,
|
|
pathMapping
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create Deluge service instance
|
|
*/
|
|
private createDelugeService(config: DownloadClientConfig, downloadDir: string): DelugeService {
|
|
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
|
? {
|
|
enabled: true,
|
|
remotePath: config.remotePath,
|
|
localPath: config.localPath,
|
|
}
|
|
: undefined;
|
|
|
|
return new DelugeService(
|
|
config.url,
|
|
config.username || '',
|
|
config.password || '',
|
|
downloadDir,
|
|
config.category || 'readmeabook',
|
|
config.disableSSLVerify,
|
|
pathMapping
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 DownloadClientType,
|
|
name: getClientDisplayName(clientType),
|
|
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();
|
|
}
|