Files
ReadMeABook/src/lib/integrations/sabnzbd.service.ts
T
kikootwo c559f8ebe9 SABnzbd path mapping + ASIN-based request deletion
Add bidirectional path mapping and complete_dir-aware category sync to the SABnzbd integration. Introduces PathMapper usage, complete_dir extraction, calculateCategoryPath(), and ensureCategory() logic to choose empty/relative/absolute category paths; ensureCategory is invoked before adding NZBs. Update singleton factory to load download_dir and path-mapping config from DownloadClientManager and recreate the service when config is not loaded. Make DownloadClientManager pass path-mapping config into the SABnzbd service. Change request deletion to remove plex_library records by ASIN (deleteMany) with a fallback to exact title/author matches so availability checks and deletions are consistent. Update documentation and tests to reflect the new behavior and APIs.
2026-02-03 12:20:44 -05:00

860 lines
28 KiB
TypeScript

/**
* 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<string> {
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<SABnzbdConfig> {
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<string> {
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<void> {
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<string> {
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<NZBInfo | null> {
// 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<QueueItem[]> {
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<HistoryItem[]> {
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<void> {
await this.client.get('/api', {
params: {
mode: 'pause',
value: nzbId,
output: 'json',
apikey: this.apiKey,
},
});
}
/**
* Resume NZB download
*/
async resumeNZB(nzbId: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<SABnzbdService> {
// 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');
}