Some checks failed
Build and Push Docker Image / build (push) Failing after 40s
CI / Security audit (push) Failing after 45s
CI / Tests & coverage (push) Failing after 55s
Docs Check / Markdown lint (push) Successful in 1m1s
Docs Check / Mermaid diagram parse check (push) Successful in 1m23s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 28s
- Remove auto-appending of /RPC2 from RTorrentClient constructor - Use exact URL from config (supports custom paths like whatbox.ca/xmlrpc) - Update .env.sample with clear URL path documentation and examples - Update README.md with comprehensive PDCA section and all download clients - Add URL path verification tests (whatbox.ca, custom paths, no auth) - Update architecture diagram to include Transmission and rTorrent - Update Docker Compose example to include all download clients - Update prerequisites to mention all supported download clients - Update "What It Does" and "The Matching Process" sections
186 lines
4.7 KiB
JavaScript
186 lines
4.7 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`);
|
|
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;
|