6fa9c79a7d
Build and Push Docker Image / build (push) Successful in 59s
Docs Check / Markdown lint (push) Failing after 1m45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m7s
CI / Security audit (push) Successful in 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 2m55s
CI / Swagger Validation & Coverage (push) Successful in 3m19s
CI / Tests & coverage (push) Successful in 3m29s
- RTorrentClient: guard d.multicall2 returning non-array, per-row try/catch, explicit Number()/String() coercions, _extractArrInfo null-safe - RTorrentClient.getClientStatus: coerce rates through Number.isFinite - SABnzbdClient: history limit now reads SAB_HISTORY_LIMIT env var (default 10) - DownloadClient: added _recordLastError, _clearLastError, getLastError on base - All four clients call _recordLastError on failure, _clearLastError on success - DownloadClientRegistry.getAllClientStatuses: includes lastError in result - GET /api/status/status: exposes downloadClients[] array with per-client lastError - Tests: RTorrentClient null-safety + lastError, SABnzbd history limit + lastError, downloadClients.test expectation updated for new lastError field
228 lines
7.5 KiB
JavaScript
228 lines
7.5 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||
const axios = require('axios');
|
||
const DownloadClient = require('./DownloadClient');
|
||
const { logToFile } = require('../utils/logger');
|
||
|
||
class TransmissionClient extends DownloadClient {
|
||
constructor(instance) {
|
||
super(instance);
|
||
this.sessionId = null;
|
||
this.rpcUrl = `${this.url}/transmission/rpc`;
|
||
}
|
||
|
||
getClientType() {
|
||
return 'transmission';
|
||
}
|
||
|
||
async testConnection() {
|
||
try {
|
||
await this.makeRequest('session-get');
|
||
logToFile(`[Transmission:${this.name}] Connection test successful`);
|
||
this._clearLastError();
|
||
return true;
|
||
} catch (error) {
|
||
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
|
||
this._recordLastError('testConnection', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async makeRequest(method, arguments_ = {}, config = {}) {
|
||
const payload = {
|
||
method,
|
||
arguments: arguments_
|
||
};
|
||
|
||
const headers = {
|
||
'Content-Type': 'application/json'
|
||
};
|
||
|
||
if (this.sessionId) {
|
||
headers['X-Transmission-Session-Id'] = this.sessionId;
|
||
}
|
||
|
||
try {
|
||
const response = await axios.post(this.rpcUrl, payload, {
|
||
headers,
|
||
...config
|
||
});
|
||
|
||
if (response.data.result !== 'success') {
|
||
throw new Error(`Transmission RPC error: ${response.data.result}`);
|
||
}
|
||
|
||
return response;
|
||
} catch (error) {
|
||
// Handle session ID conflict (409 Conflict)
|
||
if (error.response && error.response.status === 409) {
|
||
const sessionId = error.response.headers['x-transmission-session-id'];
|
||
if (sessionId) {
|
||
this.sessionId = sessionId;
|
||
logToFile(`[Transmission:${this.name}] Updated session ID`);
|
||
return this.makeRequest(method, arguments_, config);
|
||
}
|
||
}
|
||
logToFile(`[Transmission:${this.name}] RPC request failed: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getActiveDownloads() {
|
||
try {
|
||
// Get all torrents with detailed fields
|
||
const response = await this.makeRequest('torrent-get', {
|
||
fields: [
|
||
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
|
||
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
|
||
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
|
||
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
|
||
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
|
||
]
|
||
});
|
||
|
||
const torrents = response.data.arguments.torrents || [];
|
||
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
|
||
this._clearLastError();
|
||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||
} catch (error) {
|
||
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
|
||
this._recordLastError('getActiveDownloads', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async getClientStatus() {
|
||
try {
|
||
const response = await this.makeRequest('session-get');
|
||
const sessionStats = await this.makeRequest('session-stats');
|
||
|
||
this._clearLastError();
|
||
return {
|
||
session: response.data.arguments,
|
||
stats: sessionStats.data.arguments
|
||
};
|
||
} catch (error) {
|
||
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
|
||
this._recordLastError('getClientStatus', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
normalizeDownload(torrent) {
|
||
// Map Transmission status codes to normalized status
|
||
const statusMap = {
|
||
0: 'Stopped', // TORRENT_STOPPED
|
||
1: 'Queued', // TORRENT_CHECK_WAIT
|
||
2: 'Checking', // TORRENT_CHECK
|
||
3: 'Queued', // TORRENT_DOWNLOAD_WAIT
|
||
4: 'Downloading', // TORRENT_DOWNLOAD
|
||
5: 'Queued', // TORRENT_SEED_WAIT
|
||
6: 'Seeding', // TORRENT_SEED
|
||
// Status code 7 is undocumented in the Transmission RPC spec (which
|
||
// formally defines only 0–6). The legacy alias "TORRENT_IS_CHECKING"
|
||
// (a duplicate of code 2) is the best-effort interpretation; map it to
|
||
// `Checking` so it is rendered usefully rather than as `Unknown`.
|
||
7: 'Checking' // TORRENT_IS_CHECKING (undocumented; treated as code 2)
|
||
};
|
||
|
||
const status = statusMap[torrent.status] || 'Unknown';
|
||
|
||
// Calculate progress and sizes
|
||
const progress = torrent.percentDone * 100;
|
||
const size = torrent.totalSize;
|
||
const downloaded = torrent.sizeWhenDone - torrent.leftUntilDone;
|
||
|
||
// Handle ETA - Transmission uses -1 for unknown, -2 for infinite
|
||
let eta = null;
|
||
if (torrent.eta >= 0) {
|
||
eta = torrent.eta;
|
||
}
|
||
|
||
// Extract category/labels
|
||
const labels = torrent.labels || [];
|
||
const category = labels.length > 0 ? labels[0] : undefined;
|
||
|
||
// Try to extract Sonarr/Radarr info from name
|
||
const arrInfo = this.extractArrInfo(torrent.name);
|
||
|
||
return {
|
||
id: torrent.hashString,
|
||
title: torrent.name,
|
||
type: 'torrent',
|
||
client: 'transmission',
|
||
instanceId: this.id,
|
||
instanceName: this.name,
|
||
status: status,
|
||
progress: Math.round(progress),
|
||
size: size,
|
||
downloaded: downloaded,
|
||
speed: torrent.rateDownload,
|
||
eta: eta,
|
||
category: category,
|
||
tags: labels,
|
||
savePath: torrent.downloadDir || undefined,
|
||
addedOn: torrent.addedDate ? new Date(torrent.addedDate * 1000).toISOString() : undefined,
|
||
arrQueueId: arrInfo.queueId,
|
||
arrType: arrInfo.type,
|
||
raw: torrent
|
||
};
|
||
}
|
||
|
||
extractArrInfo(filename) {
|
||
// arrQueueId cannot be extracted from filename alone; *arr exposes that
|
||
// identifier only via its queue API. The reliable cross-client matching
|
||
// path is hash-based and lives in `DownloadMatcher.matchTorrents()` (see
|
||
// Issue #65), which keys on `torrent.hashString` for Transmission.
|
||
// This heuristic remains only to provide a coarse `type` hint.
|
||
|
||
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||
if (seriesMatch) {
|
||
return { type: 'series' };
|
||
}
|
||
|
||
// Look for movie year patterns like "Movie Title (2023)"
|
||
const movieMatch = filename.match(/\((\d{4})\)/);
|
||
if (movieMatch && !seriesMatch) {
|
||
return { type: 'movie' };
|
||
}
|
||
|
||
return {};
|
||
}
|
||
|
||
/**
|
||
* Start (resume) one or more torrents. `id` is the Transmission internal
|
||
* numeric id or a hashString; the RPC accepts either.
|
||
* @param {number|string|Array<number|string>} id
|
||
*/
|
||
async startTorrent(id) {
|
||
const ids = Array.isArray(id) ? id : [id];
|
||
await this.makeRequest('torrent-start', { ids });
|
||
logToFile(`[Transmission:${this.name}] Started torrent(s): ${ids.join(', ')}`);
|
||
}
|
||
|
||
/**
|
||
* Stop (pause) one or more torrents.
|
||
* @param {number|string|Array<number|string>} id
|
||
*/
|
||
async stopTorrent(id) {
|
||
const ids = Array.isArray(id) ? id : [id];
|
||
await this.makeRequest('torrent-stop', { ids });
|
||
logToFile(`[Transmission:${this.name}] Stopped torrent(s): ${ids.join(', ')}`);
|
||
}
|
||
|
||
/**
|
||
* Remove one or more torrents. When `deleteData` is true the local files
|
||
* are also deleted from disk (Transmission's `delete-local-data`).
|
||
* @param {number|string|Array<number|string>} id
|
||
* @param {boolean} [deleteData=false]
|
||
*/
|
||
async removeTorrent(id, deleteData = false) {
|
||
const ids = Array.isArray(id) ? id : [id];
|
||
await this.makeRequest('torrent-remove', { ids, 'delete-local-data': !!deleteData });
|
||
logToFile(`[Transmission:${this.name}] Removed torrent(s): ${ids.join(', ')} (deleteData=${!!deleteData})`);
|
||
}
|
||
}
|
||
|
||
module.exports = TransmissionClient;
|