mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -2,37 +2,41 @@
|
||||
* Component: Download Client Manager Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
|
||||
* 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: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
username?: string; // qBittorrent only
|
||||
password: string; // Password (qBittorrent) or API key (SABnzbd)
|
||||
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
|
||||
}
|
||||
|
||||
type ProtocolType = 'torrent' | 'usenet';
|
||||
|
||||
/**
|
||||
* Download Client Manager
|
||||
@@ -47,6 +51,7 @@ 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) {
|
||||
@@ -69,6 +74,7 @@ export class DownloadClientManager {
|
||||
static invalidate(): void {
|
||||
if (DownloadClientManager.instance) {
|
||||
DownloadClientManager.instance.clientsCache = null;
|
||||
DownloadClientManager.instance.serviceCache.clear();
|
||||
DownloadClientManager.instance.migrationPerformed = false;
|
||||
logger.debug('Download client cache invalidated');
|
||||
}
|
||||
@@ -127,16 +133,17 @@ export class DownloadClientManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client for specific protocol
|
||||
* 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 targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
|
||||
|
||||
const client = clients.find(c => c.enabled && c.type === targetType);
|
||||
const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol);
|
||||
|
||||
if (!client) {
|
||||
logger.warn(`No enabled ${targetType} client configured`);
|
||||
logger.warn(`No enabled ${protocol} client configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -152,36 +159,83 @@ export class DownloadClientManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instantiated client service for protocol
|
||||
* Get instantiated client service for protocol.
|
||||
* Returns the unified IDownloadClient interface for protocol-agnostic usage.
|
||||
*/
|
||||
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
|
||||
async getClientServiceForProtocol(protocol: ProtocolType): Promise<IDownloadClient | null> {
|
||||
const client = await this.getClientForProtocol(protocol);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client.type === 'qbittorrent') {
|
||||
return this.createQBittorrentService(client);
|
||||
} else {
|
||||
return this.createSABnzbdService(client);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for a specific client config
|
||||
* 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 {
|
||||
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})` };
|
||||
// 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 });
|
||||
@@ -192,7 +246,7 @@ export class DownloadClientManager {
|
||||
/**
|
||||
* Create qBittorrent service instance
|
||||
*/
|
||||
private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
|
||||
private createQBittorrentService(config: DownloadClientConfig, downloadDir: string): QBittorrentService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -205,8 +259,8 @@ export class DownloadClientManager {
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password || '', // Optional for IP whitelist auth
|
||||
'/downloads', // defaultSavePath
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
downloadDir,
|
||||
config.category || 'readmeabook',
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
@@ -215,7 +269,7 @@ export class DownloadClientManager {
|
||||
/**
|
||||
* Create SABnzbd service instance
|
||||
*/
|
||||
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
|
||||
private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -227,8 +281,54 @@ export class DownloadClientManager {
|
||||
return new SABnzbdService(
|
||||
config.url,
|
||||
config.password, // API key stored in password field
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
'/downloads', // defaultDownloadDir (will be overridden by singleton with actual config)
|
||||
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
|
||||
);
|
||||
@@ -272,8 +372,8 @@ export class DownloadClientManager {
|
||||
|
||||
const newClient: DownloadClientConfig = {
|
||||
id: randomUUID(),
|
||||
type: clientType as 'qbittorrent' | 'sabnzbd',
|
||||
name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
|
||||
type: clientType as DownloadClientType,
|
||||
name: getClientDisplayName(clientType),
|
||||
enabled: true,
|
||||
url: clientUrl,
|
||||
username: clientUsername || undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@ import Queue, { Job as BullJob, JobOptions } from 'bull';
|
||||
import Redis from 'ioredis';
|
||||
import { prisma } from '../db';
|
||||
import { TorrentResult } from '../utils/ranking-algorithm';
|
||||
import { DownloadClientType } from '../interfaces/download-client.interface';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('JobQueue');
|
||||
@@ -59,7 +60,7 @@ export interface MonitorDownloadPayload extends JobPayload {
|
||||
requestId: string;
|
||||
downloadHistoryId: string;
|
||||
downloadClientId: string;
|
||||
downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||
downloadClient: DownloadClientType;
|
||||
}
|
||||
|
||||
export interface OrganizeFilesPayload extends JobPayload {
|
||||
@@ -545,7 +546,7 @@ export class JobQueueService {
|
||||
requestId: string,
|
||||
downloadHistoryId: string,
|
||||
downloadClientId: string,
|
||||
downloadClient: 'qbittorrent' | 'sabnzbd',
|
||||
downloadClient: DownloadClientType,
|
||||
delaySeconds: number = 0
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { buildAudiobookPath } from '../utils/file-organizer';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
|
||||
const logger = RMABLogger.create('RequestDelete');
|
||||
|
||||
@@ -119,77 +120,73 @@ export async function deleteRequest(
|
||||
);
|
||||
}
|
||||
|
||||
// Handle based on download client type (check which ID is present)
|
||||
if (downloadHistory.torrentHash) {
|
||||
// qBittorrent download
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
// Handle download cleanup via unified interface
|
||||
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
|
||||
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||
|
||||
let torrent;
|
||||
try {
|
||||
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
} catch (error) {
|
||||
// Torrent not found in qBittorrent (already removed)
|
||||
logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`);
|
||||
}
|
||||
if (clientId && clientType !== 'direct') {
|
||||
const { getDownloadClientManager } = await import('./download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
|
||||
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||
|
||||
if (torrent) {
|
||||
// Torrent exists in qBittorrent
|
||||
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
|
||||
const isCompleted = downloadHistory.downloadStatus === 'completed';
|
||||
if (client) {
|
||||
// Get download info to check seeding status
|
||||
let downloadInfo;
|
||||
try {
|
||||
downloadInfo = await client.getDownload(clientId);
|
||||
} catch (error) {
|
||||
logger.info(`Download ${clientId} not found in ${clientType}, skipping`);
|
||||
}
|
||||
|
||||
if (isUnlimitedSeeding) {
|
||||
// Unlimited seeding - keep in qBittorrent, stop monitoring
|
||||
logger.info(
|
||||
`Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
|
||||
);
|
||||
torrentsKeptUnlimited++;
|
||||
} else if (!isCompleted) {
|
||||
// Download not completed - delete immediately
|
||||
logger.info(
|
||||
`Deleting incomplete download: ${torrent.name}`
|
||||
);
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
|
||||
torrentsRemoved++;
|
||||
} else {
|
||||
// Check if seeding requirement is met
|
||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
||||
const actualSeedingTime = torrent.seeding_time || 0;
|
||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
||||
if (downloadInfo) {
|
||||
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
|
||||
const isCompleted = downloadHistory.downloadStatus === 'completed';
|
||||
|
||||
if (hasMetRequirement) {
|
||||
// Seeding requirement met - delete now
|
||||
if (client.protocol === 'usenet') {
|
||||
// Usenet - no seeding concept, delete immediately
|
||||
try {
|
||||
await client.deleteDownload(clientId, true);
|
||||
logger.info(`Deleted download ${clientId} from ${client.clientType}`);
|
||||
torrentsRemoved++;
|
||||
} catch (error) {
|
||||
logger.info(`Download ${clientId} not found in ${client.clientType}, skipping`);
|
||||
}
|
||||
} else if (isUnlimitedSeeding) {
|
||||
// Unlimited seeding - keep in client, stop monitoring
|
||||
logger.info(
|
||||
`Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
|
||||
actualSeedingTime / 60
|
||||
)}/${seedingConfig.seedingTimeMinutes} minutes)`
|
||||
`Keeping download ${downloadInfo.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
|
||||
);
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
|
||||
torrentsKeptUnlimited++;
|
||||
} else if (!isCompleted) {
|
||||
// Download not completed - delete immediately
|
||||
logger.info(`Deleting incomplete download: ${downloadInfo.name}`);
|
||||
await client.deleteDownload(clientId, true);
|
||||
torrentsRemoved++;
|
||||
} else {
|
||||
// Still needs seeding - keep for cleanup job
|
||||
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
|
||||
logger.info(
|
||||
`Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
|
||||
);
|
||||
torrentsKeptSeeding++;
|
||||
// Check if seeding requirement is met
|
||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
||||
const actualSeedingTime = downloadInfo.seedingTime || 0;
|
||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
||||
|
||||
if (hasMetRequirement) {
|
||||
logger.info(
|
||||
`Deleting download ${downloadInfo.name} (seeding complete: ${Math.floor(
|
||||
actualSeedingTime / 60
|
||||
)}/${seedingConfig.seedingTimeMinutes} minutes)`
|
||||
);
|
||||
await client.deleteDownload(clientId, true);
|
||||
torrentsRemoved++;
|
||||
} else {
|
||||
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
|
||||
logger.info(
|
||||
`Keeping download ${downloadInfo.name} for ${remainingMinutes} more minutes of seeding`
|
||||
);
|
||||
torrentsKeptSeeding++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (downloadHistory.nzbId) {
|
||||
// SABnzbd download - no seeding concept for Usenet
|
||||
try {
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
// Try to delete the NZB from SABnzbd (might already be completed/removed)
|
||||
await sabnzbd.deleteNZB(downloadHistory.nzbId, true);
|
||||
logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`);
|
||||
torrentsRemoved++;
|
||||
} catch (error) {
|
||||
// NZB not found or already removed
|
||||
logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -208,7 +205,11 @@ export async function deleteRequest(
|
||||
const { getConfigService } = await import('./config.service');
|
||||
const configService = getConfigService();
|
||||
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
|
||||
const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
|
||||
// Use ebook-specific template for ebook requests, with fallback to audiobook template
|
||||
const audiobookTemplate = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
|
||||
const template = isEbook
|
||||
? (await configService.get('ebook_path_template')) || audiobookTemplate
|
||||
: audiobookTemplate;
|
||||
|
||||
// Fetch year from audible cache if ASIN is available
|
||||
let year: number | undefined;
|
||||
|
||||
Reference in New Issue
Block a user