feat: add rtorrent client via PDCA
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
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
This commit is contained in:
185
server/clients/RTorrentClient.js
Normal file
185
server/clients/RTorrentClient.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user