fix: rTorrent null-safety, configurable SAB_HISTORY_LIMIT, lastError visibility (#68)
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
This commit is contained in:
2026-05-28 16:22:11 +01:00
parent 3d49c926dc
commit 6fa9c79a7d
11 changed files with 284 additions and 22 deletions
+64 -14
View File
@@ -35,9 +35,11 @@ class RTorrentClient extends DownloadClient {
try {
await this._methodCall('system.client_version');
logToFile(`[rtorrent:${this.name}] Connection test successful`);
this._clearLastError();
return true;
} catch (error) {
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
this._recordLastError('testConnection', error);
return false;
}
}
@@ -77,10 +79,31 @@ class RTorrentClient extends DownloadClient {
'd.custom1='
]);
// rTorrent XML-RPC can occasionally return null/undefined or a non-array
// on misconfigured servers or transient errors. Guard against that here
// so callers always get a sane array instead of throwing on .map.
if (!Array.isArray(torrents)) {
logToFile(`[rtorrent:${this.name}] d.multicall2 returned non-array (${typeof torrents}); treating as empty`);
this._clearLastError();
return [];
}
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
this._clearLastError();
// Filter out any individual rows that fail to normalize so a single bad
// record cannot poison the whole result set.
const normalized = [];
for (const torrent of torrents) {
try {
normalized.push(this.normalizeDownload(torrent));
} catch (err) {
logToFile(`[rtorrent:${this.name}] Skipping malformed torrent row: ${err.message}`);
}
}
return normalized;
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
this._recordLastError('getActiveDownloads', error);
return [];
}
}
@@ -92,31 +115,53 @@ class RTorrentClient extends DownloadClient {
this._methodCall('throttle.global_up.rate')
]);
this._clearLastError();
return {
globalDownRate: downRate,
globalUpRate: upRate
globalDownRate: Number.isFinite(downRate) ? downRate : 0,
globalUpRate: Number.isFinite(upRate) ? upRate : 0
};
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
this._recordLastError('getClientStatus', error);
return null;
}
}
normalizeDownload(torrent) {
// rTorrent's d.multicall2 returns an array of fields in the order requested.
// If a value is missing rtorrent typically returns '' or 0, but plugins and
// older versions can return undefined/null — coerce everything explicitly so
// downstream math and string ops never blow up on null/undefined.
if (!Array.isArray(torrent)) {
throw new Error('Expected torrent row to be an array');
}
const [
hash,
name,
sizeBytes,
completedBytes,
downRate,
upRate,
state,
isActive,
isHashChecking,
directory,
custom1
hashRaw,
nameRaw,
sizeBytesRaw,
completedBytesRaw,
downRateRaw,
upRateRaw,
stateRaw,
isActiveRaw,
isHashCheckingRaw,
directoryRaw,
custom1Raw
] = torrent;
const hash = hashRaw ? String(hashRaw) : '';
const name = nameRaw ? String(nameRaw) : '';
const sizeBytes = Number(sizeBytesRaw) || 0;
const completedBytes = Number(completedBytesRaw) || 0;
const downRate = Number(downRateRaw) || 0;
const upRate = Number(upRateRaw) || 0;
const state = Number.isFinite(Number(stateRaw)) ? Number(stateRaw) : 0;
const isActive = Number.isFinite(Number(isActiveRaw)) ? Number(isActiveRaw) : 0;
const isHashChecking = Number.isFinite(Number(isHashCheckingRaw)) ? Number(isHashCheckingRaw) : 0;
const directory = directoryRaw ? String(directoryRaw) : '';
const custom1 = custom1Raw ? String(custom1Raw) : '';
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
@@ -168,6 +213,11 @@ class RTorrentClient extends DownloadClient {
}
_extractArrInfo(filename) {
// Null-safe: getActiveDownloads passes a normalized string, but guard anyway
// so callers passing raw rtorrent values cannot crash this helper.
if (!filename || typeof filename !== 'string') {
return {};
}
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };