Some checks failed
Build and Push Docker Image / build (push) Failing after 40s
CI / Security audit (push) Failing after 27s
CI / Tests & coverage (push) Failing after 35s
Docs Check / Markdown lint (push) Successful in 32s
Docs Check / Mermaid diagram parse check (push) Successful in 1m12s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 24s
- Implement RTorrentClient extending DownloadClient abstract class - Use xmlrpc package (v1.3.2) for XML-RPC communication - Support HTTP Basic Auth when credentials are configured - Map rTorrent states (d.state, d.is_active, d.is_hash_checking) to normalized statuses - Calculate ETA from download speed and remaining bytes - Add getRtorrentInstances() to config.js - Register RTorrentClient in downloadClients.js registry - Add 8 comprehensive unit tests covering all functionality - Update .env.sample with rtorrent configuration examples - Update ARCHITECTURE.md with rtorrent client details - Update ADDING-A-DOWNLOAD-CLIENT.md with rtorrent-specific notes
186 lines
4.6 KiB
JavaScript
186 lines
4.6 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 (typically ${url}/RPC2).
|
|
* Supports HTTP Basic Auth when username/password are configured.
|
|
*/
|
|
class RTorrentClient extends DownloadClient {
|
|
constructor(instance) {
|
|
super(instance);
|
|
this.rpcUrl = `${this.url}/RPC2`;
|
|
this._createClient();
|
|
}
|
|
|
|
_createClient() {
|
|
const clientOptions = { url: this.rpcUrl };
|
|
|
|
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<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='
|
|
]);
|
|
|
|
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;
|