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
+25 -11
View File
@@ -70,9 +70,9 @@ export class AudibleService {
const $el = $(element);
// Extract ASIN from data attribute or link
// Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
@@ -156,8 +156,9 @@ export class AudibleService {
const $el = $(element);
// Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
@@ -231,29 +232,42 @@ export class AudibleService {
const audiobooks: AudibleAudiobook[] = [];
// Parse search results
$('.productListItem').each((index, element) => {
// Parse search results - Audible uses s-result-item for search pages
$('.s-result-item, .productListItem').each((index, element) => {
const $el = $(element);
// Extract ASIN from product detail link - handle both /pd/ and /ac/ URLs
const asin = $el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!asin) return;
const title = $el.find('h3 a').text().trim() ||
// Extract title from h2 tag (search results) or h3 (legacy)
const title = $el.find('h2').first().text().trim() ||
$el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('.authorLabel').text().trim() ||
// Extract author from author link
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
$el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('.narratorLabel').text().trim();
// Extract narrator from narrator search link
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const runtimeText = $el.find('.runtimeLabel').text().trim();
// Extract runtime/duration
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find('span:contains("Length:")').text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
const ratingText = $el.find('.ratingsLabel').text().trim();
// Extract rating
const ratingText = $el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
audiobooks.push({
+56 -2
View File
@@ -114,8 +114,10 @@ export class ProwlarrService {
.map((result: ProwlarrSearchResult) => this.transformResult(result))
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
// Apply filters
let filtered = results;
// Filter by protocol based on configured download client
let filtered = await this.filterByProtocol(results);
// Apply additional filters
if (filters?.minSeeders) {
filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
@@ -293,6 +295,58 @@ export class ProwlarrService {
return allResults;
}
/**
* Filter results based on configured download client protocol
* If qBittorrent is configured: only return torrent results
* If SABnzbd is configured: only return NZB results
*/
private async filterByProtocol(results: TorrentResult[]): Promise<TorrentResult[]> {
try {
// Get configured download client type
const { getConfigService } = await import('../services/config.service');
const config = await getConfigService();
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
if (clientType === 'sabnzbd') {
// Filter for NZB results only
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
return filtered;
} else {
// Filter for torrent results only (default)
const filtered = results.filter(result => !ProwlarrService.isNZBResult(result));
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} torrent results for qBittorrent`);
return filtered;
}
} catch (error) {
console.error('[Prowlarr] Failed to filter by protocol, returning all results:', error);
return results; // Fallback: return unfiltered if config fails
}
}
/**
* Detect if a result is an NZB download (Usenet) or torrent (BitTorrent)
* Static method for protocol detection
*/
static isNZBResult(result: TorrentResult): boolean {
const url = result.downloadUrl.toLowerCase();
// Check file extension
if (url.endsWith('.nzb')) {
return true;
}
// Check URL path
if (url.includes('/nzb/') || url.includes('&t=get')) {
return true;
}
// Check categories (3030 is audiobooks, but some indexers use Usenet-specific codes)
// Note: This is less reliable, so we prioritize URL patterns
return false;
}
/**
* Transform Prowlarr result to our TorrentResult format
*/
+527
View File
@@ -0,0 +1,527 @@
/**
* Component: SABnzbd Integration Service
* Documentation: documentation/phase3/sabnzbd.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
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;
}>;
}
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 disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
constructor(
baseUrl: string,
apiKey: string,
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.apiKey = apiKey;
this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
// 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 {
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
*/
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');
}
return {
version: config.version || '',
categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({
name,
dir: details.dir || '',
})),
};
}
/**
* Ensure the default category exists
* Creates category if it doesn't exist
*/
async ensureCategory(downloadPath?: string): Promise<void> {
try {
const config = await this.getConfig();
const categoryExists = config.categories.some(cat => cat.name === this.defaultCategory);
if (!categoryExists) {
console.log(`[SABnzbd] Creating category: ${this.defaultCategory}`);
// Create category
await this.client.get('/api', {
params: {
mode: 'set_config',
section: 'categories',
keyword: this.defaultCategory,
value: downloadPath || '',
output: 'json',
apikey: this.apiKey,
},
});
console.log(`[SABnzbd] Category created successfully: ${this.defaultCategory}`);
} else {
console.log(`[SABnzbd] Category already exists: ${this.defaultCategory}`);
}
} catch (error) {
console.error('[SABnzbd] Failed to ensure category:', error);
// Don't throw - category creation failure shouldn't block downloads
}
}
/**
* Add NZB by URL
* Returns the NZB ID
*/
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
const response = await this.client.get('/api', {
params: {
mode: 'addurl',
name: url,
cat: options?.category || this.defaultCategory,
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];
console.log(`[SABnzbd] 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
*/
async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise<void> {
await this.client.get('/api', {
params: {
mode: 'queue',
name: 'delete',
value: nzbId,
del_files: deleteFiles ? '1' : '0',
output: 'json',
apikey: this.apiKey,
},
});
}
/**
* 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;
export async function getSABnzbdService(): Promise<SABnzbdService> {
if (sabnzbdServiceInstance) {
return sabnzbdServiceInstance;
}
// Load configuration from database
const { getConfigService } = await import('../services/config.service');
const config = await getConfigService();
const url = await config.get('download_client_url');
const apiKey = await config.get('download_client_password'); // Reuse password field for API key
const category = (await config.get('sabnzbd_category')) || 'readmeabook';
const disableSSL = ((await config.get('download_client_disable_ssl_verify')) || 'false') === 'true';
if (!url) {
throw new Error('SABnzbd URL not configured. Please configure download client settings.');
}
if (!apiKey) {
throw new Error('SABnzbd API key not configured. Please configure download client settings.');
}
sabnzbdServiceInstance = new SABnzbdService(url, apiKey, category, disableSSL);
// Ensure category exists
const downloadDir = await config.get('download_dir');
await sabnzbdServiceInstance.ensureCategory(downloadDir || undefined);
return sabnzbdServiceInstance;
}
export function invalidateSABnzbdService(): void {
sabnzbdServiceInstance = null;
console.log('[SABnzbd] Service singleton invalidated');
}
+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
+2 -2
View File
@@ -51,7 +51,7 @@ export interface MonitorDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadClientId: string;
downloadClient: 'qbittorrent' | 'transmission';
downloadClient: 'qbittorrent' | 'sabnzbd';
}
export interface OrganizeFilesPayload extends JobPayload {
@@ -479,7 +479,7 @@ export class JobQueueService {
requestId: string,
downloadHistoryId: string,
downloadClientId: string,
downloadClient: 'qbittorrent' | 'transmission',
downloadClient: 'qbittorrent' | 'sabnzbd',
delaySeconds: number = 0
): Promise<string> {
return await this.addJob(
+37 -25
View File
@@ -331,36 +331,48 @@ export class RankingAlgorithm {
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
let titleScore = 0;
if (torrentTitle.includes(requestTitle)) {
// Found the title, but is it the complete title or part of a longer one?
const titleIndex = torrentTitle.indexOf(requestTitle);
const beforeTitle = torrentTitle.substring(0, titleIndex);
const afterTitle = torrentTitle.substring(titleIndex + requestTitle.length);
// Extract significant words BEFORE the matched title
const beforeWords = extractWords(beforeTitle, stopWords);
// Try matching with full title first, then fall back to required title (without parentheses)
const titlesToTry = [requestTitle];
if (requiredTitle !== requestTitle) {
titlesToTry.push(requiredTitle); // Add required-only version if different
}
// Title is complete if:
// 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl")
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
const hasNoWordsPrefix = beforeWords.length === 0;
const hasMetadataSuffix = afterTitle === '' ||
metadataMarkers.some(marker => afterTitle.startsWith(marker));
let bestMatch = false;
for (const titleToMatch of titlesToTry) {
if (torrentTitle.includes(titleToMatch)) {
// Found the title, but is it the complete title or part of a longer one?
const titleIndex = torrentTitle.indexOf(titleToMatch);
const beforeTitle = torrentTitle.substring(0, titleIndex);
const afterTitle = torrentTitle.substring(titleIndex + titleToMatch.length);
const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix;
// Extract significant words BEFORE the matched title
const beforeWords = extractWords(beforeTitle, stopWords);
if (isCompleteTitle) {
// Complete title match → full points
titleScore = 35;
} else {
// Title has prefix words OR continues with more words
// This is likely a different book in a series → use fuzzy similarity
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
// Title is complete if:
// 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl")
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
const hasNoWordsPrefix = beforeWords.length === 0;
const hasMetadataSuffix = afterTitle === '' ||
metadataMarkers.some(marker => afterTitle.startsWith(marker));
const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix;
if (isCompleteTitle) {
// Complete title match → full points
titleScore = 35;
bestMatch = true;
break; // Found a good match, stop trying
}
}
} else {
// No substring match at all → use fuzzy similarity
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
}
if (!bestMatch) {
// No complete match found, use fuzzy similarity as fallback
// Try against full title first, then required title
const fuzzyScores = titlesToTry.map(title => compareTwoStrings(title, torrentTitle));
titleScore = Math.max(...fuzzyScores) * 35;
}
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========