// Copyright (c) 2026 Gordon Bolton. MIT License. const axios = require('axios'); const DownloadClient = require('./DownloadClient'); const { logToFile } = require('../utils/logger'); class SABnzbdClient extends DownloadClient { constructor(instance) { super(instance); } getClientType() { return 'sabnzbd'; } async testConnection() { try { const response = await this.makeRequest('', { mode: 'version' }); logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`); return true; } catch (error) { logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`); 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: 10 }) ]); const queueData = queueResponse.data; const historyData = historyResponse.data; const downloads = []; // Process active queue items if (queueData.queue && queueData.queue.slots) { for (const slot of queueData.queue.slots) { downloads.push(this.normalizeDownload(slot, 'queue')); } } // 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')); } } logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`); return downloads; } catch (error) { logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`); return []; } } async getClientStatus() { try { const response = await this.makeRequest({ mode: 'queue' }); const queueData = response.data.queue; if (!queueData) return null; 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}`); return null; } } normalizeDownload(slot, source) { const isHistory = source === 'history'; // 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; if (slot.mb && slot.mbleft !== undefined) { size = slot.mb * 1024 * 1024; // Convert MB to bytes downloaded = (slot.mb - slot.mbleft) * 1024 * 1024; progress = slot.mb > 0 ? ((slot.mb - slot.mbleft) / slot.mb) * 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: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s eta: this.calculateEta(slot.timeleft || slot.eta), category: slot.cat || undefined, tags: slot.labels ? slot.labels.split(',').filter(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;