Files
ReadMeABook/src/lib/services/download-client-manager.service.ts
T
kikootwo af0eaceb98 Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
2026-02-10 15:06:20 -05:00

421 lines
14 KiB
TypeScript

/**
* Component: Download Client Manager Service
* Documentation: documentation/phase3/download-clients.md
*
* Manages multiple download clients (qBittorrent, Transmission, 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 { 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);
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
);
}
/**
* 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();
}