Files
ReadMeABook/src/lib/integrations/nzbget.service.ts
T
kikootwo af0eaceb98 Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
2026-02-10 15:06:20 -05:00

936 lines
31 KiB
TypeScript

/**
* Component: NZBGet Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import zlib from 'zlib';
import { RMABLogger } from '@/lib/utils/logger';
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
const logger = RMABLogger.create('NZBGet');
// =========================================================================
// NZBGet-specific types
// =========================================================================
/** NZBGet queue group item from listgroups() */
interface NZBGetGroupItem {
NZBID: number;
NZBName: string;
Status: string;
FileSizeMB: number;
DownloadedSizeMB: number;
RemainingSizeMB: number;
DownloadTimeSec: number;
Category: string;
DestDir: string;
FinalDir: string;
MaxPriority: number;
ActiveDownloads: number;
Health: number;
PostInfoText: string;
PostStageProgress: number;
}
/** NZBGet history item from history() */
interface NZBGetHistoryItem {
NZBID: number;
Name: string;
Status: string;
Category: string;
FileSizeMB: number;
DownloadedSizeMB: number;
DestDir: string;
FinalDir: string;
DownloadTimeSec: number;
PostTotalTimeSec: number;
ParStatus: string;
UnpackStatus: string;
DeleteStatus: string;
MarkStatus: string;
HistoryTime: number;
FailedArticles: number;
TotalArticles: number;
}
/** NZBGet config entry from config() */
interface NZBGetConfigItem {
Name: string;
Value: string;
}
/** NZBGet status response from status() */
interface NZBGetStatus {
DownloadRate: number;
RemainingSizeMB: number;
DownloadedSizeMB: number;
DownloadPaused: boolean;
ServerStandBy: boolean;
}
/** Internal NZB info (normalized before mapping to DownloadInfo) */
interface NZBInfo {
nzbId: string;
name: string;
size: number;
bytesDownloaded: number;
progress: number;
status: DownloadStatus;
downloadSpeed: number;
eta: number;
category: string;
downloadPath?: string;
completedAt?: Date;
errorMessage?: string;
}
// =========================================================================
// NZBGet Service
// =========================================================================
export class NZBGetService implements IDownloadClient {
readonly clientType: DownloadClientType = 'nzbget';
readonly protocol: ProtocolType = 'usenet';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private defaultCategory: string;
private defaultDownloadDir: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
private pathMappingConfig: PathMappingConfig;
constructor(
baseUrl: string,
username: string,
password: string,
defaultCategory: string = 'readmeabook',
defaultDownloadDir: string = '/downloads',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username || '';
this.password = password || '';
this.defaultCategory = defaultCategory;
this.defaultDownloadDir = defaultDownloadDir;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
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,
auth: {
username: this.username,
password: this.password,
},
});
}
// =========================================================================
// JSON-RPC Communication
// =========================================================================
/**
* Make a JSON-RPC call to NZBGet.
* All NZBGet API calls go through POST /jsonrpc with Basic Auth.
*/
private async rpc<T = any>(method: string, params: any[] = []): Promise<T> {
const response = await this.client.post('/jsonrpc', {
method,
params,
});
if (response.data?.error) {
const errorMsg = typeof response.data.error === 'string'
? response.data.error
: response.data.error.message || JSON.stringify(response.data.error);
throw new Error(`NZBGet RPC error (${method}): ${errorMsg}`);
}
return response.data?.result;
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Test connection to NZBGet
*/
async testConnection(): Promise<ConnectionTestResult> {
try {
const version = await this.rpc<string>('version');
if (!version) {
return {
success: false,
message: 'Connected but failed to get NZBGet version',
};
}
return {
success: true,
version,
message: `Connected to NZBGet v${version}`,
};
} catch (error) {
return {
success: false,
message: this.formatConnectionError(error),
};
}
}
/**
* Add a download via the unified interface.
* Downloads the NZB file from the source URL and uploads to NZBGet via append().
*/
async addDownload(url: string, options?: AddDownloadOptions): 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
// (Matches SABnzbd/qBittorrent behavior — lightweight config read + conditional write)
await this.ensureCategory();
// Download the NZB file content from the source URL (Prowlarr proxy)
let nzbBuffer: Buffer;
let filename: string;
try {
logger.info('Downloading NZB file from source URL...');
const nzbResponse = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
});
nzbBuffer = Buffer.from(nzbResponse.data);
if (nzbBuffer.length === 0) {
throw new Error('NZB file is empty (0 bytes)');
}
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
// Detect and decompress gzip-compressed NZB files
// Prowlarr/indexers may serve .nzb.gz files which need decompression before upload
if (nzbBuffer[0] === 0x1f && nzbBuffer[1] === 0x8b) {
logger.info('NZB file is gzip-compressed, decompressing...');
nzbBuffer = zlib.gunzipSync(nzbBuffer);
logger.info(`Decompressed NZB file: ${nzbBuffer.length} bytes`);
}
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status) {
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
}
if (error.code === 'ECONNREFUSED') {
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
}
}
throw error;
}
// Upload to NZBGet via append()
// Parameters: Filename, Content (base64), Category, Priority, AddToTop, AddPaused,
// DupeKey, DupeScore, DupeMode, AutoCategory, PPParameters
const base64Content = nzbBuffer.toString('base64');
const priority = this.mapPriority(options?.priority);
const nzbId = await this.rpc<number>('append', [
filename, // Filename
base64Content, // Content (base64-encoded NZB)
category, // Category
priority, // Priority (0=normal, 50=high, 100=very high, 900=force)
false, // AddToTop
options?.paused || false, // AddPaused
'', // DupeKey
0, // DupeScore
'FORCE', // DupeMode — RMAB manages its own lifecycle, skip NZBGet dupe detection
[], // PPParameters
]);
if (!nzbId || nzbId <= 0) {
// Log diagnostic info to help debug rejected NZBs
const contentPreview = nzbBuffer.slice(0, 100).toString('utf-8');
logger.error('NZBGet rejected the NZB file', {
filename,
contentLength: nzbBuffer.length,
base64Length: base64Content.length,
contentPreview: contentPreview.substring(0, 80),
returnedId: nzbId,
});
throw new Error('NZBGet rejected the NZB file');
}
const id = String(nzbId);
logger.info(`Added NZB: ${id} (${filename})`);
return id;
}
/**
* Get current status of a download.
* Checks queue (listgroups) first, then history.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const nzbId = parseInt(id, 10);
if (isNaN(nzbId)) {
logger.error(`Invalid NZB ID: ${id}`);
return null;
}
// Check queue first
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
const groupItem = groups?.find(g => g.NZBID === nzbId);
if (groupItem) {
return this.mapGroupToDownloadInfo(groupItem);
}
// Not in queue, check history
const history = await this.rpc<NZBGetHistoryItem[]>('history', [false]);
const historyItem = history?.find(h => h.NZBID === nzbId);
if (historyItem) {
return this.mapHistoryToDownloadInfo(historyItem);
}
return null;
}
/**
* Pause a download via editqueue GroupPause
*/
async pauseDownload(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
const result = await this.rpc<boolean>('editqueue', ['GroupPause', '', [nzbId]]);
if (!result) {
throw new Error(`Failed to pause download ${id}`);
}
logger.info(`Paused download: ${id}`);
}
/**
* Resume a download via editqueue GroupResume
*/
async resumeDownload(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
const result = await this.rpc<boolean>('editqueue', ['GroupResume', '', [nzbId]]);
if (!result) {
throw new Error(`Failed to resume download ${id}`);
}
logger.info(`Resumed download: ${id}`);
}
/**
* Delete a download from NZBGet.
* Tries queue first (GroupFinalDelete), then history (HistoryFinalDelete).
*/
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
const nzbId = parseInt(id, 10);
logger.info(`Deleting download: ${id} (deleteFiles: ${deleteFiles})`);
// Try deleting from queue first
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
const inQueue = groups?.some(g => g.NZBID === nzbId);
if (inQueue) {
const command = deleteFiles ? 'GroupFinalDelete' : 'GroupDelete';
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
if (!result) {
throw new Error(`Failed to delete download ${id} from queue`);
}
logger.info(`Deleted download ${id} from queue`);
return;
}
// Try deleting from history
const command = deleteFiles ? 'HistoryFinalDelete' : 'HistoryDelete';
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
if (!result) {
throw new Error(`Failed to delete download ${id} from history`);
}
logger.info(`Deleted download ${id} from history`);
}
/**
* Post-download cleanup: archive from NZBGet history.
* Uses HistoryDelete to hide the item from visible history (preserves in hidden archive).
* Analogous to SABnzbd's archive behavior.
*/
async postProcess(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
logger.info(`Archiving completed download from history: ${id}`);
try {
const result = await this.rpc<boolean>('editqueue', ['HistoryDelete', '', [nzbId]]);
if (!result) {
throw new Error(`NZBGet returned false for HistoryDelete`);
}
logger.info(`Successfully archived ${id} from history`);
} catch (error) {
logger.error(`Failed to archive ${id} from history`, {
error: error instanceof Error ? error.message : String(error),
});
throw new Error(`NZB ${id} not found in history or failed to archive`);
}
}
/** Not applicable for usenet clients */
async getCategories(): Promise<string[]> {
return [];
}
/** Not applicable for usenet clients */
async setCategory(_id: string, _category: string): Promise<void> {
// No-op: post-import category is scoped to torrent clients
}
// =========================================================================
// Category Management
// =========================================================================
/**
* Ensure the category exists in NZBGet with the correct download path.
*
* NZBGet categories are config entries (Category1.Name, Category1.DestDir, etc.).
* Reads existing config, checks for our category, creates/updates via saveconfig().
*
* CRITICAL: NZBGet's saveconfig() does a FULL config replacement — passing only
* our entries would wipe every other setting and destroy the instance. We must
* always read the full config, merge our changes, and write the entire config back.
*
* After creating a new category, we call reload() so NZBGet picks up the new
* category DestDir immediately. reload() is safe when the config is correct.
*
* Called before every download (matches SABnzbd/qBittorrent pattern).
* Lightweight: reads config, writes only if category is missing or path changed.
*/
async ensureCategory(): Promise<void> {
try {
logger.debug('ensureCategory() called - syncing category with NZBGet');
const config = await this.rpc<NZBGetConfigItem[]>('config');
if (!config) {
logger.warn('Failed to get NZBGet config, skipping category check');
return;
}
// Find the main DestDir (NZBGet's base download directory)
const destDirEntry = config.find(c => c.Name === 'DestDir');
const nzbgetDestDir = destDirEntry?.Value || '';
logger.debug('NZBGet config retrieved', {
destDir: nzbgetDestDir || '(not configured)',
});
// Apply reverse path mapping to get the path from NZBGet's perspective
const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig);
logger.debug('Category path calculation', {
rmabDownloadDir: this.defaultDownloadDir,
desiredPathForNZBGet: desiredPath,
nzbgetDestDir,
pathMappingEnabled: this.pathMappingConfig.enabled,
});
// Find existing categories and our category slot
const { existingSlot, nextSlot } = this.findCategorySlot(config, this.defaultCategory);
if (existingSlot !== null) {
// Category exists - check if DestDir needs updating
const currentDestDir = config.find(c => c.Name === `Category${existingSlot}.DestDir`)?.Value || '';
if (this.normalizePath(currentDestDir) !== this.normalizePath(desiredPath)) {
logger.info(`Updating category "${this.defaultCategory}" DestDir from "${currentDestDir}" to "${desiredPath}"`);
const updatedConfig = this.mergeConfigEntries(config, [
{ Name: `Category${existingSlot}.DestDir`, Value: desiredPath },
]);
await this.rpc('saveconfig', [updatedConfig]);
await this.reloadAndWait();
} else {
logger.debug(`Category "${this.defaultCategory}" already configured correctly`);
}
} else {
// Create new category — merge into full config so we don't wipe existing settings
logger.info(`Creating category "${this.defaultCategory}" in slot ${nextSlot} with DestDir: "${desiredPath}"`);
const updatedConfig = this.mergeConfigEntries(config, [
{ Name: `Category${nextSlot}.Name`, Value: this.defaultCategory },
{ Name: `Category${nextSlot}.DestDir`, Value: desiredPath },
{ Name: `Category${nextSlot}.Unpack`, Value: 'yes' },
]);
await this.rpc('saveconfig', [updatedConfig]);
await this.reloadAndWait();
}
} 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
}
}
/**
* Read-only entries returned by NZBGet's config() RPC that must NOT be
* written back via saveconfig(). These are runtime/system properties.
*/
private static readonly READ_ONLY_CONFIG_KEYS = new Set([
'ConfigFile',
'AppBin',
'AppDir',
'Version',
]);
/**
* Merge new/updated config entries into the full NZBGet config.
* Returns a complete config array safe to pass to saveconfig().
*
* Filters out read-only system entries (ConfigFile, AppBin, AppDir, Version)
* that config() returns but saveconfig() rejects.
*
* For entries that already exist (by Name), replaces the value.
* For new entries, appends them to the array.
*/
private mergeConfigEntries(
fullConfig: NZBGetConfigItem[],
changes: NZBGetConfigItem[]
): NZBGetConfigItem[] {
const merged: NZBGetConfigItem[] = [];
for (const entry of fullConfig) {
// Skip read-only system entries that saveconfig() rejects
if (NZBGetService.READ_ONLY_CONFIG_KEYS.has(entry.Name)) {
continue;
}
const override = changes.find(c => c.Name === entry.Name);
merged.push(override ? { Name: entry.Name, Value: override.Value } : { Name: entry.Name, Value: entry.Value });
}
// Append any entries that don't exist in the current config
for (const change of changes) {
if (!fullConfig.some(entry => entry.Name === change.Name)) {
merged.push({ Name: change.Name, Value: change.Value });
}
}
return merged;
}
/**
* Find the category slot number for an existing category or determine the next available slot.
*/
private findCategorySlot(
config: NZBGetConfigItem[],
categoryName: string
): { existingSlot: number | null; nextSlot: number } {
let maxSlot = 0;
let existingSlot: number | null = null;
for (const entry of config) {
const match = entry.Name.match(/^Category(\d+)\.Name$/);
if (match) {
const slot = parseInt(match[1], 10);
if (slot > maxSlot) {
maxSlot = slot;
}
if (entry.Value === categoryName) {
existingSlot = slot;
}
}
}
return { existingSlot, nextSlot: maxSlot + 1 };
}
/**
* Reload NZBGet so config changes (new categories, DestDir updates) take effect.
* Polls version() to confirm NZBGet is back online before continuing.
*/
private async reloadAndWait(): Promise<void> {
try {
logger.info('Reloading NZBGet to apply configuration changes...');
await this.rpc('reload');
const maxWait = 10000;
const pollInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
await this.rpc<string>('version');
logger.info('NZBGet reloaded successfully');
return;
} catch {
// Still restarting, keep polling
}
}
logger.warn('NZBGet did not respond after reload within 10s, continuing anyway');
} catch (error) {
logger.warn('NZBGet reload request failed, config changes may require manual restart', {
error: error instanceof Error ? error.message : String(error),
});
}
}
// =========================================================================
// Status Mapping
// =========================================================================
/**
* Map NZBGet queue group item to unified DownloadInfo
*/
private async mapGroupToDownloadInfo(group: NZBGetGroupItem): Promise<DownloadInfo> {
const totalBytes = group.FileSizeMB * 1024 * 1024;
const downloadedBytes = group.DownloadedSizeMB * 1024 * 1024;
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1.0) : 0;
// Get global download speed for active items
let downloadSpeed = 0;
let eta = 0;
const status = this.mapGroupStatus(group.Status);
if (status === 'downloading') {
try {
const serverStatus = await this.rpc<NZBGetStatus>('status');
downloadSpeed = serverStatus?.DownloadRate || 0;
const remainingBytes = group.RemainingSizeMB * 1024 * 1024;
eta = downloadSpeed > 0 ? Math.round(remainingBytes / downloadSpeed) : 0;
} catch {
// Non-critical: speed/eta will be 0
}
}
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = group.FinalDir || group.DestDir || undefined;
return {
id: String(group.NZBID),
name: group.NZBName,
size: totalBytes,
bytesDownloaded: downloadedBytes,
progress,
status,
downloadSpeed,
eta,
category: group.Category || '',
downloadPath,
completedAt: undefined,
errorMessage: undefined,
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map NZBGet history item to unified DownloadInfo
*/
private mapHistoryToDownloadInfo(history: NZBGetHistoryItem): DownloadInfo {
const totalBytes = history.FileSizeMB * 1024 * 1024;
const downloadedBytes = history.DownloadedSizeMB * 1024 * 1024;
const status = this.mapHistoryStatus(history.Status);
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = history.FinalDir || history.DestDir || undefined;
return {
id: String(history.NZBID),
name: history.Name,
size: totalBytes,
bytesDownloaded: status === 'completed' ? totalBytes : downloadedBytes,
progress: status === 'completed' ? 1.0 : (totalBytes > 0 ? downloadedBytes / totalBytes : 0),
status,
downloadSpeed: 0,
eta: 0,
category: history.Category || '',
downloadPath,
completedAt: history.HistoryTime ? new Date(history.HistoryTime * 1000) : undefined,
errorMessage: status === 'failed' ? this.buildHistoryErrorMessage(history) : undefined,
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map NZBGet queue status string to unified DownloadStatus
*/
private mapGroupStatus(status: string): DownloadStatus {
switch (status) {
case 'QUEUED':
return 'queued';
case 'PAUSED':
return 'paused';
case 'DOWNLOADING':
case 'FETCHING':
return 'downloading';
case 'PP_QUEUED':
case 'LOADING_PARS':
case 'VERIFYING_SOURCES':
case 'REPAIRING':
case 'VERIFYING_REPAIRED':
case 'RENAMING':
case 'UNPACKING':
case 'MOVING':
case 'POST_UNPACK_RENAMING':
case 'EXECUTING_SCRIPT':
case 'PP_FINISHED':
return 'processing';
default:
logger.warn(`Unknown NZBGet queue status: ${status}, defaulting to downloading`);
return 'downloading';
}
}
/**
* Map NZBGet history status string to unified DownloadStatus.
* History statuses have format: "PREFIX/DETAIL" (e.g., "SUCCESS/ALL", "FAILURE/PAR")
*/
private mapHistoryStatus(status: string): DownloadStatus {
const prefix = status.split('/')[0];
switch (prefix) {
case 'SUCCESS':
return 'completed';
case 'WARNING':
// WARNING means the download succeeded but post-processing had issues
// From RMAB's perspective, the download is still completed
return 'completed';
case 'FAILURE':
return 'failed';
case 'DELETED':
return 'failed';
default:
logger.warn(`Unknown NZBGet history status: ${status}, defaulting to failed`);
return 'failed';
}
}
/**
* Build a descriptive error message from NZBGet history item
*/
private buildHistoryErrorMessage(history: NZBGetHistoryItem): string {
const parts: string[] = [];
// Include the raw status for context
parts.push(history.Status);
if (history.ParStatus && history.ParStatus !== 'NONE' && history.ParStatus !== 'SUCCESS') {
parts.push(`Par: ${history.ParStatus}`);
}
if (history.UnpackStatus && history.UnpackStatus !== 'NONE' && history.UnpackStatus !== 'SUCCESS') {
parts.push(`Unpack: ${history.UnpackStatus}`);
}
if (history.DeleteStatus && history.DeleteStatus !== 'NONE') {
parts.push(`Delete: ${history.DeleteStatus}`);
}
// Article failure info
if (history.FailedArticles > 0) {
const failPercent = history.TotalArticles > 0
? Math.round((history.FailedArticles / history.TotalArticles) * 100)
: 0;
parts.push(`${history.FailedArticles} failed articles (${failPercent}%)`);
}
return parts.join(' | ');
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Extract a usable filename for the NZB upload.
* Tries Content-Disposition header first, then URL path, then falls back to a default.
*/
private extractNZBFilename(url: string, contentDisposition?: string): string {
if (contentDisposition) {
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
if (match?.[1]) {
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
if (decoded) {
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
}
}
try {
const urlPath = new URL(url).pathname;
const basename = urlPath.split('/').pop();
if (basename && basename.length > 0 && basename !== 'download') {
const decoded = decodeURIComponent(basename);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
} catch {
// URL parsing failed
}
return 'download.nzb';
}
/**
* Map priority string to NZBGet priority integer.
* NZBGet priorities: -100 (very low), -50 (low), 0 (normal), 50 (high), 100 (very high), 900 (force)
*/
private mapPriority(priority?: string): number {
switch (priority) {
case 'force':
return 900;
case 'high':
return 50;
case 'low':
return -50;
case 'normal':
default:
return 0;
}
}
/**
* Format connection error into a user-friendly message
*/
private formatConnectionError(error: unknown): string {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) {
return 'Authentication failed. Check your NZBGet username and password (Settings → Security).';
}
if (status === 403) {
return 'Access denied. Check your NZBGet credentials and access permissions.';
}
if (error.code === 'ECONNREFUSED') {
return `Connection refused. Is NZBGet running and accessible at this URL?`;
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
return 'Connection timed out. Check the URL and network connectivity.';
}
if (error.message?.includes('certificate') || error.message?.includes('SSL') || error.message?.includes('TLS')) {
return 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.';
}
}
return error instanceof Error ? error.message : 'Unknown error';
}
/**
* Normalize a path for comparison (forward slashes, no trailing slash, lowercase)
*/
private normalizePath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
}
// =========================================================================
// Singleton Factory
// =========================================================================
let nzbgetServiceInstance: NZBGetService | null = null;
let configLoaded = false;
export async function getNZBGetService(): Promise<NZBGetService> {
if (nzbgetServiceInstance && configLoaded) {
return nzbgetServiceInstance;
}
try {
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('NZBGet is not configured. Please configure an NZBGet client in the admin settings.');
}
if (clientConfig.type !== 'nzbget') {
throw new Error(`Expected NZBGet client but found ${clientConfig.type}`);
}
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
const pathMappingConfig: PathMappingConfig = {
enabled: clientConfig.remotePathMappingEnabled || false,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
logger.info('Config loaded:', {
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasPassword: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
downloadDir,
pathMappingEnabled: pathMappingConfig.enabled,
});
if (!clientConfig.url || !clientConfig.password) {
throw new Error('NZBGet is not fully configured. Please check your configuration in admin settings.');
}
nzbgetServiceInstance = new NZBGetService(
clientConfig.url,
clientConfig.username || '',
clientConfig.password,
clientConfig.category || 'readmeabook',
downloadDir,
clientConfig.disableSSLVerify,
pathMappingConfig
);
await nzbgetServiceInstance.ensureCategory();
configLoaded = true;
return nzbgetServiceInstance;
} catch (error) {
logger.error('Failed to initialize service', {
error: error instanceof Error ? error.message : String(error),
});
nzbgetServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateNZBGetService(): void {
nzbgetServiceInstance = null;
configLoaded = false;
logger.info('Service singleton invalidated');
}