Add Transmission/NZBGet and per-client paths and much more

Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
kikootwo
2026-02-09 19:45:43 -05:00
parent d7acd67aa4
commit 4b90b35748
117 changed files with 9346 additions and 1488 deletions
+69
View File
@@ -0,0 +1,69 @@
/**
* Component: Audio Format Constants
* Documentation: documentation/phase3/file-organization.md
*
* Centralized audio format definitions used across the application.
* Add new formats here to enable support in all subsystems.
*/
/**
* All supported audio file extensions for audiobook detection and file organization.
* Used by: file-organizer.ts, files-hash.ts
*/
export const AUDIO_EXTENSIONS = [
'.m4b',
'.m4a',
'.mp3',
'.mp4',
'.aa',
'.aax',
'.flac',
'.ogg',
] as const;
/**
* Audio formats supported by the chapter merger (FFmpeg concat + M4B output).
* Formats here can be detected, probed, ordered, and merged into a single M4B.
* Note: .aa/.aax excluded (DRM-protected, cannot be decoded by FFmpeg without keys).
* Note: .ogg excluded (FFmpeg concat demuxer does not support Ogg container).
*/
export const CHAPTER_MERGE_FORMATS = [
'.mp3',
'.m4a',
'.m4b',
'.mp4',
'.aac',
'.flac',
] as const;
/**
* Audio formats supported by metadata tagging via FFmpeg.
* Each format maps to a specific FFmpeg output format flag and tagging strategy.
*/
export const METADATA_TAG_FORMATS = [
'.m4b',
'.m4a',
'.mp3',
'.mp4',
'.flac',
] as const;
/**
* Formats that use MP4/M4A container tags (iTunes-style metadata).
* These use `-f mp4` output format in FFmpeg.
*/
export const MP4_CONTAINER_FORMATS = ['.m4b', '.m4a', '.mp4'] as const;
/**
* Audio format identifiers detectable in torrent/NZB titles.
* Used by Prowlarr service for metadata extraction and ranking algorithm for scoring.
*/
export const TORRENT_TITLE_FORMATS = ['M4B', 'M4A', 'MP3', 'FLAC'] as const;
export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
/**
* Type helper for the format field on TorrentResult.
* 'OTHER' is used when no recognized format is detected in the title.
*/
export type AudioFormat = TorrentTitleFormat | 'OTHER';
+925
View File
@@ -0,0 +1,925 @@
/**
* 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`);
}
}
// =========================================================================
// 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');
}
+4 -7
View File
@@ -121,11 +121,6 @@ export class ProwlarrService {
filters?: SearchFilters
): Promise<TorrentResult[]> {
try {
// Get configured download client type to determine if we should filter by category
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
// Determine which categories to search
// Priority: filters.categories > filters.category > defaultCategory
let categoriesToSearch: number[];
@@ -560,20 +555,22 @@ export class ProwlarrService {
* Extract audiobook metadata from torrent title
*/
private extractMetadata(title: string): {
format?: 'M4B' | 'M4A' | 'MP3';
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC';
bitrate?: string;
hasChapters?: boolean;
} {
const upperTitle = title.toUpperCase();
// Detect format
let format: 'M4B' | 'M4A' | 'MP3' | undefined;
let format: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | undefined;
if (upperTitle.includes('M4B')) {
format = 'M4B';
} else if (upperTitle.includes('M4A')) {
format = 'M4A';
} else if (upperTitle.includes('MP3')) {
format = 'MP3';
} else if (upperTitle.includes('FLAC')) {
format = 'FLAC';
}
// Detect bitrate (e.g., "64kbps", "128 KBPS")
+219 -17
View File
@@ -5,10 +5,20 @@
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
@@ -59,7 +69,19 @@ export type TorrentState =
| 'checkingUP'
| 'error'
| 'missingFiles'
| 'allocating';
| 'allocating'
// Forced states (user clicked "Force Resume" in qBittorrent UI)
| 'forcedDL'
| 'forcedUP'
// Metadata fetching states
| 'metaDL'
| 'forcedMetaDL'
// qBittorrent v5.0+ renamed paused → stopped
| 'stoppedDL'
| 'stoppedUP'
// Other states
| 'checkingResumeData'
| 'moving';
export interface TorrentFile {
name: string;
@@ -78,7 +100,10 @@ export interface DownloadProgress {
state: string;
}
export class QBittorrentService {
export class QBittorrentService implements IDownloadClient {
readonly clientType: DownloadClientType = 'qbittorrent';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
@@ -209,7 +234,7 @@ export class QBittorrentService {
/**
* Add torrent (magnet link or file URL) - Enterprise Implementation
*/
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
async addTorrent(url: string, options?: AddTorrentOptions, retried = false): Promise<string> {
// Validate URL parameter
if (!url || typeof url !== 'string' || url.trim() === '') {
logger.error('Invalid download URL', { url });
@@ -236,11 +261,11 @@ export class QBittorrentService {
return await this.addTorrentFile(url, category, options);
}
} catch (error) {
// Try re-authenticating if we get a 403
if (axios.isAxiosError(error) && error.response?.status === 403) {
// Try re-authenticating once if we get a 403
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
logger.info('[QBittorrent] Session expired, re-authenticating...');
await this.login();
return this.addTorrent(url, options); // Retry once
return this.addTorrent(url, options, true);
}
logger.error('Failed to add torrent', { error: error instanceof Error ? error.message : String(error) });
@@ -279,12 +304,17 @@ export class QBittorrentService {
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload via 'urls' parameter
// Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's
// global seeding rules don't remove the torrent prematurely.
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
const form = new URLSearchParams({
urls: magnetUrl,
savepath: remoteSavePath,
category,
paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
ratioLimit: '-1',
seedingTimeLimit: '-1',
});
if (options?.tags) {
@@ -432,6 +462,9 @@ export class QBittorrentService {
formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
// Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle
formData.append('ratioLimit', '-1');
formData.append('seedingTimeLimit', '-1');
if (options?.tags) {
formData.append('tags', options.tags.join(','));
@@ -729,13 +762,28 @@ export class QBittorrentService {
/**
* Test connection to qBittorrent
*/
async testConnection(): Promise<boolean> {
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
return true;
// Fetch version after successful login
let version: string | undefined;
try {
const versionResponse = await this.client.get('/app/version', {
headers: { Cookie: this.cookie },
});
const raw = versionResponse.data || '';
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
} catch {
// Version fetch is non-critical - connection is still valid
logger.debug('Could not fetch qBittorrent version');
}
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
} catch (error) {
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
return false;
const message = error instanceof Error ? error.message : 'Connection failed';
logger.error('Connection test failed', { error: message });
return { success: false, message };
}
}
@@ -835,7 +883,8 @@ export class QBittorrentService {
version: versionResponse.data,
});
return versionResponse.data || 'Connected';
const rawVersion = versionResponse.data || '';
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('[QBittorrent] Test connection failed with axios error', {
@@ -931,6 +980,144 @@ export class QBittorrentService {
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Add a download via the unified interface.
* Delegates to addTorrent with sensible defaults for audiobook downloads.
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
return this.addTorrent(url, {
category: options?.category,
paused: options?.paused,
tags: ['audiobook'],
sequentialDownload: true,
});
}
/**
* Get download status via the unified interface.
* Includes retry logic to handle the race condition where a torrent
* isn't immediately available after being added.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const maxRetries = 3;
const initialDelayMs = 500;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const torrent = await this.getTorrent(id);
return this.mapTorrentToDownloadInfo(torrent);
} catch (error) {
const message = error instanceof Error ? error.message : '';
const isNotFound = message.includes('not found');
// If not a "not found" error, don't retry
if (!isNotFound) {
throw error;
}
// If this is the last attempt, return null
if (attempt === maxRetries) {
return null;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
/** Pause a download via the unified interface */
async pauseDownload(id: string): Promise<void> {
return this.pauseTorrent(id);
}
/** Resume a download via the unified interface */
async resumeDownload(id: string): Promise<void> {
return this.resumeTorrent(id);
}
/** Delete a download via the unified interface */
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
return this.deleteTorrent(id, deleteFiles);
}
/**
* Post-download cleanup via the unified interface.
* No-op for qBittorrent — torrents continue seeding until the
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
*/
async postProcess(_id: string): Promise<void> {
// No-op: torrents are managed by the seeding cleanup scheduler
}
/**
* Map a TorrentInfo object to the unified DownloadInfo format.
*/
private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
return {
id: torrent.hash,
name: torrent.name,
size: torrent.size,
bytesDownloaded: torrent.downloaded,
progress: torrent.progress,
status: this.mapStateToDownloadStatus(torrent.state),
downloadSpeed: torrent.dlspeed,
eta: torrent.eta,
category: torrent.category,
downloadPath: torrent.content_path || path.join(torrent.save_path, torrent.name),
completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined,
seedingTime: torrent.seeding_time,
ratio: torrent.ratio,
};
}
/**
* Map qBittorrent torrent state to unified DownloadStatus.
*/
private mapStateToDownloadStatus(state: TorrentState): DownloadStatus {
const stateMap: Record<TorrentState, DownloadStatus> = {
downloading: 'downloading',
uploading: 'seeding',
stalledDL: 'downloading',
stalledUP: 'seeding',
pausedDL: 'paused',
pausedUP: 'paused',
queuedDL: 'queued',
queuedUP: 'seeding',
checkingDL: 'checking',
checkingUP: 'checking',
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
// Forced states (user clicked "Force Resume" in qBittorrent UI)
forcedDL: 'downloading',
forcedUP: 'seeding',
// Metadata fetching states
metaDL: 'downloading',
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
};
return stateMap[state] || 'downloading';
}
// =========================================================================
// Legacy Methods (used internally and by direct callers)
// =========================================================================
/**
* Get download progress details
*/
@@ -963,6 +1150,18 @@ export class QBittorrentService {
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
// Forced states (user clicked "Force Resume" in qBittorrent UI)
forcedDL: 'downloading',
forcedUP: 'completed',
// Metadata fetching states
metaDL: 'downloading',
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
};
return stateMap[state] || 'unknown';
@@ -1032,8 +1231,11 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
}
// Get download_dir from main config (not part of client config)
const downloadDir = await configService.get('download_dir') || '/downloads';
// Get download_dir from main config, applying customPath if configured
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
// Path mapping configuration
const pathMappingConfig: PathMappingConfig = {
@@ -1055,10 +1257,10 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
// Test connection
logger.info('[QBittorrent] Testing connection...');
const isConnected = await qbittorrentService.testConnection();
if (!isConnected) {
logger.warn('[QBittorrent] Connection test failed');
throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.');
const connectionResult = await qbittorrentService.testConnection();
if (!connectionResult.success) {
logger.warn('[QBittorrent] Connection test failed', { message: connectionResult.message });
throw new Error(connectionResult.message || 'qBittorrent connection test failed. Please check your configuration in admin settings.');
} else {
logger.info('[QBittorrent] Connection test successful');
configLoaded = true; // Mark as successfully loaded
+234 -25
View File
@@ -5,8 +5,18 @@
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import FormData from 'form-data';
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('SABnzbd');
@@ -81,7 +91,10 @@ export interface DownloadProgress {
state: string;
}
export class SABnzbdService {
export class SABnzbdService implements IDownloadClient {
readonly clientType: DownloadClientType = 'sabnzbd';
readonly protocol: ProtocolType = 'usenet';
private client: AxiosInstance;
private baseUrl: string;
private apiKey: string;
@@ -123,13 +136,13 @@ export class SABnzbdService {
/**
* Test connection to SABnzbd
*/
async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> {
async testConnection(): Promise<ConnectionTestResult> {
try {
// Validate API key is not empty
if (!this.apiKey || this.apiKey.trim() === '') {
return {
success: false,
error: 'API key is required for SABnzbd',
message: 'API key is required for SABnzbd',
};
}
@@ -151,7 +164,7 @@ export class SABnzbdService {
const errorMsg = response.data?.error || 'Authentication failed';
return {
success: false,
error: errorMsg.includes('API Key')
message: errorMsg.includes('API Key')
? 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).'
: errorMsg,
};
@@ -160,7 +173,7 @@ export class SABnzbdService {
// 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 };
return { success: true, version, message: `Connected to SABnzbd v${version}` };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -168,28 +181,28 @@ export class SABnzbdService {
if (errorMessage.includes('ECONNREFUSED')) {
return {
success: false,
error: 'Connection refused. Is SABnzbd running and accessible at this URL?',
message: '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.',
message: '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.',
message: '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).',
message: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
};
}
return {
success: false,
error: errorMessage,
message: errorMessage,
};
}
}
@@ -447,8 +460,16 @@ export class SABnzbdService {
}
/**
* Add NZB by URL
* Returns the NZB ID
* Add NZB to SABnzbd
*
* Downloads the NZB file content from the source URL (typically a Prowlarr proxy URL)
* and uploads it directly to SABnzbd via mode=addfile. This ensures SABnzbd does not
* need network access to Prowlarr — RMAB acts as the intermediary, matching the pattern
* used by qBittorrent for .torrent files.
*
* @param url - NZB download URL (usually a Prowlarr proxy URL)
* @param options - Category, priority, and pause options
* @returns SABnzbd NZB ID (nzo_id)
*/
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
@@ -459,20 +480,70 @@ export class SABnzbdService {
// 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,
},
// Download the NZB file content from the source URL
// This decouples SABnzbd from needing direct network access to Prowlarr
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,
// Use the same SSL settings as the SABnzbd client if the NZB URL
// happens to be served over HTTPS with a self-signed cert
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`);
// Extract filename from Content-Disposition header, URL path, or use fallback
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 NZB file content to SABnzbd via mode=addfile (multipart POST)
const formData = new FormData();
formData.append('nzbfile', nzbBuffer, {
filename,
contentType: 'application/x-nzb',
});
formData.append('mode', 'addfile');
formData.append('cat', category);
formData.append('priority', this.mapPriority(options?.priority));
formData.append('pp', '3'); // Post-processing: +Repair, +Unpack, +Delete
formData.append('output', 'json');
formData.append('apikey', this.apiKey);
const response = await this.client.post('/api', formData, {
headers: formData.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
if (response.data?.status === false) {
throw new Error(response.data.error || 'Failed to add NZB');
throw new Error(response.data.error || 'Failed to add NZB to SABnzbd');
}
const nzbIds = response.data?.nzo_ids;
@@ -486,6 +557,39 @@ export class SABnzbdService {
return nzbId;
}
/**
* 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 {
// Try Content-Disposition header (e.g., 'attachment; filename="My.Audiobook.nzb"')
if (contentDisposition) {
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
if (match?.[1]) {
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
if (decoded) {
logger.debug(`Filename from Content-Disposition: ${decoded}`);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
}
}
// Try extracting from URL path (before query params)
try {
const urlPath = new URL(url).pathname;
const basename = urlPath.split('/').pop();
if (basename && basename.length > 0 && basename !== 'download') {
const decoded = decodeURIComponent(basename);
logger.debug(`Filename from URL path: ${decoded}`);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
} catch {
// URL parsing failed, fall through to default
}
return 'download.nzb';
}
/**
* Get NZB info by ID
* Checks queue first, then history
@@ -663,6 +767,108 @@ export class SABnzbdService {
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Add a download via the unified interface.
* Delegates to addNZB with mapped options.
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
const priorityMap: Record<string, 'low' | 'normal' | 'high' | 'force'> = {
low: 'low',
normal: 'normal',
high: 'high',
force: 'force',
};
return this.addNZB(url, {
category: options?.category,
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
paused: options?.paused,
});
}
/**
* Get download status via the unified interface.
* Checks both queue and history to find the NZB.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const nzbInfo = await this.getNZB(id);
if (!nzbInfo) {
return null;
}
return this.mapNZBInfoToDownloadInfo(nzbInfo);
}
/** Pause a download via the unified interface */
async pauseDownload(id: string): Promise<void> {
return this.pauseNZB(id);
}
/** Resume a download via the unified interface */
async resumeDownload(id: string): Promise<void> {
return this.resumeNZB(id);
}
/** Delete a download via the unified interface */
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
return this.deleteNZB(id, deleteFiles);
}
/**
* Post-download cleanup via the unified interface.
* Archives the completed NZB from SABnzbd history.
*/
async postProcess(id: string): Promise<void> {
await this.archiveCompletedNZB(id);
}
/**
* Map NZBInfo to the unified DownloadInfo format.
*/
private mapNZBInfoToDownloadInfo(nzb: NZBInfo): DownloadInfo {
return {
id: nzb.nzbId,
name: nzb.name,
size: nzb.size,
bytesDownloaded: Math.round(nzb.size * nzb.progress),
progress: nzb.progress,
status: this.mapNZBStatusToDownloadStatus(nzb.status),
downloadSpeed: nzb.downloadSpeed,
eta: nzb.timeLeft,
category: nzb.category,
downloadPath: nzb.downloadPath,
completedAt: nzb.completedAt,
errorMessage: nzb.errorMessage,
// Usenet has no seeding concept
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map SABnzbd NZB status to unified DownloadStatus.
*/
private mapNZBStatusToDownloadStatus(status: NZBStatus): DownloadStatus {
const statusMap: Record<NZBStatus, DownloadStatus> = {
downloading: 'downloading',
queued: 'queued',
paused: 'paused',
extracting: 'processing',
completed: 'completed',
failed: 'failed',
repairing: 'processing',
};
return statusMap[status] || 'downloading';
}
// =========================================================================
// Legacy Methods (used internally and by direct callers)
// =========================================================================
/**
* Get download progress from queue item
*/
@@ -796,8 +1002,11 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
}
// Get download_dir from main config
const downloadDir = await configService.get('download_dir') || '/downloads';
// Get download_dir from main config, applying customPath if configured
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
logger.debug('RMAB download_dir from config', { downloadDir });
@@ -0,0 +1,628 @@
/**
* Component: Transmission Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('Transmission');
/** Transmission RPC numeric status codes */
type TransmissionStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Transmission torrent fields we request */
interface TransmissionTorrent {
hashString: string;
name: string;
totalSize: number;
downloadedEver: number;
percentDone: number;
status: TransmissionStatus;
rateDownload: number;
eta: number;
labels: string[];
downloadDir: string;
doneDate: number;
errorString: string;
error: number;
secondsSeeding: number;
uploadRatio: number;
uploadedEver: number;
}
/** Fields we request from the Transmission RPC API */
const TORRENT_FIELDS = [
'hashString',
'name',
'totalSize',
'downloadedEver',
'percentDone',
'status',
'rateDownload',
'eta',
'labels',
'downloadDir',
'doneDate',
'errorString',
'error',
'secondsSeeding',
'uploadRatio',
'uploadedEver',
];
export class TransmissionService implements IDownloadClient {
readonly clientType: DownloadClientType = 'transmission';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private defaultSavePath: string;
private defaultCategory: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
private pathMappingConfig: PathMappingConfig;
private sessionId: string = '';
constructor(
baseUrl: string,
username: string,
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({ rejectUnauthorized: false });
logger.info('[Transmission] SSL certificate verification disabled');
}
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: this.httpsAgent,
});
}
/**
* Execute an RPC request to Transmission.
* Handles CSRF token (409 → capture X-Transmission-Session-Id → retry).
*/
private async rpc(method: string, args?: Record<string, any>): Promise<any> {
const body = { method, arguments: args };
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.sessionId) {
headers['X-Transmission-Session-Id'] = this.sessionId;
}
// Add Basic Auth if credentials provided
const auth = this.username
? { username: this.username, password: this.password }
: undefined;
try {
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
// Capture CSRF token and retry
const newSessionId = error.response.headers['x-transmission-session-id'];
if (newSessionId) {
this.sessionId = newSessionId;
headers['X-Transmission-Session-Id'] = this.sessionId;
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
return response.data;
}
}
throw error;
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
async testConnection(): Promise<ConnectionTestResult> {
try {
const data = await this.rpc('session-get', { fields: ['version'] });
if (data.result !== 'success') {
return { success: false, message: `Transmission RPC error: ${data.result}` };
}
const version = data.arguments?.version;
return {
success: true,
version,
message: `Connected to Transmission${version ? ` ${version}` : ''}`,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
if (axios.isAxiosError(error)) {
const code = error.code;
const status = error.response?.status;
if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
code === 'CERT_HAS_EXPIRED' || code?.includes('CERT') || code?.includes('SSL')) {
return { success: false, message: `SSL certificate verification failed (${code}). Enable "Disable SSL Verification" if you trust this server.` };
}
if (code === 'ECONNREFUSED') {
return { success: false, message: `Connection refused. Check if Transmission is running at: ${this.baseUrl}` };
}
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
return { success: false, message: `Connection timeout. Verify the URL is correct: ${this.baseUrl}` };
}
if (code === 'ENOTFOUND') {
return { success: false, message: `Host not found. Verify the address: ${this.baseUrl}` };
}
if (status === 401) {
return { success: false, message: 'Authentication failed. Check your username and password.' };
}
}
logger.error('Connection test failed', { error: message });
return { success: false, message };
}
}
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
if (!url || typeof url !== 'string' || url.trim() === '') {
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
}
const category = options?.category || this.defaultCategory;
if (url.startsWith('magnet:')) {
return this.addMagnetLink(url, category, options);
} else {
return this.addTorrentFile(url, category, options);
}
}
private async addMagnetLink(
magnetUrl: string,
category: string,
options?: AddDownloadOptions
): Promise<string> {
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) {
throw new Error('Invalid magnet link - could not extract info_hash');
}
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
// Check for duplicates
try {
await this.getTorrentByHash(infoHash);
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue
}
const localSavePath = this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
const args: Record<string, any> = {
filename: magnetUrl,
'download-dir': remoteSavePath,
paused: options?.paused || false,
labels: [category],
};
logger.info('[Transmission] Adding magnet link...');
const data = await this.rpc('torrent-add', args);
if (data.result !== 'success') {
throw new Error(`Transmission rejected magnet link: ${data.result}`);
}
// torrent-add returns torrent-added or torrent-duplicate
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
if (!added) {
throw new Error('Transmission did not return torrent info after adding');
}
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
await this.disableSeedLimits(added.hashString || infoHash);
logger.info(`Successfully added magnet link: ${infoHash}`);
return infoHash;
}
private async addTorrentFile(
torrentUrl: string,
category: string,
options?: AddDownloadOptions
): Promise<string> {
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer',
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300,
timeout: 30000,
});
// Check if response body is a magnet link
if (torrentResponse.data.length > 0) {
const responseText = torrentResponse.data.toString();
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
if (magnetMatch) {
logger.info('Response body is a magnet link');
return this.addMagnetLink(magnetMatch[0], category, options);
}
}
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) {
throw error;
}
const status = error.response.status;
if (status >= 300 && status < 400) {
const location = error.response.headers['location'];
if (location && location.startsWith('magnet:')) {
return this.addMagnetLink(location, category, options);
}
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
try {
torrentResponse = await axios.get(location, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
});
} catch {
throw new Error('Failed to download torrent file after redirect');
}
} else {
throw new Error(`Invalid redirect location: ${location}`);
}
} else {
throw new Error(`Failed to download torrent: HTTP ${status}`);
}
}
const torrentBuffer = Buffer.from(torrentResponse.data);
let parsedTorrentData: any;
try {
parsedTorrentData = await parseTorrent(torrentBuffer);
} catch {
throw new Error('Invalid .torrent file - failed to parse');
}
const infoHash = parsedTorrentData.infoHash;
if (!infoHash) {
throw new Error('Failed to extract info_hash from .torrent file');
}
logger.info(`Extracted info_hash: ${infoHash}`);
// Check for duplicates
try {
await this.getTorrentByHash(infoHash);
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue
}
const localSavePath = this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Transmission accepts base64-encoded .torrent content via 'metainfo' field
const metainfo = torrentBuffer.toString('base64');
const args: Record<string, any> = {
metainfo,
'download-dir': remoteSavePath,
paused: options?.paused || false,
labels: [category],
};
logger.info('[Transmission] Adding .torrent file...');
const data = await this.rpc('torrent-add', args);
if (data.result !== 'success') {
throw new Error(`Transmission rejected .torrent file: ${data.result}`);
}
// torrent-add returns torrent-added or torrent-duplicate
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
await this.disableSeedLimits(added?.hashString || infoHash);
logger.info(`Successfully added torrent: ${infoHash}`);
return infoHash;
}
async getDownload(id: string): Promise<DownloadInfo | null> {
const maxRetries = 3;
const initialDelayMs = 500;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const torrent = await this.getTorrentByHash(id);
return this.mapToDownloadInfo(torrent);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (!message.includes('not found')) {
throw error;
}
if (attempt === maxRetries) {
return null;
}
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
async pauseDownload(id: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-stop', { ids: [torrent.hashString] });
logger.info(`Paused torrent: ${id}`);
} catch (error) {
logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to pause torrent');
}
}
async resumeDownload(id: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-start', { ids: [torrent.hashString] });
logger.info(`Resumed torrent: ${id}`);
} catch (error) {
logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to resume torrent');
}
}
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-remove', {
ids: [torrent.hashString],
'delete-local-data': deleteFiles,
});
logger.info(`Deleted torrent: ${id}`);
} catch (error) {
logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to delete torrent');
}
}
/**
* Post-download cleanup.
* No-op for Transmission — torrents continue seeding until the
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
*/
async postProcess(_id: string): Promise<void> {
// No-op: torrents are managed by the seeding cleanup scheduler
}
// =========================================================================
// Internal Helpers
// =========================================================================
/**
* Disable Transmission's global seed ratio and idle time limits for a torrent.
* Mode 2 = unlimited (ignore global settings). RMAB manages torrent lifecycle
* via the cleanup-seeded-torrents processor using per-indexer seeding times.
*/
private async disableSeedLimits(hashOrId: string): Promise<void> {
try {
await this.rpc('torrent-set', {
ids: [hashOrId],
seedRatioMode: 2,
seedIdleMode: 2,
});
logger.info(`Disabled seed limits for torrent: ${hashOrId}`);
} catch (error) {
// Non-fatal — torrent was still added, just might get cleaned up by Transmission's rules
logger.warn(`Failed to disable seed limits for torrent ${hashOrId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get a torrent by its info hash.
*/
private async getTorrentByHash(hash: string): Promise<TransmissionTorrent> {
const data = await this.rpc('torrent-get', { ids: [hash], fields: TORRENT_FIELDS });
if (data.result !== 'success') {
throw new Error(`Transmission RPC error: ${data.result}`);
}
const torrents: TransmissionTorrent[] = data.arguments?.torrents || [];
if (torrents.length === 0) {
throw new Error(`Torrent ${hash} not found`);
}
return torrents[0];
}
/**
* Map Transmission torrent to unified DownloadInfo.
*/
private mapToDownloadInfo(torrent: TransmissionTorrent): DownloadInfo {
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = path.join(torrent.downloadDir, torrent.name);
return {
id: torrent.hashString,
name: torrent.name,
size: torrent.totalSize,
bytesDownloaded: torrent.downloadedEver,
progress: torrent.percentDone,
status: this.mapStatus(torrent.status, torrent.error),
downloadSpeed: torrent.rateDownload,
eta: torrent.eta < 0 ? 0 : torrent.eta,
category: torrent.labels?.[0] || '',
downloadPath,
completedAt: torrent.doneDate > 0 ? new Date(torrent.doneDate * 1000) : undefined,
errorMessage: torrent.error > 0 ? torrent.errorString : undefined,
seedingTime: torrent.secondsSeeding,
ratio: torrent.uploadRatio >= 0 ? torrent.uploadRatio : undefined,
};
}
/**
* Map Transmission numeric status to unified DownloadStatus.
* 0=stopped, 1=check-pending, 2=checking, 3=download-pending,
* 4=downloading, 5=seed-pending, 6=seeding
*/
private mapStatus(status: TransmissionStatus, errorCode: number): DownloadStatus {
if (errorCode > 0) {
return 'failed';
}
const statusMap: Record<TransmissionStatus, DownloadStatus> = {
0: 'paused',
1: 'checking',
2: 'checking',
3: 'queued',
4: 'downloading',
5: 'seeding',
6: 'seeding',
};
return statusMap[status] || 'downloading';
}
/**
* Extract info_hash from magnet link.
*/
private extractHashFromMagnet(magnetUrl: string): string | null {
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
if (match) {
return match[1].toLowerCase();
}
return null;
}
}
// Singleton factory (matches qBittorrent, SABnzbd, NZBGet pattern)
let transmissionServiceInstance: TransmissionService | null = null;
let configLoaded = false;
export async function getTransmissionService(): Promise<TransmissionService> {
if (transmissionServiceInstance && configLoaded) {
return transmissionServiceInstance;
}
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('[Transmission] Loading configuration from download client manager...');
const clientConfig = await manager.getClientForProtocol('torrent');
if (!clientConfig) {
throw new Error('Transmission is not configured. Please configure a Transmission client in the admin settings.');
}
if (clientConfig.type !== 'transmission') {
throw new Error(`Expected Transmission 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('[Transmission] Config loaded:', {
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasUsername: !!clientConfig.username,
hasPassword: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
downloadDir,
pathMappingEnabled: pathMappingConfig.enabled,
});
if (!clientConfig.url) {
throw new Error('Transmission is not fully configured. Please check your configuration in admin settings.');
}
transmissionServiceInstance = new TransmissionService(
clientConfig.url,
clientConfig.username || '',
clientConfig.password || '',
downloadDir,
clientConfig.category || 'readmeabook',
clientConfig.disableSSLVerify,
pathMappingConfig
);
const connectionResult = await transmissionServiceInstance.testConnection();
if (!connectionResult.success) {
throw new Error(connectionResult.message || 'Transmission connection test failed. Please check your configuration in admin settings.');
}
logger.info('[Transmission] Connection test successful');
configLoaded = true;
return transmissionServiceInstance;
} catch (error) {
logger.error('[Transmission] Failed to initialize service', {
error: error instanceof Error ? error.message : String(error),
});
transmissionServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateTransmissionService(): void {
transmissionServiceInstance = null;
configLoaded = false;
logger.info('[Transmission] Service singleton invalidated');
}
@@ -0,0 +1,180 @@
/**
* Component: Download Client Interface
* Documentation: documentation/phase3/download-clients.md
*
* Defines the contract all download clients must implement.
* Enables protocol-agnostic download management across torrent and usenet clients.
*/
// =========================================================================
// TYPE DEFINITIONS
// =========================================================================
/** Supported download client types — single source of truth */
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const;
/** Identifies the specific download client software */
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
/** Human-readable display names for each client type */
export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
qbittorrent: 'qBittorrent',
sabnzbd: 'SABnzbd',
nzbget: 'NZBGet',
transmission: 'Transmission',
};
/** Get display name for a client type, falling back to the raw type */
export function getClientDisplayName(type: string): string {
return CLIENT_DISPLAY_NAMES[type as DownloadClientType] || type;
}
/** The download protocol a client operates on */
export type ProtocolType = 'torrent' | 'usenet';
/** Maps each client type to its download protocol */
export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
qbittorrent: 'torrent',
sabnzbd: 'usenet',
nzbget: 'usenet',
transmission: 'torrent',
};
/** Unified download status across all clients */
export type DownloadStatus =
| 'downloading'
| 'completed'
| 'seeding'
| 'paused'
| 'queued'
| 'failed'
| 'processing'
| 'checking';
// =========================================================================
// DATA INTERFACES
// =========================================================================
/**
* Unified download information returned by all clients.
* Normalizes torrent and NZB data into a single shape.
*/
export interface DownloadInfo {
/** Client-assigned identifier (torrent hash or NZB ID) */
id: string;
/** Display name of the download */
name: string;
/** Total size in bytes */
size: number;
/** Bytes downloaded so far */
bytesDownloaded: number;
/** Download progress from 0.0 to 1.0 */
progress: number;
/** Normalized download status */
status: DownloadStatus;
/** Current download speed in bytes/sec */
downloadSpeed: number;
/** Estimated time remaining in seconds */
eta: number;
/** Category/label assigned to this download */
category: string;
/** Filesystem path where download is stored (available after completion) */
downloadPath?: string;
/** When the download completed */
completedAt?: Date;
/** Error message if download failed */
errorMessage?: string;
/** Time spent seeding in seconds (torrent clients only) */
seedingTime?: number;
/** Upload/download ratio (torrent clients only) */
ratio?: number;
}
/** Options for adding a new download */
export interface AddDownloadOptions {
/** Category/label to assign */
category?: string;
/** Priority level (interpretation varies by client) */
priority?: string;
/** Whether to add in paused state */
paused?: boolean;
}
/** Result of a connection test */
export interface ConnectionTestResult {
success: boolean;
message?: string;
version?: string;
}
// =========================================================================
// DOWNLOAD CLIENT INTERFACE
// =========================================================================
/**
* IDownloadClient — the contract every download client must implement.
*
* Provides a unified API for managing downloads across different protocols
* and client software. Processors interact with this interface exclusively,
* enabling new download clients to be added without modifying consumer code.
*
* To add a new client (e.g., Transmission):
* 1. Create a service class implementing IDownloadClient
* 2. Add the type to DownloadClientType
* 3. Add factory case in DownloadClientManager
*/
export interface IDownloadClient {
/** Identifies the client software (e.g., 'qbittorrent', 'sabnzbd') */
readonly clientType: DownloadClientType;
/** The protocol this client operates on */
readonly protocol: ProtocolType;
/**
* Test the connection to the download client.
* @returns Connection test result with success/failure and optional version
*/
testConnection(): Promise<ConnectionTestResult>;
/**
* Add a new download.
* @param url - Download URL (magnet link, .torrent URL, or .nzb URL)
* @param options - Optional download settings
* @returns Client-assigned download ID (torrent hash or NZB ID)
*/
addDownload(url: string, options?: AddDownloadOptions): Promise<string>;
/**
* Get current status of a download.
* Includes retry logic for race conditions (e.g., torrent not immediately available after adding).
* @param id - Download ID returned by addDownload
* @returns Download info, or null if not found
*/
getDownload(id: string): Promise<DownloadInfo | null>;
/**
* Pause a download.
* @param id - Download ID
*/
pauseDownload(id: string): Promise<void>;
/**
* Resume a paused download.
* @param id - Download ID
*/
resumeDownload(id: string): Promise<void>;
/**
* Delete a download from the client.
* @param id - Download ID
* @param deleteFiles - Whether to also delete downloaded files (default: false)
*/
deleteDownload(id: string, deleteFiles?: boolean): Promise<void>;
/**
* Perform post-download cleanup specific to the client.
* - qBittorrent: No-op (torrents continue seeding, handled by cleanup job)
* - SABnzbd: Archives the completed NZB from history
* @param id - Download ID
*/
postProcess(id: string): Promise<void>;
}
+33
View File
@@ -220,3 +220,36 @@ export async function isLocalAdmin(userId: string): Promise<boolean> {
return user.isSetupAdmin && user.plexId.startsWith('local-');
}
/**
* Middleware: Require setup to be incomplete
* Blocks access to setup-only endpoints after initial setup is finished.
* Returns 403 if setup is already complete, otherwise invokes the handler.
*/
export async function requireSetupIncomplete(
request: NextRequest,
handler: (request: NextRequest) => Promise<NextResponse>
): Promise<NextResponse> {
try {
const config = await prisma.configuration.findUnique({
where: { key: 'setup_completed' },
});
if (config?.value === 'true') {
logger.warn('Setup endpoint called after setup is complete', {
path: request.nextUrl.pathname,
});
return NextResponse.json(
{
error: 'Forbidden',
message: 'Setup has already been completed',
},
{ status: 403 }
);
}
} catch {
// If database is not ready, setup is definitely not complete — allow through
}
return handler(request);
}
@@ -2,11 +2,13 @@
* Component: Cleanup Seeded Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Cleans up torrents that have met their seeding requirements
* Cleans up downloads that have met their seeding requirements.
* Uses the IDownloadClient interface for client-agnostic operation.
*/
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
export interface CleanupSeededTorrentsPayload {
jobId?: string;
@@ -22,7 +24,9 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
try {
// Get indexer configuration with per-indexer seeding times
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
@@ -44,22 +48,28 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
// Find all completed requests + soft-deleted requests (orphaned downloads)
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
// Before deleting torrent, we check if other active requests are using it
// NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
// NOTE: Ebooks downloaded via indexer search use torrent clients and need seeding cleanup too.
// Direct HTTP ebook downloads are naturally skipped (no torrent hash / unknown client type).
const completedRequests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
OR: [
// Active requests that are fully available (scanned by Plex/ABS)
// Audiobook requests that are fully available (matched in Plex/ABS)
{
type: 'audiobook',
status: 'available',
deletedAt: null,
},
// Soft-deleted requests (orphaned downloads)
// We'll check if torrent is shared with active requests before deletion
// Ebook requests that are fully downloaded (terminal state for ebooks)
{
type: 'ebook',
status: 'downloaded',
deletedAt: null,
},
// Soft-deleted requests of any type (orphaned downloads)
{
deletedAt: { not: null },
},
@@ -78,11 +88,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
take: 100, // Limit to 100 requests per run
});
logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
logger.info(`Found ${completedRequests.length} requests to check (audiobook: available, ebook: downloaded, or soft-deleted)`);
let cleaned = 0;
let skipped = 0;
let noConfig = 0;
const deletedHashes = new Set<string>(); // Track torrents already deleted this run
for (const request of completedRequests) {
try {
@@ -92,18 +103,27 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
// Skip SABnzbd downloads - Usenet doesn't have seeding concept
// Skip Usenet downloads - no seeding concept
if (downloadHistory.nzbId && !downloadHistory.torrentHash) {
// For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed)
// For soft-deleted Usenet requests, hard delete immediately (no seeding needed)
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned SABnzbd request ${request.id}`);
logger.info(`Hard-deleted orphaned Usenet request ${request.id}`);
}
continue;
}
// Only process torrent downloads
if (!downloadHistory.torrentHash) {
// Only process downloads that have a client ID
if (!downloadHistory.downloadClientId && !downloadHistory.torrentHash) {
continue;
}
// Determine the download client ID and protocol
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash!;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType}, skipping`);
continue;
}
@@ -126,20 +146,40 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
// Get torrent info from qBittorrent to check seeding time
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
// Skip if this torrent was already deleted earlier in this run
if (deletedHashes.has(clientId.toLowerCase())) {
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (torrent already cleaned this run)`);
}
cleaned++;
continue;
}
let torrent;
// Get download info from the appropriate client via the interface
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (!client) {
logger.warn(`No ${clientType} client configured, skipping request ${request.id}`);
skipped++;
continue;
}
let downloadInfo;
try {
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
downloadInfo = await client.getDownload(clientId);
} catch (error) {
// Torrent might already be deleted, skip
// Download not found in client (already removed), skip
continue;
}
if (!downloadInfo) {
// Download not found in client (already removed)
continue;
}
// Check if seeding time requirement is met
const actualSeedingTime = torrent.seeding_time || 0;
const actualSeedingTime = downloadInfo.seedingTime || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (!hasMetRequirement) {
@@ -148,47 +188,49 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
// CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash
// This prevents deleting shared torrents when user re-requests the same audiobook
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
type: 'audiobook', // Only check audiobook requests
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: downloadHistory.torrentHash,
selected: true,
// CRITICAL: Check if any other active (non-deleted) request is using this same download
const hashToCheck = downloadHistory.torrentHash;
if (hashToCheck) {
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: hashToCheck,
selected: true,
},
},
},
},
select: { id: true, status: true },
});
select: { id: true, status: true },
});
if (otherActiveRequests.length > 0) {
logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
if (otherActiveRequests.length > 0) {
logger.info(`Skipping download deletion - ${otherActiveRequests.length} other active request(s) still using this download (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
// If this is a soft-deleted request, hard delete it but DON'T delete the download
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared download for active requests)`);
}
skipped++;
continue;
}
skipped++;
continue;
}
// Safe to delete - no other active requests using this torrent
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
// Safe to delete - no other active requests using this download
await client.deleteDownload(clientId, true); // true = delete files
deletedHashes.add(clientId.toLowerCase());
// If this is a soft-deleted request (orphaned download), hard delete it now
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`);
} else {
logger.info(`Deleted torrent and files for active request ${request.id}`);
logger.info(`Deleted download and files for active request ${request.id}`);
}
cleaned++;
@@ -197,7 +239,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
}
}
logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
return {
success: true,
@@ -78,7 +78,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay
// Get download configuration
const configService = getConfigService();
const downloadsDir = await configService.get('downloads_dir') || '/downloads';
const downloadsDir = await configService.get('download_dir') || '/downloads';
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
+62 -130
View File
@@ -5,8 +5,6 @@
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 { getDownloadClientManager } from '../services/download-client-manager.service';
import { ProwlarrService } from '../integrations/prowlarr.service';
@@ -14,7 +12,7 @@ import { RMABLogger } from '../utils/logger';
/**
* Process download job
* Routes to appropriate download client based on configuration
* Routes to appropriate download client based on protocol detection
* Adds selected result to download client and starts monitoring
*/
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
@@ -41,151 +39,85 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
},
});
// Detect protocol from result and route to appropriate client
// Detect protocol from result and get appropriate client
const isUsenet = ProwlarrService.isNZBResult(torrent);
const protocol = isUsenet ? 'usenet' : 'torrent';
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent');
const client = await manager.getClientServiceForProtocol(protocol);
if (!clientConfig) {
const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)';
throw new Error(`No ${protocol} client configured`);
if (!client) {
throw new Error(`No ${protocol} download client configured. Please add a ${protocol} client in Settings > Download Clients.`);
}
let downloadClientId: string;
let downloadClient: 'qbittorrent' | 'sabnzbd';
// Get client config for category
const clientConfig = await manager.getClientForProtocol(protocol);
const category = clientConfig?.category || 'readmeabook';
if (isUsenet) {
// Route to SABnzbd
logger.info(`Routing to SABnzbd`);
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
const sabnzbd = await getSABnzbdService();
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
category: clientConfig.category || 'readmeabook',
priority: 'normal',
});
downloadClient = 'sabnzbd';
// Add download via unified interface
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
category,
priority: 'normal',
});
logger.info(`NZB added with ID: ${downloadClientId}`);
logger.info(`Download added with ID: ${downloadClientId}`);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'sabnzbd',
downloadClientId,
torrentName: torrent.title,
nzbId: downloadClientId, // Store NZB ID
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
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(),
},
});
logger.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
downloadHistory.id,
indexerName: torrent.indexer,
indexerId: torrent.indexerId,
downloadClient: client.clientType,
downloadClientId,
'sabnzbd',
3 // Wait 3 seconds before first check
);
torrentName: torrent.title,
// Set protocol-specific ID fields for backward compatibility
torrentHash: client.protocol === 'torrent' ? (torrent.infoHash || downloadClientId) : undefined,
nzbId: client.protocol === 'usenet' ? downloadClientId : undefined,
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl,
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
logger.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
logger.info(`Created download history record: ${downloadHistory.id}`);
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)
logger.info(`Routing to qBittorrent`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
downloadClientId,
client.clientType,
3 // Wait 3 seconds before first check
);
const qbt = await getQBittorrentService();
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
category: clientConfig.category || 'readmeabook',
tags: ['audiobook'],
sequentialDownload: true,
paused: false,
});
downloadClient = 'qbittorrent';
logger.info(`Started monitoring job for request ${requestId} (${client.clientType}, 3s initial delay)`);
logger.info(`Torrent added with hash: ${downloadClientId}`);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'qbittorrent',
downloadClientId,
torrentName: torrent.title,
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
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
);
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 || 0,
format: torrent.format,
},
};
}
return {
success: true,
message: `Download added to ${client.clientType} and monitoring started`,
requestId,
downloadHistoryId: downloadHistory.id,
downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders || 0,
format: torrent.format,
},
};
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -3,50 +3,13 @@
* Documentation: documentation/phase3/README.md
*/
import path from 'path';
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
/**
* Helper function to retry getTorrent with exponential backoff
* Handles race condition where torrent isn't immediately available after adding
*/
async function getTorrentWithRetry(
qbt: any,
hash: string,
logger: RMABLogger,
maxRetries: number = 3,
initialDelayMs: number = 500
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await qbt.getTorrent(hash);
} catch (error) {
lastError = error as Error;
// If this is the last attempt, throw the error
if (attempt === maxRetries - 1) {
break;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// All retries failed
throw lastError || new Error('Failed to get torrent after retries');
}
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
/**
* Process monitor download job
@@ -59,57 +22,42 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
try {
let progress: any;
let downloadPath: string | undefined;
// Get the download client service via the manager
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType];
if (!protocol) {
throw new Error(`Unknown download client type: ${downloadClient}`);
}
const client = await manager.getClientServiceForProtocol(protocol);
if (downloadClient === 'qbittorrent') {
// qBittorrent flow
const qbt = await getQBittorrentService();
if (!client) {
throw new Error(`No ${downloadClient} client configured`);
}
// Get torrent status with retry logic (handles race condition)
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
progress = qbt.getDownloadProgress(torrent);
// Get download status via unified interface
const info = await client.getDownload(downloadClientId);
// 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();
if (!info) {
throw new Error(`Download ${downloadClientId} not found in ${downloadClient}`);
}
// Get NZB status
const nzbInfo = await sabnzbd.getNZB(downloadClientId);
// Build progress object for request updates
const progressPercent = Math.round(info.progress * 100);
const progressState = info.status;
if (!nzbInfo) {
throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`);
}
// Convert NZBInfo to progress format
progress = {
percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format)
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;
logger.info(`SABnzbd status: ${nzbInfo.status}`, {
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
if (client.protocol === 'usenet') {
logger.info(`${client.clientType} status: ${info.status}`, {
progress: `${(info.progress * 100).toFixed(1)}%`,
speed: `${(info.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
});
} else {
throw new Error(`Download client ${downloadClient} not supported`);
}
// Update request progress
await prisma.request.update({
where: { id: requestId },
data: {
progress: progress.percent,
progress: progressPercent,
updatedAt: new Date(),
},
});
@@ -118,23 +66,21 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: progress.state,
downloadStatus: progressState,
},
});
// Check download state
if (progress.state === 'completed') {
if (progressState === 'completed' || progressState === 'seeding') {
logger.info(`Download completed for request ${requestId}`);
// Ensure we have a download path
const downloadPath = info.downloadPath;
if (!downloadPath) {
throw new Error('Download path not available from download client');
}
// Get path mapping configuration from the specific download client
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
// Build path mapping config from client settings
@@ -150,17 +96,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
const organizePath = PathMapper.transform(downloadPath, pathMappingConfig);
logger.info(`Download completed`, {
downloadClient,
downloadClient: client.clientType,
downloadPath,
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
});
// Update download history to completed
// Update download history to completed (store mapped path for retry reliability)
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: 'completed',
completedAt: new Date(),
downloadPath: organizePath,
},
});
@@ -197,10 +144,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
progress: 100,
downloadPath: organizePath,
};
} else if (progress.state === 'failed') {
} else if (progressState === 'failed') {
logger.error(`Download failed for request ${requestId}`);
const errorMessage = 'Download failed in qBittorrent';
const errorMessage = `Download failed in ${client.clientType}`;
// Update request to failed
await prisma.request.update({
@@ -249,7 +196,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
completed: true,
message: 'Download failed',
requestId,
progress: progress.percent,
progress: progressPercent,
};
} else {
// Still downloading - schedule another check in 10 seconds
@@ -263,11 +210,11 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
);
// Only log every 5% progress to reduce log spam
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5;
if (shouldLog) {
logger.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
speed: progress.speed,
eta: progress.eta,
logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, {
speed: info.downloadSpeed,
eta: info.eta,
});
}
@@ -276,20 +223,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
completed: false,
message: 'Download in progress, monitoring continues',
requestId,
progress: progress.percent,
speed: progress.speed,
eta: progress.eta,
state: progress.state,
progress: progressPercent,
speed: info.downloadSpeed,
eta: info.eta,
state: progressState,
};
}
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Check if this is a transient "torrent not found" error
// Check if this is a transient "not found" error
const errorMessage = error instanceof Error ? error.message : '';
const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found');
const isNotFound = errorMessage.includes('not found');
if (isTorrentNotFound) {
if (isNotFound) {
// Transient error - don't mark request as failed, let Bull retry
// The request stays in 'downloading' status until Bull exhausts all retries
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
+141 -197
View File
@@ -9,6 +9,8 @@ import { getFileOrganizer } from '../utils/file-organizer';
import { RMABLogger } from '../utils/logger';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
@@ -242,106 +244,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
);
}
// Cleanup Usenet downloads if configured
try {
logger.info('Checking if cleanup is needed for this download');
// Get download history to find NZB ID and indexer
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
// Note: We only archive from history, not queue. If the NZB is still in the queue
// when we're organizing files, something went wrong with the download monitoring.
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
return {
success: true,
@@ -592,12 +496,20 @@ async function processEbookOrganization(
const isIndexerDownload = downloadHistory?.downloadClient !== 'direct';
logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`);
// Get file organizer and template
// Get file organizer and ebook-specific template (falls back to audiobook template)
const organizer = await getFileOrganizer();
const templateConfig = await prisma.configuration.findUnique({
where: { key: 'audiobook_path_template' },
const ebookTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'ebook_path_template' },
});
const template = templateConfig?.value || '{author}/{title} {asin}';
let template: string;
if (ebookTemplateConfig?.value) {
template = ebookTemplateConfig.value;
} else {
const audiobookTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'audiobook_path_template' },
});
template = audiobookTemplateConfig?.value || '{author}/{title} {asin}';
}
// Check if Kindle EPUB fix is needed
let effectiveDownloadPath = downloadPath;
@@ -739,99 +651,8 @@ async function processEbookOrganization(
logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`);
}
// Cleanup Usenet downloads if configured (same logic as audiobooks)
try {
logger.info('Checking if cleanup is needed for ebook download');
// downloadHistory was already fetched earlier in this function
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
return {
success: true,
@@ -932,6 +753,129 @@ async function createEbookRequestIfEnabled(
}
}
// =========================================================================
// DOWNLOAD CLEANUP
// =========================================================================
/**
* Cleanup download files and archive from download client after successful organization.
* Uses the IDownloadClient.postProcess() method for client-specific cleanup (e.g., SABnzbd archive).
* Shared between audiobook and ebook organization flows.
*/
async function cleanupDownloadAfterOrganize(
requestId: string,
downloadPath: string,
configService: any,
jobId: string | undefined,
logger: RMABLogger
): Promise<void> {
try {
logger.info('Checking if cleanup is needed for this download');
// Get download history to find client ID and indexer
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasDownloadClientId: !!downloadHistory?.downloadClientId,
hasIndexerId: !!downloadHistory?.indexerId,
downloadClient: downloadHistory?.downloadClient || 'none',
});
if (!downloadHistory?.indexerId || !downloadHistory?.downloadClientId) {
return;
}
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
if (!indexersConfig) {
return;
}
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a non-torrent indexer with cleanup enabled
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) {
return;
}
logger.info(`Cleaning up download ${downloadHistory.downloadClientId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then use the download client interface for client-specific post-processing
// (e.g., usenet clients archive from history, torrent clients are a no-op)
const clientType = downloadHistory.downloadClient;
if (clientType && clientType !== 'direct') {
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType}, skipping post-processing`);
return;
}
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (client) {
await client.postProcess(downloadHistory.downloadClientId);
logger.info(`Successfully post-processed download ${downloadHistory.downloadClientId} via ${client.clientType}`);
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
}
// =========================================================================
// HELPER FUNCTIONS
// =========================================================================
@@ -2,15 +2,18 @@
* Component: Retry Failed Imports Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries file organization for requests that are awaiting import
* Retries file organization for requests that are awaiting import.
* Uses the IDownloadClient interface for client-agnostic path resolution.
*/
import path from 'path';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getJobQueueService } from '../services/job-queue.service';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface';
export interface RetryFailedImportsPayload {
jobId?: string;
@@ -30,7 +33,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
// Helper function to get path mapping config for a specific download client type
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent';
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig && clientConfig.remotePathMappingEnabled) {
@@ -43,11 +46,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
return { enabled: false, remotePath: '', localPath: '' };
};
// Find all active audiobook requests in awaiting_import status
// Note: Ebook requests use the same organize_files processor but with type branching
// Find all requests in awaiting_import status (both audiobook and ebook)
// The organize_files processor handles both types with type-based branching
const requests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
status: 'awaiting_import',
deletedAt: null,
},
@@ -90,111 +92,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
let downloadPath: string;
// Try to get download path from the appropriate download client
// Get path mapping for this specific download client
const clientType = downloadHistory.downloadClient || 'qbittorrent';
const mappingConfig = await getPathMappingForClient(clientType);
if (downloadHistory.torrentHash) {
// qBittorrent download
try {
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
const qbPath = `${torrent.save_path}/${torrent.name}`;
downloadPath = PathMapper.transform(qbPath, mappingConfig);
logger.info(
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
(downloadPath !== qbPath ? `${downloadPath} (mapped)` : '')
);
} catch (qbtError) {
// Torrent not found in qBittorrent - try to construct path from config
logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
if (!downloadHistory.torrentName) {
logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
(downloadPath !== fallbackPath ? `${downloadPath} (mapped)` : '')
);
}
} else if (downloadHistory.nzbId) {
// SABnzbd download
try {
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
if (nzbInfo && nzbInfo.downloadPath) {
downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig);
logger.info(
`Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` +
(downloadPath !== nzbInfo.downloadPath ? `${downloadPath} (mapped)` : '')
);
} else {
logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
if (!downloadHistory.torrentName) {
logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
(downloadPath !== fallbackPath ? `${downloadPath} (mapped)` : '')
);
}
} catch (sabnzbdError) {
logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
skipped++;
continue;
}
// Direct downloads (e.g. Anna's Archive ebooks) have no external download client
// Use stored path or construct from download_dir directly
if (clientType === 'direct') {
const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' };
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger);
} else {
// No download client ID - use fallback path
if (!downloadHistory.torrentName) {
logger.warn(`No download client ID or name for request ${request.id}, skipping`);
// Real download client — resolve path via client API with path mapping
const mappingConfig = await getPathMappingForClient(clientType);
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined;
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (clientId) {
// Try to get path from download client via unified interface
const client = await manager.getClientServiceForProtocol(protocol);
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
if (client) {
try {
const info = await client.getDownload(clientId);
if (info?.downloadPath) {
downloadPath = PathMapper.transform(info.downloadPath, mappingConfig);
logger.info(
`Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` +
(downloadPath !== info.downloadPath ? `${downloadPath} (mapped)` : '')
);
} else {
// Download found but no path — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} catch (clientError) {
// Client error — try stored path, then fallback
logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`);
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client configured — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client ID — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
}
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
logger.info(
`Using configured download path for request ${request.id}: ${configuredPath}` +
(downloadPath !== configuredPath ? `${downloadPath} (mapped)` : '')
);
// Check if we got a valid path (getFallbackPath returns empty string on failure)
if (!downloadPath) {
skipped++;
continue;
}
await jobQueue.addOrganizeJob(
@@ -203,7 +156,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
downloadPath
);
triggered++;
logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
} catch (error) {
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
@@ -224,3 +177,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
throw error;
}
}
/**
* Return the stored download path from the database (saved at download completion time).
* Returns empty string if not available (old records won't have this field).
*/
function getStoredPath(
downloadHistory: { downloadPath?: string | null },
requestId: string,
logger: RMABLogger
): string {
if (downloadHistory.downloadPath) {
logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`);
return downloadHistory.downloadPath;
}
return '';
}
/**
* Construct a fallback download path from config when the download client can't provide one.
* Returns empty string if path cannot be determined (caller should skip the request).
*/
async function getFallbackPath(
downloadHistory: { torrentName: string | null },
configService: any,
mappingConfig: PathMappingConfig,
requestId: string,
logger: RMABLogger,
manager?: DownloadClientManager,
protocol?: ProtocolType
): Promise<string> {
if (!downloadHistory.torrentName) {
logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`);
return '';
}
const baseDir = await configService.get('download_dir');
if (!baseDir) {
logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`);
return '';
}
// Resolve customPath from the client config if available
let downloadDir = baseDir;
if (manager && protocol) {
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig?.customPath) {
downloadDir = path.join(baseDir, clientConfig.customPath);
}
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
const mappedPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${requestId}: ${fallbackPath}` +
(mappedPath !== fallbackPath ? `${mappedPath} (mapped)` : '')
);
return mappedPath;
}
+7 -2
View File
@@ -243,9 +243,14 @@ async function searchIndexers(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by their EBOOK category configuration
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
@@ -58,9 +58,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Group indexers by their category configuration
// This minimizes API calls while ensuring each indexer only searches its configured categories
const groups = groupIndexersByCategories(indexersConfig);
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
@@ -2,37 +2,41 @@
* Component: Download Client Manager Service
* Documentation: documentation/phase3/download-clients.md
*
* Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
* Manages multiple download clients (qBittorrent, Transmission, SABnzbd, NZBGet) with protocol-based routing.
* Supports migration from legacy single-client config to multi-client JSON array format.
*/
import { randomUUID } from 'crypto';
import path from 'path';
import { ConfigurationService } from './config.service';
import { getEncryptionService } from './encryption.service';
import { isEncryptedFormat } from './credential-migration.service';
import { RMABLogger } from '@/lib/utils/logger';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { NZBGetService } from '@/lib/integrations/nzbget.service';
import { TransmissionService } from '@/lib/integrations/transmission.service';
import { PathMappingConfig } from '@/lib/utils/path-mapper';
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('DownloadClientManager');
export interface DownloadClientConfig {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
enabled: boolean;
url: string;
username?: string; // qBittorrent only
password: string; // Password (qBittorrent) or API key (SABnzbd)
username?: string; // qBittorrent/Transmission/NZBGet only
password: string; // Password (qBittorrent/Transmission/NZBGet) or API key (SABnzbd)
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean;
remotePath?: string;
localPath?: string;
category?: string; // Default: 'readmeabook'
customPath?: string; // Relative sub-path appended to download_dir
}
type ProtocolType = 'torrent' | 'usenet';
/**
* Download Client Manager
@@ -47,6 +51,7 @@ export class DownloadClientManager {
private static instance: DownloadClientManager | null = null;
private configService: ConfigurationService;
private clientsCache: DownloadClientConfig[] | null = null;
private serviceCache: Map<string, IDownloadClient> = new Map();
private migrationPerformed = false;
private constructor(configService: ConfigurationService) {
@@ -69,6 +74,7 @@ export class DownloadClientManager {
static invalidate(): void {
if (DownloadClientManager.instance) {
DownloadClientManager.instance.clientsCache = null;
DownloadClientManager.instance.serviceCache.clear();
DownloadClientManager.instance.migrationPerformed = false;
logger.debug('Download client cache invalidated');
}
@@ -127,16 +133,17 @@ export class DownloadClientManager {
}
/**
* Get client for specific protocol
* Get client for specific protocol.
* Uses CLIENT_PROTOCOL_MAP so any client type matching the protocol is found
* (e.g. both qBittorrent and Transmission can serve the 'torrent' protocol).
*/
async getClientForProtocol(protocol: ProtocolType): Promise<DownloadClientConfig | null> {
const clients = await this.getAllClients();
const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
const client = clients.find(c => c.enabled && c.type === targetType);
const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol);
if (!client) {
logger.warn(`No enabled ${targetType} client configured`);
logger.warn(`No enabled ${protocol} client configured`);
return null;
}
@@ -152,36 +159,83 @@ export class DownloadClientManager {
}
/**
* Get instantiated client service for protocol
* Get instantiated client service for protocol.
* Returns the unified IDownloadClient interface for protocol-agnostic usage.
*/
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
async getClientServiceForProtocol(protocol: ProtocolType): Promise<IDownloadClient | null> {
const client = await this.getClientForProtocol(protocol);
if (!client) {
return null;
}
if (client.type === 'qbittorrent') {
return this.createQBittorrentService(client);
} else {
return this.createSABnzbdService(client);
return this.getOrCreateService(client);
}
/**
* Factory: create a new IDownloadClient from config.
* This is the single place where client type maps to a concrete class.
* Add new client types (e.g. Transmission, NZBGet) here.
*/
private async createService(config: DownloadClientConfig): Promise<IDownloadClient> {
const baseDir = await this.configService.get('download_dir') || '/downloads';
const downloadDir = config.customPath
? path.join(baseDir, config.customPath)
: baseDir;
switch (config.type) {
case 'qbittorrent':
return this.createQBittorrentService(config, downloadDir);
case 'sabnzbd':
return this.createSABnzbdService(config, downloadDir);
case 'nzbget':
return this.createNZBGetService(config, downloadDir);
case 'transmission':
return this.createTransmissionService(config, downloadDir);
default:
throw new Error(`Unsupported download client type: ${config.type}`);
}
}
/**
* Test connection for a specific client config
* Get a cached service instance or create a new one.
* Caches by client config ID to preserve session state (e.g. qBittorrent SID cookie).
*/
private async getOrCreateService(config: DownloadClientConfig): Promise<IDownloadClient> {
const cached = this.serviceCache.get(config.id);
if (cached) {
return cached;
}
const service = await this.createService(config);
this.serviceCache.set(config.id, service);
return service;
}
/**
* Create an IDownloadClient instance from a config object.
* Uses cached instances when available to preserve session state.
*/
async createClientFromConfig(config: DownloadClientConfig): Promise<IDownloadClient> {
return this.getOrCreateService(config);
}
/**
* Test connection for a specific client config.
* Uses the unified IDownloadClient.testConnection() method.
*/
async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
try {
if (config.type === 'qbittorrent') {
const service = this.createQBittorrentService(config);
await service.testConnection();
return { success: true, message: 'Successfully connected to qBittorrent' };
} else {
const service = this.createSABnzbdService(config);
const version = await service.getVersion();
return { success: true, message: `Successfully connected to SABnzbd (v${version})` };
// Always create a fresh instance for connection testing (don't use cache)
const service = await this.createService(config);
const result = await service.testConnection();
if (result.success) {
const versionSuffix = result.version ? ` (v${result.version})` : '';
return { success: true, message: `Successfully connected to ${config.name}${versionSuffix}` };
}
return { success: false, message: result.message || 'Connection failed' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('Connection test failed', { type: config.type, error: message });
@@ -192,7 +246,7 @@ export class DownloadClientManager {
/**
* Create qBittorrent service instance
*/
private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
private createQBittorrentService(config: DownloadClientConfig, downloadDir: string): QBittorrentService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
@@ -205,8 +259,8 @@ export class DownloadClientManager {
config.url,
config.username || '',
config.password || '', // Optional for IP whitelist auth
'/downloads', // defaultSavePath
config.category || 'readmeabook', // defaultCategory
downloadDir,
config.category || 'readmeabook',
config.disableSSLVerify,
pathMapping
);
@@ -215,7 +269,7 @@ export class DownloadClientManager {
/**
* Create SABnzbd service instance
*/
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
@@ -227,8 +281,54 @@ export class DownloadClientManager {
return new SABnzbdService(
config.url,
config.password, // API key stored in password field
config.category || 'readmeabook', // defaultCategory
'/downloads', // defaultDownloadDir (will be overridden by singleton with actual config)
config.category || 'readmeabook',
downloadDir,
config.disableSSLVerify,
pathMapping
);
}
/**
* Create NZBGet service instance
*/
private createNZBGetService(config: DownloadClientConfig, downloadDir: string): NZBGetService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
remotePath: config.remotePath,
localPath: config.localPath,
}
: undefined;
return new NZBGetService(
config.url,
config.username || '',
config.password,
config.category || 'readmeabook',
downloadDir,
config.disableSSLVerify,
pathMapping
);
}
/**
* Create Transmission service instance
*/
private createTransmissionService(config: DownloadClientConfig, downloadDir: string): TransmissionService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
remotePath: config.remotePath,
localPath: config.localPath,
}
: undefined;
return new TransmissionService(
config.url,
config.username || '',
config.password || '',
downloadDir,
config.category || 'readmeabook',
config.disableSSLVerify,
pathMapping
);
@@ -272,8 +372,8 @@ export class DownloadClientManager {
const newClient: DownloadClientConfig = {
id: randomUUID(),
type: clientType as 'qbittorrent' | 'sabnzbd',
name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
type: clientType as DownloadClientType,
name: getClientDisplayName(clientType),
enabled: true,
url: clientUrl,
username: clientUsername || undefined,
+3 -2
View File
@@ -7,6 +7,7 @@ import Queue, { Job as BullJob, JobOptions } from 'bull';
import Redis from 'ioredis';
import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm';
import { DownloadClientType } from '../interfaces/download-client.interface';
import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('JobQueue');
@@ -59,7 +60,7 @@ export interface MonitorDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadClientId: string;
downloadClient: 'qbittorrent' | 'sabnzbd';
downloadClient: DownloadClientType;
}
export interface OrganizeFilesPayload extends JobPayload {
@@ -545,7 +546,7 @@ export class JobQueueService {
requestId: string,
downloadHistoryId: string,
downloadClientId: string,
downloadClient: 'qbittorrent' | 'sabnzbd',
downloadClient: DownloadClientType,
delaySeconds: number = 0
): Promise<string> {
return await this.addJob(
+62 -61
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { RMABLogger } from '../utils/logger';
import { buildAudiobookPath } from '../utils/file-organizer';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
const logger = RMABLogger.create('RequestDelete');
@@ -119,77 +120,73 @@ export async function deleteRequest(
);
}
// Handle based on download client type (check which ID is present)
if (downloadHistory.torrentHash) {
// qBittorrent download
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
// Handle download cleanup via unified interface
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
let torrent;
try {
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
} catch (error) {
// Torrent not found in qBittorrent (already removed)
logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`);
}
if (clientId && clientType !== 'direct') {
const { getDownloadClientManager } = await import('./download-client-manager.service');
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (torrent) {
// Torrent exists in qBittorrent
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
const isCompleted = downloadHistory.downloadStatus === 'completed';
if (client) {
// Get download info to check seeding status
let downloadInfo;
try {
downloadInfo = await client.getDownload(clientId);
} catch (error) {
logger.info(`Download ${clientId} not found in ${clientType}, skipping`);
}
if (isUnlimitedSeeding) {
// Unlimited seeding - keep in qBittorrent, stop monitoring
logger.info(
`Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
);
torrentsKeptUnlimited++;
} else if (!isCompleted) {
// Download not completed - delete immediately
logger.info(
`Deleting incomplete download: ${torrent.name}`
);
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
torrentsRemoved++;
} else {
// Check if seeding requirement is met
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
const actualSeedingTime = torrent.seeding_time || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (downloadInfo) {
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
const isCompleted = downloadHistory.downloadStatus === 'completed';
if (hasMetRequirement) {
// Seeding requirement met - delete now
if (client.protocol === 'usenet') {
// Usenet - no seeding concept, delete immediately
try {
await client.deleteDownload(clientId, true);
logger.info(`Deleted download ${clientId} from ${client.clientType}`);
torrentsRemoved++;
} catch (error) {
logger.info(`Download ${clientId} not found in ${client.clientType}, skipping`);
}
} else if (isUnlimitedSeeding) {
// Unlimited seeding - keep in client, stop monitoring
logger.info(
`Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
actualSeedingTime / 60
)}/${seedingConfig.seedingTimeMinutes} minutes)`
`Keeping download ${downloadInfo.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
);
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
torrentsKeptUnlimited++;
} else if (!isCompleted) {
// Download not completed - delete immediately
logger.info(`Deleting incomplete download: ${downloadInfo.name}`);
await client.deleteDownload(clientId, true);
torrentsRemoved++;
} else {
// Still needs seeding - keep for cleanup job
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
logger.info(
`Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
);
torrentsKeptSeeding++;
// Check if seeding requirement is met
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
const actualSeedingTime = downloadInfo.seedingTime || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (hasMetRequirement) {
logger.info(
`Deleting download ${downloadInfo.name} (seeding complete: ${Math.floor(
actualSeedingTime / 60
)}/${seedingConfig.seedingTimeMinutes} minutes)`
);
await client.deleteDownload(clientId, true);
torrentsRemoved++;
} else {
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
logger.info(
`Keeping download ${downloadInfo.name} for ${remainingMinutes} more minutes of seeding`
);
torrentsKeptSeeding++;
}
}
}
}
} else if (downloadHistory.nzbId) {
// SABnzbd download - no seeding concept for Usenet
try {
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
// Try to delete the NZB from SABnzbd (might already be completed/removed)
await sabnzbd.deleteNZB(downloadHistory.nzbId, true);
logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`);
torrentsRemoved++;
} catch (error) {
// NZB not found or already removed
logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`);
}
}
} catch (error) {
logger.error(
@@ -208,7 +205,11 @@ export async function deleteRequest(
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
// Use ebook-specific template for ebook requests, with fallback to audiobook template
const audiobookTemplate = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
const template = isEbook
? (await configService.get('ebook_path_template')) || audiobookTemplate
: audiobookTemplate;
// Fetch year from audible cache if ASIN is available
let year: number | undefined;
+11 -8
View File
@@ -11,11 +11,12 @@ import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { RMABLogger } from './logger';
import { CHAPTER_MERGE_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
// Supported audio formats for chapter merging
const SUPPORTED_FORMATS = ['.mp3', '.m4a', '.m4b', '.mp4', '.aac'];
// Supported audio formats for chapter merging (from shared constants)
const SUPPORTED_FORMATS: readonly string[] = CHAPTER_MERGE_FORMATS;
// Patterns that indicate chapter-based files
const CHAPTER_PATTERNS = [
@@ -629,9 +630,9 @@ export async function mergeChapters(
await fs.writeFile(metadataFile, chapterMetadata);
await logger?.info(`Generated chapter metadata with ${chapters.length} chapter markers`);
// Determine if we need to re-encode (MP3 input requires conversion to AAC)
// Determine if we need to re-encode (non-AAC input requires conversion to AAC for M4B)
const inputFormat = path.extname(chapters[0].path).toLowerCase();
const needsReencode = inputFormat === '.mp3';
const needsReencode = inputFormat === '.mp3' || inputFormat === '.flac' || inputFormat === '.aac';
// Build ffmpeg command
const args: string[] = [
@@ -646,26 +647,28 @@ export async function mergeChapters(
];
if (needsReencode) {
// MP3 -> M4B requires re-encoding to AAC
// Non-AAC -> M4B requires re-encoding to AAC
const bitrate = determineOutputBitrate(chapters);
// Check for libfdk_aac (higher quality) or fall back to native aac
const hasFdkAac = await checkLibFdkAac();
const formatLabel = inputFormat.slice(1).toUpperCase(); // '.mp3' -> 'MP3', '.flac' -> 'FLAC'
if (hasFdkAac) {
args.push('-c:a', 'libfdk_aac');
args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality)
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
} else {
// Use VBR for better quality at same average bitrate
const vbrQuality = bitrateToVbrQuality(bitrate);
args.push('-c:a', 'aac');
args.push('-q:a', vbrQuality.toString());
args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
}
} else {
// M4A/M4B -> M4B can use codec copy (fast, lossless)
// M4A/M4B/MP4 -> M4B can use codec copy (fast, lossless)
args.push('-c', 'copy');
await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`);
}
+39 -2
View File
@@ -20,6 +20,7 @@ import {
} from './chapter-merger';
import { prisma } from '../db';
import { substituteTemplate, type TemplateVariables } from './path-template.util';
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
export interface AudiobookMetadata {
title: string;
@@ -362,6 +363,34 @@ export class FileOrganizer {
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`Failed to copy ${filename}: ${errorMsg}`);
// If the tagged temp file failed to copy, clean it up and try the original untagged file
if (taggedFilePath) {
// Clean up the tagged temp file that failed to copy
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file after copy failure: ${path.basename(taggedFilePath)}`);
} catch {
// Ignore cleanup errors
}
// Fallback: attempt to copy the original untagged file instead
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
try {
await fs.access(originalSourcePath, fs.constants.R_OK);
await fs.copyFile(originalSourcePath, targetFilePath);
await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
result.errors.push(`Tagged copy failed for ${filename}, copied original without metadata tags`);
continue;
} catch (fallbackError) {
const fallbackMsg = fallbackError instanceof Error ? fallbackError.message : 'Unknown error';
await logger?.error(`Fallback copy of original file also failed: ${fallbackMsg}`);
}
}
result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`);
// Continue with other files instead of throwing
}
@@ -411,7 +440,15 @@ export class FileOrganizer {
// This replaces the old inline ebook sidecar download that happened here.
result.targetPath = targetPath;
result.success = true;
// Only mark as success if at least one audio file was placed in the target directory
// (either freshly copied or already existed from a previous attempt)
if (result.audioFiles.length > 0) {
result.success = true;
} else {
result.errors.push('No audio files were successfully copied to the target directory');
await logger?.error(`Organization failed: no audio files copied despite ${audioFiles.length} file(s) found`);
}
// DO NOT clean up download directory - files needed for seeding
// Cleanup will be handled by the seeding cleanup job after seeding requirements are met
@@ -431,7 +468,7 @@ export class FileOrganizer {
private async findAudiobookFiles(
downloadPath: string
): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> {
const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
const audioExtensions: readonly string[] = AUDIO_EXTENSIONS;
const coverPatterns = [
/cover\.(jpg|jpeg|png)$/i,
/folder\.(jpg|jpeg|png)$/i,
+2 -6
View File
@@ -8,11 +8,7 @@
import crypto from 'crypto';
import path from 'path';
/**
* Supported audio file extensions for hash generation
*/
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
/**
* Generates a SHA256 hash of audio filenames for library matching.
@@ -51,7 +47,7 @@ export function generateFilesHash(filePaths: string[]): string {
})
.filter((basename) => {
const ext = path.extname(basename).toLowerCase();
return AUDIO_EXTENSIONS.includes(ext);
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
})
.map((basename) => basename.toLowerCase()) // Normalize case
.sort(); // Sort alphabetically for deterministic hash
+33 -29
View File
@@ -5,6 +5,7 @@
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
* Supports separate audiobook and ebook category configurations per indexer.
* Indexers with no categories for a given type are skipped (effectively disabled).
*/
export type CategoryType = 'audiobook' | 'ebook';
@@ -25,22 +26,33 @@ export interface IndexerGroup {
indexers: IndexerConfig[];
}
export interface GroupingResult {
groups: IndexerGroup[];
skippedIndexers: IndexerConfig[]; // Indexers skipped due to no categories for the type
}
/**
* Gets the appropriate categories from an indexer based on the category type.
*
* Returns empty array when the field is explicitly set to [] (user disabled this type).
* Falls back to defaults only when the field is undefined/missing (legacy configs).
*
* @param indexer - The indexer configuration
* @param type - The category type ('audiobook' or 'ebook')
* @returns Array of category IDs
* @returns Array of category IDs (empty = disabled for this type)
*/
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
if (type === 'ebook') {
return indexer.ebookCategories && indexer.ebookCategories.length > 0
? indexer.ebookCategories
: [7020]; // Default ebook category
// Field exists (even if empty) — respect it
if (Array.isArray(indexer.ebookCategories)) {
return indexer.ebookCategories;
}
// Field missing — legacy config, use default
return [7020];
}
// Audiobook - check new field first, then legacy field
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
// Audiobook check new field first, then legacy field
if (Array.isArray(indexer.audiobookCategories)) {
return indexer.audiobookCategories;
}
if (indexer.categories && indexer.categories.length > 0) {
@@ -52,57 +64,49 @@ export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType)
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
* Indexers with no categories for the specified type are skipped.
*
* @param indexers - Array of indexer configurations
* @param type - The category type to group by ('audiobook' or 'ebook')
* @returns Array of groups, each containing indexers with matching categories
* @returns GroupingResult with groups and skipped indexers
*
* @example
* const indexers = [
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [] },
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
* ];
*
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
* // Result:
* // [
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
* // ]
* const result = groupIndexersByCategories(indexers, 'ebook');
* // result.groups: [{ categories: [7020], indexerIds: [1, 3], indexers: [...] }]
* // result.skippedIndexers: [{ id: 2, ... }] (no ebook categories)
*/
export function groupIndexersByCategories(
indexers: IndexerConfig[],
type: CategoryType = 'audiobook'
): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
): GroupingResult {
const groupMap = new Map<string, IndexerConfig[]>();
const skippedIndexers: IndexerConfig[] = [];
for (const indexer of indexers) {
// Get categories for the specified type
const categories = getCategoriesForType(indexer, type);
// Skip indexers with no categories for this type (effectively disabled)
if (categories.length === 0) {
skippedIndexers.push(indexer);
continue;
}
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
const sortedCategories = [...categories].sort((a, b) => a - b);
const key = sortedCategories.join(',');
// Add indexer to group
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(indexer);
}
// Convert map to array of groups
const groups: IndexerGroup[] = [];
for (const [key, indexersInGroup] of groupMap.entries()) {
const categories = key.split(',').map(Number);
@@ -115,7 +119,7 @@ export function groupIndexersByCategories(
});
}
return groups;
return { groups, skippedIndexers };
}
/**
+28 -2
View File
@@ -7,6 +7,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { METADATA_TAG_FORMATS, MP4_CONTAINER_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
@@ -41,7 +42,7 @@ export async function tagAudioFileMetadata(
const ext = path.extname(filePath).toLowerCase();
// Only process supported formats
if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) {
if (!(METADATA_TAG_FORMATS as readonly string[]).includes(ext)) {
return {
success: false,
filePath,
@@ -61,7 +62,7 @@ export async function tagAudioFileMetadata(
];
// For m4b/m4a/mp4 files, use standard metadata tags
if (['.m4b', '.m4a', '.mp4'].includes(ext)) {
if ((MP4_CONTAINER_FORMATS as readonly string[]).includes(ext)) {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this)
@@ -85,6 +86,31 @@ export async function tagAudioFileMetadata(
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp4');
}
// For FLAC files, use Vorbis comment tags (native FLAC metadata)
else if (ext === '.flac') {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`,
'-metadata', `albumartist="${escapeMetadata(metadata.author)}"`,
'-metadata', `artist="${escapeMetadata(metadata.author)}"`
);
if (metadata.narrator) {
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`);
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
if (metadata.asin) {
// FLAC supports arbitrary Vorbis comment tags
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
}
// Explicitly specify output format
args.push('-f', 'flac');
}
// For mp3 files, use ID3v2 tags
else if (ext === '.mp3') {
args.push(
+57
View File
@@ -0,0 +1,57 @@
/**
* Utility: Permission Resolution
* Documentation: documentation/admin-dashboard.md
*
* Resolves effective user permissions from the tri-state pattern:
* admin always granted
* per-user setting (true/false) explicit override
* null falls back to global setting
*/
import { prisma } from '@/lib/db';
/**
* Resolve a tri-state permission (admin per-user global fallback).
* @param userRole - 'admin' or 'user'
* @param userValue - per-user setting (true, false, or null)
* @param globalValue - global setting from Configuration table
* @returns effective boolean permission
*/
export function resolvePermission(
userRole: string,
userValue: boolean | null,
globalValue: boolean
): boolean {
if (userRole === 'admin') return true;
if (userValue === true) return true;
if (userValue === false) return false;
return globalValue;
}
/**
* Fetch a global boolean setting from the Configuration table.
* @param key - Configuration key
* @param defaultValue - Value to use if the key doesn't exist
*/
export async function getGlobalBooleanSetting(
key: string,
defaultValue: boolean = true
): Promise<boolean> {
const config = await prisma.configuration.findUnique({
where: { key },
});
return config == null ? defaultValue : config.value === 'true';
}
/**
* Resolve a user's effective interactive search access permission.
*/
export async function resolveInteractiveSearchAccess(
userRole: string,
userInteractiveSearchAccess: boolean | null
): Promise<boolean> {
if (userRole === 'admin') return true;
if (userInteractiveSearchAccess === true) return true;
if (userInteractiveSearchAccess === false) return false;
return getGlobalBooleanSetting('interactive_search_access', true);
}
+28 -8
View File
@@ -17,7 +17,7 @@ export interface TorrentResult {
infoUrl?: string; // Link to indexer's info page (for user reference)
infoHash?: string;
guid: string;
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER';
bitrate?: string;
hasChapters?: boolean;
flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc.
@@ -254,6 +254,7 @@ export class RankingAlgorithm {
* Reduced from 25 to make room for data-driven size scoring
* M4B with chapters: 10 pts
* M4B without chapters: 9 pts
* FLAC: 7 pts (lossless audio, excellent quality)
* M4A: 6 pts
* MP3: 4 pts
* Other: 1 pt
@@ -264,6 +265,8 @@ export class RankingAlgorithm {
switch (format) {
case 'M4B':
return torrent.hasChapters !== false ? 10 : 9;
case 'FLAC':
return 7;
case 'M4A':
return 6;
case 'MP3':
@@ -395,11 +398,13 @@ export class RankingAlgorithm {
.filter(word => word.length > 0 && !stopList.includes(word));
};
// Separate required words (outside parentheses/brackets) from optional words (inside)
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
// Note: Run on ORIGINAL title to preserve brackets, then normalize the result
// Separate required words (outside parentheses/brackets/colon subtitles) from optional words
// This handles common patterns like:
// "Title (Subtitle)" where subtitle may be omitted
// "Title: Series Name" where Audible appends series names after a colon
// Note: Run on ORIGINAL title to preserve brackets/colons, then normalize the result
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
// Work with original title format for bracket detection
// Work with original title format for bracket/colon detection
const originalTitle = audiobook.title.toLowerCase();
// Extract content in parentheses/brackets as optional
@@ -411,8 +416,20 @@ export class RankingAlgorithm {
optionalMatches.push(match[1]);
}
// Remove parenthetical/bracketed content to get required portion
const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Remove parenthetical/bracketed content to get the non-bracketed portion
let requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Treat content after a colon as optional (Audible commonly appends series names)
// e.g., "The Finest Edge of Twilight: Dungeons & Dragons" → required: title, optional: series
const colonIndex = requiredRaw.indexOf(':');
if (colonIndex > 0 && colonIndex < requiredRaw.length - 1) {
const afterColon = requiredRaw.substring(colonIndex + 1).trim();
if (afterColon.length > 0) {
optionalMatches.push(afterColon);
}
requiredRaw = requiredRaw.substring(0, colonIndex).trim();
}
// Normalize the required portion (handles CamelCase, punctuation)
const required = this.normalizeForMatching(requiredRaw);
const optional = optionalMatches.join(' ');
@@ -652,7 +669,7 @@ export class RankingAlgorithm {
/**
* Detect format from torrent title
*/
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'OTHER' {
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER' {
// Use explicit format if provided
if (torrent.format) {
return torrent.format;
@@ -664,6 +681,7 @@ export class RankingAlgorithm {
if (title.includes('M4B')) return 'M4B';
if (title.includes('M4A')) return 'M4A';
if (title.includes('MP3')) return 'MP3';
if (title.includes('FLAC')) return 'FLAC';
// Default to OTHER if no format detected
return 'OTHER';
@@ -686,6 +704,8 @@ export class RankingAlgorithm {
if (torrent.hasChapters !== false) {
notes.push('Has chapter markers');
}
} else if (format === 'FLAC') {
notes.push('Lossless format (FLAC)');
} else if (format === 'M4A') {
notes.push('Good format (M4A)');
} else if (format === 'MP3') {
+23
View File
@@ -80,3 +80,26 @@ export function isParentCategory(categoryId: number): boolean {
const category = TORRENT_CATEGORIES.find((cat) => cat.id === categoryId);
return !!category?.children && category.children.length > 0;
}
/**
* Get all standard category IDs (parents and children) from the predefined tree
*/
export function getAllStandardCategoryIds(): Set<number> {
const ids = new Set<number>();
for (const parent of TORRENT_CATEGORIES) {
ids.add(parent.id);
if (parent.children) {
for (const child of parent.children) {
ids.add(child.id);
}
}
}
return ids;
}
/**
* Check if a category ID exists in the predefined category tree
*/
export function isStandardCategory(categoryId: number): boolean {
return getAllStandardCategoryIds().has(categoryId);
}