Add SABnzbd Usenet/NZB integration and documentation

Introduces SABnzbd as a supported download client for Usenet/NZB alongside qBittorrent, including service implementation, setup wizard and admin settings UI updates, and protocol-specific job processor logic. Updates documentation, PRD, and database schema to support NZB downloads, adds comprehensive technical details and testing strategies, and fixes Audible integration issues related to search and ASIN extraction.
This commit is contained in:
kikootwo
2026-01-07 02:40:11 -05:00
parent 23881eb670
commit e008744df1
21 changed files with 2378 additions and 254 deletions
+128 -59
View File
@@ -1,16 +1,19 @@
/**
* Component: Download Torrent Job Processor
* Component: Download Job Processor
* Documentation: documentation/phase3/README.md
*/
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { getSABnzbdService } from '../integrations/sabnzbd.service';
import { getConfigService } from '../services/config.service';
import { createJobLogger } from '../utils/job-logger';
/**
* Process download torrent job
* Adds selected torrent to download client and starts monitoring
* Process download job
* Routes to appropriate download client based on configuration
* Adds selected result to download client and starts monitoring
*/
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
const { requestId, audiobook, torrent, jobId } = payload;
@@ -18,7 +21,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
const logger = jobId ? createJobLogger(jobId, 'DownloadTorrent') : null;
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
await logger?.info(`Selected torrent: ${torrent.title}`, {
await logger?.info(`Selected result: ${torrent.title}`, {
size: torrent.size,
seeders: torrent.seeders,
format: torrent.format,
@@ -36,69 +39,135 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
},
});
// Get qBittorrent service
const qbt = await getQBittorrentService();
// Get configured download client type
const config = await getConfigService();
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
// Add torrent to qBittorrent
await logger?.info(`Adding torrent to qBittorrent`);
let downloadClientId: string;
let downloadClient: 'qbittorrent' | 'sabnzbd';
const torrentHash = await qbt.addTorrent(torrent.downloadUrl, {
category: 'readmeabook',
tags: ['audiobook'], // Generic tag for all audiobooks
sequentialDownload: true, // Download in order for potential streaming
paused: false, // Start immediately
});
if (clientType === 'sabnzbd') {
// Route to SABnzbd
await logger?.info(`Routing to SABnzbd`);
await logger?.info(`Torrent added with hash: ${torrentHash}`);
const sabnzbd = await getSABnzbdService();
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
category: 'readmeabook',
priority: 'normal',
});
downloadClient = 'sabnzbd';
// Create DownloadHistory record
const downloadHistory = await prisma.downloadHistory.create({
data: {
await logger?.info(`NZB added with ID: ${downloadClientId}`);
// Create DownloadHistory record
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
downloadClient: 'sabnzbd',
downloadClientId,
torrentName: torrent.title,
nzbId: downloadClientId, // Store NZB ID
torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid, // Source URL
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
leechers: 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
await logger?.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
indexerName: torrent.indexer,
downloadClient: 'qbittorrent',
downloadClientId: torrentHash,
torrentName: torrent.title,
torrentHash: torrent.infoHash || torrentHash,
torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid, // Source URL for the torrent page
magnetLink: torrent.downloadUrl, // Download URL (magnet or .torrent)
seeders: torrent.seeders,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
downloadHistory.id,
downloadClientId,
'sabnzbd',
3 // Wait 3 seconds before first check
);
await logger?.info(`Created download history record: ${downloadHistory.id}`);
await logger?.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
// Trigger monitor download job with initial delay
// qBittorrent needs a few seconds to process the torrent before it's available via API
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
torrentHash,
'qbittorrent',
3 // Wait 3 seconds before first check to avoid race condition
);
return {
success: true,
message: 'NZB added to SABnzbd and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
nzbId: downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
format: torrent.format,
},
};
} else {
// Route to qBittorrent (default)
await logger?.info(`Routing to qBittorrent`);
await logger?.info(`Started monitoring job for request ${requestId} (3s initial delay)`);
const qbt = await getQBittorrentService();
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
category: 'readmeabook',
tags: ['audiobook'],
sequentialDownload: true,
paused: false,
});
downloadClient = 'qbittorrent';
return {
success: true,
message: 'Torrent added to download client and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
torrentHash,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders,
format: torrent.format,
},
};
await logger?.info(`Torrent added with hash: ${downloadClientId}`);
// Create DownloadHistory record
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
downloadClient: 'qbittorrent',
downloadClientId,
torrentName: torrent.title,
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid,
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
await logger?.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
downloadClientId,
'qbittorrent',
3 // Wait 3 seconds before first check to avoid race condition
);
await logger?.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
return {
success: true,
message: 'Torrent added to qBittorrent and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
torrentHash: downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders,
format: torrent.format,
},
};
}
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -107,7 +176,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Failed to add torrent to download client',
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
updatedAt: new Date(),
},
});
@@ -58,17 +58,52 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
const logger = jobId ? createJobLogger(jobId, 'MonitorDownload') : null;
try {
// Get download client service (currently only qBittorrent supported)
if (downloadClient !== 'qbittorrent') {
throw new Error(`Download client ${downloadClient} not yet supported`);
let progress: any;
let downloadPath: string | undefined;
if (downloadClient === 'qbittorrent') {
// qBittorrent flow
const qbt = await getQBittorrentService();
// Get torrent status with retry logic (handles race condition)
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
progress = qbt.getDownloadProgress(torrent);
// Store download path for later use
downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
} else if (downloadClient === 'sabnzbd') {
// SABnzbd flow
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
// Get NZB status
const nzbInfo = await sabnzbd.getNZB(downloadClientId);
if (!nzbInfo) {
throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`);
}
// Convert NZBInfo to progress format
progress = {
percent: nzbInfo.progress,
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
bytesTotal: nzbInfo.size,
speed: nzbInfo.downloadSpeed,
eta: nzbInfo.timeLeft,
state: nzbInfo.status,
};
// Store download path if available (only set after completion)
downloadPath = nzbInfo.downloadPath;
await logger?.info(`SABnzbd status: ${nzbInfo.status}`, {
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
});
} else {
throw new Error(`Download client ${downloadClient} not supported`);
}
const qbt = await getQBittorrentService();
// Get torrent status with retry logic (handles race condition)
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
const progress = qbt.getDownloadProgress(torrent);
// Update request progress
await prisma.request.update({
where: { id: requestId },
@@ -90,15 +125,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
if (progress.state === 'completed') {
await logger?.info(`Download completed for request ${requestId}`);
// Get torrent files to find download path
const files = await qbt.getFiles(downloadClientId);
// Determine actual content path for file organization
// Priority 1: Use content_path if provided by qBittorrent (most reliable)
// Priority 2: Construct path using path.join() for proper normalization
const qbPath = torrent.content_path
? torrent.content_path
: path.join(torrent.save_path, torrent.name);
// Ensure we have a download path
if (!downloadPath) {
throw new Error('Download path not available from download client');
}
// Load path mapping configuration
const configService = getConfigService();
@@ -109,19 +139,16 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
]);
// Apply remote-to-local path transformation if enabled
const organizePath = PathMapper.transform(qbPath, {
const organizePath = PathMapper.transform(downloadPath, {
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
remotePath: pathMappingConfig.download_client_remote_path || '',
localPath: pathMappingConfig.download_client_local_path || '',
});
await logger?.info(`Download completed`, {
filesCount: files.length,
torrentName: torrent.name,
savePath: torrent.save_path,
contentPath: torrent.content_path || '(not provided)',
qbittorrentPath: qbPath,
organizePath: organizePath !== qbPath ? `${organizePath} (mapped)` : organizePath,
downloadClient,
downloadPath,
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
});
// Update download history to completed