Files
sofarr/server/clients/SABnzbdClient.js
T
gronod 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
fix: rTorrent null-safety, configurable SAB_HISTORY_LIMIT, lastError visibility (#68)
- 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
2026-05-28 16:22:11 +01:00

303 lines
9.9 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
// Number of recently completed jobs to pull from SABnzbd's /api?mode=history on
// every poll. Larger values let DownloadMatcher correlate slightly older jobs
// with their Sonarr/Radarr queue entries at the cost of one wider HTTP
// response per poll cycle. Configurable via the SAB_HISTORY_LIMIT environment
// variable; defaults to 10 to match the previous hardcoded value.
const DEFAULT_HISTORY_LIMIT = 10;
function resolveHistoryLimit() {
const raw = process.env.SAB_HISTORY_LIMIT;
if (raw === undefined || raw === null || raw === '') return DEFAULT_HISTORY_LIMIT;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
logToFile(`[SABnzbd] Invalid SAB_HISTORY_LIMIT='${raw}'; falling back to ${DEFAULT_HISTORY_LIMIT}`);
return DEFAULT_HISTORY_LIMIT;
}
return parsed;
}
class SABnzbdClient extends DownloadClient {
constructor(instance) {
super(instance);
this.historyLimit = resolveHistoryLimit();
}
getClientType() {
return 'sabnzbd';
}
async testConnection() {
try {
const response = await this.makeRequest('', { mode: 'version' });
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
this._clearLastError();
return true;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
this._recordLastError('testConnection', error);
return false;
}
}
async makeRequest(additionalParams = {}, config = {}) {
const params = {
output: 'json',
apikey: this.apiKey,
...additionalParams
};
try {
const response = await axios.get(`${this.url}/api`, {
params,
...config
});
return response;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] API request failed: ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
// Get both queue and history to provide complete picture
const [queueResponse, historyResponse] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: this.historyLimit })
]);
const queueData = queueResponse.data;
const historyData = historyResponse.data;
// Extract client status directly from the fetched queue data instead of making a redundant HTTP request
let clientStatus = null;
if (queueData && queueData.queue) {
const q = queueData.queue;
clientStatus = {
status: q.status,
speed: q.speed,
kbpersec: q.kbpersec,
sizeleft: q.sizeleft,
mbleft: q.mbleft,
mb: q.mb,
diskspace1: q.diskspace1,
diskspace2: q.diskspace2,
loadavg: q.loadavg,
pause_int: q.pause_int
};
}
const downloads = [];
// Process active queue items
if (queueData.queue && queueData.queue.slots) {
const kbpersec = (queueData.queue && queueData.queue.kbpersec) || (clientStatus && clientStatus.kbpersec) || 0;
const globalSpeed = parseFloat(kbpersec) * 1024;
logToFile(`[SABnzbd:${this.name}] Global Speed: ${globalSpeed}, Client status: ${clientStatus ? JSON.stringify({ kbpersec: clientStatus.kbpersec }) : 'none'}`);
for (const slot of queueData.queue.slots) {
let slotSpeed = 0;
if (slot.status === 'Downloading') {
slotSpeed = globalSpeed;
} else if (slot.status === 'Paused' && slot.kbpersec && parseFloat(slot.kbpersec) > 0) {
slotSpeed = globalSpeed;
}
logToFile(`[SABnzbd:${this.name}] Slot ${slot.nzo_id} status ${slot.status}, speed ${slotSpeed}`);
downloads.push(this.normalizeDownload(slot, 'queue', slotSpeed));
}
}
// Process recent history items (last 10)
if (historyData.history && historyData.history.slots) {
for (const slot of historyData.history.slots) {
downloads.push(this.normalizeDownload(slot, 'history', 0));
}
}
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads (historyLimit=${this.historyLimit})`);
this._clearLastError();
return downloads;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
this._recordLastError('getActiveDownloads', error);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest({ mode: 'queue' });
const queueData = response.data.queue;
if (!queueData) {
this._clearLastError();
return null;
}
this._clearLastError();
return {
status: queueData.status,
speed: queueData.speed,
kbpersec: queueData.kbpersec,
sizeleft: queueData.sizeleft,
mbleft: queueData.mbleft,
mb: queueData.mb,
diskspace1: queueData.diskspace1,
diskspace2: queueData.diskspace2,
loadavg: queueData.loadavg,
pause_int: queueData.pause_int
};
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
this._recordLastError('getClientStatus', error);
return null;
}
}
normalizeDownload(slot, source, speed) {
const isHistory = source === 'history';
const finalSpeed = speed !== undefined ? speed : (slot.kbpersec ? parseFloat(slot.kbpersec) * 1024 : 0);
// Map SABnzbd statuses to normalized status
const statusMap = {
'Downloading': 'Downloading',
'Paused': 'Paused',
'Waiting': 'Queued',
'Completed': 'Completed',
'Failed': 'Error',
'Verifying': 'Checking',
'Extracting': 'Extracting',
'Moving': 'Moving',
'QuickCheck': 'Checking',
'Repairing': 'Repairing'
};
const status = statusMap[slot.status] || slot.status;
// Calculate progress
let progress = 0;
let downloaded = 0;
let size = 0;
const hasMb = slot.mb !== undefined && slot.mb !== null;
const hasMbLeft = slot.mbleft !== undefined && slot.mbleft !== null;
const mbValue = hasMb ? parseFloat(slot.mb) : 0;
const mbLeftValue = hasMbLeft ? parseFloat(slot.mbleft) : 0;
if (hasMb && hasMbLeft && mbValue !== 0) {
size = mbValue * 1024 * 1024; // Convert MB to bytes
downloaded = (mbValue - mbLeftValue) * 1024 * 1024;
progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
} else if (slot.size) {
// Try to parse size string (e.g., "1.5 GB")
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
if (sizeMatch) {
const [, sizeValue, sizeUnit] = sizeMatch;
const multiplier = this.getUnitMultiplier(sizeUnit);
size = parseFloat(sizeValue) * multiplier;
if (slot.sizeleft) {
const leftMatch = slot.sizeleft.match(/^([\d.]+)\s*(\w+)$/i);
if (leftMatch) {
const [, leftValue, leftUnit] = leftMatch;
const leftMultiplier = this.getUnitMultiplier(leftUnit);
downloaded = size - (parseFloat(leftValue) * leftMultiplier);
progress = size > 0 ? (downloaded / size) * 100 : 0;
}
}
}
}
// Extract Sonarr/Radarr info from nzb_name if present
const arrInfo = this.extractArrInfo(slot.nzb_name || slot.filename || '');
return {
id: slot.nzo_id || slot.id,
title: slot.filename || slot.nzb_name || 'Unknown',
type: 'usenet',
client: 'sabnzbd',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: Math.round(size),
downloaded: Math.round(downloaded),
speed: finalSpeed,
eta: this.calculateEta(slot.timeleft || slot.eta),
category: slot.cat || undefined,
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
savePath: slot.final_name || undefined,
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: { ...slot, source }
};
}
getUnitMultiplier(unit) {
const unitMap = {
'b': 1,
'byte': 1,
'bytes': 1,
'kb': 1024,
'k': 1024,
'mb': 1024 * 1024,
'm': 1024 * 1024,
'gb': 1024 * 1024 * 1024,
'g': 1024 * 1024 * 1024,
'tb': 1024 * 1024 * 1024 * 1024,
't': 1024 * 1024 * 1024 * 1024
};
return unitMap[unit.toLowerCase()] || 1;
}
calculateEta(timeLeft) {
if (!timeLeft || timeLeft === '0:00' || timeLeft === 'unknown') {
return null;
}
// Parse time in various formats: "0:05:30", "15:30", "330"
const parts = timeLeft.split(':').reverse();
let totalSeconds = 0;
if (parts.length === 1) {
// Just seconds
totalSeconds = parseInt(parts[0], 10);
} else if (parts.length === 2) {
// MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60;
} else if (parts.length === 3) {
// HH:MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60 + parseInt(parts[2], 10) * 3600;
}
return isNaN(totalSeconds) ? null : totalSeconds;
}
extractArrInfo(filename) {
// Try to extract Sonarr/Radarr info from filename patterns
// This is a simple implementation - could be enhanced with regex patterns
// 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 {};
}
}
module.exports = SABnzbdClient;