mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add SABnzbd Usenet/NZB integration and documentation
Introduces SABnzbd as a supported download client for Usenet/NZB alongside qBittorrent, including service implementation, setup wizard and admin settings UI updates, and protocol-specific job processor logic. Updates documentation, PRD, and database schema to support NZB downloads, adds comprehensive technical details and testing strategies, and fixes Audible integration issues related to search and ASIN extraction.
This commit is contained in:
@@ -70,9 +70,9 @@ export class AudibleService {
|
||||
|
||||
const $el = $(element);
|
||||
|
||||
// Extract ASIN from data attribute or link
|
||||
// Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs
|
||||
const asin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
|
||||
if (!asin) return;
|
||||
|
||||
@@ -156,8 +156,9 @@ export class AudibleService {
|
||||
|
||||
const $el = $(element);
|
||||
|
||||
// Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs
|
||||
const asin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
|
||||
if (!asin) return;
|
||||
|
||||
@@ -231,29 +232,42 @@ export class AudibleService {
|
||||
|
||||
const audiobooks: AudibleAudiobook[] = [];
|
||||
|
||||
// Parse search results
|
||||
$('.productListItem').each((index, element) => {
|
||||
// Parse search results - Audible uses s-result-item for search pages
|
||||
$('.s-result-item, .productListItem').each((index, element) => {
|
||||
const $el = $(element);
|
||||
|
||||
// Extract ASIN from product detail link - handle both /pd/ and /ac/ URLs
|
||||
const asin = $el.find('li').attr('data-asin') ||
|
||||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||
|
||||
if (!asin) return;
|
||||
|
||||
const title = $el.find('h3 a').text().trim() ||
|
||||
// Extract title from h2 tag (search results) or h3 (legacy)
|
||||
const title = $el.find('h2').first().text().trim() ||
|
||||
$el.find('h3 a').text().trim() ||
|
||||
$el.find('.bc-heading a').text().trim();
|
||||
|
||||
const authorText = $el.find('.authorLabel').text().trim() ||
|
||||
// Extract author from author link
|
||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||
$el.find('.authorLabel').text().trim() ||
|
||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||
|
||||
const narratorText = $el.find('.narratorLabel').text().trim();
|
||||
// Extract narrator from narrator search link
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim();
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim();
|
||||
// Extract runtime/duration
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||
$el.find('span:contains("Length:")').text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim();
|
||||
// Extract rating
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||
$el.find('.a-icon-star span').first().text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
audiobooks.push({
|
||||
|
||||
@@ -114,8 +114,10 @@ export class ProwlarrService {
|
||||
.map((result: ProwlarrSearchResult) => this.transformResult(result))
|
||||
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
|
||||
|
||||
// Apply filters
|
||||
let filtered = results;
|
||||
// Filter by protocol based on configured download client
|
||||
let filtered = await this.filterByProtocol(results);
|
||||
|
||||
// Apply additional filters
|
||||
|
||||
if (filters?.minSeeders) {
|
||||
filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
|
||||
@@ -293,6 +295,58 @@ export class ProwlarrService {
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter results based on configured download client protocol
|
||||
* If qBittorrent is configured: only return torrent results
|
||||
* If SABnzbd is configured: only return NZB results
|
||||
*/
|
||||
private async filterByProtocol(results: TorrentResult[]): Promise<TorrentResult[]> {
|
||||
try {
|
||||
// Get configured download client type
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const config = await getConfigService();
|
||||
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
|
||||
|
||||
if (clientType === 'sabnzbd') {
|
||||
// Filter for NZB results only
|
||||
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
|
||||
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
|
||||
return filtered;
|
||||
} else {
|
||||
// Filter for torrent results only (default)
|
||||
const filtered = results.filter(result => !ProwlarrService.isNZBResult(result));
|
||||
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} torrent results for qBittorrent`);
|
||||
return filtered;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Prowlarr] Failed to filter by protocol, returning all results:', error);
|
||||
return results; // Fallback: return unfiltered if config fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a result is an NZB download (Usenet) or torrent (BitTorrent)
|
||||
* Static method for protocol detection
|
||||
*/
|
||||
static isNZBResult(result: TorrentResult): boolean {
|
||||
const url = result.downloadUrl.toLowerCase();
|
||||
|
||||
// Check file extension
|
||||
if (url.endsWith('.nzb')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check URL path
|
||||
if (url.includes('/nzb/') || url.includes('&t=get')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check categories (3030 is audiobooks, but some indexers use Usenet-specific codes)
|
||||
// Note: This is less reliable, so we prioritize URL patterns
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform Prowlarr result to our TorrentResult format
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* Component: SABnzbd Integration Service
|
||||
* Documentation: documentation/phase3/sabnzbd.md
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
|
||||
export interface AddNZBOptions {
|
||||
category?: string;
|
||||
priority?: 'low' | 'normal' | 'high' | 'force';
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface NZBInfo {
|
||||
nzbId: string;
|
||||
name: string;
|
||||
size: number; // Bytes
|
||||
progress: number; // 0.0 to 1.0
|
||||
status: NZBStatus;
|
||||
downloadSpeed: number; // Bytes/sec
|
||||
timeLeft: number; // Seconds
|
||||
category: string;
|
||||
downloadPath?: string;
|
||||
completedAt?: Date;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export type NZBStatus =
|
||||
| 'downloading'
|
||||
| 'queued'
|
||||
| 'paused'
|
||||
| 'extracting'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'repairing';
|
||||
|
||||
export interface QueueItem {
|
||||
nzbId: string;
|
||||
name: string;
|
||||
size: number; // MB (converted to bytes in getNZB)
|
||||
sizeLeft: number; // MB
|
||||
percentage: number; // 0-100
|
||||
status: string; // "Downloading", "Paused", "Queued"
|
||||
timeLeft: string; // "0:15:30" format
|
||||
category: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
nzbId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string; // "Completed", "Failed"
|
||||
bytes: string; // Size in bytes (as string)
|
||||
failMessage: string;
|
||||
storage: string; // Download path
|
||||
completedTimestamp: string; // Unix timestamp
|
||||
downloadTime: string; // Seconds (as string)
|
||||
}
|
||||
|
||||
export interface SABnzbdConfig {
|
||||
version: string;
|
||||
categories: Array<{
|
||||
name: string;
|
||||
dir: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
percent: number;
|
||||
bytesDownloaded: number;
|
||||
bytesTotal: number;
|
||||
speed: number;
|
||||
eta: number;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export class SABnzbdService {
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
private httpsAgent?: https.Agent;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
defaultCategory: string = 'readmeabook',
|
||||
disableSSLVerify: boolean = false
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = apiKey;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
|
||||
// Configure HTTPS agent if SSL verification is disabled
|
||||
if (this.disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 30000,
|
||||
httpsAgent: this.httpsAgent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to SABnzbd
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
const version = await this.getVersion();
|
||||
return { success: true, version };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Enhanced error messages for common issues
|
||||
if (errorMessage.includes('ECONNREFUSED')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection refused. Is SABnzbd running and accessible at this URL?',
|
||||
};
|
||||
} else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection timed out. Check the URL and network connectivity.',
|
||||
};
|
||||
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
|
||||
};
|
||||
} else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SABnzbd version
|
||||
*/
|
||||
async getVersion(): Promise<string> {
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'version',
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.version) {
|
||||
return response.data.version;
|
||||
}
|
||||
|
||||
throw new Error('Failed to get SABnzbd version');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SABnzbd configuration
|
||||
*/
|
||||
async getConfig(): Promise<SABnzbdConfig> {
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'get_config',
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
const config = response.data?.config;
|
||||
if (!config) {
|
||||
throw new Error('Failed to get SABnzbd configuration');
|
||||
}
|
||||
|
||||
return {
|
||||
version: config.version || '',
|
||||
categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({
|
||||
name,
|
||||
dir: details.dir || '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the default category exists
|
||||
* Creates category if it doesn't exist
|
||||
*/
|
||||
async ensureCategory(downloadPath?: string): Promise<void> {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
const categoryExists = config.categories.some(cat => cat.name === this.defaultCategory);
|
||||
|
||||
if (!categoryExists) {
|
||||
console.log(`[SABnzbd] Creating category: ${this.defaultCategory}`);
|
||||
|
||||
// Create category
|
||||
await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'set_config',
|
||||
section: 'categories',
|
||||
keyword: this.defaultCategory,
|
||||
value: downloadPath || '',
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[SABnzbd] Category created successfully: ${this.defaultCategory}`);
|
||||
} else {
|
||||
console.log(`[SABnzbd] Category already exists: ${this.defaultCategory}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SABnzbd] Failed to ensure category:', error);
|
||||
// Don't throw - category creation failure shouldn't block downloads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add NZB by URL
|
||||
* Returns the NZB ID
|
||||
*/
|
||||
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'addurl',
|
||||
name: url,
|
||||
cat: options?.category || this.defaultCategory,
|
||||
priority: this.mapPriority(options?.priority),
|
||||
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.status === false) {
|
||||
throw new Error(response.data.error || 'Failed to add NZB');
|
||||
}
|
||||
|
||||
const nzbIds = response.data?.nzo_ids;
|
||||
if (!nzbIds || nzbIds.length === 0) {
|
||||
throw new Error('SABnzbd did not return an NZB ID');
|
||||
}
|
||||
|
||||
const nzbId = nzbIds[0];
|
||||
console.log(`[SABnzbd] Added NZB: ${nzbId}`);
|
||||
|
||||
return nzbId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NZB info by ID
|
||||
* Checks queue first, then history
|
||||
*/
|
||||
async getNZB(nzbId: string): Promise<NZBInfo | null> {
|
||||
// Check queue first
|
||||
const queue = await this.getQueue();
|
||||
const queueItem = queue.find(item => item.nzbId === nzbId);
|
||||
|
||||
if (queueItem) {
|
||||
return this.mapQueueItemToNZBInfo(queueItem);
|
||||
}
|
||||
|
||||
// Not in queue, check history
|
||||
const history = await this.getHistory(100);
|
||||
const historyItem = history.find(item => item.nzbId === nzbId);
|
||||
|
||||
if (historyItem) {
|
||||
return this.mapHistoryItemToNZBInfo(historyItem);
|
||||
}
|
||||
|
||||
// Not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current download queue
|
||||
*/
|
||||
async getQueue(): Promise<QueueItem[]> {
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'queue',
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
const slots = response.data?.queue?.slots || [];
|
||||
return slots.map((slot: any) => ({
|
||||
nzbId: slot.nzo_id,
|
||||
name: slot.filename,
|
||||
size: parseFloat(slot.mb || '0'),
|
||||
sizeLeft: parseFloat(slot.mbleft || '0'),
|
||||
percentage: parseInt(slot.percentage || '0', 10),
|
||||
status: slot.status,
|
||||
timeLeft: slot.timeleft || '0:00:00',
|
||||
category: slot.cat || '',
|
||||
priority: slot.priority || 'Normal',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download history
|
||||
*/
|
||||
async getHistory(limit: number = 100): Promise<HistoryItem[]> {
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'history',
|
||||
limit,
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
const slots = response.data?.history?.slots || [];
|
||||
return slots.map((slot: any) => ({
|
||||
nzbId: slot.nzo_id,
|
||||
name: slot.name,
|
||||
category: slot.category || '',
|
||||
status: slot.status,
|
||||
bytes: slot.bytes || '0',
|
||||
failMessage: slot.fail_message || '',
|
||||
storage: slot.storage || '',
|
||||
completedTimestamp: slot.completed || '0',
|
||||
downloadTime: slot.download_time || '0',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause NZB download
|
||||
*/
|
||||
async pauseNZB(nzbId: string): Promise<void> {
|
||||
await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'pause',
|
||||
value: nzbId,
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume NZB download
|
||||
*/
|
||||
async resumeNZB(nzbId: string): Promise<void> {
|
||||
await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'resume',
|
||||
value: nzbId,
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete NZB download
|
||||
*/
|
||||
async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise<void> {
|
||||
await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'queue',
|
||||
name: 'delete',
|
||||
value: nzbId,
|
||||
del_files: deleteFiles ? '1' : '0',
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download progress from queue item
|
||||
*/
|
||||
getDownloadProgress(queueItem: QueueItem): DownloadProgress {
|
||||
const bytesTotal = queueItem.size * 1024 * 1024; // Convert MB to bytes
|
||||
const bytesLeft = queueItem.sizeLeft * 1024 * 1024;
|
||||
const bytesDownloaded = bytesTotal - bytesLeft;
|
||||
const percent = queueItem.percentage / 100; // Convert 0-100 to 0.0-1.0
|
||||
|
||||
// Parse time left (format: "0:15:30")
|
||||
let etaSeconds = 0;
|
||||
if (queueItem.timeLeft && queueItem.timeLeft !== '0:00:00') {
|
||||
const parts = queueItem.timeLeft.split(':');
|
||||
if (parts.length === 3) {
|
||||
etaSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate speed (bytes/sec)
|
||||
const speed = etaSeconds > 0 ? bytesLeft / etaSeconds : 0;
|
||||
|
||||
// Map SABnzbd status to our state format
|
||||
let state = 'downloading';
|
||||
const statusLower = queueItem.status.toLowerCase();
|
||||
if (statusLower.includes('paused')) {
|
||||
state = 'paused';
|
||||
} else if (statusLower.includes('queued')) {
|
||||
state = 'queued';
|
||||
} else if (statusLower.includes('extracting') || statusLower.includes('unpacking')) {
|
||||
state = 'extracting';
|
||||
} else if (statusLower.includes('repairing') || statusLower.includes('verifying')) {
|
||||
state = 'repairing';
|
||||
} else if (percent >= 1.0) {
|
||||
state = 'completed';
|
||||
}
|
||||
|
||||
return {
|
||||
percent: Math.min(percent, 1.0),
|
||||
bytesDownloaded,
|
||||
bytesTotal,
|
||||
speed,
|
||||
eta: etaSeconds,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map queue item to NZBInfo
|
||||
*/
|
||||
private mapQueueItemToNZBInfo(queueItem: QueueItem): NZBInfo {
|
||||
const progress = this.getDownloadProgress(queueItem);
|
||||
return {
|
||||
nzbId: queueItem.nzbId,
|
||||
name: queueItem.name,
|
||||
size: queueItem.size * 1024 * 1024, // MB to bytes
|
||||
progress: progress.percent,
|
||||
status: progress.state as NZBStatus,
|
||||
downloadSpeed: progress.speed,
|
||||
timeLeft: progress.eta,
|
||||
category: queueItem.category,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map history item to NZBInfo
|
||||
*/
|
||||
private mapHistoryItemToNZBInfo(historyItem: HistoryItem): NZBInfo {
|
||||
const isCompleted = historyItem.status.toLowerCase().includes('completed');
|
||||
const isFailed = historyItem.status.toLowerCase().includes('failed');
|
||||
|
||||
return {
|
||||
nzbId: historyItem.nzbId,
|
||||
name: historyItem.name,
|
||||
size: parseInt(historyItem.bytes || '0', 10),
|
||||
progress: isCompleted ? 1.0 : 0.0,
|
||||
status: isFailed ? 'failed' : isCompleted ? 'completed' : 'downloading',
|
||||
downloadSpeed: 0,
|
||||
timeLeft: 0,
|
||||
category: historyItem.category,
|
||||
downloadPath: historyItem.storage,
|
||||
completedAt: historyItem.completedTimestamp ? new Date(parseInt(historyItem.completedTimestamp) * 1000) : undefined,
|
||||
errorMessage: historyItem.failMessage || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map priority option to SABnzbd priority value
|
||||
*/
|
||||
private mapPriority(priority?: 'low' | 'normal' | 'high' | 'force'): string {
|
||||
switch (priority) {
|
||||
case 'force':
|
||||
return '2'; // Force (highest)
|
||||
case 'high':
|
||||
return '1'; // High
|
||||
case 'low':
|
||||
return '-1'; // Low
|
||||
case 'normal':
|
||||
default:
|
||||
return '0'; // Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance and factory
|
||||
*/
|
||||
let sabnzbdServiceInstance: SABnzbdService | null = null;
|
||||
|
||||
export async function getSABnzbdService(): Promise<SABnzbdService> {
|
||||
if (sabnzbdServiceInstance) {
|
||||
return sabnzbdServiceInstance;
|
||||
}
|
||||
|
||||
// Load configuration from database
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const config = await getConfigService();
|
||||
|
||||
const url = await config.get('download_client_url');
|
||||
const apiKey = await config.get('download_client_password'); // Reuse password field for API key
|
||||
const category = (await config.get('sabnzbd_category')) || 'readmeabook';
|
||||
const disableSSL = ((await config.get('download_client_disable_ssl_verify')) || 'false') === 'true';
|
||||
|
||||
if (!url) {
|
||||
throw new Error('SABnzbd URL not configured. Please configure download client settings.');
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('SABnzbd API key not configured. Please configure download client settings.');
|
||||
}
|
||||
|
||||
sabnzbdServiceInstance = new SABnzbdService(url, apiKey, category, disableSSL);
|
||||
|
||||
// Ensure category exists
|
||||
const downloadDir = await config.get('download_dir');
|
||||
await sabnzbdServiceInstance.ensureCategory(downloadDir || undefined);
|
||||
|
||||
return sabnzbdServiceInstance;
|
||||
}
|
||||
|
||||
export function invalidateSABnzbdService(): void {
|
||||
sabnzbdServiceInstance = null;
|
||||
console.log('[SABnzbd] Service singleton invalidated');
|
||||
}
|
||||
Reference in New Issue
Block a user