mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-18 12:10:15 +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,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');
|
||||
}
|
||||
Reference in New Issue
Block a user