/** * Component: SABnzbd Integration Service * Documentation: documentation/phase3/sabnzbd.md */ import axios, { AxiosInstance } from 'axios'; import https from 'https'; import { RMABLogger } from '@/lib/utils/logger'; import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper'; const logger = RMABLogger.create('SABnzbd'); export interface AddNZBOptions { category?: string; priority?: 'low' | 'normal' | 'high' | 'force'; paused?: boolean; } export interface NZBInfo { nzbId: string; name: string; size: number; // Bytes progress: number; // 0.0 to 1.0 status: NZBStatus; downloadSpeed: number; // Bytes/sec timeLeft: number; // Seconds category: string; downloadPath?: string; completedAt?: Date; errorMessage?: string; } export type NZBStatus = | 'downloading' | 'queued' | 'paused' | 'extracting' | 'completed' | 'failed' | 'repairing'; export interface QueueItem { nzbId: string; name: string; size: number; // MB (converted to bytes in getNZB) sizeLeft: number; // MB percentage: number; // 0-100 status: string; // "Downloading", "Paused", "Queued" timeLeft: string; // "0:15:30" format category: string; priority: string; } export interface HistoryItem { nzbId: string; name: string; category: string; status: string; // "Completed", "Failed" bytes: string; // Size in bytes (as string) failMessage: string; storage: string; // Download path completedTimestamp: string; // Unix timestamp downloadTime: string; // Seconds (as string) } export interface SABnzbdConfig { version: string; categories: Array<{ name: string; dir: string; }>; completeDir: string; // SABnzbd's configured complete download folder } export interface DownloadProgress { percent: number; bytesDownloaded: number; bytesTotal: number; speed: number; eta: number; state: string; } export class SABnzbdService { private client: AxiosInstance; private baseUrl: string; private apiKey: string; private defaultCategory: string; private defaultDownloadDir: string; private disableSSLVerify: boolean; private httpsAgent?: https.Agent; private pathMappingConfig: PathMappingConfig; constructor( baseUrl: string, apiKey: string, defaultCategory: string = 'readmeabook', defaultDownloadDir: string = '/downloads', disableSSLVerify: boolean = false, pathMappingConfig?: PathMappingConfig ) { this.baseUrl = baseUrl.replace(/\/$/, ''); this.apiKey = apiKey?.trim() || ''; this.defaultCategory = defaultCategory; this.defaultDownloadDir = defaultDownloadDir; this.disableSSLVerify = disableSSLVerify; this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' }; // Configure HTTPS agent if SSL verification is disabled if (this.disableSSLVerify && this.baseUrl.startsWith('https')) { this.httpsAgent = new https.Agent({ rejectUnauthorized: false, }); } this.client = axios.create({ baseURL: this.baseUrl, timeout: 30000, httpsAgent: this.httpsAgent, }); } /** * Test connection to SABnzbd */ async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> { try { // Validate API key is not empty if (!this.apiKey || this.apiKey.trim() === '') { return { success: false, error: 'API key is required for SABnzbd', }; } // Use queue endpoint to test authentication (requires valid API key) const response = await this.client.get('/api', { params: { mode: 'queue', output: 'json', apikey: this.apiKey, }, }); // Check if SABnzbd returned an error (invalid API key) // SABnzbd can return errors in different formats: // - { status: false, error: "message" } // - { error: "message" } // - Plain text error if (response.data?.status === false || response.data?.error) { const errorMsg = response.data?.error || 'Authentication failed'; return { success: false, error: errorMsg.includes('API Key') ? 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).' : errorMsg, }; } // Queue endpoint requires auth - if we got here, API key is valid // Now get the version const version = await this.getVersion(); return { success: true, version }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; // Enhanced error messages for common issues if (errorMessage.includes('ECONNREFUSED')) { return { success: false, error: 'Connection refused. Is SABnzbd running and accessible at this URL?', }; } else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) { return { success: false, error: 'Connection timed out. Check the URL and network connectivity.', }; } else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) { return { success: false, error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.', }; } else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) { return { success: false, error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).', }; } return { success: false, error: errorMessage, }; } } /** * Get SABnzbd version */ async getVersion(): Promise { const response = await this.client.get('/api', { params: { mode: 'version', output: 'json', apikey: this.apiKey, }, }); if (response.data?.version) { return response.data.version; } throw new Error('Failed to get SABnzbd version'); } /** * Get SABnzbd configuration including complete download folder * * SABnzbd config structure: * - misc.complete_dir: The base folder where completed downloads are stored * - categories: Object mapping category names to their settings (dir is relative to complete_dir) */ async getConfig(): Promise { const response = await this.client.get('/api', { params: { mode: 'get_config', output: 'json', apikey: this.apiKey, }, }); const config = response.data?.config; if (!config) { throw new Error('Failed to get SABnzbd configuration'); } // Extract complete_dir from misc section // This is where SABnzbd stores completed downloads before category subdirectories are applied const completeDir = config.misc?.complete_dir || ''; logger.debug('SABnzbd config retrieved from API', { completeDir: completeDir || '(not configured)', downloadDir: config.misc?.download_dir || '(not set)', categoryCount: Object.keys(config.categories || {}).length, categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({ name, dir: details.dir || '(root)', })), }); return { version: config.version || '', completeDir, categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({ name, dir: details.dir || '', })), }; } /** * Get SABnzbd's complete download folder * This is the base directory where SABnzbd stores completed downloads */ async getCompleteDir(): Promise { const config = await this.getConfig(); return config.completeDir; } /** * Calculate the correct category path for SABnzbd * * SABnzbd categories use paths relative to complete_dir by default, but can also * accept absolute paths. This method calculates the correct path based on: * 1. SABnzbd's complete_dir setting * 2. RMAB's desired download path * 3. Remote path mapping (if enabled) * * @returns The path to set for the category (relative, absolute, or empty string) */ private calculateCategoryPath(completeDir: string, desiredPath: string): string { // Normalize paths for comparison (convert backslashes, remove trailing slashes) const normalizeForCompare = (p: string): string => { return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); }; const normalizedComplete = normalizeForCompare(completeDir); const normalizedDesired = normalizeForCompare(desiredPath); logger.debug('Path comparison (normalized)', { completeDir: { original: completeDir, normalized: normalizedComplete }, desiredPath: { original: desiredPath, normalized: normalizedDesired }, }); // Case 1: Desired path exactly matches complete_dir // Use empty string so downloads go to complete_dir root if (normalizedComplete === normalizedDesired) { logger.debug('Path match result: EXACT_MATCH - paths are identical after normalization'); logger.info('Desired path matches SABnzbd complete_dir, using category root'); return ''; } // Case 2: Desired path is under complete_dir // Calculate relative path (SABnzbd will append it to complete_dir) if (normalizedDesired.startsWith(normalizedComplete + '/')) { const relativePath = desiredPath.substring(completeDir.length).replace(/^[/\\]+/, ''); logger.debug('Path match result: SUBDIRECTORY - desired path is under complete_dir', { relativePath, calculation: `"${desiredPath}".substring(${completeDir.length}) = "${relativePath}"`, }); logger.info(`Desired path is under complete_dir, using relative path: ${relativePath}`); return relativePath; } // Case 3: Desired path is completely different // Use absolute path (SABnzbd will use it directly) logger.debug('Path match result: DIFFERENT - paths do not overlap, using absolute path'); logger.info(`Desired path differs from complete_dir, using absolute path: ${desiredPath}`); return desiredPath; } /** * Ensure the category exists with the correct download path * * This method handles the complexity of SABnzbd's path handling: * - Fetches SABnzbd's complete_dir to understand where downloads go * - Applies remote path mapping to translate between RMAB and SABnzbd perspectives * - Calculates the appropriate category path (relative or absolute) * - Creates or updates the category as needed * * Called before every download to ensure path settings stay synchronized. */ async ensureCategory(): Promise { try { logger.debug('ensureCategory() called - syncing category path with SABnzbd'); // Get SABnzbd's configuration including complete_dir const config = await this.getConfig(); const completeDir = config.completeDir; logger.debug('Retrieved SABnzbd configuration', { completeDir: completeDir || '(not set)', existingCategories: config.categories.map(c => ({ name: c.name, dir: c.dir || '(root)' })), }); if (!completeDir) { logger.warn('SABnzbd complete_dir not found in config, category path may be incorrect'); } // Apply reverse path mapping to get the path from SABnzbd's perspective // Example: RMAB sees /downloads, SABnzbd sees /mnt/usenet/complete logger.debug('Applying reverse path mapping', { inputPath: this.defaultDownloadDir, pathMappingEnabled: this.pathMappingConfig.enabled, remotePath: this.pathMappingConfig.remotePath || '(not set)', localPath: this.pathMappingConfig.localPath || '(not set)', }); const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig); const pathWasTransformed = desiredPath !== this.defaultDownloadDir; logger.debug('Reverse path mapping result', { originalPath: this.defaultDownloadDir, transformedPath: desiredPath, wasTransformed: pathWasTransformed, }); logger.info('Category path calculation', { rmabDownloadDir: this.defaultDownloadDir, pathMappingEnabled: this.pathMappingConfig.enabled, desiredPathForSab: desiredPath, sabCompleteDir: completeDir, }); // Calculate the correct category path const categoryPath = completeDir ? this.calculateCategoryPath(completeDir, desiredPath) : desiredPath; // Fallback to desired path if complete_dir unknown logger.debug('Final category path determined', { categoryPath: categoryPath || '(empty - downloads to complete_dir root)', category: this.defaultCategory, }); // Check if category exists and has the correct path const existingCategory = config.categories.find(cat => cat.name === this.defaultCategory); logger.debug('Checking existing category', { categoryName: this.defaultCategory, exists: !!existingCategory, currentDir: existingCategory?.dir || '(not set)', targetDir: categoryPath || '(root)', needsUpdate: existingCategory ? existingCategory.dir !== categoryPath : true, }); if (!existingCategory) { // Create new category logger.info(`Creating category "${this.defaultCategory}" with path: "${categoryPath || '(root)'}"`); logger.debug('SABnzbd API call: set_config (create category)', { section: 'categories', keyword: this.defaultCategory, dir: categoryPath, }); await this.client.get('/api', { params: { mode: 'set_config', section: 'categories', keyword: this.defaultCategory, dir: categoryPath, output: 'json', apikey: this.apiKey, }, }); logger.info(`Category "${this.defaultCategory}" created successfully`); } else if (existingCategory.dir !== categoryPath) { // Update existing category with new path logger.info(`Updating category "${this.defaultCategory}" path from "${existingCategory.dir || '(root)'}" to "${categoryPath || '(root)'}"`); logger.debug('SABnzbd API call: set_config (update category)', { section: 'categories', keyword: this.defaultCategory, oldDir: existingCategory.dir, newDir: categoryPath, }); await this.client.get('/api', { params: { mode: 'set_config', section: 'categories', keyword: this.defaultCategory, dir: categoryPath, output: 'json', apikey: this.apiKey, }, }); logger.info(`Category "${this.defaultCategory}" path updated successfully`); } else { logger.debug(`Category "${this.defaultCategory}" already has correct path: "${categoryPath || '(root)'}" - no update needed`); } } catch (error) { logger.error('Failed to ensure category', { error: error instanceof Error ? error.message : String(error) }); // Don't throw - category issues shouldn't block downloads // Downloads will still work, just may end up in wrong location } } /** * Add NZB by URL * Returns the NZB ID */ async addNZB(url: string, options?: AddNZBOptions): Promise { logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`); const category = options?.category || this.defaultCategory; // Ensure category exists with correct path before every download // This syncs the category path with SABnzbd's complete_dir and handles path mapping await this.ensureCategory(); const response = await this.client.get('/api', { params: { mode: 'addurl', name: url, cat: category, priority: this.mapPriority(options?.priority), pp: '3', // Post-processing: +Repair, +Unpack, +Delete output: 'json', apikey: this.apiKey, }, }); if (response.data?.status === false) { throw new Error(response.data.error || 'Failed to add NZB'); } const nzbIds = response.data?.nzo_ids; if (!nzbIds || nzbIds.length === 0) { throw new Error('SABnzbd did not return an NZB ID'); } const nzbId = nzbIds[0]; logger.info(`Added NZB: ${nzbId}`); return nzbId; } /** * Get NZB info by ID * Checks queue first, then history */ async getNZB(nzbId: string): Promise { // Check queue first const queue = await this.getQueue(); const queueItem = queue.find(item => item.nzbId === nzbId); if (queueItem) { return this.mapQueueItemToNZBInfo(queueItem); } // Not in queue, check history const history = await this.getHistory(100); const historyItem = history.find(item => item.nzbId === nzbId); if (historyItem) { return this.mapHistoryItemToNZBInfo(historyItem); } // Not found return null; } /** * Get current download queue */ async getQueue(): Promise { const response = await this.client.get('/api', { params: { mode: 'queue', output: 'json', apikey: this.apiKey, }, }); const slots = response.data?.queue?.slots || []; return slots.map((slot: any) => ({ nzbId: slot.nzo_id, name: slot.filename, size: parseFloat(slot.mb || '0'), sizeLeft: parseFloat(slot.mbleft || '0'), percentage: parseInt(slot.percentage || '0', 10), status: slot.status, timeLeft: slot.timeleft || '0:00:00', category: slot.cat || '', priority: slot.priority || 'Normal', })); } /** * Get download history */ async getHistory(limit: number = 100): Promise { const response = await this.client.get('/api', { params: { mode: 'history', limit, output: 'json', apikey: this.apiKey, }, }); const slots = response.data?.history?.slots || []; return slots.map((slot: any) => ({ nzbId: slot.nzo_id, name: slot.name, category: slot.category || '', status: slot.status, bytes: slot.bytes || '0', failMessage: slot.fail_message || '', storage: slot.storage || '', completedTimestamp: slot.completed || '0', downloadTime: slot.download_time || '0', })); } /** * Pause NZB download */ async pauseNZB(nzbId: string): Promise { await this.client.get('/api', { params: { mode: 'pause', value: nzbId, output: 'json', apikey: this.apiKey, }, }); } /** * Resume NZB download */ async resumeNZB(nzbId: string): Promise { await this.client.get('/api', { params: { mode: 'resume', value: nzbId, output: 'json', apikey: this.apiKey, }, }); } /** * Delete NZB download from queue */ async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise { logger.info(`Deleting NZB from queue: ${nzbId} (del_files: ${deleteFiles ? '1' : '0'})`); const response = await this.client.get('/api', { params: { mode: 'queue', name: 'delete', value: nzbId, del_files: deleteFiles ? '1' : '0', output: 'json', apikey: this.apiKey, }, }); logger.info(`SABnzbd queue delete response: ${JSON.stringify(response.data)}`); // Check if SABnzbd returned an error if (response.data?.status === false) { throw new Error(response.data.error || `Failed to delete NZB ${nzbId} from queue`); } } /** * Archive NZB from history (hides from main view but preserves for troubleshooting) * Note: SABnzbd's default behavior is to archive. Use archive=0 to permanently delete. */ async archiveFromHistory(nzbId: string): Promise { logger.info(`Archiving NZB from history: ${nzbId}`); const response = await this.client.get('/api', { params: { mode: 'history', name: 'delete', value: nzbId, // No del_files parameter - we'll handle file cleanup manually // No archive parameter - defaults to archive=1 (move to hidden archive, not permanent delete) output: 'json', apikey: this.apiKey, }, }); logger.info(`SABnzbd history archive response: ${JSON.stringify(response.data)}`); // Check if SABnzbd returned an error if (response.data?.status === false) { throw new Error(response.data.error || `Failed to archive NZB ${nzbId} from history`); } } /** * Archive completed NZB from history after file organization * Note: Only archives from history (not queue). If still in queue, something went wrong. * Archives to SABnzbd's hidden archive (preserves for troubleshooting, doesn't permanently delete) */ async archiveCompletedNZB(nzbId: string): Promise { logger.info(`Attempting to archive completed NZB ${nzbId}`); try { await this.archiveFromHistory(nzbId); logger.info(`Successfully archived ${nzbId} from history`); } catch (error) { logger.error(`Failed to archive ${nzbId} from history`, { error: error instanceof Error ? error.message : String(error), }); throw new Error(`NZB ${nzbId} not found in history or failed to archive`); } } /** * Get download progress from queue item */ getDownloadProgress(queueItem: QueueItem): DownloadProgress { const bytesTotal = queueItem.size * 1024 * 1024; // Convert MB to bytes const bytesLeft = queueItem.sizeLeft * 1024 * 1024; const bytesDownloaded = bytesTotal - bytesLeft; const percent = queueItem.percentage / 100; // Convert 0-100 to 0.0-1.0 // Parse time left (format: "0:15:30") let etaSeconds = 0; if (queueItem.timeLeft && queueItem.timeLeft !== '0:00:00') { const parts = queueItem.timeLeft.split(':'); if (parts.length === 3) { etaSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]); } } // Calculate speed (bytes/sec) const speed = etaSeconds > 0 ? bytesLeft / etaSeconds : 0; // Map SABnzbd status to our state format let state = 'downloading'; const statusLower = queueItem.status.toLowerCase(); if (statusLower.includes('paused')) { state = 'paused'; } else if (statusLower.includes('queued')) { state = 'queued'; } else if (statusLower.includes('extracting') || statusLower.includes('unpacking')) { state = 'extracting'; } else if (statusLower.includes('repairing') || statusLower.includes('verifying')) { state = 'repairing'; } else if (percent >= 1.0) { state = 'completed'; } return { percent: Math.min(percent, 1.0), bytesDownloaded, bytesTotal, speed, eta: etaSeconds, state, }; } /** * Map queue item to NZBInfo */ private mapQueueItemToNZBInfo(queueItem: QueueItem): NZBInfo { const progress = this.getDownloadProgress(queueItem); return { nzbId: queueItem.nzbId, name: queueItem.name, size: queueItem.size * 1024 * 1024, // MB to bytes progress: progress.percent, status: progress.state as NZBStatus, downloadSpeed: progress.speed, timeLeft: progress.eta, category: queueItem.category, }; } /** * Map history item to NZBInfo */ private mapHistoryItemToNZBInfo(historyItem: HistoryItem): NZBInfo { const isCompleted = historyItem.status.toLowerCase().includes('completed'); const isFailed = historyItem.status.toLowerCase().includes('failed'); return { nzbId: historyItem.nzbId, name: historyItem.name, size: parseInt(historyItem.bytes || '0', 10), progress: isCompleted ? 1.0 : 0.0, status: isFailed ? 'failed' : isCompleted ? 'completed' : 'downloading', downloadSpeed: 0, timeLeft: 0, category: historyItem.category, downloadPath: historyItem.storage, completedAt: historyItem.completedTimestamp ? new Date(parseInt(historyItem.completedTimestamp) * 1000) : undefined, errorMessage: historyItem.failMessage || undefined, }; } /** * Map priority option to SABnzbd priority value */ private mapPriority(priority?: 'low' | 'normal' | 'high' | 'force'): string { switch (priority) { case 'force': return '2'; // Force (highest) case 'high': return '1'; // High case 'low': return '-1'; // Low case 'normal': default: return '0'; // Normal } } } /** * Singleton instance and factory */ let sabnzbdServiceInstance: SABnzbdService | null = null; let configLoaded = false; export async function getSABnzbdService(): Promise { // Always recreate if config hasn't been loaded successfully if (sabnzbdServiceInstance && configLoaded) { return sabnzbdServiceInstance; } try { // Load configuration from download client manager (uses new multi-client config format) const { getConfigService } = await import('../services/config.service'); const { getDownloadClientManager } = await import('../services/download-client-manager.service'); const configService = await getConfigService(); const manager = getDownloadClientManager(configService); logger.info('Loading configuration from download client manager...'); const clientConfig = await manager.getClientForProtocol('usenet'); if (!clientConfig) { throw new Error('SABnzbd is not configured. Please configure a SABnzbd client in the admin settings.'); } if (clientConfig.type !== 'sabnzbd') { throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`); } // Get download_dir from main config const downloadDir = await configService.get('download_dir') || '/downloads'; logger.debug('RMAB download_dir from config', { downloadDir }); // Build path mapping configuration from client settings const pathMappingConfig: PathMappingConfig = { enabled: clientConfig.remotePathMappingEnabled || false, remotePath: clientConfig.remotePath || '', localPath: clientConfig.localPath || '', }; logger.debug('Path mapping configuration built', { enabled: pathMappingConfig.enabled, remotePath: pathMappingConfig.remotePath || '(not set)', localPath: pathMappingConfig.localPath || '(not set)', explanation: pathMappingConfig.enabled ? `Will translate "${pathMappingConfig.localPath}" ↔ "${pathMappingConfig.remotePath}"` : 'Path mapping disabled - paths used as-is', }); logger.info('Config loaded:', { name: clientConfig.name, hasUrl: !!clientConfig.url, hasApiKey: !!clientConfig.password, disableSSLVerify: clientConfig.disableSSLVerify, downloadDir, pathMappingEnabled: pathMappingConfig.enabled, }); if (!clientConfig.url || !clientConfig.password) { throw new Error('SABnzbd is not fully configured. Please check your configuration in admin settings.'); } sabnzbdServiceInstance = new SABnzbdService( clientConfig.url, clientConfig.password, // API key stored in password field clientConfig.category || 'readmeabook', downloadDir, clientConfig.disableSSLVerify, pathMappingConfig ); // Ensure category exists with correct path (handles path mapping and complete_dir sync) await sabnzbdServiceInstance.ensureCategory(); configLoaded = true; return sabnzbdServiceInstance; } catch (error) { logger.error('Failed to initialize service', { error: error instanceof Error ? error.message : String(error) }); sabnzbdServiceInstance = null; configLoaded = false; throw error; } } export function invalidateSABnzbdService(): void { sabnzbdServiceInstance = null; configLoaded = false; logger.info('Service singleton invalidated'); }