Files
ReadMeABook/src/lib/integrations/deluge.service.ts
T
kikootwo b15a472bab Centralize download client timeout constant
Add DOWNLOAD_CLIENT_TIMEOUT (60000ms) in src/lib/constants/download-timeouts.ts and replace hardcoded 60000ms timeouts across Deluge, Prowlarr, qBittorrent and Transmission integrations. This centralizes the download/API timeout (gives headroom for indexers that enforce ~30s waits) and makes future adjustments easier without changing behavior.
2026-02-24 01:09:58 -05:00

387 lines
17 KiB
TypeScript

/**
* Component: Deluge Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient, DownloadClientType, ProtocolType,
DownloadInfo, DownloadStatus, AddDownloadOptions, ConnectionTestResult,
} from '../interfaces/download-client.interface';
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('Deluge');
export class DelugeService implements IDownloadClient {
readonly clientType: DownloadClientType = 'deluge';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private password: string;
private defaultSavePath: string;
private defaultCategory: string;
private pathMappingConfig: PathMappingConfig;
private sessionCookie: string = '';
private requestId: number = 0;
constructor(
baseUrl: string,
_username: string, // Unused — Deluge uses password-only auth; kept for consistent signature
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
const httpsAgent = disableSSLVerify && this.baseUrl.startsWith('https')
? new https.Agent({ rejectUnauthorized: false }) : undefined;
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled');
this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent });
}
/** JSON-RPC call with automatic re-authentication on auth failure */
private async rpc(method: string, params: any[] = [], retried = false): Promise<any> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.sessionCookie) headers['Cookie'] = this.sessionCookie;
try {
const reqId = ++this.requestId;
const { data } = await this.client.post('/json', { method, params, id: reqId }, { headers });
// Deluge error.code === 1: "Not authenticated" — re-login then retry
if (data.error?.code === 1 && !retried) {
await this.login();
return this.rpc(method, params, true);
}
// Deluge error.code === 2: "Unknown method" — daemon disconnected, force reconnect
// Only retry for core.* methods — plugin methods (label.*) fail because the plugin
// isn't enabled, not because the daemon is disconnected.
if (data.error?.code === 2 && !retried && method.startsWith('core.')) {
await this.login(true);
return this.rpc(method, params, true);
}
return data;
} catch (error) {
if (!retried) { await this.login(); return this.rpc(method, params, true); }
throw error;
}
}
private async login(forceReconnect: boolean = false): Promise<void> {
const { data, headers } = await this.client.post(
'/json',
{ method: 'auth.login', params: [this.password], id: ++this.requestId },
{ headers: { 'Content-Type': 'application/json' } }
);
if (!data?.result) throw new Error('Failed to authenticate with Deluge — check your password');
const cookies = headers['set-cookie'];
if (cookies?.length) this.sessionCookie = cookies[0].split(';')[0];
logger.info('Successfully authenticated with Deluge');
// Deluge Web UI requires a daemon connection before core.* methods work.
// When forceReconnect is true, skip the web.connected check and force a fresh connection.
await this.ensureDaemonConnected(forceReconnect);
}
/**
* Ensure the Web UI is connected to a deluged daemon host.
* Uses web.connected (returns boolean) as the check — daemon.info is NOT a valid
* method through the Deluge Web UI JSON-RPC; only web.* and core.* methods work.
*/
private async ensureDaemonConnected(force: boolean = false): Promise<void> {
if (!force) {
const test = await this.rpc('web.connected', [], true);
if (test.result === true) return;
}
logger.info('Connecting to daemon...');
const hostsData = await this.rpc('web.get_hosts', [], true);
const hosts: any[] = hostsData.result || [];
if (hosts.length === 0) {
throw new Error('Deluge has no daemon hosts configured. Add a host in the Deluge Web UI under Connection Manager.');
}
const hostId = hosts[0][0];
const connectResult = await this.rpc('web.connect', [hostId], true);
if (connectResult.error) {
throw new Error(`Failed to connect to Deluge daemon: ${connectResult.error.message}`);
}
// Verify connection is established
const verify = await this.rpc('web.connected', [], true);
if (verify.result !== true) {
throw new Error('Deluge daemon failed to respond after web.connect. Check that deluged is running.');
}
logger.info('Connected to Deluge daemon');
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
return { success: true, message: 'Connected to Deluge' };
} catch (error) {
const msg = error instanceof Error ? error.message : 'Connection failed';
if (axios.isAxiosError(error)) {
const c = error.code;
if (c?.includes('CERT') || c?.includes('SSL')) return { success: false, message: `SSL verification failed (${c}). Enable "Disable SSL Verification".` };
if (c === 'ECONNREFUSED') return { success: false, message: `Connection refused at: ${this.baseUrl}` };
if (c === 'ETIMEDOUT' || c === 'ECONNABORTED') return { success: false, message: `Connection timeout: ${this.baseUrl}` };
if (c === 'ENOTFOUND') return { success: false, message: `Host not found: ${this.baseUrl}` };
if (error.response?.status === 401) return { success: false, message: 'Authentication failed. Check your password.' };
}
logger.error('Connection test failed', { error: msg });
return { success: false, message: msg };
}
}
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
if (!url || typeof url !== 'string' || url.trim() === '') {
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
}
const category = options?.category || this.defaultCategory;
return url.startsWith('magnet:')
? this.addMagnetLink(url, category, options)
: this.addTorrentFile(url, category, options);
}
private async addMagnetLink(magnetUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) throw new Error('Invalid magnet link - could not extract info_hash');
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
if (existing.result && Object.keys(existing.result).length > 0) {
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
return infoHash;
}
const opts = this.buildTorrentOptions(options?.paused);
const data = await this.rpc('core.add_torrent_magnet', [magnetUrl, opts]);
if (!data.result) throw new Error(`Deluge rejected magnet link: ${data.error?.message || 'unknown error'}`);
await this.postAddSetup(data.result, category);
logger.info(`Successfully added magnet link: ${infoHash}`);
return infoHash;
}
private async addTorrentFile(torrentUrl: string, category: string, options?: AddDownloadOptions): Promise<string> {
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer', maxRedirects: 0,
validateStatus: (s) => s >= 200 && s < 300, timeout: DOWNLOAD_CLIENT_TIMEOUT,
});
if (torrentResponse.data.length > 0) {
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
if (magnetMatch) return this.addMagnetLink(magnetMatch[0], category, options);
}
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) throw error;
const status = error.response.status;
if (status >= 300 && status < 400) {
const loc = error.response.headers['location'];
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
if (loc?.startsWith('http://') || loc?.startsWith('https://')) {
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5 }); }
catch { throw new Error('Failed to download torrent file after redirect'); }
} else { throw new Error(`Invalid redirect location: ${loc}`); }
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
}
const torrentBuffer = Buffer.from(torrentResponse.data);
let parsed: any;
try { parsed = await parseTorrent(torrentBuffer); }
catch { throw new Error('Invalid .torrent file - failed to parse'); }
const infoHash = parsed.infoHash;
if (!infoHash) throw new Error('Failed to extract info_hash from .torrent file');
logger.info(`Extracted info_hash: ${infoHash}`);
const existing = await this.rpc('core.get_torrent_status', [infoHash, ['name']]);
if (existing.result && Object.keys(existing.result).length > 0) {
logger.info(`Torrent ${infoHash} already exists (duplicate)`);
return infoHash;
}
const filename = parsed.name ? `${parsed.name}.torrent` : 'torrent.torrent';
const opts = this.buildTorrentOptions(options?.paused);
const data = await this.rpc('core.add_torrent_file', [filename, torrentBuffer.toString('base64'), opts]);
if (!data.result) throw new Error(`Deluge rejected .torrent file: ${data.error?.message || 'unknown error'}`);
await this.postAddSetup(infoHash, category);
logger.info(`Successfully added torrent: ${infoHash}`);
return infoHash;
}
async getDownload(id: string): Promise<DownloadInfo | null> {
const fields = ['name', 'total_size', 'total_done', 'progress', 'state',
'download_payload_rate', 'eta', 'label', 'save_path',
'time_added', 'is_finished', 'seeding_time', 'ratio', 'message'];
for (let attempt = 0; attempt <= 3; attempt++) {
const { result } = await this.rpc('core.get_torrent_status', [id, fields]);
if (result && Object.keys(result).length > 0) return this.mapToDownloadInfo(id, result);
if (attempt === 3) return null;
const delay = 500 * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delay}ms (${attempt + 1}/3)`);
await new Promise(r => setTimeout(r, delay));
}
return null;
}
async pauseDownload(id: string): Promise<void> {
await this.rpc('core.pause_torrent', [[id]]);
logger.info(`Paused torrent: ${id}`);
}
async resumeDownload(id: string): Promise<void> {
await this.rpc('core.resume_torrent', [[id]]);
logger.info(`Resumed torrent: ${id}`);
}
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
await this.rpc('core.remove_torrent', [id, deleteFiles]);
logger.info(`Deleted torrent: ${id}`);
}
async postProcess(_id: string): Promise<void> {} // No-op: seeding cleanup scheduler manages lifecycle
async getCategories(): Promise<string[]> {
try { const { result } = await this.rpc('label.get_labels'); return Array.isArray(result) ? result : []; }
catch { return []; }
}
async setCategory(id: string, category: string): Promise<void> {
await this.applyLabel(id, category);
logger.info(`Set label for torrent ${id}: ${category}`);
}
// =========================================================================
// Internal Helpers
// =========================================================================
private buildTorrentOptions(paused?: boolean): Record<string, any> {
const remoteSavePath = PathMapper.reverseTransform(this.defaultSavePath, this.pathMappingConfig);
const opts: Record<string, any> = { download_location: remoteSavePath, move_completed: false, move_completed_path: '' };
if (paused) opts.add_paused = true;
return opts;
}
private async postAddSetup(hash: string, category: string): Promise<void> {
await this.disableSeedLimits(hash);
await this.applyLabel(hash, category);
}
private async applyLabel(hash: string, label: string): Promise<void> {
try {
try { await this.rpc('label.add', [label]); } catch { /* may already exist */ }
await this.rpc('label.set_torrent', [hash, label]);
} catch (error) {
logger.warn(`Failed to apply label "${label}" to ${hash}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async disableSeedLimits(hash: string): Promise<void> {
try {
await this.rpc('core.set_torrent_options', [[hash], { stop_at_ratio: false, seed_time_limit: -1 }]);
} catch (error) {
logger.warn(`Failed to disable seed limits for ${hash}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private mapToDownloadInfo(hash: string, t: Record<string, any>): DownloadInfo {
return {
id: hash, name: t.name || '', size: t.total_size || 0,
bytesDownloaded: t.total_done || 0, progress: (t.progress || 0) / 100,
status: this.mapStatus(t.state), downloadSpeed: t.download_payload_rate || 0,
eta: t.eta > 0 ? t.eta : 0, category: t.label || '',
downloadPath: t.save_path ? path.join(t.save_path, t.name || '') : undefined,
completedAt: t.is_finished && t.time_added ? new Date(t.time_added * 1000) : undefined,
errorMessage: t.message || undefined, seedingTime: t.seeding_time,
ratio: t.ratio >= 0 ? t.ratio : undefined,
};
}
private mapStatus(state: string): DownloadStatus {
const map: Record<string, DownloadStatus> = {
'Downloading': 'downloading', 'Seeding': 'seeding', 'Paused': 'paused',
'Checking': 'checking', 'Queued': 'queued', 'Error': 'failed', 'Moving': 'downloading',
};
return map[state] || 'downloading';
}
private extractHashFromMagnet(magnetUrl: string): string | null {
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
return match ? match[1].toLowerCase() : null;
}
}
// Singleton factory (matches Transmission/qBittorrent pattern)
let delugeServiceInstance: DelugeService | null = null;
let configLoaded = false;
export async function getDelugeService(): Promise<DelugeService> {
if (delugeServiceInstance && configLoaded) return delugeServiceInstance;
try {
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
const clientConfig = await manager.getClientForProtocol('torrent');
if (!clientConfig) throw new Error('Deluge is not configured. Please configure a Deluge client in admin settings.');
if (clientConfig.type !== 'deluge') throw new Error(`Expected Deluge client but found ${clientConfig.type}`);
if (!clientConfig.url) throw new Error('Deluge is not fully configured. Check your configuration in admin settings.');
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath ? require('path').join(baseDir, clientConfig.customPath) : baseDir;
delugeServiceInstance = new DelugeService(
clientConfig.url, clientConfig.username || '', clientConfig.password || '',
downloadDir, clientConfig.category || 'readmeabook', clientConfig.disableSSLVerify,
{ enabled: clientConfig.remotePathMappingEnabled || false, remotePath: clientConfig.remotePath || '', localPath: clientConfig.localPath || '' }
);
const result = await delugeServiceInstance.testConnection();
if (!result.success) throw new Error(result.message || 'Deluge connection test failed.');
logger.info('[Deluge] Connection test successful');
configLoaded = true;
return delugeServiceInstance;
} catch (error) {
logger.error('[Deluge] Failed to initialize service', { error: error instanceof Error ? error.message : String(error) });
delugeServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateDelugeService(): void {
delugeServiceInstance = null;
configLoaded = false;
logger.info('[Deluge] Service singleton invalidated');
}