feat: add rtorrent client via PDCA
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
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:
+14
@@ -94,6 +94,20 @@ QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","u
|
|||||||
# QBITTORRENT_USERNAME=admin
|
# QBITTORRENT_USERNAME=admin
|
||||||
# QBITTORRENT_PASSWORD=your-password
|
# 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)
|
# SONARR INSTANCES (JSON Array Format)
|
||||||
# Add one or more Sonarr instances as a single-line JSON array
|
# Add one or more Sonarr instances as a single-line JSON array
|
||||||
|
|||||||
@@ -346,6 +346,18 @@ For a complete example, refer to the existing client implementations:
|
|||||||
- **SABnzbdClient.js**: Simple REST API client
|
- **SABnzbdClient.js**: Simple REST API client
|
||||||
- **QBittorrentClient.js**: Complex client with sync API and fallback
|
- **QBittorrentClient.js**: Complex client with sync API and fallback
|
||||||
- **TransmissionClient.js**: JSON-RPC client with session management
|
- **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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,13 @@ interface NormalizedDownload {
|
|||||||
- Handles session ID management and conflict resolution
|
- Handles session ID management and conflict resolution
|
||||||
- Demonstrates how easy it is to add new client types
|
- 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`)
|
### 4.4.5 Registry and Factory (`downloadClients.js`)
|
||||||
|
|
||||||
The `DownloadClientRegistry` manages all client instances:
|
The `DownloadClientRegistry` manages all client instances:
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,8 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.0.0",
|
"express-rate-limit": "^7.0.0",
|
||||||
"helmet": "^7.0.0",
|
"helmet": "^7.0.0",
|
||||||
"jsdom": "^29.1.1"
|
"jsdom": "^29.1.1",
|
||||||
|
"xmlrpc": "^1.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-v8": "^4.1.6",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 = {
|
module.exports = {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances,
|
getRadarrInstances,
|
||||||
getQbittorrentInstances,
|
getQbittorrentInstances,
|
||||||
getTransmissionInstances,
|
getTransmissionInstances,
|
||||||
|
getRtorrentInstances,
|
||||||
parseInstances,
|
parseInstances,
|
||||||
validateInstanceUrl
|
validateInstanceUrl
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,19 +3,22 @@ const { logToFile } = require('./logger');
|
|||||||
const {
|
const {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getQbittorrentInstances,
|
getQbittorrentInstances,
|
||||||
getTransmissionInstances
|
getTransmissionInstances,
|
||||||
|
getRtorrentInstances
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
|
|
||||||
// Import client classes
|
// Import client classes
|
||||||
const SABnzbdClient = require('../clients/SABnzbdClient');
|
const SABnzbdClient = require('../clients/SABnzbdClient');
|
||||||
const QBittorrentClient = require('../clients/QBittorrentClient');
|
const QBittorrentClient = require('../clients/QBittorrentClient');
|
||||||
const TransmissionClient = require('../clients/TransmissionClient');
|
const TransmissionClient = require('../clients/TransmissionClient');
|
||||||
|
const RTorrentClient = require('../clients/RTorrentClient');
|
||||||
|
|
||||||
// Client type mapping
|
// Client type mapping
|
||||||
const clientClasses = {
|
const clientClasses = {
|
||||||
sabnzbd: SABnzbdClient,
|
sabnzbd: SABnzbdClient,
|
||||||
qbittorrent: QBittorrentClient,
|
qbittorrent: QBittorrentClient,
|
||||||
transmission: TransmissionClient
|
transmission: TransmissionClient,
|
||||||
|
rtorrent: RTorrentClient
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,12 +44,14 @@ class DownloadClientRegistry {
|
|||||||
const sabnzbdInstances = getSABnzbdInstances();
|
const sabnzbdInstances = getSABnzbdInstances();
|
||||||
const qbittorrentInstances = getQbittorrentInstances();
|
const qbittorrentInstances = getQbittorrentInstances();
|
||||||
const transmissionInstances = getTransmissionInstances();
|
const transmissionInstances = getTransmissionInstances();
|
||||||
|
const rtorrentInstances = getRtorrentInstances();
|
||||||
|
|
||||||
// Create client instances
|
// Create client instances
|
||||||
const instanceConfigs = [
|
const instanceConfigs = [
|
||||||
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
|
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
|
||||||
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
|
...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) {
|
for (const config of instanceConfigs) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user