6fa9c79a7d
Build and Push Docker Image / build (push) Successful in 59s
Docs Check / Markdown lint (push) Failing after 1m45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m7s
CI / Security audit (push) Successful in 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 2m55s
CI / Swagger Validation & Coverage (push) Successful in 3m19s
CI / Tests & coverage (push) Successful in 3m29s
- RTorrentClient: guard d.multicall2 returning non-array, per-row try/catch, explicit Number()/String() coercions, _extractArrInfo null-safe - RTorrentClient.getClientStatus: coerce rates through Number.isFinite - SABnzbdClient: history limit now reads SAB_HISTORY_LIMIT env var (default 10) - DownloadClient: added _recordLastError, _clearLastError, getLastError on base - All four clients call _recordLastError on failure, _clearLastError on success - DownloadClientRegistry.getAllClientStatuses: includes lastError in result - GET /api/status/status: exposes downloadClients[] array with per-client lastError - Tests: RTorrentClient null-safety + lastError, SABnzbd history limit + lastError, downloadClients.test expectation updated for new lastError field
236 lines
7.1 KiB
JavaScript
236 lines
7.1 KiB
JavaScript
// 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<any>}
|
|
*/
|
|
_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;
|