// 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`); this._clearLastError(); return true; } catch (error) { logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`); this._recordLastError('testConnection', error); 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=' ]); // rTorrent XML-RPC can occasionally return null/undefined or a non-array // on misconfigured servers or transient errors. Guard against that here // so callers always get a sane array instead of throwing on .map. if (!Array.isArray(torrents)) { logToFile(`[rtorrent:${this.name}] d.multicall2 returned non-array (${typeof torrents}); treating as empty`); this._clearLastError(); return []; } logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`); this._clearLastError(); // Filter out any individual rows that fail to normalize so a single bad // record cannot poison the whole result set. const normalized = []; for (const torrent of torrents) { try { normalized.push(this.normalizeDownload(torrent)); } catch (err) { logToFile(`[rtorrent:${this.name}] Skipping malformed torrent row: ${err.message}`); } } return normalized; } catch (error) { logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`); this._recordLastError('getActiveDownloads', error); return []; } } async getClientStatus() { try { const [downRate, upRate] = await Promise.all([ this._methodCall('throttle.global_down.rate'), this._methodCall('throttle.global_up.rate') ]); this._clearLastError(); return { globalDownRate: Number.isFinite(downRate) ? downRate : 0, globalUpRate: Number.isFinite(upRate) ? upRate : 0 }; } catch (error) { logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`); this._recordLastError('getClientStatus', error); return null; } } normalizeDownload(torrent) { // rTorrent's d.multicall2 returns an array of fields in the order requested. // If a value is missing rtorrent typically returns '' or 0, but plugins and // older versions can return undefined/null — coerce everything explicitly so // downstream math and string ops never blow up on null/undefined. if (!Array.isArray(torrent)) { throw new Error('Expected torrent row to be an array'); } const [ hashRaw, nameRaw, sizeBytesRaw, completedBytesRaw, downRateRaw, upRateRaw, stateRaw, isActiveRaw, isHashCheckingRaw, directoryRaw, custom1Raw ] = torrent; const hash = hashRaw ? String(hashRaw) : ''; const name = nameRaw ? String(nameRaw) : ''; const sizeBytes = Number(sizeBytesRaw) || 0; const completedBytes = Number(completedBytesRaw) || 0; const downRate = Number(downRateRaw) || 0; const upRate = Number(upRateRaw) || 0; const state = Number.isFinite(Number(stateRaw)) ? Number(stateRaw) : 0; const isActive = Number.isFinite(Number(isActiveRaw)) ? Number(isActiveRaw) : 0; const isHashChecking = Number.isFinite(Number(isHashCheckingRaw)) ? Number(isHashCheckingRaw) : 0; const directory = directoryRaw ? String(directoryRaw) : ''; const custom1 = custom1Raw ? String(custom1Raw) : ''; 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) { // Null-safe: getActiveDownloads passes a normalized string, but guard anyway // so callers passing raw rtorrent values cannot crash this helper. if (!filename || typeof filename !== 'string') { return {}; } 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;