mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
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:
@@ -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';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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)`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user