diff --git a/.env.sample b/.env.sample index 38abe82..41d2560 100644 --- a/.env.sample +++ b/.env.sample @@ -94,6 +94,20 @@ QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","u # QBITTORRENT_USERNAME=admin # QBITTORRENT_PASSWORD=your-password +# ============================================================================= +# RTORRENT INSTANCES (JSON Array Format) +# Add one or more rTorrent instances as a single-line JSON array +# Uses username/password authentication (optional) +# Format: [{"name":"instance-name","url":"https://...","username":"...","password":"..."}] +# XML-RPC endpoint is automatically appended: ${url}/RPC2 +# ============================================================================= +# RTORRENT_INSTANCES=[{"name":"main","url":"https://rtorrent.example.com","username":"rtorrent","password":"rtorrent"}] + +# Legacy single-instance format (optional - still supported) +# RTORRENT_URL=https://rtorrent.example.com +# RTORRENT_USERNAME=rtorrent +# RTORRENT_PASSWORD=rtorrent + # ============================================================================= # SONARR INSTANCES (JSON Array Format) # Add one or more Sonarr instances as a single-line JSON array diff --git a/docs/ADDING-A-DOWNLOAD-CLIENT.md b/docs/ADDING-A-DOWNLOAD-CLIENT.md index b14f410..defb335 100644 --- a/docs/ADDING-A-DOWNLOAD-CLIENT.md +++ b/docs/ADDING-A-DOWNLOAD-CLIENT.md @@ -346,6 +346,18 @@ For a complete example, refer to the existing client implementations: - **SABnzbdClient.js**: Simple REST API client - **QBittorrentClient.js**: Complex client with sync API and fallback - **TransmissionClient.js**: JSON-RPC client with session management +- **RTorrentClient.js**: XML-RPC client with HTTP Basic Auth + +### rTorrent Specific Notes + +rTorrent uses XML-RPC over HTTP with the following specifics: + +- **Endpoint**: `${url}/RPC2` (most common) +- **Authentication**: HTTP Basic Auth (handled by reverse proxy or web server) +- **Primary Method**: `d.multicall2` for efficient bulk torrent data retrieval +- **Library**: Uses the `xmlrpc` package (v1.3.2) +- **Status Mapping**: Combines `d.state`, `d.is_active`, and `d.is_hash_checking` to determine status +- **ETA Calculation**: Computed from download speed and remaining bytes when actively downloading ## Troubleshooting diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8cdd9f0..ffc6757 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -326,6 +326,13 @@ interface NormalizedDownload { - Handles session ID management and conflict resolution - Demonstrates how easy it is to add new client types +#### RTorrentClient +- XML-RPC implementation for rTorrent daemon +- Uses the xmlrpc package (v1.3.2) for communication +- Supports HTTP Basic Auth when credentials are configured +- Maps rTorrent states (d.state, d.is_active, d.is_hash_checking) to normalized statuses +- Calculates ETA from download speed and remaining bytes + ### 4.4.5 Registry and Factory (`downloadClients.js`) The `DownloadClientRegistry` manages all client instances: diff --git a/package.json b/package.json index 4f9a695..bd34b7e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "express": "^4.18.2", "express-rate-limit": "^7.0.0", "helmet": "^7.0.0", - "jsdom": "^29.1.1" + "jsdom": "^29.1.1", + "xmlrpc": "^1.3.2" }, "devDependencies": { "@vitest/coverage-v8": "^4.1.6", diff --git a/server/clients/RTorrentClient.js b/server/clients/RTorrentClient.js new file mode 100644 index 0000000..ba28454 --- /dev/null +++ b/server/clients/RTorrentClient.js @@ -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} + */ + _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; diff --git a/server/utils/config.js b/server/utils/config.js index f09b9a7..35e1b71 100644 --- a/server/utils/config.js +++ b/server/utils/config.js @@ -104,12 +104,23 @@ function getTransmissionInstances() { ); } +function getRtorrentInstances() { + return parseInstances( + process.env.RTORRENT_INSTANCES, + process.env.RTORRENT_URL, + null, // no apiKey for rtorrent + process.env.RTORRENT_USERNAME, + process.env.RTORRENT_PASSWORD + ); +} + module.exports = { getSABnzbdInstances, getSonarrInstances, getRadarrInstances, getQbittorrentInstances, getTransmissionInstances, + getRtorrentInstances, parseInstances, validateInstanceUrl }; diff --git a/server/utils/downloadClients.js b/server/utils/downloadClients.js index dc0b697..60ea6ed 100644 --- a/server/utils/downloadClients.js +++ b/server/utils/downloadClients.js @@ -3,19 +3,22 @@ const { logToFile } = require('./logger'); const { getSABnzbdInstances, getQbittorrentInstances, - getTransmissionInstances + getTransmissionInstances, + getRtorrentInstances } = require('./config'); // Import client classes const SABnzbdClient = require('../clients/SABnzbdClient'); const QBittorrentClient = require('../clients/QBittorrentClient'); const TransmissionClient = require('../clients/TransmissionClient'); +const RTorrentClient = require('../clients/RTorrentClient'); // Client type mapping const clientClasses = { sabnzbd: SABnzbdClient, qbittorrent: QBittorrentClient, - transmission: TransmissionClient + transmission: TransmissionClient, + rtorrent: RTorrentClient }; /** @@ -41,12 +44,14 @@ class DownloadClientRegistry { const sabnzbdInstances = getSABnzbdInstances(); const qbittorrentInstances = getQbittorrentInstances(); const transmissionInstances = getTransmissionInstances(); + const rtorrentInstances = getRtorrentInstances(); // Create client instances const instanceConfigs = [ ...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })), ...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })), - ...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })) + ...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })), + ...rtorrentInstances.map(inst => ({ ...inst, type: 'rtorrent' })) ]; for (const config of instanceConfigs) { diff --git a/tests/unit/clients/RTorrentClient.test.js b/tests/unit/clients/RTorrentClient.test.js new file mode 100644 index 0000000..d2bd485 --- /dev/null +++ b/tests/unit/clients/RTorrentClient.test.js @@ -0,0 +1,420 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +const RTorrentClient = require('../../../server/clients/RTorrentClient'); +const xmlrpc = require('xmlrpc'); + +jest.mock('xmlrpc', () => ({ + createClient: jest.fn() +})); + +jest.mock('../../../server/utils/logger', () => ({ + logToFile: jest.fn() +})); + +describe('RTorrentClient', () => { + let client; + let mockConfig; + let mockMethodCall; + + beforeEach(() => { + mockMethodCall = jest.fn(); + xmlrpc.createClient.mockReturnValue({ + methodCall: mockMethodCall + }); + + mockConfig = { + id: 'test-rtorrent', + name: 'Test rTorrent', + url: 'http://localhost:8080', + username: 'rtorrent', + password: 'rtorrent' + }; + + client = new RTorrentClient(mockConfig); + jest.clearAllMocks(); + }); + + describe('Constructor', () => { + it('should initialize with correct properties', () => { + expect(client.getClientType()).toBe('rtorrent'); + expect(client.getInstanceId()).toBe('test-rtorrent'); + expect(client.name).toBe('Test rTorrent'); + expect(client.url).toBe('http://localhost:8080'); + expect(client.rpcUrl).toBe('http://localhost:8080/RPC2'); + }); + + it('should create xmlrpc client with basic auth when credentials provided', () => { + expect(xmlrpc.createClient).toHaveBeenCalledWith({ + url: 'http://localhost:8080/RPC2', + headers: { + Authorization: `Basic ${Buffer.from('rtorrent:rtorrent').toString('base64')}` + } + }); + }); + + it('should create xmlrpc client without auth when no credentials', () => { + xmlrpc.createClient.mockClear(); + const noAuthConfig = { + id: 'test-rtorrent-noauth', + name: 'Test rTorrent No Auth', + url: 'http://localhost:8080' + }; + new RTorrentClient(noAuthConfig); + expect(xmlrpc.createClient).toHaveBeenCalledWith({ + url: 'http://localhost:8080/RPC2' + }); + }); + }); + + describe('Connection Test', () => { + it('should test connection successfully', async () => { + mockMethodCall.mockImplementation((method, params, callback) => { + callback(null, '0.9.8'); + }); + + const result = await client.testConnection(); + + expect(result).toBe(true); + expect(mockMethodCall).toHaveBeenCalledWith( + 'system.client_version', + [], + expect.any(Function) + ); + }); + + it('should handle connection test failure', async () => { + mockMethodCall.mockImplementation((method, params, callback) => { + callback(new Error('Connection refused')); + }); + + const result = await client.testConnection(); + + expect(result).toBe(false); + }); + }); + + describe('getActiveDownloads', () => { + it('should fetch and normalize torrents', async () => { + const mockTorrents = [ + [ + 'abc123def456', + 'Test Torrent 1', + 1000000000, + 750000000, + 1048576, + 0, + 1, + 1, + 0, + '/downloads/test', + 'movies' + ], + [ + 'def789abc012', + 'Test Torrent 2', + 2000000000, + 2000000000, + 0, + 512000, + 1, + 1, + 0, + '/downloads/complete', + 'tv' + ] + ]; + + mockMethodCall.mockImplementation((method, params, callback) => { + callback(null, mockTorrents); + }); + + const downloads = await client.getActiveDownloads(); + + expect(downloads).toHaveLength(2); + expect(downloads[0].id).toBe('abc123def456'); + expect(downloads[0].title).toBe('Test Torrent 1'); + expect(downloads[0].status).toBe('Downloading'); + expect(downloads[0].progress).toBe(75); + expect(downloads[0].category).toBe('movies'); + expect(downloads[1].status).toBe('Seeding'); + expect(downloads[1].category).toBe('tv'); + }); + + it('should handle empty torrent list', async () => { + mockMethodCall.mockImplementation((method, params, callback) => { + callback(null, []); + }); + + const downloads = await client.getActiveDownloads(); + + expect(downloads).toEqual([]); + }); + + it('should handle XML-RPC errors gracefully', async () => { + mockMethodCall.mockImplementation((method, params, callback) => { + callback(new Error('XML-RPC fault')); + }); + + const downloads = await client.getActiveDownloads(); + + expect(downloads).toEqual([]); + }); + }); + + describe('normalizeDownload', () => { + it('should normalize a downloading torrent', () => { + const torrent = [ + 'hash123', + 'Downloading Torrent', + 1000000000, + 500000000, + 1048576, + 0, + 1, + 1, + 0, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + + expect(normalized).toEqual({ + id: 'hash123', + title: 'Downloading Torrent', + type: 'torrent', + client: 'rtorrent', + instanceId: 'test-rtorrent', + instanceName: 'Test rTorrent', + status: 'Downloading', + progress: 50, + size: 1000000000, + downloaded: 500000000, + speed: 1048576, + eta: 476, + category: undefined, + tags: [], + savePath: '/downloads', + addedOn: undefined, + arrQueueId: undefined, + arrType: undefined, + raw: torrent + }); + }); + + it('should normalize a seeding torrent', () => { + const torrent = [ + 'hash456', + 'Seeding Torrent', + 500000000, + 500000000, + 0, + 204800, + 1, + 1, + 0, + '/downloads/complete', + 'movies' + ]; + + const normalized = client.normalizeDownload(torrent); + + expect(normalized.status).toBe('Seeding'); + expect(normalized.progress).toBe(100); + expect(normalized.speed).toBe(204800); + expect(normalized.eta).toBeNull(); + expect(normalized.category).toBe('movies'); + expect(normalized.tags).toEqual(['movies']); + }); + + it('should normalize a paused torrent', () => { + const torrent = [ + 'hash789', + 'Paused Torrent', + 1000000000, + 250000000, + 0, + 0, + 1, + 0, + 0, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + + expect(normalized.status).toBe('Paused'); + expect(normalized.speed).toBe(0); + expect(normalized.eta).toBeNull(); + }); + + it('should normalize a stopped torrent', () => { + const torrent = [ + 'hashabc', + 'Stopped Torrent', + 1000000000, + 0, + 0, + 0, + 0, + 0, + 0, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + + expect(normalized.status).toBe('Stopped'); + }); + + it('should normalize a checking torrent', () => { + const torrent = [ + 'hashdef', + 'Checking Torrent', + 1000000000, + 500000000, + 0, + 0, + 1, + 0, + 1, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + + expect(normalized.status).toBe('Checking'); + }); + + it('should handle zero-size torrent', () => { + const torrent = [ + 'hash000', + 'Zero Size', + 0, + 0, + 0, + 0, + 1, + 1, + 0, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + + expect(normalized.progress).toBe(0); + expect(normalized.size).toBe(0); + expect(normalized.downloaded).toBe(0); + }); + }); + + describe('Status Mapping', () => { + const testCases = [ + { state: 0, isActive: 0, isHashChecking: 0, completed: 0, size: 100, expected: 'Stopped' }, + { state: 1, isActive: 1, isHashChecking: 0, completed: 50, size: 100, expected: 'Downloading' }, + { state: 1, isActive: 1, isHashChecking: 0, completed: 100, size: 100, expected: 'Seeding' }, + { state: 1, isActive: 0, isHashChecking: 0, completed: 50, size: 100, expected: 'Paused' }, + { state: 1, isActive: 0, isHashChecking: 0, completed: 100, size: 100, expected: 'Paused' }, + { state: 1, isActive: 0, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' }, + { state: 1, isActive: 1, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' } + ]; + + testCases.forEach(({ state, isActive, isHashChecking, completed, size, expected }) => { + it(`should map state=${state} isActive=${isActive} isHashChecking=${isHashChecking} to ${expected}`, () => { + const status = client._mapStatus(state, isActive, isHashChecking, completed, size); + expect(status).toBe(expected); + }); + }); + }); + + describe('ARR Info Extraction', () => { + it('should extract series info from filename', () => { + const torrent = [ + 'hash123', + 'Show Name - S01E02 - Episode Title', + 1000000000, + 500000000, + 1048576, + 0, + 1, + 1, + 0, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + expect(normalized.arrType).toBe('series'); + }); + + it('should extract movie info from filename', () => { + const torrent = [ + 'hash456', + 'Movie Title (2023) 1080p', + 2000000000, + 1000000000, + 1048576, + 0, + 1, + 1, + 0, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + expect(normalized.arrType).toBe('movie'); + }); + + it('should not extract ARR info from generic filename', () => { + const torrent = [ + 'hash789', + 'Generic File Name.mkv', + 1000000000, + 500000000, + 1048576, + 0, + 1, + 1, + 0, + '/downloads', + '' + ]; + + const normalized = client.normalizeDownload(torrent); + expect(normalized.arrType).toBeUndefined(); + }); + }); + + describe('Client Status', () => { + it('should get client status', async () => { + mockMethodCall.mockImplementation((method, params, callback) => { + if (method === 'throttle.global_down.rate') { + callback(null, 1048576); + } else if (method === 'throttle.global_up.rate') { + callback(null, 512000); + } + }); + + const status = await client.getClientStatus(); + + expect(status).toEqual({ + globalDownRate: 1048576, + globalUpRate: 512000 + }); + }); + + it('should handle status request errors', async () => { + mockMethodCall.mockImplementation((method, params, callback) => { + callback(new Error('Status error')); + }); + + const status = await client.getClientStatus(); + + expect(status).toBeNull(); + }); + }); +});