// Copyright (c) 2026 Gordon Bolton. MIT License. const xmlrpc = require('xmlrpc'); const DownloadClient = require('./DownloadClient'); const { logToFile } = require('../utils/logger'); /** * rTorrent download client implementation. * Communicates via XML-RPC over HTTP. * Supports HTTP Basic Auth when username/password are configured. * The URL field must include the full XML-RPC endpoint path (e.g., http://rtorrent.local:8080/RPC2 or https://user.whatbox.ca/xmlrpc). */ class RTorrentClient extends DownloadClient { constructor(instance) { super(instance); this._createClient(); } _createClient() { const clientOptions = { url: this.url }; if (this.username && this.password) { clientOptions.headers = { Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}` }; } this.client = xmlrpc.createClient(clientOptions); } getClientType() { return 'rtorrent'; } async testConnection() { try { await this._methodCall('system.client_version'); logToFile(`[rtorrent:${this.name}] Connection test successful`); return true; } catch (error) { logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`); return false; } } /** * Wrap xmlrpc methodCall in a Promise. * @param {string} method - XML-RPC method name * @param {Array} params - Method parameters * @returns {Promise} */ _methodCall(method, params = []) { return new Promise((resolve, reject) => { this.client.methodCall(method, params, (error, value) => { if (error) { reject(error); } else { resolve(value); } }); }); } async getActiveDownloads() { try { const torrents = await this._methodCall('d.multicall2', [ '', 'd.hash=', 'd.name=', 'd.size_bytes=', 'd.completed_bytes=', 'd.down.rate=', 'd.up.rate=', 'd.state=', 'd.is_active=', 'd.is_hash_checking=', 'd.directory=', 'd.custom1=' ]); logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`); return torrents.map(torrent => this.normalizeDownload(torrent)); } catch (error) { logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`); return []; } } async getClientStatus() { try { const [downRate, upRate] = await Promise.all([ this._methodCall('throttle.global_down.rate'), this._methodCall('throttle.global_up.rate') ]); return { globalDownRate: downRate, globalUpRate: upRate }; } catch (error) { logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`); return null; } } normalizeDownload(torrent) { const [ hash, name, sizeBytes, completedBytes, downRate, upRate, state, isActive, isHashChecking, directory, custom1 ] = torrent; const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes); const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0; // Calculate ETA when actively downloading let eta = null; if (status === 'Downloading' && downRate > 0 && completedBytes < sizeBytes) { eta = Math.round((sizeBytes - completedBytes) / downRate); } const arrInfo = this._extractArrInfo(name); return { id: hash, title: name, type: 'torrent', client: 'rtorrent', instanceId: this.id, instanceName: this.name, status, progress, size: sizeBytes, downloaded: completedBytes, speed: status === 'Seeding' ? upRate : downRate, eta, category: custom1 || undefined, tags: custom1 ? [custom1] : [], savePath: directory || undefined, addedOn: undefined, // rtorrent does not expose added time via multicall2 arrQueueId: arrInfo.queueId, arrType: arrInfo.type, raw: torrent }; } _mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes) { if (isHashChecking === 1) { return 'Checking'; } if (state === 0) { return 'Stopped'; } if (isActive === 1) { return completedBytes >= sizeBytes && sizeBytes > 0 ? 'Seeding' : 'Downloading'; } return 'Paused'; } _extractArrInfo(filename) { const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i); if (seriesMatch) { return { type: 'series' }; } const movieMatch = filename.match(/\((\d{4})\)/); if (movieMatch) { return { type: 'movie' }; } return {}; } } module.exports = RTorrentClient;