mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user