From e008744df1a70afc13d756ac1a960f4cef07eea6 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 7 Jan 2026 02:40:11 -0500 Subject: [PATCH] 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. --- documentation/TABLEOFCONTENTS.md | 8 +- .../features/usenet-nzb-integration-prd.md | 1035 +++++++++++++++++ documentation/integrations/audible.md | 22 + documentation/phase3/ranking-algorithm.md | 5 +- documentation/phase3/sabnzbd.md | 203 ++++ prisma/schema.prisma | 5 +- src/app/admin/settings/page.tsx | 116 +- .../admin/settings/download-client/route.ts | 37 +- .../settings/test-download-client/route.ts | 62 +- .../api/setup/test-download-client/route.ts | 73 +- src/app/setup/page.tsx | 2 +- src/app/setup/steps/DownloadClientStep.tsx | 107 +- src/app/setup/steps/ReviewStep.tsx | 2 +- src/app/setup/steps/WelcomeStep.tsx | 4 +- src/lib/integrations/audible.service.ts | 36 +- src/lib/integrations/prowlarr.service.ts | 58 +- src/lib/integrations/sabnzbd.service.ts | 527 +++++++++ .../processors/download-torrent.processor.ts | 187 ++- .../processors/monitor-download.processor.ts | 77 +- src/lib/services/job-queue.service.ts | 4 +- src/lib/utils/ranking-algorithm.ts | 62 +- 21 files changed, 2378 insertions(+), 254 deletions(-) create mode 100644 documentation/features/usenet-nzb-integration-prd.md create mode 100644 documentation/phase3/sabnzbd.md create mode 100644 src/lib/integrations/sabnzbd.service.ts diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 53fd55b..d10cc6b 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -38,9 +38,10 @@ ## Automation Pipeline - **Full pipeline overview** → [phase3/README.md](phase3/README.md) -- **Torrent search via Prowlarr** → [phase3/prowlarr.md](phase3/prowlarr.md) +- **Search via Prowlarr (torrents + NZBs)** → [phase3/prowlarr.md](phase3/prowlarr.md) - **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md) -- **qBittorrent integration** → [phase3/qbittorrent.md](phase3/qbittorrent.md) +- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md) +- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md) - **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md) - **Chapter merging (PRD, not implemented)** → [features/chapter-merging.md](features/chapter-merging.md) @@ -72,7 +73,8 @@ ## Feature-Specific Lookups **"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation) -**"How do downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md) +**"How do torrent downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md) +**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [backend/services/jobs.md](backend/services/jobs.md) **"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md) **"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md) **"How do I configure external services?"** → [setup-wizard.md](setup-wizard.md), [settings-pages.md](settings-pages.md) diff --git a/documentation/features/usenet-nzb-integration-prd.md b/documentation/features/usenet-nzb-integration-prd.md new file mode 100644 index 0000000..0bd572e --- /dev/null +++ b/documentation/features/usenet-nzb-integration-prd.md @@ -0,0 +1,1035 @@ +# Usenet/NZB Integration - Product Requirements Document + +**Status:** 🚧 Implementation In Progress | Approved 2026-01-06 + +**Priority:** High | Strategic feature expansion + +--- + +## Executive Summary + +Add SABnzbd integration to support Usenet/NZB downloads alongside existing qBittorrent torrenting. Prowlarr already indexes NZB content natively. This feature enables users to choose between torrent or Usenet download methods during setup, expanding the application's reach to the Usenet community. + +**Key Benefits:** +- Dual download protocol support (BitTorrent + Usenet) +- Leverage existing Prowlarr NZB indexing +- No code changes to ranking algorithm (works with NZB results) +- Minimal architectural changes (follows existing patterns) +- Rock-solid implementation with comprehensive test coverage + +--- + +## Technology Selection: SABnzbd + +### Why SABnzbd (Recommended) + +**Pros:** +- **Industry Standard:** Most widely deployed Usenet client in automation stacks (Sonarr/Radarr/*arr ecosystem) +- **User-Friendly:** Intuitive web UI with wizard-driven setup (lower support burden) +- **Well-Documented API:** Comprehensive REST API with JSON responses +- **Active Development:** Regular updates, strong community support +- **Docker-Ready:** Official Docker images, well-tested in containerized environments +- **Post-Processing:** Built-in verification (par2), extraction (rar/zip), cleanup +- **Category Support:** Similar to qBittorrent categories (matches existing architecture) + +**Cons:** +- Python-based (slightly higher resource usage vs NZBGet's C++) +- Requires more CPU during post-processing + +### Why Not NZBGet + +**Pros:** +- C++ based (lower resource usage) +- Slightly faster downloads + +**Cons:** +- **Abandoned and Forked:** Original project archived, now maintained by community fork (stability concern) +- **Steeper Learning Curve:** More complex configuration (higher support burden) +- **Less Integration Testing:** Fewer production deployments in automation stacks +- **Fragmented Support:** Discord-only support for fork (vs SABnzbd's established forums/docs) + +### Decision + +**SABnzbd is the clear choice** for this project due to better user experience, proven stability in automation workflows, and lower support burden. Resource usage is negligible for audiobook downloads (small files, infrequent downloads). + +--- + +## Architecture Overview + +### Design Principles + +1. **Parallel Systems:** Torrent and Usenet pipelines run independently, no mixing +2. **Shared Infrastructure:** Reuse ranking algorithm, file organization, job queue +3. **Configuration Isolation:** Clear separation between qBittorrent and SABnzbd config +4. **Graceful Degradation:** Each download client can function independently +5. **Test-Driven:** Comprehensive unit and integration tests (no Usenet server required for dev) + +### High-Level Flow + +``` +User Setup + ├─ Choose Download Client: qBittorrent OR SABnzbd + ├─ Configure credentials and connection + └─ Test connection + +Prowlarr Search + ├─ Returns both torrent AND NZB results (already implemented) + └─ Ranking algorithm scores all results (protocol-agnostic) + +Download Selection + ├─ IF top result is Torrent → qBittorrent pipeline + └─ IF top result is NZB → SABnzbd pipeline + +Download Monitoring + ├─ qBittorrent: Poll torrent status via getTorrent() + └─ SABnzbd: Poll NZB status via queue/history APIs + +File Organization + └─ Identical for both (copy files to media library, tag metadata) + +Plex Scan + └─ Identical for both (scan library, fuzzy match) +``` + +--- + +## Database Schema Changes + +### Configuration Table (No Changes Required) + +**Existing keys work as-is:** +``` +download_client_type = 'qbittorrent' | 'sabnzbd' +download_client_url +download_client_username +download_client_password +download_client_disable_ssl_verify +download_dir +``` + +**New SABnzbd-specific keys:** +``` +sabnzbd_api_key # SABnzbd API key +sabnzbd_category # Category name (default: 'readmeabook') +sabnzbd_verify_ssl # Boolean (default: true, inverse of disable_ssl_verify for clarity) +``` + +**Why minimal changes?** +- SABnzbd doesn't use username/password (API key only) - reuse `download_client_password` for API key +- Path mapping works identically (reuse existing fields) +- SSL verification works identically + +### Download_History Table (Minor Addition) + +**New optional field:** +```typescript +nzb_id?: string; // SABnzbd NZB ID (equivalent to torrent hash) +``` + +**Rationale:** Keep `torrent_hash` field (nullable) for backwards compatibility. Add `nzb_id` for SABnzbd jobs. Monitor job uses whichever is populated. + +--- + +## New Services & Components + +### 1. SABnzbd Service (`src/lib/integrations/sabnzbd.service.ts`) + +**Mirrors qBittorrent service structure:** + +```typescript +export class SABnzbdService { + private client: AxiosInstance; + private baseUrl: string; + private apiKey: string; + private defaultCategory: string; + private disableSSLVerify: boolean; + + constructor( + baseUrl: string, + apiKey: string, + defaultCategory: string = 'readmeabook', + disableSSLVerify: boolean = false + ) { } + + // Connection & Health + async testConnection(): Promise<{ success: boolean; version?: string; }> + async getVersion(): Promise + async getConfig(): Promise + + // NZB Management + async addNZB(url: string, options?: AddNZBOptions): Promise // Returns nzbId + async addNZBFile(nzbContent: Buffer, filename: string, options?: AddNZBOptions): Promise + async getNZB(nzbId: string): Promise + async getQueue(): Promise + async getHistory(limit?: number): Promise + + // Category Management + async ensureCategory(): Promise // Create category if not exists, set download path + + // Download Control + async pauseNZB(nzbId: string): Promise + async resumeNZB(nzbId: string): Promise + async deleteNZB(nzbId: string, deleteFiles?: boolean): Promise + + // Progress Tracking + getDownloadProgress(queueItem: QueueItem): DownloadProgress +} +``` + +**Key API Endpoints:** +``` +GET /api?mode=version&apikey={key} +GET /api?mode=queue&apikey={key} +GET /api?mode=history&limit=100&apikey={key} +POST /api?mode=addurl&name={url}&cat={category}&apikey={key} +POST /api?mode=addfile (multipart/form-data) +GET /api?mode=pause&value={nzbId}&apikey={key} +GET /api?mode=resume&value={nzbId}&apikey={key} +POST /api?mode=queue&name=delete&value={nzbId}&apikey={key} +``` + +**Data Models:** +```typescript +interface NZBInfo { + nzbId: string; // SABnzbd NZB ID + name: string; // NZB filename + size: number; // Bytes + progress: number; // 0.0 to 1.0 + status: NZBStatus; // 'downloading' | 'queued' | 'paused' | 'extracting' | 'completed' | 'failed' + downloadSpeed: number; // Bytes/sec + timeLeft: number; // Seconds + category: string; + downloadPath: string; + completedAt?: Date; + errorMessage?: string; +} + +interface AddNZBOptions { + category?: string; + priority?: 'low' | 'normal' | 'high' | 'force'; + paused?: boolean; +} + +interface DownloadProgress { + percent: number; + bytesDownloaded: number; + bytesTotal: number; + speed: number; + eta: number; + state: string; +} +``` + +**Singleton Pattern (matches qBittorrent):** +```typescript +let sabnzbdServiceInstance: SABnzbdService | null = null; + +export async function getSABnzbdService(): Promise { + if (sabnzbdServiceInstance) return sabnzbdServiceInstance; + + const config = await getConfigService(); + const url = await config.get('download_client_url'); + const apiKey = await config.get('download_client_password'); // Reuse password field + const category = await config.getOrDefault('sabnzbd_category', 'readmeabook'); + const disableSSL = (await config.getOrDefault('download_client_disable_ssl_verify', 'false')) === 'true'; + + if (!url || !apiKey) throw new Error('SABnzbd not configured'); + + sabnzbdServiceInstance = new SABnzbdService(url, apiKey, category, disableSSL); + await sabnzbdServiceInstance.ensureCategory(); // Ensure category exists + + return sabnzbdServiceInstance; +} + +export function invalidateSABnzbdService() { + sabnzbdServiceInstance = null; +} +``` + +--- + +### 2. Download Service Factory (`src/lib/integrations/download-client.factory.ts`) + +**Abstraction layer for download client selection:** + +```typescript +export type DownloadClientType = 'qbittorrent' | 'sabnzbd'; + +export interface IDownloadClient { + testConnection(): Promise<{ success: boolean; version?: string; }>; + addDownload(url: string, metadata: DownloadMetadata): Promise; // Returns hash/nzbId + getDownloadStatus(id: string): Promise; + pauseDownload(id: string): Promise; + resumeDownload(id: string): Promise; + deleteDownload(id: string, deleteFiles?: boolean): Promise; +} + +export interface DownloadMetadata { + title: string; + author: string; + category?: string; +} + +export interface DownloadStatus { + id: string; + name: string; + progress: number; + state: string; + downloadPath?: string; + completedAt?: Date; + errorMessage?: string; +} + +export async function getDownloadClient(): Promise<{ + type: DownloadClientType; + client: IDownloadClient; +}> { + const config = await getConfigService(); + const type = await config.get('download_client_type') as DownloadClientType; + + if (!type) throw new Error('No download client configured'); + + if (type === 'qbittorrent') { + return { type, client: new QBittorrentAdapter(await getQBittorrentService()) }; + } else if (type === 'sabnzbd') { + return { type, client: new SABnzbdAdapter(await getSABnzbdService()) }; + } + + throw new Error(`Unknown download client type: ${type}`); +} +``` + +**Adapter Pattern:** Wrap existing services to implement `IDownloadClient` interface. This allows download-agnostic code in jobs/processors. + +--- + +## Integration Points + +### 1. Setup Wizard (`src/app/setup/steps/DownloadClientStep.tsx`) + +**Current State:** +- 2 buttons: qBittorrent (active) | Transmission (disabled) + +**New State:** +- 2 buttons: qBittorrent | SABnzbd (both active) + +**Changes:** +```tsx +
+ + + +
+ +{/* Conditional form fields based on selection */} +{downloadClient === 'qbittorrent' && ( + +)} + +{downloadClient === 'sabnzbd' && ( + {/* URL + API Key (no username) */} +)} +``` + +**Form Differences:** +- **qBittorrent:** URL, Username, Password, SSL verify toggle, Path mapping +- **SABnzbd:** URL, API Key (no username), SSL verify toggle, Path mapping +- Reuse existing path mapping UI (works identically for both) + +**Test Connection:** +- Route: `POST /api/setup/test-download-client` +- Body: `{ type: 'sabnzbd', url, apiKey, ... }` +- Returns: `{ success: true, version: 'SABnzbd 4.x.x' }` + +--- + +### 2. Search & Ranking (No Changes Required!) + +**Implementation Strategy (Approved):** + +1. **Prowlarr Search (`src/lib/integrations/prowlarr.service.ts`)** + - Already returns both torrent AND NZB results + - **NEW:** Filter results by configured download client protocol + - If `download_client_type = 'qbittorrent'` → only return torrent results + - If `download_client_type = 'sabnzbd'` → only return NZB results + - Filtering happens BEFORE ranking algorithm + +2. **Ranking Algorithm (`src/lib/utils/ranking-algorithm.ts`)** + - Protocol-agnostic scoring (title/author match, seeders, format, size) + - Works with both torrent and NZB results + - No changes needed (receives pre-filtered results) + +3. **Result Selection** + - Best result always matches user's configured client + - No protocol auto-detection needed + - Simpler, cleaner logic + +**Protocol Detection (for filtering):** +```typescript +function getResultProtocol(result: TorrentResult): 'torrent' | 'nzb' { + if (result.downloadUrl.endsWith('.nzb') || + result.downloadUrl.includes('/nzb/') || + result.categories?.includes(3030)) { // Usenet category + return 'nzb'; + } + return 'torrent'; +} +``` + +--- + +### 3. Download Job Processor (`src/lib/processors/download-torrent.processor.ts`) + +**Rename to:** `download.processor.ts` (handles both protocols) + +**Current Logic:** +```typescript +export async function processDownloadTorrent(payload) { + const qbt = await getQBittorrentService(); + const hash = await qbt.addTorrent(downloadUrl); + + await prisma.downloadHistory.update({ + data: { torrent_hash: hash } + }); + + // Schedule monitor job +} +``` + +**New Logic (Config-Based Routing - APPROVED):** + +User's configured download client handles ALL downloads. Prowlarr results are pre-filtered to match the client type, so downloads always match the user's infrastructure. + +```typescript +export async function processDownload(payload) { + const config = await getConfigService(); + const clientType = await config.get('download_client_type'); + + let downloadId: string; + let downloadClient: 'qbittorrent' | 'sabnzbd'; + + if (clientType === 'sabnzbd') { + // Download via SABnzbd + const sabnzbd = await getSABnzbdService(); + downloadId = await sabnzbd.addNZB(payload.torrent.downloadUrl, { category: 'readmeabook' }); + downloadClient = 'sabnzbd'; + + await prisma.downloadHistory.update({ + where: { id: payload.downloadHistoryId }, + data: { + nzb_id: downloadId, + download_client: 'sabnzbd', + } + }); + } else { + // Download via qBittorrent (default) + const qbt = await getQBittorrentService(); + downloadId = await qbt.addTorrent(payload.torrent.downloadUrl); + downloadClient = 'qbittorrent'; + + await prisma.downloadHistory.update({ + where: { id: payload.downloadHistoryId }, + data: { + torrent_hash: downloadId, + download_client: 'qbittorrent', + } + }); + } + + // Schedule monitor job (unified) + await jobQueue.addMonitorJob( + payload.requestId, + payload.downloadHistoryId, + downloadId, + downloadClient, + 3 // 3 second initial delay + ); +} +``` + +**Benefits:** +- Simpler logic (no protocol auto-detection) +- Respects user's explicit choice during setup +- No mixed protocols in system +- Prowlarr filtering ensures results match client type + +--- + +### 4. Monitor Download Job (`src/lib/processors/monitor-download.processor.ts`) + +**Current Logic:** +```typescript +export async function processMonitorDownload(payload) { + const { downloadClientId, downloadClient } = payload; + + if (downloadClient !== 'qbittorrent') { + throw new Error(`Client ${downloadClient} not supported`); + } + + const qbt = await getQBittorrentService(); + const torrent = await qbt.getTorrent(downloadClientId); + const progress = qbt.getDownloadProgress(torrent); + + // Update request progress + // Check if completed → trigger organize job +} +``` + +**New Logic (Protocol Branching):** +```typescript +export async function processMonitorDownload(payload) { + const { downloadClientId, downloadClient, requestId, downloadHistoryId } = payload; + + let progress: DownloadProgress; + let downloadPath: string | undefined; + + if (downloadClient === 'qbittorrent') { + const qbt = await getQBittorrentService(); + const torrent = await qbt.getTorrent(downloadClientId); + progress = qbt.getDownloadProgress(torrent); + downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name); + + } else if (downloadClient === 'sabnzbd') { + const sabnzbd = await getSABnzbdService(); + + // Check queue first, then history + const queueItem = (await sabnzbd.getQueue()).find(item => item.nzbId === downloadClientId); + if (queueItem) { + progress = sabnzbd.getDownloadProgress(queueItem); + } else { + // Not in queue, check history + const historyItem = (await sabnzbd.getHistory()).find(item => item.nzbId === downloadClientId); + if (!historyItem) throw new Error(`NZB ${downloadClientId} not found`); + + progress = { + percent: historyItem.status === 'completed' ? 100 : 0, + bytesDownloaded: historyItem.size, + bytesTotal: historyItem.size, + speed: 0, + eta: 0, + state: historyItem.status, + }; + downloadPath = historyItem.downloadPath; + } + + } else { + throw new Error(`Download client ${downloadClient} not supported`); + } + + // Update request progress (unified) + await prisma.request.update({ + where: { id: requestId }, + data: { progress: progress.percent }, + }); + + // Check completion (unified) + if (progress.state === 'completed') { + await logger?.info('Download completed'); + + // Apply path mapping (works for both) + const organizePath = PathMapper.transform(downloadPath, pathMappingConfig); + + // Trigger organize job (unified) + await jobQueue.addJob('organize_files', { + requestId, + audiobookId: request.audiobook.id, + downloadPath: organizePath, + targetPath: mediaDir, + }); + } else { + // Re-schedule monitoring (unified) + await jobQueue.addJob('monitor_download', payload, { delay: 10000 }); + } +} +``` + +**Key Points:** +- SABnzbd queue/history API differs from qBittorrent (queue = active, history = completed/failed) +- SABnzbd handles post-processing (par2 verification, rar extraction) automatically +- Path from SABnzbd is post-processed directory (already extracted) + +--- + +### 5. File Organization (No Changes Required) + +**Current Implementation Already Compatible:** +- Accepts `downloadPath` (directory or file) +- Copies audiobook files (`.m4b`, `.mp3`, `.m4a`) to media library +- Tags metadata with ffmpeg +- Downloads/copies cover art + +**SABnzbd Compatibility:** +- SABnzbd extracts `.rar`/`.zip` archives automatically +- `downloadPath` points to extracted directory +- File organizer finds audiobook files identically + +**No code changes needed!** + +--- + +### 6. Admin Settings Page + +**Current State:** +- Download Client tab: qBittorrent fields only + +**New State:** +- Show fields based on `download_client_type` config +- Allow switching between qBittorrent and SABnzbd +- Test connection button (revalidate on change) + +**UI Changes:** +```tsx +const [clientType, setClientType] = useState<'qbittorrent' | 'sabnzbd'>('qbittorrent'); + + + +{clientType === 'qbittorrent' && } +{clientType === 'sabnzbd' && } +``` + +**Warning on Switch:** +> Changing download clients will affect new downloads only. Existing downloads will continue with their original client. + +--- + +## Testing Strategy (Rock Solid Without Usenet) + +### Challenge +You don't have an active Usenet account. We need comprehensive tests that don't require real Usenet servers. + +### Solution: Mock-Based Testing + +#### 1. Unit Tests (SABnzbd Service) + +**Mock HTTP responses:** +```typescript +describe('SABnzbdService', () => { + let mockAxios: jest.Mocked; + let service: SABnzbdService; + + beforeEach(() => { + mockAxios = axios.create() as jest.Mocked; + service = new SABnzbdService('http://sabnzbd:8080', 'test-api-key'); + }); + + test('addNZB returns nzbId', async () => { + mockAxios.post.mockResolvedValue({ + data: { status: true, nzo_ids: ['SABnzbd_nzo_abc123'] } + }); + + const nzbId = await service.addNZB('http://indexer.com/nzb/123.nzb'); + expect(nzbId).toBe('SABnzbd_nzo_abc123'); + }); + + test('getQueue returns active downloads', async () => { + mockAxios.get.mockResolvedValue({ + data: { + queue: { + slots: [{ + nzo_id: 'SABnzbd_nzo_abc123', + filename: 'Audiobook.Name', + mb: '250.5', + mbleft: '125.25', + percentage: '50', + status: 'Downloading', + timeleft: '0:15:30', + }] + } + } + }); + + const queue = await service.getQueue(); + expect(queue).toHaveLength(1); + expect(queue[0].progress).toBe(0.5); + }); + + // Test error handling, retries, category creation, etc. +}); +``` + +#### 2. Integration Tests (Job Processors) + +**Mock SABnzbd service:** +```typescript +jest.mock('../integrations/sabnzbd.service'); + +describe('Download Processor', () => { + test('routes NZB downloads to SABnzbd', async () => { + const mockSABnzbd = { + addNZB: jest.fn().resolvedValue('SABnzbd_nzo_abc123'), + }; + (getSABnzbdService as jest.Mock).mockResolvedValue(mockSABnzbd); + + await processDownload({ + downloadUrl: 'http://indexer.com/nzb/audiobook.nzb', + requestId: 'test-request-id', + }); + + expect(mockSABnzbd.addNZB).toHaveBeenCalledWith( + 'http://indexer.com/nzb/audiobook.nzb', + expect.objectContaining({ category: 'readmeabook' }) + ); + }); +}); +``` + +#### 3. Manual Testing with Docker Compose + +**Add SABnzbd to docker-compose.yml (test mode):** +```yaml +services: + sabnzbd: + image: linuxserver/sabnzbd:latest + container_name: sabnzbd-test + ports: + - "8080:8080" + environment: + - PUID=1000 + - PGID=1000 + volumes: + - ./test-data/sabnzbd-config:/config + - ./test-data/sabnzbd-downloads:/downloads +``` + +**Configure with fake Usenet server:** +- Host: `fake.usenet.server` (will fail gracefully) +- Add test NZB files manually via web UI +- Test ReadMeABook integration without real downloads + +#### 4. Simulated Download Flow + +**Test NZB file (minimal valid structure):** +```xml + + + + + alt.binaries.audiobooks + + test123@example.com + + + +``` + +Upload to SABnzbd → will fail download but test monitoring/state management. + +--- + +## Implementation Plan + +### Phase 1: Core SABnzbd Service (Week 1) + +**Deliverables:** +- [ ] `src/lib/integrations/sabnzbd.service.ts` (full implementation) +- [ ] `src/lib/integrations/sabnzbd.service.test.ts` (unit tests with mocks) +- [ ] Documentation: `documentation/phase3/sabnzbd.md` (token-efficient format) +- [ ] Update TABLEOFCONTENTS.md + +**Acceptance Criteria:** +- All unit tests pass (100% coverage on service methods) +- Mock-based tests validate API response parsing +- Error handling for common failure modes (401, 503, network errors) + +--- + +### Phase 2: Setup Wizard Integration (Week 1) + +**Deliverables:** +- [ ] Update `src/app/setup/steps/DownloadClientStep.tsx` (add SABnzbd option) +- [ ] Create SABnzbd field component (URL + API key form) +- [ ] Update `src/app/api/setup/test-download-client/route.ts` (add SABnzbd test logic) +- [ ] Update setup wizard documentation + +**Acceptance Criteria:** +- SABnzbd selection shows correct form fields +- Test connection validates API key and returns version +- Successful test enables "Next" button +- Config saved to database correctly + +--- + +### Phase 3: Download & Monitor Jobs (Week 2) + +**Deliverables:** +- [ ] Rename `download-torrent.processor.ts` → `download.processor.ts` +- [ ] Add SABnzbd routing logic (config-based) +- [ ] Update `monitor-download.processor.ts` (add SABnzbd branch) +- [ ] Update database schema (add `nzb_id` field) +- [ ] Integration tests for both protocols + +**Acceptance Criteria:** +- Downloads route to correct client based on config +- Monitor job polls SABnzbd queue/history correctly +- Progress updates reflect SABnzbd states (downloading, extracting, completed) +- Failed downloads trigger proper error handling + +--- + +### Phase 4: Admin Settings & Polish (Week 2) + +**Deliverables:** +- [ ] Update `src/app/admin/settings/page.tsx` (download client tab) +- [ ] Add SABnzbd settings form +- [ ] Update `src/app/api/admin/settings/download-client/route.ts` +- [ ] Add client type switcher with warning +- [ ] Test connection in settings page + +**Acceptance Criteria:** +- Settings page shows correct fields for selected client +- Switching clients saves config and invalidates singleton +- Test connection works from settings page +- Warning displayed when switching clients + +--- + +### Phase 5: Testing & Documentation (Week 3) + +**Deliverables:** +- [ ] Manual testing with Docker SABnzbd instance +- [ ] End-to-end test: Setup → Search → Download → Monitor → Organize +- [ ] Update all documentation (PRD, implementation guide, troubleshooting) +- [ ] Create migration guide for existing qBittorrent users + +**Acceptance Criteria:** +- Full download flow works with mock SABnzbd (no real Usenet) +- All integration tests pass +- Documentation complete and accurate +- Zero regressions in qBittorrent flow + +--- + +## Risk Mitigation + +### Risk 1: No Real Usenet Access for Testing + +**Mitigation:** +- Comprehensive mock-based unit tests (cover 90%+ of code paths) +- Docker SABnzbd instance with fake server (test API integration) +- Community beta testing (recruit 2-3 Usenet users for real-world validation) +- Graceful error handling (assume Usenet server issues common) + +--- + +### Risk 2: SABnzbd API Differences Across Versions + +**Mitigation:** +- Test against SABnzbd 4.x (latest stable) +- Document minimum supported version (3.x or 4.x) +- Version detection in testConnection() warns if unsupported +- Graceful degradation for missing API features + +--- + +### Risk 3: NZB Post-Processing Failures + +**Mitigation:** +- SABnzbd handles par2 repair and extraction automatically +- Monitor for "failed" status in history +- Log post-processing errors to job events +- Retry logic for transient failures (network issues) +- User-facing error messages with actionable guidance + +--- + +### Risk 4: Breaking Existing qBittorrent Flow + +**Mitigation:** +- No changes to qBittorrent service (isolated) +- Download/monitor processors use branching (not replacement) +- Integration tests cover both protocols +- Manual regression testing before release +- Feature flag (optional): `usenet_enabled` config to disable if issues arise + +--- + +## Success Metrics + +1. **Zero Regressions:** All existing qBittorrent tests pass +2. **High Test Coverage:** ≥90% coverage on new SABnzbd code +3. **Mock Test Success:** All unit/integration tests pass without real Usenet +4. **Beta Validation:** 2-3 community testers confirm working downloads +5. **Documentation Complete:** Setup wizard, admin guide, troubleshooting docs + +--- + +## Approved Decisions (2026-01-06) + +1. **Download Client Selection:** ✅ **Option A Approved** + - User picks ONE client during setup (qBittorrent OR SABnzbd) + - All downloads use that client + - Prowlarr results filtered by configured backend's protocol + - Simpler UX, matches user's infrastructure reality + +2. **Transmission Support:** ✅ **Removed Entirely** + - Transmission references scrubbed from codebase + - No placeholder button in UI + - Clean two-option choice: qBittorrent vs SABnzbd + +3. **Beta Testing:** ✅ **User Has Beta Testers Ready** + - Community testers lined up for real-world validation + - No additional recruitment needed + +4. **Priority:** ✅ **Implement Immediately** + - Full end-to-end implementation approved + - Target: Complete, polished, professional product + +--- + +## Appendices + +### Appendix A: SABnzbd API Reference + +**Key Endpoints:** +``` +GET /api?mode=version&output=json&apikey={key} +GET /api?mode=queue&output=json&apikey={key} +GET /api?mode=history&output=json&limit=100&apikey={key} +GET /api?mode=addurl&name={url}&cat={cat}&output=json&apikey={key} +POST /api?mode=addfile&cat={cat}&output=json&apikey={key} (multipart: nzbfile) +GET /api?mode=pause&value={nzbId}&output=json&apikey={key} +GET /api?mode=resume&value={nzbId}&output=json&apikey={key} +GET /api?mode=queue&name=delete&value={nzbId}&del_files=1&output=json&apikey={key} +GET /api?mode=get_cats&output=json&apikey={key} +POST /api?mode=set_config§ion=categories&keyword={cat}&value={path}&output=json&apikey={key} +``` + +**Response Format (Queue):** +```json +{ + "queue": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_abc123", + "filename": "Audiobook.Name.2024", + "mb": "250.50", + "mbleft": "125.25", + "percentage": "50", + "status": "Downloading", + "timeleft": "0:15:30", + "cat": "readmeabook", + "script": "None", + "priority": "Normal" + } + ], + "speed": "5.2 MB/s", + "mbleft": "125.25" + } +} +``` + +**Response Format (History):** +```json +{ + "history": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_abc123", + "name": "Audiobook.Name.2024", + "category": "readmeabook", + "status": "Completed", + "bytes": "262656000", + "fail_message": "", + "storage": "/downloads/complete/Audiobook.Name.2024", + "completed": "1640000000", + "download_time": "900" + } + ] + } +} +``` + +--- + +### Appendix B: Database Migration Script + +**Add `nzb_id` field to DownloadHistory:** +```sql +-- Migration: Add NZB ID field for SABnzbd integration +ALTER TABLE "DownloadHistory" +ADD COLUMN "nzb_id" TEXT; + +-- Add index for fast NZB lookups +CREATE INDEX "DownloadHistory_nzb_id_idx" ON "DownloadHistory"("nzb_id"); + +-- Make torrent_hash nullable (was implicitly nullable, now explicit) +ALTER TABLE "DownloadHistory" +ALTER COLUMN "torrent_hash" DROP NOT NULL; + +-- Add constraint: at least one of torrent_hash or nzb_id must be set +ALTER TABLE "DownloadHistory" +ADD CONSTRAINT "DownloadHistory_download_id_check" +CHECK ( + (torrent_hash IS NOT NULL AND nzb_id IS NULL) OR + (torrent_hash IS NULL AND nzb_id IS NOT NULL) +); +``` + +**Prisma Schema Update:** +```prisma +model DownloadHistory { + id String @id @default(uuid()) + requestId String + indexerName String + torrentName String + torrentHash String? // Nullable for NZB downloads + nzbId String? // SABnzbd NZB ID + torrentSizeBytes BigInt + magnetLink String? + torrentUrl String? + seeders Int + leechers Int + qualityScore Float + selected Boolean @default(false) + downloadClient String // 'qbittorrent' | 'sabnzbd' + downloadClientId String // torrentHash or nzbId (redundant but convenient) + downloadStatus String + downloadError String? + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + + request Request @relation(fields: [requestId], references: [id], onDelete: Cascade) + + @@index([requestId]) + @@index([selected]) + @@index([torrentHash]) + @@index([nzbId]) + @@index([createdAt(sort: Desc)]) +} +``` + +--- + +## Summary + +This PRD outlines a **rock-solid, professionally architected NZB/Usenet integration** using SABnzbd. The design: + +✅ **Minimally invasive:** Reuses 90% of existing infrastructure (ranking, jobs, file org) +✅ **Well-tested:** Comprehensive mock-based tests (no Usenet required for dev) +✅ **User-friendly:** Simple setup wizard, clear documentation +✅ **Production-ready:** Error handling, retry logic, graceful degradation +✅ **Future-proof:** Adapter pattern supports adding more clients (NZBGet, Deluge, etc.) + +**Next Steps:** +1. Review this PRD and provide feedback +2. Answer open questions (download client selection model, beta testing, priority) +3. Approve for implementation OR request revisions +4. Begin Phase 1 development + +**Estimated Timeline:** 3 weeks (part-time development) +**Risk Level:** Low (isolated changes, comprehensive testing) +**User Impact:** High (unlocks entire Usenet user base) + +--- + +**Ready for your review!** 🚀 diff --git a/documentation/integrations/audible.md b/documentation/integrations/audible.md index 220fdb9..941473f 100644 --- a/documentation/integrations/audible.md +++ b/documentation/integrations/audible.md @@ -144,3 +144,25 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook { - Redis (caching, optional) - Database (PostgreSQL) - string-similarity (matching) + +## Fixed Issues + +**Search returning empty results (2026-01-07)** +- **Problem:** Audible changed HTML structure for search results from `.productListItem` to `.s-result-item` +- **Impact:** All search queries returned 0 results +- **Fix:** Updated `search()` method to support both `.s-result-item` (current) and `.productListItem` (legacy) +- **Selectors updated:** + - Main: `.s-result-item, .productListItem` + - Title: `h2` (new) or `h3 a` (legacy) + - Author: `a[href*="/author/"]` (new) or `.authorLabel` (legacy) + - Narrator: `a[href*="searchNarrator="]` (new) or `.narratorLabel` (legacy) + - Runtime: `span:contains("Length:")` (new) or `.runtimeLabel` (legacy) + - Rating: `.a-icon-star span` (new) or `.ratingsLabel` (legacy) +- **Location:** `src/lib/integrations/audible.service.ts:235` + +**Some audiobooks missing from search results (2026-01-07)** +- **Problem:** ASIN extraction only matched `/pd/` URLs but some audiobooks use `/ac/` URLs +- **Impact:** Books like "Beatitude" by DJ Krimmer (ASIN: B0DVH7XL36) were skipped +- **Fix:** Updated ASIN regex to match both `/pd/` and `/ac/` URL patterns: `/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/` +- **Location:** `src/lib/integrations/audible.service.ts:75, 161, 240` +- **Affects:** `getPopularAudiobooks()`, `getNewReleases()`, `search()` methods diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 3de984b..7cde914 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -26,13 +26,16 @@ Evaluates and scores torrents to automatically select best audiobook download. **Stage 2: Title Matching (0-35 pts)** - Only scored if Stage 1 passes +- **Tries full title first, then required title (without parentheses)** if no match + - Example: "We Are Legion (We Are Bob)" tries both full title and "We Are Legion" + - Handles torrents that include subtitle AND those that omit it - Complete title match requirements (both must be true): - No significant words BEFORE matched title (prevents "This Inevitable Ruin Dungeon Crawler Carl, Book 7") - Followed by metadata markers: " by", " [", " -", " (", " {", " :", "," - Complete match → 35 pts - Title has prefix/suffix words OR continues with more words → fuzzy similarity (partial credit) - Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret", "Dungeon Crawler Carl" vs "Book 7" -- No substring match → fuzzy similarity (partial credit) +- No substring match → fuzzy similarity (best score from full or required title) **Stage 3: Author Matching (0-15 pts)** - Exact substring match → proportional credit diff --git a/documentation/phase3/sabnzbd.md b/documentation/phase3/sabnzbd.md new file mode 100644 index 0000000..df2b744 --- /dev/null +++ b/documentation/phase3/sabnzbd.md @@ -0,0 +1,203 @@ +# SABnzbd Integration + +**Status:** ✅ Implemented + +Free, open-source Usenet/NZB download client with comprehensive Web API. Industry standard for automation workflows. + +## Key Features + +- **Protocol:** Usenet/NZB downloads (not torrents) +- **Post-Processing:** Automatic par2 repair, rar/zip extraction, cleanup +- **Category Support:** Per-category download paths +- **API:** RESTful JSON API with API key authentication +- **Status:** No hash extraction needed (NZB ID returned immediately) + +## API Endpoints + +**Base:** `http://sabnzbd:8080/api` +**Auth:** API key parameter (`apikey={key}`) +**Format:** All requests use `output=json` for JSON responses + +**GET /api?mode=version&output=json&apikey={key}** - Get SABnzbd version +**GET /api?mode=addurl&name={url}&cat={category}&output=json&apikey={key}** - Add NZB by URL +**GET /api?mode=queue&output=json&apikey={key}** - Get active downloads +**GET /api?mode=history&limit=100&output=json&apikey={key}** - Get completed/failed downloads +**GET /api?mode=pause&value={nzbId}&output=json&apikey={key}** - Pause download +**GET /api?mode=resume&value={nzbId}&output=json&apikey={key}** - Resume download +**GET /api?mode=queue&name=delete&value={nzbId}&del_files={0|1}&output=json&apikey={key}** - Delete download +**GET /api?mode=get_config&output=json&apikey={key}** - Get configuration (categories) +**GET /api?mode=set_config§ion=categories&keyword={cat}&value={path}&output=json&apikey={key}** - Create/update category + +## Config + +**Required (database only, no env fallbacks):** +- `download_client_type` - Must be 'sabnzbd' +- `download_client_url` - SABnzbd Web UI URL (supports HTTP and HTTPS) +- `download_client_password` - API key (reuses password field) +- `download_dir` - Download save path (passed to SABnzbd category) + +**Optional (SSL/TLS):** +- `download_client_disable_ssl_verify` - Disable SSL certificate verification (boolean as string "true"/"false", default: "false") + - Use when connecting to SABnzbd with self-signed certificates + - ⚠️ Security warning: Only use on trusted private networks + +**Optional (Remote Path Mapping):** +- `download_client_remote_path_mapping_enabled` - Enable path mapping (boolean) +- `download_client_remote_path` - Remote path prefix from SABnzbd +- `download_client_local_path` - Local path prefix for ReadMeABook + +**Optional (SABnzbd-specific):** +- `sabnzbd_category` - Category name (default: 'readmeabook') + +Validation: All required fields checked before service initialization. Path mapping fields validated when enabled. + +**Singleton Invalidation:** +Service uses singleton pattern. When settings change, singleton invalidated to force reload: +- `invalidateSABnzbdService()` called after updating settings +- Forces service to re-read database config +- Ensures category and credentials are always current + +## Category Management + +**Category:** `readmeabook` (auto-created for all downloads) + +**Save Path Synchronization:** +- Category created on first download if not exists +- Category path set to `download_dir` config value +- Unlike qBittorrent, SABnzbd categories are less frequently updated (set once at creation) + +## Post-Processing + +**Automatic (Built-in SABnzbd Features):** +- **Par2 Repair:** Verifies and repairs damaged downloads +- **Archive Extraction:** Extracts .rar, .zip, .7z archives automatically +- **Cleanup:** Deletes .par2, .nfo, .nzb files after extraction +- **Result:** `downloadPath` points to extracted directory (ready for file organizer) + +**Configuration:** Post-processing level set to `pp=3` (Repair + Unpack + Delete) on all downloads + +## Data Models + +```typescript +interface NZBInfo { + nzbId: string; // SABnzbd NZB ID + name: string; + size: number; // bytes + progress: number; // 0.0-1.0 + status: NZBStatus; + downloadSpeed: number; // bytes/s + timeLeft: number; // seconds + category: string; + downloadPath?: string; // Available after completion + completedAt?: Date; + errorMessage?: string; +} + +type NZBStatus = 'downloading' | 'queued' | 'paused' | 'extracting' | 'completed' | 'failed' | 'repairing'; + +interface QueueItem { + nzbId: string; + name: string; + size: number; // MB + sizeLeft: number; // MB + percentage: number; // 0-100 + status: string; // "Downloading", "Paused", "Queued" + timeLeft: string; // "0:15:30" format + category: string; + priority: string; +} + +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 +} +``` + +## NZB ID vs Torrent Hash + +**Key Difference:** SABnzbd returns NZB ID immediately (no extraction needed) + +**qBittorrent:** +- Returns "Ok." without hash +- Requires parsing magnet link or .torrent file +- Hash extraction needed for tracking + +**SABnzbd:** +- Returns `nzo_ids` array immediately +- NZB ID format: `SABnzbd_nzo_abc123xyz` +- No extraction needed, can track immediately + +**Database Storage:** +- qBittorrent: `torrent_hash` field +- SABnzbd: `nzb_id` field (new) +- Both nullable, exactly one must be set + +## Status Tracking + +**Queue vs History:** +- **Queue:** Active downloads (downloading, queued, paused) +- **History:** Completed or failed downloads + +**Monitoring Flow:** +1. Check queue for NZB ID +2. If found: Parse progress, status, speed +3. If not in queue: Check history +4. If in history: Parse completion status or error + +**States:** +- `Downloading` → Active download +- `Queued` → Waiting in queue +- `Paused` → Manually paused +- `Extracting/Unpacking` → Post-processing extraction +- `Repairing/Verifying` → Post-processing par2 repair +- `Completed` → Successfully downloaded and extracted +- `Failed` → Download or post-processing failed + +## Remote Path Mapping + +**Use Case:** SABnzbd runs on different machine/container with different filesystem perspective. + +**Example Scenario:** +- SABnzbd reports: `/remote/usenet/complete/Audiobook.Name` +- ReadMeABook needs: `/downloads/Audiobook.Name` +- Mapping: Remote `/remote/usenet/complete` → Local `/downloads` + +**Implementation:** Same as qBittorrent (uses `PathMapper` utility) + +## Fixed Issues ✅ + +**1. API Key Authentication** - Uses `apikey` parameter (not username/password) +**2. Immediate NZB ID** - No hash extraction needed (returned by API) +**3. Post-Processing Tracking** - Monitors extracting/repairing states +**4. Queue vs History Logic** - Checks queue first, falls back to history +**5. SSL Certificate Errors** - Optional SSL verification disable for self-signed certs + +## Comparison: SABnzbd vs qBittorrent + +| Feature | SABnzbd | qBittorrent | +|---------|---------|-------------| +| Protocol | Usenet/NZB | BitTorrent | +| Auth | API key only | Username + Password | +| ID Format | NZB ID (immediate) | Torrent hash (extracted) | +| Post-Processing | Automatic (par2, extraction) | None (manual) | +| Seeding | N/A (Usenet is not P2P) | Required (tracker) | +| Categories | Path-based | Path + tag-based | +| File Handling | Auto-extracts archives | Downloads as-is | + +## Tech Stack + +- axios (HTTP client) +- Node.js https (SSL/TLS agent) +- JSON API responses + +## Related + +- See [File Organization](./file-organization.md) for post-download processing +- See [qBittorrent Integration](./qbittorrent.md) for torrent alternative diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a6718b..76ce013 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -238,6 +238,7 @@ model DownloadHistory { indexerName String @map("indexer_name") torrentName String? @map("torrent_name") torrentHash String? @map("torrent_hash") + nzbId String? @map("nzb_id") // SABnzbd NZB ID (mutually exclusive with torrentHash) torrentSizeBytes BigInt? @map("torrent_size_bytes") magnetLink String? @map("magnet_link") @db.Text torrentUrl String? @map("torrent_url") @db.Text @@ -245,7 +246,7 @@ model DownloadHistory { leechers Int? qualityScore Int? @map("quality_score") selected Boolean @default(false) - downloadClient String? @map("download_client") // qbittorrent, transmission + downloadClient String? @map("download_client") // qbittorrent, sabnzbd downloadClientId String? @map("download_client_id") downloadStatus String? @map("download_status") // Status values: queued, downloading, completed, failed, stalled @@ -259,6 +260,8 @@ model DownloadHistory { @@index([requestId]) @@index([selected]) + @@index([torrentHash]) + @@index([nzbId]) @@index([createdAt(sort: Desc)]) @@map("download_history") } diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 6cddabf..9460e5e 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -1521,7 +1521,7 @@ export default function AdminSettings() { Download Client

- Configure your torrent download client (qBittorrent/Transmission). + Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.

@@ -1541,7 +1541,7 @@ export default function AdminSettings() { className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" > - + @@ -1563,47 +1563,79 @@ export default function AdminSettings() { /> -
- - { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - username: e.target.value, - }, - }); - setValidated({ ...validated, download: false }); - }} - placeholder="admin" - /> -
+ {/* qBittorrent: Username + Password */} + {settings.downloadClient.type === 'qbittorrent' && ( + <> +
+ + { + setSettings({ + ...settings, + downloadClient: { + ...settings.downloadClient, + username: e.target.value, + }, + }); + setValidated({ ...validated, download: false }); + }} + placeholder="admin" + /> +
-
- - { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - password: e.target.value, - }, - }); - setValidated({ ...validated, download: false }); - }} - placeholder="Enter password" - /> -
+
+ + { + setSettings({ + ...settings, + downloadClient: { + ...settings.downloadClient, + password: e.target.value, + }, + }); + setValidated({ ...validated, download: false }); + }} + placeholder="Enter password" + /> +
+ + )} + + {/* SABnzbd: API Key only */} + {settings.downloadClient.type === 'sabnzbd' && ( +
+ + { + setSettings({ + ...settings, + downloadClient: { + ...settings.downloadClient, + password: e.target.value, + }, + }); + setValidated({ ...validated, download: false }); + }} + placeholder="Enter SABnzbd API key" + /> +

+ Find this in SABnzbd under Config → General → API Key +

+
+ )} {/* SSL Verification Toggle */} {settings.downloadClient.url.startsWith('https') && ( diff --git a/src/app/api/admin/settings/download-client/route.ts b/src/app/api/admin/settings/download-client/route.ts index cbb84b3..2c65bf3 100644 --- a/src/app/api/admin/settings/download-client/route.ts +++ b/src/app/api/admin/settings/download-client/route.ts @@ -23,19 +23,29 @@ export async function PUT(request: NextRequest) { localPath, } = await request.json(); - if (!type || !url || !username || !password) { + // Validate type + if (type !== 'qbittorrent' && type !== 'sabnzbd') { return NextResponse.json( - { error: 'Type, URL, username, and password are required' }, + { error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, { status: 400 } ); } - // Validate type - if (type !== 'qbittorrent' && type !== 'transmission') { - return NextResponse.json( - { error: 'Invalid client type. Must be qbittorrent or transmission' }, - { status: 400 } - ); + // Validate required fields (SABnzbd only needs URL and API key) + if (type === 'sabnzbd') { + if (!url || !password) { + return NextResponse.json( + { error: 'URL and API key (password) are required for SABnzbd' }, + { status: 400 } + ); + } + } else if (type === 'qbittorrent') { + if (!url || !username || !password) { + return NextResponse.json( + { error: 'URL, username, and password are required for qBittorrent' }, + { status: 400 } + ); + } } // Validate path mapping if enabled @@ -127,9 +137,14 @@ export async function PUT(request: NextRequest) { console.log('[Admin] Download client settings updated'); - // Invalidate qBittorrent service singleton to force reload of credentials and URL - const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service'); - invalidateQBittorrentService(); + // Invalidate download client service singleton to force reload of credentials and URL + if (type === 'qbittorrent') { + const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service'); + invalidateQBittorrentService(); + } else if (type === 'sabnzbd') { + const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service'); + invalidateSABnzbdService(); + } return NextResponse.json({ success: true, diff --git a/src/app/api/admin/settings/test-download-client/route.ts b/src/app/api/admin/settings/test-download-client/route.ts index cb6be75..670573a 100644 --- a/src/app/api/admin/settings/test-download-client/route.ts +++ b/src/app/api/admin/settings/test-download-client/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { QBittorrentService } from '@/lib/integrations/qbittorrent.service'; +import { SABnzbdService } from '@/lib/integrations/sabnzbd.service'; export async function POST(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { @@ -23,30 +24,30 @@ export async function POST(request: NextRequest) { localPath, } = await request.json(); - if (!type || !url || !username || !password) { + if (!type || !url) { return NextResponse.json( - { success: false, error: 'All fields are required' }, + { success: false, error: 'Type and URL are required' }, { status: 400 } ); } - if (type !== 'qbittorrent') { + if (type !== 'qbittorrent' && type !== 'sabnzbd') { return NextResponse.json( - { success: false, error: 'Only qBittorrent is currently supported' }, + { success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, { status: 400 } ); } // If password is masked, fetch the actual value from database let actualPassword = password; - if (password.startsWith('••••')) { + if (password && password.startsWith('••••')) { const storedPassword = await prisma.configuration.findUnique({ where: { key: 'download_client_password' }, }); if (!storedPassword?.value) { return NextResponse.json( - { success: false, error: 'No stored password found. Please re-enter your download client password.' }, + { success: false, error: 'No stored password/API key found. Please re-enter it.' }, { status: 400 } ); } @@ -54,13 +55,48 @@ export async function POST(request: NextRequest) { actualPassword = storedPassword.value; } - // Test connection with credentials - const version = await QBittorrentService.testConnectionWithCredentials( - url, - username, - actualPassword, - disableSSLVerify || false - ); + // Validate required fields per client type and test connection + let version: string | undefined; + + if (type === 'qbittorrent') { + if (!username || !actualPassword) { + return NextResponse.json( + { success: false, error: 'Username and password are required for qBittorrent' }, + { status: 400 } + ); + } + + // Test qBittorrent connection + version = await QBittorrentService.testConnectionWithCredentials( + url, + username, + actualPassword, + disableSSLVerify || false + ); + } else if (type === 'sabnzbd') { + if (!actualPassword) { + return NextResponse.json( + { success: false, error: 'API key (password) is required for SABnzbd' }, + { status: 400 } + ); + } + + // Test SABnzbd connection + const sabnzbd = new SABnzbdService(url, actualPassword, 'readmeabook', disableSSLVerify || false); + const result = await sabnzbd.testConnection(); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: result.error || 'Failed to connect to SABnzbd', + }, + { status: 500 } + ); + } + + version = result.version; + } // If path mapping enabled, validate local path exists if (remotePathMappingEnabled) { diff --git a/src/app/api/setup/test-download-client/route.ts b/src/app/api/setup/test-download-client/route.ts index 684f2fe..ccb1f16 100644 --- a/src/app/api/setup/test-download-client/route.ts +++ b/src/app/api/setup/test-download-client/route.ts @@ -5,37 +5,80 @@ import { NextRequest, NextResponse } from 'next/server'; import { QBittorrentService } from '@/lib/integrations/qbittorrent.service'; +import { SABnzbdService } from '@/lib/integrations/sabnzbd.service'; export async function POST(request: NextRequest) { try { const { type, url, username, password, disableSSLVerify } = await request.json(); - if (!type || !url || !username || !password) { + if (!type || !url) { return NextResponse.json( - { success: false, error: 'All fields are required' }, + { success: false, error: 'Type and URL are required' }, { status: 400 } ); } - if (type !== 'qbittorrent') { + if (type !== 'qbittorrent' && type !== 'sabnzbd') { return NextResponse.json( - { success: false, error: 'Only qBittorrent is currently supported' }, + { success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, { status: 400 } ); } - // Test connection with custom credentials - const version = await QBittorrentService.testConnectionWithCredentials( - url, - username, - password, - disableSSLVerify || false + // Validate required fields per client type + if (type === 'qbittorrent') { + if (!username || !password) { + return NextResponse.json( + { success: false, error: 'Username and password are required for qBittorrent' }, + { status: 400 } + ); + } + + // Test qBittorrent connection + const version = await QBittorrentService.testConnectionWithCredentials( + url, + username, + password, + disableSSLVerify || false + ); + + return NextResponse.json({ + success: true, + version, + }); + } else if (type === 'sabnzbd') { + if (!password) { + return NextResponse.json( + { success: false, error: 'API key (password) is required for SABnzbd' }, + { status: 400 } + ); + } + + // Test SABnzbd connection + const sabnzbd = new SABnzbdService(url, password, 'readmeabook', disableSSLVerify || false); + const result = await sabnzbd.testConnection(); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: result.error || 'Failed to connect to SABnzbd', + }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + version: result.version, + }); + } + + // Should never reach here + return NextResponse.json( + { success: false, error: 'Invalid client type' }, + { status: 400 } ); - - return NextResponse.json({ - success: true, - version, - }); } catch (error) { console.error('[Setup] Download client test failed:', error); return NextResponse.json( diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 0e85999..74e413c 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -73,7 +73,7 @@ interface SetupState { prowlarrUrl: string; prowlarrApiKey: string; prowlarrIndexers: SelectedIndexer[]; - downloadClient: 'qbittorrent' | 'transmission'; + downloadClient: 'qbittorrent' | 'sabnzbd'; downloadClientUrl: string; downloadClientUsername: string; downloadClientPassword: string; diff --git a/src/app/setup/steps/DownloadClientStep.tsx b/src/app/setup/steps/DownloadClientStep.tsx index b40504d..3a87b3c 100644 --- a/src/app/setup/steps/DownloadClientStep.tsx +++ b/src/app/setup/steps/DownloadClientStep.tsx @@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; interface DownloadClientStepProps { - downloadClient: 'qbittorrent' | 'transmission'; + downloadClient: 'qbittorrent' | 'sabnzbd'; downloadClientUrl: string; downloadClientUsername: string; downloadClientPassword: string; @@ -99,6 +99,11 @@ export function DownloadClientStep({ onNext(); }; + // SABnzbd only requires URL and API key (no username) + const isFormValid = downloadClient === 'sabnzbd' + ? downloadClientUrl && downloadClientPassword // Password field stores API key for SABnzbd + : downloadClientUrl && downloadClientUsername && downloadClientPassword; + return (
@@ -106,7 +111,7 @@ export function DownloadClientStep({ Configure Download Client

- Choose and configure your torrent download client. + Choose your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.

@@ -127,21 +132,21 @@ export function DownloadClientStep({ >
qBittorrent
- Recommended - Full feature support + Torrent downloads
@@ -149,11 +154,11 @@ export function DownloadClientStep({
onUpdate('downloadClientUrl', e.target.value)} /> @@ -162,31 +167,53 @@ export function DownloadClientStep({

-
- - onUpdate('downloadClientUsername', e.target.value)} - autoComplete="username" - /> -
+ {downloadClient === 'qbittorrent' && ( + <> +
+ + onUpdate('downloadClientUsername', e.target.value)} + autoComplete="username" + /> +
-
- - onUpdate('downloadClientPassword', e.target.value)} - autoComplete="current-password" - /> -
+
+ + onUpdate('downloadClientPassword', e.target.value)} + autoComplete="current-password" + /> +
+ + )} + + {downloadClient === 'sabnzbd' && ( +
+ + onUpdate('downloadClientPassword', e.target.value)} + autoComplete="off" + /> +

+ Find this in SABnzbd under Config → General → API Key +

+
+ )} {/* SSL Verification Toggle */} {downloadClientUrl.startsWith('https') && ( @@ -215,7 +242,7 @@ export function DownloadClientStep({ )} - {/* Remote Path Mapping */} + {/* Remote Path Mapping (only for clients that download to filesystem) */}

- Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers) + Use this when {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)

Example: Remote /remote/mnt/d/done → Local /downloads @@ -244,7 +271,7 @@ export function DownloadClientStep({

onUpdate('remotePath', e.target.value)} />

- The path prefix as reported by qBittorrent + The path prefix as reported by {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'}

@@ -280,7 +307,7 @@ export function DownloadClientStep({
diff --git a/src/app/setup/steps/ReviewStep.tsx b/src/app/setup/steps/ReviewStep.tsx index 0efd6d6..e5846ed 100644 --- a/src/app/setup/steps/ReviewStep.tsx +++ b/src/app/setup/steps/ReviewStep.tsx @@ -26,7 +26,7 @@ interface ReviewStepProps { // Common config prowlarrUrl: string; - downloadClient: 'qbittorrent' | 'transmission'; + downloadClient: 'qbittorrent' | 'sabnzbd'; downloadClientUrl: string; downloadDir: string; mediaDir: string; diff --git a/src/app/setup/steps/WelcomeStep.tsx b/src/app/setup/steps/WelcomeStep.tsx index 4dc6729..8ccb189 100644 --- a/src/app/setup/steps/WelcomeStep.tsx +++ b/src/app/setup/steps/WelcomeStep.tsx @@ -98,10 +98,10 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
- qBittorrent or Transmission + qBittorrent or SABnzbd

- Download client for managing torrent downloads (URL and credentials) + Download client for torrents (qBittorrent) or Usenet/NZB (SABnzbd)

diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index bf57f73..28c7169 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -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({ diff --git a/src/lib/integrations/prowlarr.service.ts b/src/lib/integrations/prowlarr.service.ts index 7b67c9f..484d51c 100644 --- a/src/lib/integrations/prowlarr.service.ts +++ b/src/lib/integrations/prowlarr.service.ts @@ -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 { + 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 */ diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts new file mode 100644 index 0000000..22fa265 --- /dev/null +++ b/src/lib/integrations/sabnzbd.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + await this.client.get('/api', { + params: { + mode: 'pause', + value: nzbId, + output: 'json', + apikey: this.apiKey, + }, + }); + } + + /** + * Resume NZB download + */ + async resumeNZB(nzbId: string): Promise { + 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 { + 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 { + 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'); +} diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index b487295..a3e4d6a 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -1,16 +1,19 @@ /** - * Component: Download Torrent Job Processor + * Component: Download Job Processor * Documentation: documentation/phase3/README.md */ import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service'; import { prisma } from '../db'; import { getQBittorrentService } from '../integrations/qbittorrent.service'; +import { getSABnzbdService } from '../integrations/sabnzbd.service'; +import { getConfigService } from '../services/config.service'; import { createJobLogger } from '../utils/job-logger'; /** - * Process download torrent job - * Adds selected torrent to download client and starts monitoring + * Process download job + * Routes to appropriate download client based on configuration + * Adds selected result to download client and starts monitoring */ export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise { const { requestId, audiobook, torrent, jobId } = payload; @@ -18,7 +21,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P const logger = jobId ? createJobLogger(jobId, 'DownloadTorrent') : null; await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`); - await logger?.info(`Selected torrent: ${torrent.title}`, { + await logger?.info(`Selected result: ${torrent.title}`, { size: torrent.size, seeders: torrent.seeders, format: torrent.format, @@ -36,69 +39,135 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P }, }); - // Get qBittorrent service - const qbt = await getQBittorrentService(); + // Get configured download client type + const config = await getConfigService(); + const clientType = (await config.get('download_client_type')) || 'qbittorrent'; - // Add torrent to qBittorrent - await logger?.info(`Adding torrent to qBittorrent`); + let downloadClientId: string; + let downloadClient: 'qbittorrent' | 'sabnzbd'; - const torrentHash = await qbt.addTorrent(torrent.downloadUrl, { - category: 'readmeabook', - tags: ['audiobook'], // Generic tag for all audiobooks - sequentialDownload: true, // Download in order for potential streaming - paused: false, // Start immediately - }); + if (clientType === 'sabnzbd') { + // Route to SABnzbd + await logger?.info(`Routing to SABnzbd`); - await logger?.info(`Torrent added with hash: ${torrentHash}`); + const sabnzbd = await getSABnzbdService(); + downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, { + category: 'readmeabook', + priority: 'normal', + }); + downloadClient = 'sabnzbd'; - // Create DownloadHistory record - const downloadHistory = await prisma.downloadHistory.create({ - data: { + await logger?.info(`NZB added with ID: ${downloadClientId}`); + + // Create DownloadHistory record + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId, + indexerName: torrent.indexer, + downloadClient: 'sabnzbd', + downloadClientId, + torrentName: torrent.title, + nzbId: downloadClientId, // Store NZB ID + torrentSizeBytes: torrent.size, + torrentUrl: torrent.guid, // Source URL + magnetLink: torrent.downloadUrl, // Download URL (.nzb file) + seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency + leechers: 0, + downloadStatus: 'downloading', + selected: true, + startedAt: new Date(), + }, + }); + + await logger?.info(`Created download history record: ${downloadHistory.id}`); + + // Trigger monitor download job with initial delay + const jobQueue = getJobQueueService(); + await jobQueue.addMonitorJob( requestId, - indexerName: torrent.indexer, - downloadClient: 'qbittorrent', - downloadClientId: torrentHash, - torrentName: torrent.title, - torrentHash: torrent.infoHash || torrentHash, - torrentSizeBytes: torrent.size, - torrentUrl: torrent.guid, // Source URL for the torrent page - magnetLink: torrent.downloadUrl, // Download URL (magnet or .torrent) - seeders: torrent.seeders, - leechers: torrent.leechers || 0, - downloadStatus: 'downloading', - selected: true, - startedAt: new Date(), - }, - }); + downloadHistory.id, + downloadClientId, + 'sabnzbd', + 3 // Wait 3 seconds before first check + ); - await logger?.info(`Created download history record: ${downloadHistory.id}`); + await logger?.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`); - // Trigger monitor download job with initial delay - // qBittorrent needs a few seconds to process the torrent before it's available via API - const jobQueue = getJobQueueService(); - await jobQueue.addMonitorJob( - requestId, - downloadHistory.id, - torrentHash, - 'qbittorrent', - 3 // Wait 3 seconds before first check to avoid race condition - ); + return { + success: true, + message: 'NZB added to SABnzbd and monitoring started', + requestId, + downloadHistoryId: downloadHistory.id, + nzbId: downloadClientId, + torrent: { + title: torrent.title, + size: torrent.size, + format: torrent.format, + }, + }; + } else { + // Route to qBittorrent (default) + await logger?.info(`Routing to qBittorrent`); - await logger?.info(`Started monitoring job for request ${requestId} (3s initial delay)`); + const qbt = await getQBittorrentService(); + downloadClientId = await qbt.addTorrent(torrent.downloadUrl, { + category: 'readmeabook', + tags: ['audiobook'], + sequentialDownload: true, + paused: false, + }); + downloadClient = 'qbittorrent'; - return { - success: true, - message: 'Torrent added to download client and monitoring started', - requestId, - downloadHistoryId: downloadHistory.id, - torrentHash, - torrent: { - title: torrent.title, - size: torrent.size, - seeders: torrent.seeders, - format: torrent.format, - }, - }; + await logger?.info(`Torrent added with hash: ${downloadClientId}`); + + // Create DownloadHistory record + const downloadHistory = await prisma.downloadHistory.create({ + data: { + requestId, + indexerName: torrent.indexer, + downloadClient: 'qbittorrent', + downloadClientId, + torrentName: torrent.title, + torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash + torrentSizeBytes: torrent.size, + torrentUrl: torrent.guid, + magnetLink: torrent.downloadUrl, + seeders: torrent.seeders, + leechers: torrent.leechers || 0, + downloadStatus: 'downloading', + selected: true, + startedAt: new Date(), + }, + }); + + await logger?.info(`Created download history record: ${downloadHistory.id}`); + + // Trigger monitor download job with initial delay + const jobQueue = getJobQueueService(); + await jobQueue.addMonitorJob( + requestId, + downloadHistory.id, + downloadClientId, + 'qbittorrent', + 3 // Wait 3 seconds before first check to avoid race condition + ); + + await logger?.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`); + + return { + success: true, + message: 'Torrent added to qBittorrent and monitoring started', + requestId, + downloadHistoryId: downloadHistory.id, + torrentHash: downloadClientId, + torrent: { + title: torrent.title, + size: torrent.size, + seeders: torrent.seeders, + format: torrent.format, + }, + }; + } } catch (error) { await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -107,7 +176,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P where: { id: requestId }, data: { status: 'failed', - errorMessage: error instanceof Error ? error.message : 'Failed to add torrent to download client', + errorMessage: error instanceof Error ? error.message : 'Failed to add download to client', updatedAt: new Date(), }, }); diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts index 8ac6e16..4d74dde 100644 --- a/src/lib/processors/monitor-download.processor.ts +++ b/src/lib/processors/monitor-download.processor.ts @@ -58,17 +58,52 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P const logger = jobId ? createJobLogger(jobId, 'MonitorDownload') : null; try { - // Get download client service (currently only qBittorrent supported) - if (downloadClient !== 'qbittorrent') { - throw new Error(`Download client ${downloadClient} not yet supported`); + let progress: any; + let downloadPath: string | undefined; + + if (downloadClient === 'qbittorrent') { + // qBittorrent flow + const qbt = await getQBittorrentService(); + + // Get torrent status with retry logic (handles race condition) + const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger); + progress = qbt.getDownloadProgress(torrent); + + // Store download path for later use + downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name); + } else if (downloadClient === 'sabnzbd') { + // SABnzbd flow + const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); + const sabnzbd = await getSABnzbdService(); + + // Get NZB status + const nzbInfo = await sabnzbd.getNZB(downloadClientId); + + if (!nzbInfo) { + throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`); + } + + // Convert NZBInfo to progress format + progress = { + percent: nzbInfo.progress, + bytesDownloaded: nzbInfo.size * nzbInfo.progress, + bytesTotal: nzbInfo.size, + speed: nzbInfo.downloadSpeed, + eta: nzbInfo.timeLeft, + state: nzbInfo.status, + }; + + // Store download path if available (only set after completion) + downloadPath = nzbInfo.downloadPath; + + await logger?.info(`SABnzbd status: ${nzbInfo.status}`, { + progress: `${(nzbInfo.progress * 100).toFixed(1)}%`, + speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`, + }); + } else { + throw new Error(`Download client ${downloadClient} not supported`); } - const qbt = await getQBittorrentService(); - - // Get torrent status with retry logic (handles race condition) - const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger); - const progress = qbt.getDownloadProgress(torrent); - // Update request progress await prisma.request.update({ where: { id: requestId }, @@ -90,15 +125,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P if (progress.state === 'completed') { await logger?.info(`Download completed for request ${requestId}`); - // Get torrent files to find download path - const files = await qbt.getFiles(downloadClientId); - - // Determine actual content path for file organization - // Priority 1: Use content_path if provided by qBittorrent (most reliable) - // Priority 2: Construct path using path.join() for proper normalization - const qbPath = torrent.content_path - ? torrent.content_path - : path.join(torrent.save_path, torrent.name); + // Ensure we have a download path + if (!downloadPath) { + throw new Error('Download path not available from download client'); + } // Load path mapping configuration const configService = getConfigService(); @@ -109,19 +139,16 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P ]); // Apply remote-to-local path transformation if enabled - const organizePath = PathMapper.transform(qbPath, { + const organizePath = PathMapper.transform(downloadPath, { enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true', remotePath: pathMappingConfig.download_client_remote_path || '', localPath: pathMappingConfig.download_client_local_path || '', }); await logger?.info(`Download completed`, { - filesCount: files.length, - torrentName: torrent.name, - savePath: torrent.save_path, - contentPath: torrent.content_path || '(not provided)', - qbittorrentPath: qbPath, - organizePath: organizePath !== qbPath ? `${organizePath} (mapped)` : organizePath, + downloadClient, + downloadPath, + organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath, }); // Update download history to completed diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 1baef7f..de6892d 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -51,7 +51,7 @@ export interface MonitorDownloadPayload extends JobPayload { requestId: string; downloadHistoryId: string; downloadClientId: string; - downloadClient: 'qbittorrent' | 'transmission'; + downloadClient: 'qbittorrent' | 'sabnzbd'; } export interface OrganizeFilesPayload extends JobPayload { @@ -479,7 +479,7 @@ export class JobQueueService { requestId: string, downloadHistoryId: string, downloadClientId: string, - downloadClient: 'qbittorrent' | 'transmission', + downloadClient: 'qbittorrent' | 'sabnzbd', delaySeconds: number = 0 ): Promise { return await this.addJob( diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts index ae37894..7ba0273 100644 --- a/src/lib/utils/ranking-algorithm.ts +++ b/src/lib/utils/ranking-algorithm.ts @@ -331,36 +331,48 @@ export class RankingAlgorithm { // ========== STAGE 2: TITLE MATCHING (0-35 points) ========== let titleScore = 0; - if (torrentTitle.includes(requestTitle)) { - // Found the title, but is it the complete title or part of a longer one? - const titleIndex = torrentTitle.indexOf(requestTitle); - const beforeTitle = torrentTitle.substring(0, titleIndex); - const afterTitle = torrentTitle.substring(titleIndex + requestTitle.length); - // Extract significant words BEFORE the matched title - const beforeWords = extractWords(beforeTitle, stopWords); + // Try matching with full title first, then fall back to required title (without parentheses) + const titlesToTry = [requestTitle]; + if (requiredTitle !== requestTitle) { + titlesToTry.push(requiredTitle); // Add required-only version if different + } - // Title is complete if: - // 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl") - // 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching") - const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ',']; - const hasNoWordsPrefix = beforeWords.length === 0; - const hasMetadataSuffix = afterTitle === '' || - metadataMarkers.some(marker => afterTitle.startsWith(marker)); + let bestMatch = false; + for (const titleToMatch of titlesToTry) { + if (torrentTitle.includes(titleToMatch)) { + // Found the title, but is it the complete title or part of a longer one? + const titleIndex = torrentTitle.indexOf(titleToMatch); + const beforeTitle = torrentTitle.substring(0, titleIndex); + const afterTitle = torrentTitle.substring(titleIndex + titleToMatch.length); - const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix; + // Extract significant words BEFORE the matched title + const beforeWords = extractWords(beforeTitle, stopWords); - if (isCompleteTitle) { - // Complete title match → full points - titleScore = 35; - } else { - // Title has prefix words OR continues with more words - // This is likely a different book in a series → use fuzzy similarity - titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35; + // Title is complete if: + // 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl") + // 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching") + const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ',']; + const hasNoWordsPrefix = beforeWords.length === 0; + const hasMetadataSuffix = afterTitle === '' || + metadataMarkers.some(marker => afterTitle.startsWith(marker)); + + const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix; + + if (isCompleteTitle) { + // Complete title match → full points + titleScore = 35; + bestMatch = true; + break; // Found a good match, stop trying + } } - } else { - // No substring match at all → use fuzzy similarity - titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35; + } + + if (!bestMatch) { + // No complete match found, use fuzzy similarity as fallback + // Try against full title first, then required title + const fuzzyScores = titlesToTry.map(title => compareTwoStrings(title, torrentTitle)); + titleScore = Math.max(...fuzzyScores) * 35; } // ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========