diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index bec406e..0d7fb76 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -52,7 +52,7 @@ - **Full pipeline overview** → [phase3/README.md](phase3/README.md) - **Search via Prowlarr (torrents + NZBs)** → [phase3/prowlarr.md](phase3/prowlarr.md) - **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md) -- **Multi-download-client support (qBittorrent + SABnzbd)** → [phase3/download-clients.md](phase3/download-clients.md) +- **Multi-download-client support (qBittorrent, Transmission, SABnzbd, NZBGet)** → [phase3/download-clients.md](phase3/download-clients.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) @@ -111,8 +111,11 @@ **"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation) **"How do I configure multiple download clients?"** → [phase3/download-clients.md](phase3/download-clients.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 do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [phase3/download-clients.md](phase3/download-clients.md), [backend/services/jobs.md](backend/services/jobs.md) **"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md) +**"How do I use NZBGet instead of SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md) +**"How do I use Transmission instead of qBittorrent?"** → [phase3/download-clients.md](phase3/download-clients.md) +**"How do I set different download paths per client?"** → [phase3/download-clients.md](phase3/download-clients.md#per-client-custom-download-path) **"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md) **"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) **"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar) @@ -134,7 +137,7 @@ **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one) **"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md) **"Why can't RMAB find my downloaded files?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md) -**"How do I set up volume mapping for qBittorrent/SABnzbd?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md) +**"How do I set up volume mapping for qBittorrent/Transmission/SABnzbd/NZBGet?"** → [deployment/volume-mapping.md](deployment/volume-mapping.md) **"OAuth redirects to localhost / PUBLIC_URL not working"** → [backend/services/environment.md](backend/services/environment.md) **"What environment variables do I need?"** → [backend/services/environment.md](backend/services/environment.md) **"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md) diff --git a/documentation/phase3/README.md b/documentation/phase3/README.md index b1399fe..e189d94 100644 --- a/documentation/phase3/README.md +++ b/documentation/phase3/README.md @@ -15,7 +15,7 @@ Request → search_indexers → rank_results → download_torrent 1. **search_indexers** - Search Prowlarr for torrents 2. **rank_results** - Apply ranking algorithm, select best -3. **download_torrent** - Add to qBittorrent +3. **download_torrent** - Add to download client (qBittorrent/Transmission/SABnzbd) 4. **monitor_download** - Poll progress (10s intervals) 5. **process_audiobook** - Organize files to media directory 6. **update_plex** - Trigger scan, fuzzy match @@ -23,7 +23,7 @@ Request → search_indexers → rank_results → download_torrent ## Integration Points **Indexers:** Prowlarr (primary), Jackett (fallback) -**Download Clients:** qBittorrent (primary), Transmission (fallback) +**Download Clients:** qBittorrent or Transmission (torrent), SABnzbd (usenet) — [details](./download-clients.md) **Media Server:** Plex (scan + match) ## Job Queue (Bull) @@ -43,7 +43,9 @@ Request → search_indexers → rank_results → download_torrent ## Related Docs - [Prowlarr](./prowlarr.md) +- [Download Clients](./download-clients.md) - Multi-client management, protocol routing - [qBittorrent](./qbittorrent.md) +- [SABnzbd](./sabnzbd.md) - [Ranking Algorithm](./ranking-algorithm.md) - [File Organization](./file-organization.md) - [Plex Integration](../integrations/plex.md) diff --git a/documentation/phase3/download-clients.md b/documentation/phase3/download-clients.md index 6bea13c..a3d2270 100644 --- a/documentation/phase3/download-clients.md +++ b/documentation/phase3/download-clients.md @@ -1,45 +1,127 @@ # Multi-Download-Client Support -**Status:** ✅ Implemented | Simultaneous qBittorrent + SABnzbd support +**Status:** ✅ Implemented | qBittorrent, Transmission, SABnzbd, and NZBGet support ## Overview -Users can configure both qBittorrent (torrents) and SABnzbd (Usenet) simultaneously. System selects best release across all indexer types regardless of protocol. +Users can configure one torrent client (qBittorrent or Transmission) and one usenet client (SABnzbd or NZBGet) simultaneously. System selects best release across all indexer types regardless of protocol. -**Constraint:** 1 client per type (torrent/usenet) for now; architecture supports future expansion. +**Constraint:** 1 client per protocol (torrent/usenet). Users must remove an existing torrent client before adding a different one. ## Key Details +### Supported Clients + +| Client | Protocol | Auth | Categories | +|--------|----------|------|------------| +| qBittorrent | torrent | Cookie-based (login endpoint) | Categories | +| Transmission | torrent | HTTP Basic Auth + CSRF (`X-Transmission-Session-Id`) | Labels | +| SABnzbd | usenet | API key | Categories | +| NZBGet | usenet | HTTP Basic Auth (JSON-RPC) | Config-based categories | + +### Protocol Map +**File:** `src/lib/interfaces/download-client.interface.ts` + +```typescript +export const CLIENT_PROTOCOL_MAP: Record = { + qbittorrent: 'torrent', + sabnzbd: 'usenet', + nzbget: 'usenet', + transmission: 'torrent', +}; +``` + +Used by manager's `getClientForProtocol()` and UI's protocol-level enforcement. + ### Configuration Structure **Key:** `download_clients` (JSON array, replaces legacy flat keys) ```typescript interface DownloadClientConfig { id: string; // UUID - type: 'qbittorrent' | 'sabnzbd'; + type: 'qbittorrent' | 'sabnzbd' | 'nzbget' | 'transmission'; name: string; // User-friendly name enabled: boolean; url: string; - username?: string; // qBittorrent only + username?: string; // qBittorrent/Transmission/NZBGet only password: string; // Password or API key disableSSLVerify: boolean; remotePathMappingEnabled: boolean; remotePath?: string; localPath?: string; category?: string; // Default: 'readmeabook' + customPath?: string; // Relative sub-path appended to download_dir } ``` +### Transmission Service +**File:** `src/lib/integrations/transmission.service.ts` + +- **RPC endpoint:** `POST /transmission/rpc` (JSON-RPC) +- **CSRF:** 409 → capture `X-Transmission-Session-Id` header → retry +- **Auth:** HTTP Basic Auth (optional) +- **Categories:** Uses `labels` array on `torrent-add` +- **Download path:** `download-dir` argument on `torrent-add` +- **Torrent files:** Base64-encoded via `metainfo` field +- **Status codes:** 0=stopped→paused, 1=check-pending→checking, 2=checking→checking, 3=download-pending→queued, 4=downloading→downloading, 5=seed-pending→seeding, 6=seeding→seeding +- **Error handling:** `error > 0` → failed status +- **postProcess():** No-op (same as qBittorrent) + +### NZBGet Service +**File:** `src/lib/integrations/nzbget.service.ts` + +- **RPC endpoint:** `POST /jsonrpc` (JSON-RPC with Basic Auth) +- **Auth:** HTTP Basic Auth (username + password) +- **Categories:** Config-based (`Category1.Name`, `Category1.DestDir`), managed via `config()` + `saveconfig()` +- **Adding NZBs:** Downloads NZB content from Prowlarr, base64-encodes, uploads via `append()` +- **Queue status:** `listgroups(0)` — QUEUED, PAUSED, DOWNLOADING, FETCHING, PP_* (processing states) +- **History status:** `history(false)` — SUCCESS/*, WARNING/* → completed; FAILURE/*, DELETED/* → failed +- **Pause/Resume/Delete:** `editqueue()` with GroupPause/GroupResume/GroupDelete/HistoryDelete commands +- **postProcess():** `editqueue('HistoryDelete')` — archives from visible history (preserves in hidden archive) +- **IDs:** Integer NZBIDs (stored as strings in RMAB system) + +### Per-Client Custom Download Path +**Field:** `customPath` (optional string, blank = use base `download_dir` as-is) + +Allows each download client to download to a different subdirectory under `download_dir`. Useful for separating torrent and usenet downloads. + +**Path Resolution (in `createService()`):** +``` +finalPath = config.customPath ? path.join(downloadDir, config.customPath) : downloadDir +``` + +**Example:** +- `download_dir` = `/downloads`, qBittorrent `customPath` = `torrents` → `/downloads/torrents` +- `download_dir` = `/downloads`, SABnzbd `customPath` = `usenet` → `/downloads/usenet` +- `download_dir` = `/downloads`, `customPath` = blank → `/downloads` + +**Validation:** +- Leading/trailing slashes stripped on save +- Paths containing `..` rejected (frontend + API) +- Backward-compatible: existing configs without `customPath` default to base `download_dir` + +**Resolved path used by:** +- Service constructors (`defaultSavePath` / `defaultDownloadDir`) +- Category creation (qBittorrent `ensureCategory`, SABnzbd `ensureCategory`) +- Torrent/NZB addition (save path / download-dir) +- Remote path mapping (applied after customPath resolution) +- Singleton getters (`getQBittorrentService`, `getSABnzbdService`) +- Retry fallback path construction (`retry-failed-imports.processor.ts`) + +**UI:** Modal shows real-time path preview: `Downloads to: /downloads/torrents` + ### Download Client Manager Service **File:** `src/lib/services/download-client-manager.service.ts` **Methods:** -- `getClientForProtocol(protocol: 'torrent' | 'usenet')` - Get client by protocol +- `getClientForProtocol(protocol: 'torrent' | 'usenet')` - Get client by protocol (uses `CLIENT_PROTOCOL_MAP`) - `hasClientForProtocol(protocol)` - Check if protocol configured - `getAllClients()` - List all configs - `testConnection(config)` - Test specific config - `invalidate()` - Clear cache on config change - `getClientServiceForProtocol(protocol)` - Get instantiated service +**Factory Cases:** `qbittorrent` → `QBittorrentService`, `sabnzbd` → `SABnzbdService`, `nzbget` → `NZBGetService`, `transmission` → `TransmissionService` + **Singleton Pattern:** Uses caching with invalidation on config changes. ### Protocol Filtering @@ -57,7 +139,7 @@ interface DownloadClientConfig { **Logic:** 1. Detect protocol from result (`ProwlarrService.isNZBResult()`) 2. Get appropriate client via manager (`getClientForProtocol()`) -3. Route to qBittorrent or SABnzbd service +3. Route to correct service (qBittorrent, Transmission, or SABnzbd) 4. Create download history record ### Migration @@ -76,7 +158,7 @@ interface DownloadClientConfig { **POST /api/admin/settings/download-clients/test** - Test connection **Validation:** -- Only 1 client per type allowed (enforced on add) +- Only 1 client per protocol allowed (enforced on add via `CLIENT_PROTOCOL_MAP`) - Test connection required before save - Password masking in responses (`********`) @@ -86,14 +168,18 @@ interface DownloadClientConfig { | Component | Purpose | |-----------|---------| -| `DownloadClientManagement.tsx` | Container with add buttons + configured cards | -| `DownloadClientCard.tsx` | Card with name, type badge, edit/delete | -| `DownloadClientModal.tsx` | Add/edit modal with type-specific fields | +| `DownloadClientManagement.tsx` | Container with add cards (4-column: qBittorrent, Transmission, SABnzbd, NZBGet) + configured cards; protocol-level enforcement (grayed out when protocol taken) | +| `DownloadClientCard.tsx` | Card with name, type badge (blue=qBittorrent, green=Transmission, purple=SABnzbd, orange=NZBGet), custom path display, edit/delete | +| `DownloadClientModal.tsx` | Add/edit modal with type-specific fields; Username shown for qBittorrent + Transmission + NZBGet; URL placeholder per-type | **UI Flow:** -1. **Add Client Section:** Two cards (qBittorrent, SABnzbd) with "Add" button or "Already configured" badge -2. **Configured Clients:** Grid of cards showing name, type, URL, status -3. **Modal:** Type-specific fields, SSL toggle, path mapping, test connection +1. **Add Client Section:** Four cards (qBittorrent, Transmission, SABnzbd, NZBGet) with "Add" button or "Protocol already configured" when protocol is taken (card grayed out with `opacity-50`) +2. **Configured Clients:** Grid of cards showing name, type, URL, custom path (if set), status +3. **Modal:** Type-specific fields, custom download path with live preview, SSL toggle, path mapping, test connection + +**downloadDir Prop Flow:** +- **Settings mode:** `DownloadClientManagement` fetches from `GET /api/admin/settings` → `settings.paths.downloadDir` on mount +- **Wizard mode:** `setup/page.tsx` passes `state.downloadDir` → `DownloadClientStep` → `DownloadClientManagement` → `DownloadClientModal` ## Integration Points @@ -107,6 +193,8 @@ Replaced legacy form with `` Replaced single-client form with `` +**Props:** Accepts `downloadDir` from setup page state, passes to management component + **Validation:** At least 1 enabled client required to proceed ### Setup Complete API @@ -123,25 +211,38 @@ Accepts both legacy single client and new array format: **Client disabled:** Results for that protocol filtered out **Connection failure:** Per-download error handling (existing) **Mixed results:** Best release selected regardless of protocol when both clients configured +**Custom path blank:** Uses base `download_dir` (backward-compatible default) +**Custom path with slashes:** Leading/trailing slashes stripped automatically +**Custom path with `..`:** Rejected by frontend validation and API validation +**Switching torrent clients:** Must delete existing torrent client before adding Transmission (or vice versa) ## Verification Steps 1. **Migration:** Existing single-client users see config as card after update 2. **Single client:** Configure only qBittorrent → only torrent results shown -3. **Both clients:** Configure both → mixed results, best selected across protocols -4. **Download routing:** Torrent result → qBittorrent; NZB result → SABnzbd +3. **Both clients:** Configure torrent + usenet → mixed results, best selected across protocols +4. **Download routing:** Torrent result → torrent client; NZB result → usenet client (SABnzbd or NZBGet) 5. **Wizard:** Must add at least one client to proceed 6. **Settings:** Can add/edit/delete/test clients; changes persist +7. **Custom path:** Set `torrents` on torrent client → save path includes subdirectory +8. **Custom path preview:** Modal shows resolved path in real-time as user types +9. **Custom path persistence:** Save, reopen modal → value persists +10. **Custom path on card:** Configured cards show custom path if set +11. **Transmission CSRF:** First RPC call gets 409, captures session ID, retry succeeds +12. **Protocol enforcement:** Adding qBittorrent grays out Transmission card (and vice versa) ## Critical Files | File | Changes | |------|---------| -| `src/lib/services/download-client-manager.service.ts` | **NEW** - Core multi-client service | +| `src/lib/interfaces/download-client.interface.ts` | Client types, display names, `CLIENT_PROTOCOL_MAP` | +| `src/lib/integrations/nzbget.service.ts` | NZBGet JSON-RPC implementation | +| `src/lib/integrations/transmission.service.ts` | Transmission RPC implementation | +| `src/lib/services/download-client-manager.service.ts` | Core multi-client service, protocol-based routing | | `src/lib/integrations/prowlarr.service.ts:379` | Protocol filtering logic (both clients = all results) | | `src/lib/processors/download-torrent.processor.ts:44` | Download routing (detect protocol → route) | -| `src/app/api/admin/settings/download-clients/*` | **NEW** - CRUD API routes | -| `src/components/admin/download-clients/*` | **NEW** - UI components (card-based) | +| `src/app/api/admin/settings/download-clients/*` | CRUD API routes, protocol-level duplicate check | +| `src/components/admin/download-clients/*` | UI components (3-column card layout, protocol enforcement) | | `src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx` | Replaced with management component | | `src/app/setup/steps/DownloadClientStep.tsx` | Replaced with management component | | `src/app/api/setup/complete/route.ts` | Save as JSON array, support legacy | @@ -149,5 +250,5 @@ Accepts both legacy single client and new array format: ## Related - [qBittorrent Integration](./qbittorrent.md) - Torrent client details -- [SABnzbd Integration](./sabnzbd.md) - Usenet client details +- [SABnzbd Integration](./sabnzbd.md) - Usenet client details (SABnzbd) - [Prowlarr Integration](./prowlarr.md) - Indexer search diff --git a/documentation/phase3/file-organization.md b/documentation/phase3/file-organization.md index a3226e1..0f2750c 100644 --- a/documentation/phase3/file-organization.md +++ b/documentation/phase3/file-organization.md @@ -37,7 +37,8 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/ ## Process 1. Download completes in `/downloads/[torrent-name]/` or `/downloads/[filename]` (single file) -2. Identify audiobook files (.m4b, .m4a, .mp3) - supports both directories and single files +1b. **Path stored** in `DownloadHistory.downloadPath` (mapped local path) for retry reliability — avoids reconstructing path from `torrentName` which may differ from actual folder name +2. Identify audiobook files (.m4b, .m4a, .mp3, .mp4, .aa, .aax, .flac, .ogg) - supports both directories and single files 3. Read media directory and path template from database config (`media_dir`, `audiobook_path_template`) 4. Apply template to create target path: `[media_dir]/[template result]/` 5. **Copy** files (not move - originals stay for seeding) @@ -94,6 +95,7 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/ **Supported Formats:** - m4b, m4a, mp4 (AAC audiobooks) - mp3 (ID3v2 tags) +- flac (Vorbis comment tags) **Metadata Written:** - `title` - Book title diff --git a/documentation/phase3/qbittorrent.md b/documentation/phase3/qbittorrent.md index 1c1ec62..66f2563 100644 --- a/documentation/phase3/qbittorrent.md +++ b/documentation/phase3/qbittorrent.md @@ -46,7 +46,7 @@ Free, open-source BitTorrent client with comprehensive Web API. - `download_client_url` - qBittorrent Web UI URL (supports HTTP and HTTPS) - `download_client_username` - qBittorrent username - `download_client_password` - qBittorrent password -- `download_dir` - Download save path (passed to qBittorrent for all torrents) +- `download_dir` - Base download save path (joined with per-client `customPath` if configured) **Optional (SSL/TLS):** - `download_client_disable_ssl_verify` - Disable SSL certificate verification for HTTPS (boolean as string "true"/"false", default: "false") @@ -65,7 +65,8 @@ Validation: All required fields checked before service initialization. Path mapp Service uses singleton pattern for performance. When settings change (via admin settings page), singleton is invalidated to force reload: - `invalidateQBittorrentService()` called after updating paths or download client settings - Forces service to re-read database config on next torrent addition -- Ensures category save path and credentials are always current +- Ensures category save path, credentials, and `customPath` resolution are always current +- Singleton getter resolves `customPath` from client config (consistent with manager's `createService()`) ## Category Management @@ -73,9 +74,10 @@ Service uses singleton pattern for performance. When settings change (via admin **Save Path Synchronization:** - Category created/updated on every torrent addition -- Category save path always synced with `download_dir` config -- Handles config changes: if user changes `download_dir`, category updates automatically +- Category save path synced with resolved download path (`download_dir` + per-client `customPath`) +- Handles config changes: if user changes `download_dir` or `customPath`, category updates automatically - Uses both `createCategory` and `editCategory` APIs for reliability +- Remote path mapping applied after `customPath` resolution (outgoing: local → remote) **Why Both Create and Edit:** 1. Create: Ensures category exists (idempotent, won't fail if exists) @@ -83,6 +85,11 @@ Service uses singleton pattern for performance. When settings change (via admin This prevents issues where category retains old save path after user changes `download_dir` setting. +**Per-Client Custom Path:** +- If `customPath` is set (e.g., `torrents`), category save path becomes `/downloads/torrents` +- Remote path mapping applies to the resolved path: `reverseTransform(/downloads/torrents)` → remote equivalent +- See [download-clients.md](./download-clients.md#per-client-custom-download-path) for details + ## Remote Path Mapping **Use Case:** qBittorrent runs on different machine/container with different filesystem perspective. @@ -167,8 +174,22 @@ interface TorrentInfo { completionDate: number; } -type TorrentState = 'downloading' | 'uploading' | 'stalledDL' | - 'pausedDL' | 'queuedDL' | 'checkingDL' | 'error' | 'missingFiles'; +type TorrentState = + // Core states + | 'downloading' | 'uploading' + | 'stalledDL' | 'stalledUP' + | 'pausedDL' | 'pausedUP' + | 'queuedDL' | 'queuedUP' + | 'checkingDL' | 'checkingUP' + | 'error' | 'missingFiles' | 'allocating' + // Forced states (user clicked "Force Resume") + | 'forcedDL' | 'forcedUP' + // Metadata fetching + | 'metaDL' | 'forcedMetaDL' + // qBittorrent v5.0+ (renamed paused → stopped) + | 'stoppedDL' | 'stoppedUP' + // Other + | 'checkingResumeData' | 'moving'; ``` ## Fixed Issues ✅ @@ -216,6 +237,12 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' | - Service constructor accepts `PathMappingConfig` parameter - Singleton loads path mapping config from database +**15. Missing qBittorrent torrent states** - Monitor never detected completion for force-resumed torrents (`forcedDL`/`forcedUP`), causing infinite polling at 100%. Also missing metadata states (`metaDL`/`forcedMetaDL`), qBittorrent v5.x renamed states (`stoppedDL`/`stoppedUP`), and utility states (`checkingResumeData`/`moving`). Fixed by: + - Adding all 8 missing states to `TorrentState` type union + - Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface) + - `forcedUP` → `seeding`/`completed` enables monitor to trigger import + - `stoppedDL`/`stoppedUP` → `paused` ensures qBittorrent v5.x compatibility + ## Tech Stack - axios (HTTP + cookie mgmt) diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 8204a2f..f4bd215 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -135,12 +135,13 @@ Evaluates and scores torrents to automatically select best audiobook download. - Proportional credit: If 2 of 3 authors match → 10 pts (2/3 × 15) - Full credit: If all authors match → 15 pts -**2. Format Quality (25 pts max)** -- M4B with chapters: 25 -- M4B without chapters: 22 -- M4A: 16 -- MP3: 10 -- Other: 3 +**2. Format Quality (10 pts max)** +- M4B with chapters: 10 +- M4B without chapters: 9 +- FLAC: 7 (lossless audio) +- M4A: 6 +- MP3: 4 +- Other: 1 **3. Seeder Count (15 pts max)** - Formula: `Math.min(15, Math.log10(seeders + 1) * 6)` diff --git a/documentation/phase3/sabnzbd.md b/documentation/phase3/sabnzbd.md index a5f8318..f4ecd29 100644 --- a/documentation/phase3/sabnzbd.md +++ b/documentation/phase3/sabnzbd.md @@ -19,7 +19,7 @@ Free, open-source Usenet/NZB download client with comprehensive Web API. Industr **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 +**POST /api (multipart: mode=addfile, nzbfile={binary})** - Add NZB by file upload (RMAB downloads NZB from Prowlarr, uploads to SABnzbd) **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 @@ -37,7 +37,7 @@ Free, open-source Usenet/NZB download client with comprehensive Web API. Industr - `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) +- `download_dir` - Base download save path (joined with per-client `customPath` if configured) **Optional (SSL/TLS):** - `download_client_disable_ssl_verify` - Disable SSL certificate verification (boolean as string "true"/"false", default: "false") @@ -58,7 +58,8 @@ Validation: All required fields checked before service initialization. Path mapp 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 +- Ensures category, credentials, and `customPath` resolution are always current +- Singleton getter resolves `customPath` from client config (consistent with manager's `createService()`) ## Category Management @@ -67,16 +68,21 @@ Service uses singleton pattern. When settings change, singleton invalidated to f **Save Path Synchronization:** - Category created/updated on every download (matches qBittorrent behavior) - Fetches SABnzbd's `complete_dir` setting via API to understand download location -- Applies remote path mapping to translate RMAB's `download_dir` to SABnzbd's perspective +- Applies remote path mapping to translate RMAB's resolved download path to SABnzbd's perspective - Calculates optimal category path (relative, absolute, or root) +- Resolved path includes per-client `customPath` if configured (e.g., `/downloads/usenet`) **Smart Path Calculation:** 1. Get SABnzbd's `complete_dir` from `misc.complete_dir` config -2. Apply `PathMapper.reverseTransform()` to RMAB's `download_dir` +2. Apply `PathMapper.reverseTransform()` to RMAB's resolved download path (`download_dir` + `customPath`) 3. Compare transformed path to `complete_dir`: - **Match:** Use empty string (downloads go to complete_dir root) - - **Subdirectory:** Use relative path (e.g., `audiobooks`) - - **Different:** Use absolute path (e.g., `/mnt/media/audiobooks`) + - **Subdirectory:** Use relative path (e.g., `usenet`) + - **Different:** Use absolute path (e.g., `/mnt/media/usenet`) + +**Per-Client Custom Path:** +- If `customPath` is set (e.g., `usenet`), category path calculated from `/downloads/usenet` +- See [download-clients.md](./download-clients.md#per-client-custom-download-path) for details ## Post-Processing @@ -290,9 +296,20 @@ organizePath = PathMapper.transform(sabPath, config) | Path Mapping | ✅ Bidirectional (same as qBit) | ✅ Bidirectional | | Category Sync | ✅ Every download | ✅ Every download | +## NZB Download Proxy + +**RMAB proxies NZB files** — SABnzbd does not need network access to Prowlarr. + +Prowlarr returns download URLs that point back to itself (proxy URLs like `http://prowlarr:9696/3/download?apikey=...&link=...`). +RMAB downloads the NZB file content from that URL, then uploads it to SABnzbd via `mode=addfile` (multipart POST). +This matches qBittorrent's pattern where RMAB downloads `.torrent` files and uploads the binary content. + +**Result:** Download clients only need network access to RMAB. No direct Prowlarr connectivity required. + ## Tech Stack -- axios (HTTP client) +- axios (HTTP client, NZB file download) +- form-data (multipart file upload to SABnzbd) - Node.js https (SSL/TLS agent) - JSON API responses diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index 1c788f8..7c59ef8 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -36,7 +36,7 @@ src/app/admin/settings/ │ ├── IndexersTab.tsx │ ├── useIndexersSettings.ts │ └── index.ts - ├── DownloadTab/ # qBittorrent/SABnzbd + ├── DownloadTab/ # qBittorrent/Transmission/SABnzbd │ ├── DownloadTab.tsx │ ├── useDownloadSettings.ts │ └── index.ts @@ -67,7 +67,7 @@ src/app/admin/settings/ 1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle 2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle 3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer** -4. **Download Client** - Type, URL, credentials (masked) +4. **Download Client** - Type (qBittorrent, Transmission, SABnzbd), URL, credentials (masked), custom download path (per-client relative sub-path with live preview) 5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle 6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format 7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history @@ -324,7 +324,7 @@ src/app/admin/settings/ **Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected **Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean -**Download Client:** Valid URL, credentials required, type must be 'qbittorrent' or 'transmission' +**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd' **Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}` ## Tech Stack diff --git a/prisma/migrations/20260209000000_add_download_path_to_history/migration.sql b/prisma/migrations/20260209000000_add_download_path_to_history/migration.sql new file mode 100644 index 0000000..80f96ed --- /dev/null +++ b/prisma/migrations/20260209000000_add_download_path_to_history/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "download_history" ADD COLUMN "download_path" TEXT; diff --git a/prisma/migrations/20260210000000_add_interactive_search_access/migration.sql b/prisma/migrations/20260210000000_add_interactive_search_access/migration.sql new file mode 100644 index 0000000..0a9d073 --- /dev/null +++ b/prisma/migrations/20260210000000_add_interactive_search_access/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "interactive_search_access" BOOLEAN; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a37cb95..bf35442 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,9 @@ model User { // Request approval preferences autoApproveRequests Boolean? @map("auto_approve_requests") // null = use global setting, true = auto-approve, false = require approval + // Fine-grained permissions + interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny + // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID who deleted this user @@ -275,6 +278,7 @@ model DownloadHistory { downloadStatus String? @map("download_status") // Status values: queued, downloading, completed, failed, stalled downloadError String? @map("download_error") @db.Text + downloadPath String? @map("download_path") @db.Text startedAt DateTime? @map("started_at") completedAt DateTime? @map("completed_at") createdAt DateTime @default(now()) @map("created_at") diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 9c290bd..00f4b69 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -97,6 +97,7 @@ export interface PathsSettings { downloadDir: string; mediaDir: string; audiobookPathTemplate?: string; + ebookPathTemplate?: string; metadataTaggingEnabled: boolean; chapterMergingEnabled: boolean; } diff --git a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx index 8b04194..63e96dd 100644 --- a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx +++ b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx @@ -18,6 +18,12 @@ interface PathsTabProps { onValidationChange: (isValid: boolean) => void; } +interface TemplatePreview { + isValid: boolean; + error?: string; + previewPaths?: string[]; +} + export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) { const { testing, testResult, updatePath, testPaths } = usePathsSettings({ paths, @@ -25,31 +31,52 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) onValidationChange, }); - // Live preview state (client-side validation) - const [livePreview, setLivePreview] = useState<{ - isValid: boolean; - error?: string; - previewPaths?: string[]; - } | null>(null); + // Live preview state for audiobook template + const [audiobookPreview, setAudiobookPreview] = useState(null); - // Update live preview whenever template changes + // Live preview state for ebook template + const [ebookPreview, setEbookPreview] = useState(null); + + // Update audiobook live preview whenever template changes useEffect(() => { const template = paths.audiobookPathTemplate || '{author}/{title} {asin}'; const validation = validateTemplate(template); if (validation.valid) { - setLivePreview({ + setAudiobookPreview({ isValid: true, previewPaths: generateMockPreviews(template), }); } else { - setLivePreview({ + setAudiobookPreview({ isValid: false, error: validation.error, }); } }, [paths.audiobookPathTemplate]); + // Update ebook live preview whenever template changes + useEffect(() => { + const template = paths.ebookPathTemplate || '{author}/{title} {asin}'; + const validation = validateTemplate(template); + + if (validation.valid) { + setEbookPreview({ + isValid: true, + previewPaths: generateMockPreviews(template), + }); + } else { + setEbookPreview({ + isValid: false, + error: validation.error, + }); + } + }, [paths.ebookPathTemplate]); + + const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}'; + const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}'; + const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate; + return (
@@ -74,7 +101,7 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) className="font-mono" />

- Temporary location for torrent downloads (kept for seeding) + Temporary location for downloads before they are organized into the media library

@@ -111,61 +138,24 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) Customize how audiobooks are organized within the media directory

- {/* Variable Reference Panel */} -
-

- Available Variables -

-
-
- {'{author}'} - - Book author -
-
- {'{title}'} - - Book title -
-
- {'{narrator}'} - - Narrator name -
-
- {'{year}'} - - Release year -
-
- {'{asin}'} - - Audible ASIN -
-
- {'{series}'} - - Book series name -
-
- {'{seriesPart}'} - - Series part/position -
-
-
- - {/* Live Preview - Client-side validation */} - {livePreview && !livePreview.isValid && ( + {/* Audiobook Validation Error */} + {audiobookPreview && !audiobookPreview.isValid && (
- {livePreview.error || 'Invalid template format'} + {audiobookPreview.error || 'Invalid template format'}
)} - {/* Live Preview Examples - Show while editing */} - {livePreview && livePreview.isValid && livePreview.previewPaths && ( + {/* Audiobook Preview Examples */} + {audiobookPreview && audiobookPreview.isValid && audiobookPreview.previewPaths && (

Preview Examples

- {livePreview.previewPaths.map((preview, index) => ( + {audiobookPreview.previewPaths.map((preview, index) => (
{paths.mediaDir || '/media/audiobooks'}/{preview}
@@ -175,6 +165,96 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) )}
+ {/* Ebook Organization Template */} +
+ +
+ updatePath('ebookPathTemplate', e.target.value)} + placeholder="{author}/{title} {asin}" + className="font-mono flex-1" + /> + +
+

+ Customize how ebooks are organized within the media directory +

+ + {/* Ebook Validation Error */} + {ebookPreview && !ebookPreview.isValid && ( +
+ +
+ {ebookPreview.error || 'Invalid template format'} +
+
+ )} + + {/* Ebook Preview Examples */} + {ebookPreview && ebookPreview.isValid && ebookPreview.previewPaths && ( +
+

+ Preview Examples +

+
+ {ebookPreview.previewPaths.map((preview, index) => ( +
+ {paths.mediaDir || '/media/audiobooks'}/{preview} +
+ ))} +
+
+ )} +
+ + {/* Variable Reference Panel (shared for both templates) */} +
+

+ Available Variables +

+
+
+ {'{author}'} + - Book author +
+
+ {'{title}'} + - Book title +
+
+ {'{narrator}'} + - Narrator name +
+
+ {'{year}'} + - Release year +
+
+ {'{asin}'} + - Audible ASIN +
+
+ {'{series}'} + - Book series name +
+
+ {'{seriesPart}'} + - Series part/position +
+
+
+ {/* Metadata Tagging Toggle */}
diff --git a/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts index f741d99..336ccfd 100644 --- a/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts +++ b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts @@ -41,6 +41,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat downloadDir: paths.downloadDir, mediaDir: paths.mediaDir, audiobookPathTemplate: paths.audiobookPathTemplate, + ebookPathTemplate: paths.ebookPathTemplate, }), }); diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 6cc7970..3b3bf73 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -11,6 +11,8 @@ import Link from 'next/link'; import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api'; import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { GlobalUserSettingsModal } from '@/components/admin/users/GlobalUserSettingsModal'; +import { UserPermissionsModal } from '@/components/admin/users/UserPermissionsModal'; interface User { id: string; @@ -25,6 +27,7 @@ interface User { updatedAt: string; lastLoginAt: string | null; autoApproveRequests: boolean | null; + interactiveSearchAccess: boolean | null; _count: { requests: number; }; @@ -48,6 +51,10 @@ function AdminUsersPageContent() { '/api/admin/settings/auto-approve', authenticatedFetcher ); + const { data: globalInteractiveSearchData, mutate: mutateGlobalInteractiveSearch } = useSWR( + '/api/admin/settings/interactive-search', + authenticatedFetcher + ); const [editDialog, setEditDialog] = useState<{ isOpen: boolean; user: User | null; @@ -66,6 +73,9 @@ function AdminUsersPageContent() { }>({ isOpen: false, user: null }); const [deleting, setDeleting] = useState(false); const [globalAutoApprove, setGlobalAutoApprove] = useState(false); + const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState(true); + const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); + const [permissionsUserId, setPermissionsUserId] = useState(null); const toast = useToast(); const isLoading = !data && !error; @@ -81,6 +91,15 @@ function AdminUsersPageContent() { } }, [globalAutoApproveData]); + // Sync global interactive search state (default to true if not set) + useEffect(() => { + if (globalInteractiveSearchData?.interactiveSearchAccess !== undefined) { + setGlobalInteractiveSearch(globalInteractiveSearchData.interactiveSearchAccess); + } else if (globalInteractiveSearchData !== undefined && globalInteractiveSearchData.interactiveSearchAccess === undefined) { + setGlobalInteractiveSearch(true); + } + }, [globalInteractiveSearchData]); + const handleGlobalAutoApproveToggle = async (newValue: boolean) => { // Optimistic update setGlobalAutoApprove(newValue); @@ -102,6 +121,27 @@ function AdminUsersPageContent() { } }; + const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => { + // Optimistic update + setGlobalInteractiveSearch(newValue); + + try { + await fetchJSON('/api/admin/settings/interactive-search', { + method: 'PATCH', + body: JSON.stringify({ interactiveSearchAccess: newValue }), + }); + toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`); + mutateGlobalInteractiveSearch(); + mutate(); // Refresh users list to show updated state + } catch (err) { + // Revert on error + setGlobalInteractiveSearch(!newValue); + const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting'; + toast.error(errorMsg); + console.error(err); + } + }; + const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => { console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue }); @@ -136,6 +176,33 @@ function AdminUsersPageContent() { } }; + const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => { + // Optimistic update + const previousUsers = data?.users || []; + const optimisticUsers = previousUsers.map((u: User) => + u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u + ); + mutate({ users: optimisticUsers }, false); + + try { + await fetchJSON(`/api/admin/users/${user.id}`, { + method: 'PUT', + body: JSON.stringify({ + role: user.role, + interactiveSearchAccess: newValue + }), + }); + toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); + mutate(); // Refresh users list + } catch (err) { + // Revert on error + mutate({ users: previousUsers }, false); + const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting'; + toast.error(errorMsg); + console.error(err); + } + }; + const showEditDialog = (user: User) => { setEditRole(user.role); setEditDialog({ isOpen: true, user }); @@ -273,6 +340,7 @@ function AdminUsersPageContent() { } const users: User[] = data?.users || []; + const permissionsUser = permissionsUserId ? users.find((u) => u.id === permissionsUserId) ?? null : null; return (
@@ -287,40 +355,26 @@ function AdminUsersPageContent() { Manage user roles and permissions

- - - - - Back to Dashboard - -
- - {/* Global Auto-Approve Toggle */} -
-
+
-
- -

- When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings below. -

-
+ + + + + Back to Dashboard +
@@ -403,7 +457,7 @@ function AdminUsersPageContent() { Role - Auto-Approve + Permissions Requests @@ -471,31 +525,34 @@ function AdminUsersPageContent() {
-
+ + + Manual + )} -
+ + + + {user._count.requests} @@ -587,7 +644,7 @@ function AdminUsersPageContent() {
  • User: Can request audiobooks, view own requests, and search the catalog
  • Admin: Full system access including settings, user management, and all requests
  • Setup Admin: The initial admin account created during setup - this account is protected and cannot be changed or deleted
  • -
  • Auto-Approve: When the global setting is enabled, all requests are automatically processed. When disabled, you can control auto-approval per user. Admin requests are always auto-approved.
  • +
  • Permissions: Click a user's permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.
  • OIDC Users: Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.
  • Plex Users: Can have their roles changed, but cannot be deleted as access is managed by Plex.
  • Local Users: Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).
  • @@ -722,6 +779,31 @@ function AdminUsersPageContent() { isLoading={deleting} variant="danger" /> + + {/* Global User Settings Modal */} + setGlobalSettingsOpen(false)} + globalAutoApprove={globalAutoApprove} + onToggleAutoApprove={handleGlobalAutoApproveToggle} + globalInteractiveSearch={globalInteractiveSearch} + onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle} + /> + + {/* User Permissions Modal */} + setPermissionsUserId(null)} + user={permissionsUser} + globalAutoApprove={globalAutoApprove} + globalInteractiveSearch={globalInteractiveSearch} + onToggleAutoApprove={(user, newValue) => { + handleUserAutoApproveToggle(user as User, newValue); + }} + onToggleInteractiveSearch={(user, newValue) => { + handleUserInteractiveSearchToggle(user as User, newValue); + }} + />
    ); diff --git a/src/app/api/admin/downloads/active/route.ts b/src/app/api/admin/downloads/active/route.ts index 9726179..86ee53d 100644 --- a/src/app/api/admin/downloads/active/route.ts +++ b/src/app/api/admin/downloads/active/route.ts @@ -6,10 +6,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; -import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service'; -import { getSABnzbdService } from '@/lib/integrations/sabnzbd.service'; import { getConfigService } from '@/lib/services/config.service'; +import { getDownloadClientManager } from '@/lib/services/download-client-manager.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface'; const logger = RMABLogger.create('API.Admin.Downloads'); @@ -55,6 +55,7 @@ export async function GET(request: NextRequest) { torrentName: true, torrentHash: true, nzbId: true, + downloadClientId: true, downloadClient: true, // qbittorrent, sabnzbd, or direct torrentSizeBytes: true, startedAt: true, @@ -68,9 +69,9 @@ export async function GET(request: NextRequest) { take: 20, }); - // Get configured download client type + // Get download client manager const configService = getConfigService(); - const clientType = (await configService.get('download_client_type')) || 'qbittorrent'; + const manager = getDownloadClientManager(configService); // Format response with speed and ETA from download client const formatted = await Promise.all( @@ -98,24 +99,19 @@ export async function GET(request: NextRequest) { eta = speed > 0 ? Math.round(remainingBytes / speed) : null; } } - } else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) { - // Get torrent hash from download history - const torrentHash = downloadHistory?.torrentHash; - if (torrentHash) { - const qbService = await getQBittorrentService(); - const torrentInfo = await qbService.getTorrent(torrentHash); - speed = torrentInfo.dlspeed; - eta = torrentInfo.eta > 0 ? torrentInfo.eta : null; - } - } else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) { - // Get NZB ID from download history - const nzbId = downloadHistory?.nzbId; - if (nzbId) { - const sabnzbdService = await getSABnzbdService(); - const nzbInfo = await sabnzbdService.getNZB(nzbId); - if (nzbInfo) { - speed = nzbInfo.downloadSpeed; - eta = nzbInfo.timeLeft > 0 ? nzbInfo.timeLeft : null; + } else { + // Use unified interface for all download clients (qBittorrent, SABnzbd, etc.) + const clientId = downloadHistory?.downloadClientId || downloadHistory?.torrentHash || downloadHistory?.nzbId; + if (clientId && downloadClient) { + const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType] || 'torrent'; + const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet'); + + if (client) { + const info = await client.getDownload(clientId); + if (info) { + speed = info.downloadSpeed; + eta = info.eta > 0 ? info.eta : null; + } } } } diff --git a/src/app/api/admin/settings/download-client/route.ts b/src/app/api/admin/settings/download-client/route.ts index 65b1289..635eac6 100644 --- a/src/app/api/admin/settings/download-client/route.ts +++ b/src/app/api/admin/settings/download-client/route.ts @@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { getConfigService } from '@/lib/services/config.service'; import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service'; +import { SUPPORTED_CLIENT_TYPES, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; import { PathMapper } from '@/lib/utils/path-mapper'; import { RMABLogger } from '@/lib/utils/logger'; import { randomUUID } from 'crypto'; @@ -35,9 +36,9 @@ export async function PUT(request: NextRequest) { logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.'); // Validate type - if (type !== 'qbittorrent' && type !== 'sabnzbd') { + if (!SUPPORTED_CLIENT_TYPES.includes(type)) { return NextResponse.json( - { error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, + { error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` }, { status: 400 } ); } @@ -97,7 +98,7 @@ export async function PUT(request: NextRequest) { const updatedClient: DownloadClientConfig = { id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(), type, - name: type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd', + name: getClientDisplayName(type), enabled: true, url, username: username || undefined, @@ -137,6 +138,12 @@ export async function PUT(request: NextRequest) { } else if (type === 'sabnzbd') { const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service'); invalidateSABnzbdService(); + } else if (type === 'nzbget') { + const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service'); + invalidateNZBGetService(); + } else if (type === 'transmission') { + const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service'); + invalidateTransmissionService(); } return NextResponse.json({ diff --git a/src/app/api/admin/settings/download-clients/[id]/route.ts b/src/app/api/admin/settings/download-clients/[id]/route.ts index 79504a7..f5a092c 100644 --- a/src/app/api/admin/settings/download-clients/[id]/route.ts +++ b/src/app/api/admin/settings/download-clients/[id]/route.ts @@ -37,6 +37,7 @@ export async function PUT( remotePath, localPath, category, + customPath, } = body; const config = await getConfigService(); @@ -53,6 +54,14 @@ export async function PUT( const existingClient = clients[clientIndex]; + // Validate customPath: reject paths containing ".." + if (customPath && customPath.includes('..')) { + return NextResponse.json( + { error: 'Custom path cannot contain ".."' }, + { status: 400 } + ); + } + // Build updated client (preserve fields not in request) const updatedClient: DownloadClientConfig = { ...existingClient, @@ -66,6 +75,7 @@ export async function PUT( remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath, localPath: localPath !== undefined ? localPath : existingClient.localPath, category: category !== undefined ? category : existingClient.category, + customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath, }; // Validate path mapping if enabled diff --git a/src/app/api/admin/settings/download-clients/route.ts b/src/app/api/admin/settings/download-clients/route.ts index 9ba1063..33e5105 100644 --- a/src/app/api/admin/settings/download-clients/route.ts +++ b/src/app/api/admin/settings/download-clients/route.ts @@ -6,8 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { getConfigService } from '@/lib/services/config.service'; -import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service'; -import { DownloadClientConfig } from '@/lib/services/download-client-manager.service'; +import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service'; +import { SUPPORTED_CLIENT_TYPES, CLIENT_PROTOCOL_MAP, DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; import { getEncryptionService } from '@/lib/services/encryption.service'; import { RMABLogger } from '@/lib/utils/logger'; import { randomUUID } from 'crypto'; @@ -62,12 +62,13 @@ export async function POST(request: NextRequest) { remotePath, localPath, category, + customPath, } = body; // Validate type - if (type !== 'qbittorrent' && type !== 'sabnzbd') { + if (!SUPPORTED_CLIENT_TYPES.includes(type)) { return NextResponse.json( - { error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, + { error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` }, { status: 400 } ); } @@ -99,21 +100,30 @@ export async function POST(request: NextRequest) { } } - // Check for duplicate type (only one client per type for now) + // Check for duplicate protocol (only one client per protocol) const config = await getConfigService(); const manager = getDownloadClientManager(config); const existingClients = await manager.getAllClients(); - const duplicateType = existingClients.find(c => c.type === type && c.enabled); - if (duplicateType) { + const protocol = CLIENT_PROTOCOL_MAP[type as DownloadClientType]; + const duplicateProtocol = existingClients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol); + if (duplicateProtocol) { return NextResponse.json( - { error: `A ${type} client is already configured. Please disable or remove it first.` }, + { error: `A ${protocol} client (${getClientDisplayName(duplicateProtocol.type)}) is already configured. Remove it first to add a different ${protocol} client.` }, { status: 400 } ); } // Create new client config for testing (with plaintext password) // qBittorrent credentials are optional (supports IP whitelist auth) + // Validate customPath: reject paths containing ".." + if (customPath && customPath.includes('..')) { + return NextResponse.json( + { error: 'Custom path cannot contain ".."' }, + { status: 400 } + ); + } + const newClient: DownloadClientConfig = { id: randomUUID(), type, @@ -127,6 +137,7 @@ export async function POST(request: NextRequest) { remotePath: remotePath || undefined, localPath: localPath || undefined, category: category || 'readmeabook', + customPath: customPath || undefined, }; // Test connection before saving diff --git a/src/app/api/admin/settings/download-clients/test/route.ts b/src/app/api/admin/settings/download-clients/test/route.ts index a4e5ef6..cae2620 100644 --- a/src/app/api/admin/settings/download-clients/test/route.ts +++ b/src/app/api/admin/settings/download-clients/test/route.ts @@ -6,8 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { getConfigService } from '@/lib/services/config.service'; -import { getDownloadClientManager } from '@/lib/services/download-client-manager.service'; -import { DownloadClientConfig } from '@/lib/services/download-client-manager.service'; +import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service'; +import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Test'); @@ -23,6 +23,7 @@ export async function POST(request: NextRequest) { const { clientId, // Optional: existing client ID to use stored password type, + name: clientName, url, username, password, @@ -33,9 +34,9 @@ export async function POST(request: NextRequest) { } = body; // Validate type - if (type !== 'qbittorrent' && type !== 'sabnzbd') { + if (!SUPPORTED_CLIENT_TYPES.includes(type)) { return NextResponse.json( - { error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, + { error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` }, { status: 400 } ); } @@ -87,7 +88,7 @@ export async function POST(request: NextRequest) { const testConfig: DownloadClientConfig = { id: 'test', type, - name: 'Test Client', + name: clientName || type, enabled: true, url, username: effectiveUsername || '', diff --git a/src/app/api/admin/settings/interactive-search/route.ts b/src/app/api/admin/settings/interactive-search/route.ts new file mode 100644 index 0000000..b3d2edc --- /dev/null +++ b/src/app/api/admin/settings/interactive-search/route.ts @@ -0,0 +1,91 @@ +/** + * Component: Admin Interactive Search Settings API + * Documentation: documentation/settings-pages.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Admin.Settings.InteractiveSearch'); + +const CONFIG_KEY = 'interactive_search_access'; + +/** + * GET /api/admin/settings/interactive-search + * Get current global interactive search access setting + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const config = await prisma.configuration.findUnique({ + where: { key: CONFIG_KEY }, + }); + + // Default to true if not configured (backward compatibility) + const interactiveSearchAccess = config === null ? true : config.value === 'true'; + + return NextResponse.json({ interactiveSearchAccess }); + } catch (error) { + logger.error('Failed to fetch interactive search setting', { + error: error instanceof Error ? error.message : String(error) + }); + return NextResponse.json( + { error: 'Failed to fetch interactive search setting' }, + { status: 500 } + ); + } + }); + }); +} + +/** + * PATCH /api/admin/settings/interactive-search + * Update global interactive search access setting + */ +export async function PATCH(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const body = await request.json(); + const { interactiveSearchAccess } = body; + + // Validate input + if (typeof interactiveSearchAccess !== 'boolean') { + return NextResponse.json( + { error: 'Invalid input. interactiveSearchAccess must be a boolean' }, + { status: 400 } + ); + } + + // Update configuration + await prisma.configuration.upsert({ + where: { key: CONFIG_KEY }, + create: { + key: CONFIG_KEY, + value: interactiveSearchAccess.toString(), + }, + update: { + value: interactiveSearchAccess.toString(), + }, + }); + + logger.info(`Interactive search access setting updated to: ${interactiveSearchAccess}`, { + userId: req.user?.sub, + }); + + return NextResponse.json({ interactiveSearchAccess }); + } catch (error) { + logger.error('Failed to update interactive search setting', { + error: error instanceof Error ? error.message : String(error) + }); + return NextResponse.json( + { error: 'Failed to update interactive search setting' }, + { status: 500 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/settings/paths/route.ts b/src/app/api/admin/settings/paths/route.ts index 1ede465..3caaaf4 100644 --- a/src/app/api/admin/settings/paths/route.ts +++ b/src/app/api/admin/settings/paths/route.ts @@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { - const { downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json(); + const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json(); if (!downloadDir || !mediaDir) { return NextResponse.json( @@ -59,6 +59,20 @@ export async function PUT(request: NextRequest) { }); } + // Update ebook path template + if (ebookPathTemplate !== undefined) { + await prisma.configuration.upsert({ + where: { key: 'ebook_path_template' }, + update: { value: ebookPathTemplate }, + create: { + key: 'ebook_path_template', + value: ebookPathTemplate, + category: 'automation', + description: 'Template for organizing ebook files in media directory', + }, + }); + } + // Update metadata tagging setting await prisma.configuration.upsert({ where: { key: 'metadata_tagging_enabled' }, @@ -90,12 +104,21 @@ export async function PUT(request: NextRequest) { configService.clearCache('download_dir'); configService.clearCache('media_dir'); configService.clearCache('audiobook_path_template'); + configService.clearCache('ebook_path_template'); configService.clearCache('metadata_tagging_enabled'); configService.clearCache('chapter_merging_enabled'); - // Invalidate qBittorrent service singleton to force reload of download_dir + // Invalidate all download client singletons to force reload of download_dir + const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); + invalidateDownloadClientManager(); const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service'); invalidateQBittorrentService(); + const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service'); + invalidateSABnzbdService(); + const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service'); + invalidateNZBGetService(); + const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service'); + invalidateTransmissionService(); return NextResponse.json({ success: true, diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 7fd98f3..0f31863 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -125,6 +125,7 @@ export async function GET(request: NextRequest) { downloadDir: configMap.get('download_dir') || '/downloads', mediaDir: configMap.get('media_dir') || '/media/audiobooks', audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}', + ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}', metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true', chapterMergingEnabled: configMap.get('chapter_merging_enabled') === '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 fc8161c..55ba26b 100644 --- a/src/app/api/admin/settings/test-download-client/route.ts +++ b/src/app/api/admin/settings/test-download-client/route.ts @@ -1,14 +1,16 @@ /** - * Component: Admin Settings Test Download Client API + * Component: Admin Settings Test Download Client API (DEPRECATED) * Documentation: documentation/settings-pages.md + * + * DEPRECATED: Use /api/admin/settings/download-clients/test instead. + * Maintained for backward compatibility. */ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { getConfigService } from '@/lib/services/config.service'; -import { getDownloadClientManager } from '@/lib/services/download-client-manager.service'; -import { QBittorrentService } from '@/lib/integrations/qbittorrent.service'; -import { SABnzbdService } from '@/lib/integrations/sabnzbd.service'; +import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service'; +import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.TestDownloadClient'); @@ -19,6 +21,7 @@ export async function POST(request: NextRequest) { try { const { type, + name: clientName, url, username, password, @@ -37,9 +40,9 @@ export async function POST(request: NextRequest) { ); } - if (type !== 'qbittorrent' && type !== 'sabnzbd') { + if (!SUPPORTED_CLIENT_TYPES.includes(type)) { return NextResponse.json( - { success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, + { success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` }, { status: 400 } ); } @@ -64,53 +67,28 @@ export async function POST(request: NextRequest) { actualPassword = matchingClient.password; } - // Validate required fields per client type and test connection - let version: string | undefined; + // Build a temporary config for testing + const testConfig: DownloadClientConfig = { + id: 'legacy-test', + type, + name: clientName || type, + enabled: true, + url, + username: username || '', + password: actualPassword || '', + disableSSLVerify: disableSSLVerify || false, + remotePathMappingEnabled: remotePathMappingEnabled || false, + remotePath: remotePath || undefined, + localPath: localPath || undefined, + category: 'readmeabook', + }; - if (type === 'qbittorrent') { - logger.debug('Testing qBittorrent connection'); - 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') { - logger.debug('Testing SABnzbd connection'); - 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; - } + const configService = getConfigService(); + const manager = getDownloadClientManager(configService); + const result = await manager.testConnection(testConfig); // If path mapping enabled, validate local path exists - if (remotePathMappingEnabled) { + if (result.success && remotePathMappingEnabled) { if (!remotePath || !localPath) { return NextResponse.json( { @@ -136,10 +114,14 @@ export async function POST(request: NextRequest) { } } - return NextResponse.json({ - success: true, - version, - }); + if (result.success) { + return NextResponse.json({ success: true, message: result.message }); + } + + return NextResponse.json( + { success: false, error: result.message }, + { status: 400 } + ); } catch (error) { logger.error('Download client test failed', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 9b491f2..f10e67f 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -19,7 +19,7 @@ export async function PUT( try { const { id } = await params; const body = await request.json(); - const { role, autoApproveRequests } = body; + const { role, autoApproveRequests, interactiveSearchAccess } = body; // Validate role if (!role || (role !== 'user' && role !== 'admin')) { @@ -37,6 +37,14 @@ export async function PUT( ); } + // Validate interactiveSearchAccess (optional) + if (interactiveSearchAccess !== undefined && interactiveSearchAccess !== null && typeof interactiveSearchAccess !== 'boolean') { + return NextResponse.json( + { error: 'Invalid interactiveSearchAccess. Must be a boolean or null' }, + { status: 400 } + ); + } + // Prevent user from demoting themselves if (req.user && id === req.user.sub) { return NextResponse.json( @@ -91,21 +99,30 @@ export async function PUT( ); } - // Validate that admins cannot have autoApproveRequests set to false + // Validate that admins cannot have permissions set to false if (role === 'admin' && autoApproveRequests === false) { return NextResponse.json( { error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' }, { status: 400 } ); } + if (role === 'admin' && interactiveSearchAccess === false) { + return NextResponse.json( + { error: 'Admins always have interactive search access. Cannot set interactiveSearchAccess to false for admin users.' }, + { status: 400 } + ); + } // Prepare update data - const updateData: { role: string; autoApproveRequests?: boolean | null } = { role }; + const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role }; if (autoApproveRequests !== undefined) { updateData.autoApproveRequests = autoApproveRequests; } + if (interactiveSearchAccess !== undefined) { + updateData.interactiveSearchAccess = interactiveSearchAccess; + } - // Update user role and autoApproveRequests + // Update user const updatedUser = await prisma.user.update({ where: { id }, data: updateData, @@ -114,6 +131,7 @@ export async function PUT( plexUsername: true, role: true, autoApproveRequests: true, + interactiveSearchAccess: true, }, }); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 071d640..6a517ab 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -31,6 +31,7 @@ export async function GET(request: NextRequest) { updatedAt: true, lastLoginAt: true, autoApproveRequests: true, + interactiveSearchAccess: true, _count: { select: { requests: true, diff --git a/src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts b/src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts index 86668f8..e83042f 100644 --- a/src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts +++ b/src/app/api/audiobooks/[asin]/interactive-search-ebook/route.ts @@ -17,6 +17,7 @@ import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping'; import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; import { searchByAsin, searchByTitle, @@ -83,6 +84,21 @@ export async function POST( return requireAuth(request, async (req: AuthenticatedRequest) => { try { const { asin } = await params; + + // Check interactive search access permission + if (req.user) { + const callingUser = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { role: true, interactiveSearchAccess: true }, + }); + if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) { + return NextResponse.json( + { error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' }, + { status: 403 } + ); + } + } + const body = await request.json().catch(() => ({})); const customTitle = body.customTitle as string | undefined; @@ -410,9 +426,14 @@ async function searchIndexersForInteractive( const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; // Group indexers by ebook categories - const groups = groupIndexersByCategories(indexersConfig, 'ebook'); + const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook'); - logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`); + if (skippedIndexers.length > 0) { + const skippedNames = skippedIndexers.map(idx => idx.name).join(', '); + logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`); + } + + logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`); // Get Prowlarr service const prowlarr = await getProwlarrService(); diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts index ebd9de2..23b67f3 100644 --- a/src/app/api/audiobooks/search-torrents/route.ts +++ b/src/app/api/audiobooks/search-torrents/route.ts @@ -70,9 +70,14 @@ export async function POST(request: NextRequest) { // Group indexers by their category configuration // This minimizes API calls while ensuring each indexer only searches its configured categories - const groups = groupIndexersByCategories(indexersConfig); + const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig); - logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title }); + if (skippedIndexers.length > 0) { + const skippedNames = skippedIndexers.map(idx => idx.name).join(', '); + logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`); + } + + logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title }); // Log each group for transparency groups.forEach((group, index) => { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 113ccf7..8bd12fe 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; +import { resolvePermission, getGlobalBooleanSetting } from '@/lib/utils/permissions'; /** * GET /api/auth/me @@ -37,6 +38,7 @@ export async function GET(request: NextRequest) { authProvider: true, createdAt: true, lastLoginAt: true, + interactiveSearchAccess: true, }, }); @@ -53,6 +55,14 @@ export async function GET(request: NextRequest) { // Determine if user is local admin (setup admin with local authentication) const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-'); + // Resolve effective permissions + const globalInteractiveSearch = await getGlobalBooleanSetting('interactive_search_access', true); + const effectiveInteractiveSearch = resolvePermission( + user.role, + user.interactiveSearchAccess, + globalInteractiveSearch + ); + return NextResponse.json({ user: { id: user.id, @@ -65,6 +75,9 @@ export async function GET(request: NextRequest) { authProvider: user.authProvider, createdAt: user.createdAt, lastLoginAt: user.lastLoginAt, + permissions: { + interactiveSearch: effectiveInteractiveSearch, + }, }, }); }); diff --git a/src/app/api/requests/[id]/interactive-search-ebook/route.ts b/src/app/api/requests/[id]/interactive-search-ebook/route.ts index 6dc586b..0e3ed6e 100644 --- a/src/app/api/requests/[id]/interactive-search-ebook/route.ts +++ b/src/app/api/requests/[id]/interactive-search-ebook/route.ts @@ -321,9 +321,14 @@ async function searchIndexersForInteractive( const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; // Group indexers by ebook categories - const groups = groupIndexersByCategories(indexersConfig, 'ebook'); + const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook'); - logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`); + if (skippedIndexers.length > 0) { + const skippedNames = skippedIndexers.map(idx => idx.name).join(', '); + logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`); + } + + logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`); // Get Prowlarr service const prowlarr = await getProwlarrService(); diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index 4878f34..d75f918 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -9,6 +9,7 @@ import { prisma } from '@/lib/db'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { RMABLogger } from '@/lib/utils/logger'; +import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; const logger = RMABLogger.create('API.InteractiveSearch'); @@ -71,6 +72,18 @@ export async function POST( ); } + // Check interactive search access permission + const callingUser = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { role: true, interactiveSearchAccess: true }, + }); + if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) { + return NextResponse.json( + { error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' }, + { status: 403 } + ); + } + // Get enabled indexers from configuration const { getConfigService } = await import('@/lib/services/config.service'); const configService = getConfigService(); diff --git a/src/app/api/requests/[id]/route.ts b/src/app/api/requests/[id]/route.ts index ec73749..9a842ba 100644 --- a/src/app/api/requests/[id]/route.ts +++ b/src/app/api/requests/[id]/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface'; const logger = RMABLogger.create('API.RequestById'); @@ -200,28 +201,11 @@ export async function PATCH( // Get download path from the appropriate download client let downloadPath: string; - if (downloadHistory.torrentHash) { - // qBittorrent - get path from torrent info - const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service'); - const qbt = await getQBittorrentService(); - const torrent = await qbt.getTorrent(downloadHistory.torrentHash); - downloadPath = `${torrent.save_path}/${torrent.name}`; - } else if (downloadHistory.nzbId) { - // SABnzbd - get path from NZB info - const { getSABnzbdService } = await import('@/lib/integrations/sabnzbd.service'); - const sabnzbd = await getSABnzbdService(); - const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId); - if (!nzbInfo || !nzbInfo.downloadPath) { - return NextResponse.json( - { - error: 'ValidationError', - message: 'Download path not available from SABnzbd', - }, - { status: 400 } - ); - } - downloadPath = nzbInfo.downloadPath; - } else { + // Get download path via unified interface + const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId; + const clientType = downloadHistory.downloadClient || 'qbittorrent'; + + if (!clientId || clientType === 'direct') { return NextResponse.json( { error: 'ValidationError', @@ -231,6 +215,35 @@ export async function PATCH( ); } + const { getConfigService } = await import('@/lib/services/config.service'); + const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); + const configService = getConfigService(); + const manager = getDownloadClientManager(configService); + const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent'; + const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet'); + + if (!client) { + return NextResponse.json( + { + error: 'ValidationError', + message: `No ${clientType} client configured`, + }, + { status: 400 } + ); + } + + const info = await client.getDownload(clientId); + if (!info?.downloadPath) { + return NextResponse.json( + { + error: 'ValidationError', + message: `Download path not available from ${client.clientType}`, + }, + { status: 400 } + ); + } + downloadPath = info.downloadPath; + await jobQueue.addOrganizeJob( id, requestWithData.audiobook.id, diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts index 74bb3b4..24dd593 100644 --- a/src/app/api/setup/complete/route.ts +++ b/src/app/api/setup/complete/route.ts @@ -9,11 +9,14 @@ import bcrypt from 'bcrypt'; import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; import { getEncryptionService } from '@/lib/services/encryption.service'; import { getPlexService } from '@/lib/integrations/plex.service'; +import { getClientDisplayName } from '@/lib/interfaces/download-client.interface'; +import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Setup.Complete'); export async function POST(request: NextRequest) { + return requireSetupIncomplete(request, async (req) => { try { const { backendMode, @@ -28,7 +31,7 @@ export async function POST(request: NextRequest) { downloadClient, paths, bookdate, - } = await request.json(); + } = await req.json(); // Validate backend mode if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) { @@ -401,7 +404,7 @@ export async function POST(request: NextRequest) { downloadClientsArray = [{ id: `temp-${Date.now()}`, type: downloadClient.type, - name: downloadClient.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd', + name: getClientDisplayName(downloadClient.type), enabled: true, url: downloadClient.url, username: downloadClient.username, @@ -562,4 +565,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } + }); } diff --git a/src/app/api/setup/test-abs/route.ts b/src/app/api/setup/test-abs/route.ts index d576e31..74fabea 100644 --- a/src/app/api/setup/test-abs/route.ts +++ b/src/app/api/setup/test-abs/route.ts @@ -4,10 +4,12 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { requireSetupIncomplete } from '@/lib/middleware/auth'; export async function POST(request: NextRequest) { + return requireSetupIncomplete(request, async (req) => { try { - const { serverUrl, apiToken } = await request.json(); + const { serverUrl, apiToken } = await req.json(); if (!serverUrl) { return NextResponse.json( @@ -79,4 +81,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } + }); } diff --git a/src/app/api/setup/test-download-client/route.ts b/src/app/api/setup/test-download-client/route.ts index 9ddc5e1..984c417 100644 --- a/src/app/api/setup/test-download-client/route.ts +++ b/src/app/api/setup/test-download-client/route.ts @@ -4,15 +4,18 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { QBittorrentService } from '@/lib/integrations/qbittorrent.service'; -import { SABnzbdService } from '@/lib/integrations/sabnzbd.service'; +import { getConfigService } from '@/lib/services/config.service'; +import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service'; +import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface'; +import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Setup.TestDownloadClient'); export async function POST(request: NextRequest) { + return requireSetupIncomplete(request, async (req) => { try { - const { type, url, username, password, disableSSLVerify } = await request.json(); + const { type, name, url, username, password, disableSSLVerify } = await req.json(); if (!type || !url) { return NextResponse.json( @@ -21,59 +24,39 @@ export async function POST(request: NextRequest) { ); } - if (type !== 'qbittorrent' && type !== 'sabnzbd') { + if (!SUPPORTED_CLIENT_TYPES.includes(type)) { return NextResponse.json( - { success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' }, + { success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` }, { status: 400 } ); } - // Validate required fields per client type - // qBittorrent credentials are optional (supports IP whitelist auth) - if (type === 'qbittorrent') { - // Test qBittorrent connection (empty credentials work with IP whitelist) - const version = await QBittorrentService.testConnectionWithCredentials( - url, - username || '', - password || '', - disableSSLVerify || false - ); + // Build a temporary config for testing + const testConfig: DownloadClientConfig = { + id: 'setup-test', + type, + name: name || type, + enabled: true, + url, + username: username || '', + password: password || '', + disableSSLVerify: disableSSLVerify || false, + remotePathMappingEnabled: false, + }; + const configService = getConfigService(); + const manager = getDownloadClientManager(configService); + const result = await manager.testConnection(testConfig); + + if (result.success) { 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, + message: result.message, }); } - // Should never reach here return NextResponse.json( - { success: false, error: 'Invalid client type' }, + { success: false, error: result.message }, { status: 400 } ); } catch (error) { @@ -86,4 +69,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } + }); } diff --git a/src/app/api/setup/test-oidc/route.ts b/src/app/api/setup/test-oidc/route.ts index 0aa0e84..3894cf1 100644 --- a/src/app/api/setup/test-oidc/route.ts +++ b/src/app/api/setup/test-oidc/route.ts @@ -5,13 +5,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { Issuer } from 'openid-client'; +import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Setup.TestOIDC'); export async function POST(request: NextRequest) { + return requireSetupIncomplete(request, async (req) => { try { - const body = await request.json(); + const body = await req.json(); const { issuerUrl, clientId, clientSecret } = body; // Validate required fields @@ -93,4 +95,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } + }); } diff --git a/src/app/api/setup/test-paths/route.ts b/src/app/api/setup/test-paths/route.ts index cac8e90..a7bee2b 100644 --- a/src/app/api/setup/test-paths/route.ts +++ b/src/app/api/setup/test-paths/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs/promises'; import path from 'path'; +import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; @@ -45,8 +46,9 @@ async function testPath(dirPath: string): Promise { } export async function POST(request: NextRequest) { + return requireSetupIncomplete(request, async (req) => { try { - const { downloadDir, mediaDir, audiobookPathTemplate } = await request.json(); + const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json(); if (!downloadDir || !mediaDir) { return NextResponse.json( @@ -126,4 +128,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } + }); } diff --git a/src/app/api/setup/test-plex/route.ts b/src/app/api/setup/test-plex/route.ts index a5715b2..e3bd578 100644 --- a/src/app/api/setup/test-plex/route.ts +++ b/src/app/api/setup/test-plex/route.ts @@ -5,13 +5,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPlexService } from '@/lib/integrations/plex.service'; +import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Setup.TestPlex'); export async function POST(request: NextRequest) { + return requireSetupIncomplete(request, async (req) => { try { - const { url, token } = await request.json(); + const { url, token } = await req.json(); if (!url || !token) { return NextResponse.json( @@ -61,4 +63,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } + }); } diff --git a/src/app/api/setup/test-prowlarr/route.ts b/src/app/api/setup/test-prowlarr/route.ts index 7d70275..066d405 100644 --- a/src/app/api/setup/test-prowlarr/route.ts +++ b/src/app/api/setup/test-prowlarr/route.ts @@ -5,13 +5,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { ProwlarrService } from '@/lib/integrations/prowlarr.service'; +import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Setup.TestProwlarr'); export async function POST(request: NextRequest) { + return requireSetupIncomplete(request, async (req) => { try { - const { url, apiKey } = await request.json(); + const { url, apiKey } = await req.json(); if (!url || !apiKey) { return NextResponse.json( @@ -50,4 +52,5 @@ export async function POST(request: NextRequest) { { status: 500 } ); } + }); } diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index 3a4461c..0841f78 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -10,12 +10,14 @@ import { Header } from '@/components/layout/Header'; import { RequestCard } from '@/components/requests/RequestCard'; import { useRequests } from '@/lib/hooks/useRequests'; import { useAuth } from '@/contexts/AuthContext'; +import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled'; export default function RequestsPage() { const { user } = useAuth(); + const { squareCovers } = usePreferences(); const [filter, setFilter] = useState('all'); // Always fetch only the current user's requests (even for admins) @@ -133,7 +135,10 @@ export default function RequestsPage() { className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse" >
    -
    +
    diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 6c3155a..580b99f 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -495,6 +495,7 @@ export default function SetupWizard() { return ( goToStep(currentStepNumber + 1)} onBack={() => goToStep(currentStepNumber - 1)} diff --git a/src/app/setup/steps/DownloadClientStep.tsx b/src/app/setup/steps/DownloadClientStep.tsx index a1b92f0..a18c403 100644 --- a/src/app/setup/steps/DownloadClientStep.tsx +++ b/src/app/setup/steps/DownloadClientStep.tsx @@ -8,10 +8,11 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/Button'; import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement'; +import { DownloadClientType } from '@/lib/interfaces/download-client.interface'; interface DownloadClient { id: string; - type: 'qbittorrent' | 'sabnzbd'; + type: DownloadClientType; name: string; enabled: boolean; url: string; @@ -22,10 +23,12 @@ interface DownloadClient { remotePath?: string; localPath?: string; category?: string; + customPath?: string; } interface DownloadClientStepProps { downloadClients: DownloadClient[]; + downloadDir?: string; onUpdate: (field: string, value: any) => void; onNext: () => void; onBack: () => void; @@ -33,6 +36,7 @@ interface DownloadClientStepProps { export function DownloadClientStep({ downloadClients, + downloadDir, onUpdate, onNext, onBack, @@ -66,7 +70,7 @@ export function DownloadClientStep({ Configure Download Clients

    - Add at least one download client. You can configure both qBittorrent (torrents) and SABnzbd (Usenet) to search across all indexer types. + Add at least one download client. You can configure a torrent client (qBittorrent or Transmission) and/or a usenet client (SABnzbd or NZBGet) to search across all indexer types.

    @@ -80,6 +84,7 @@ export function DownloadClientStep({ mode="wizard" initialClients={clients} onClientsChange={handleClientsChange} + downloadDir={downloadDir} />
    diff --git a/src/app/setup/steps/WelcomeStep.tsx b/src/app/setup/steps/WelcomeStep.tsx index 8ccb189..79b9847 100644 --- a/src/app/setup/steps/WelcomeStep.tsx +++ b/src/app/setup/steps/WelcomeStep.tsx @@ -17,13 +17,13 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
    ReadMeABook Logo
    @@ -57,9 +57,9 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) { />
    - Plex Media Server + Plex or Audiobookshelf

    - Your Plex server URL and authentication token + Your media server URL and authentication credentials

    @@ -79,7 +79,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
    Prowlarr

    - Indexer aggregator for searching torrents (URL and API key) + Indexer aggregator for searching torrents and usenet (URL and API key)

    @@ -98,10 +98,10 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
    - qBittorrent or SABnzbd + Download Client

    - Download client for torrents (qBittorrent) or Usenet/NZB (SABnzbd) + qBittorrent, Transmission, SABnzbd, or NZBGet

    diff --git a/src/components/admin/download-clients/DownloadClientCard.tsx b/src/components/admin/download-clients/DownloadClientCard.tsx index b667638..309fbcd 100644 --- a/src/components/admin/download-clients/DownloadClientCard.tsx +++ b/src/components/admin/download-clients/DownloadClientCard.tsx @@ -6,22 +6,30 @@ 'use client'; import React from 'react'; +import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; interface DownloadClientCardProps { client: { id: string; - type: 'qbittorrent' | 'sabnzbd'; + type: DownloadClientType; name: string; url: string; enabled: boolean; + customPath?: string; }; onEdit: () => void; onDelete: () => void; } export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientCardProps) { - const typeName = client.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'; - const typeColor = client.type === 'qbittorrent' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'; + const typeName = getClientDisplayName(client.type); + const typeColorMap: Record = { + qbittorrent: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', + transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300', + sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300', + nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300', + }; + const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent; // Truncate URL for display const displayUrl = client.url.length > 40 ? `${client.url.substring(0, 40)}...` : client.url; @@ -49,6 +57,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC

    {displayUrl}

    + {client.customPath && ( +

    + Path: {client.customPath} +

    + )}
    diff --git a/src/components/admin/download-clients/DownloadClientManagement.tsx b/src/components/admin/download-clients/DownloadClientManagement.tsx index 9e63e7a..22e422a 100644 --- a/src/components/admin/download-clients/DownloadClientManagement.tsx +++ b/src/components/admin/download-clients/DownloadClientManagement.tsx @@ -10,10 +10,11 @@ import { Button } from '@/components/ui/Button'; import { DownloadClientCard } from './DownloadClientCard'; import { DownloadClientModal } from './DownloadClientModal'; import { fetchWithAuth } from '@/lib/utils/api'; +import { DownloadClientType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; interface DownloadClient { id: string; - type: 'qbittorrent' | 'sabnzbd'; + type: DownloadClientType; name: string; url: string; username?: string; @@ -24,24 +25,27 @@ interface DownloadClient { remotePath?: string; localPath?: string; category?: string; + customPath?: string; } interface DownloadClientManagementProps { mode: 'wizard' | 'settings'; initialClients?: DownloadClient[]; onClientsChange?: (clients: DownloadClient[]) => void; + downloadDir?: string; } export function DownloadClientManagement({ mode, initialClients = [], onClientsChange, + downloadDir: downloadDirProp, }: DownloadClientManagementProps) { const [clients, setClients] = useState(initialClients); const [modalState, setModalState] = useState<{ isOpen: boolean; mode: 'add' | 'edit'; - clientType?: 'qbittorrent' | 'sabnzbd'; + clientType?: DownloadClientType; currentClient?: DownloadClient; }>({ isOpen: false, mode: 'add' }); const [loading, setLoading] = useState(false); @@ -51,14 +55,23 @@ export function DownloadClientManagement({ clientId?: string; clientName?: string; }>({ isOpen: false }); + const [resolvedDownloadDir, setResolvedDownloadDir] = useState(downloadDirProp || '/downloads'); - // Fetch clients when in settings mode + // Fetch clients and download dir when in settings mode useEffect(() => { if (mode === 'settings') { fetchClients(); + fetchDownloadDir(); } }, [mode]); + // Sync downloadDir prop (wizard mode) + useEffect(() => { + if (downloadDirProp) { + setResolvedDownloadDir(downloadDirProp); + } + }, [downloadDirProp]); + // Sync with parent when clients change useEffect(() => { if (onClientsChange) { @@ -93,11 +106,26 @@ export function DownloadClientManagement({ } }; - const handleAddClient = (type: 'qbittorrent' | 'sabnzbd') => { - // Check if this type already exists - const existingClient = clients.find(c => c.type === type && c.enabled); + const fetchDownloadDir = async () => { + try { + const response = await fetchWithAuth('/api/admin/settings'); + if (response.ok) { + const data = await response.json(); + if (data.paths?.downloadDir) { + setResolvedDownloadDir(data.paths.downloadDir); + } + } + } catch { + // Non-critical: fall back to default + } + }; + + const handleAddClient = (type: DownloadClientType) => { + // Check if the protocol is already taken (regardless of enabled status) + const protocol = CLIENT_PROTOCOL_MAP[type]; + const existingClient = clients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol); if (existingClient) { - setError(`A ${type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} client is already configured.`); + setError(`A ${protocol} client (${getClientDisplayName(existingClient.type)}) is already configured. Remove it first to add a different ${protocol} client.`); return; } @@ -210,8 +238,8 @@ export function DownloadClientManagement({ } }; - const hasQBittorrent = clients.some(c => c.type === 'qbittorrent' && c.enabled); - const hasSABnzbd = clients.some(c => c.type === 'sabnzbd' && c.enabled); + const hasTorrentClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'torrent'); + const hasUsenetClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'usenet'); return (
    @@ -233,9 +261,9 @@ export function DownloadClientManagement({

    Add Download Client

    -
    +
    {/* qBittorrent Card */} -
    +

    @@ -249,9 +277,9 @@ export function DownloadClientManagement({ Torrent

    - {hasQBittorrent ? ( + {hasTorrentClient ? (
    - Already configured + Protocol already configured
    ) : ( + )} +
    + {/* SABnzbd Card */} -
    +

    @@ -280,9 +339,9 @@ export function DownloadClientManagement({ Usenet

    - {hasSABnzbd ? ( + {hasUsenetClient ? (
    - Already configured + Protocol already configured
    ) : (
    + + {/* NZBGet Card */} +
    +
    +
    +

    + NZBGet +

    +

    + Usenet/NZB downloads +

    +
    + + Usenet + +
    + {hasUsenetClient ? ( +
    + Protocol already configured +
    + ) : ( + + )} +
    @@ -338,6 +428,7 @@ export function DownloadClientManagement({ initialClient={modalState.currentClient} onSave={handleSaveClient} apiMode={mode} + downloadDir={resolvedDownloadDir} /> {/* Delete Confirmation Modal */} diff --git a/src/components/admin/download-clients/DownloadClientModal.tsx b/src/components/admin/download-clients/DownloadClientModal.tsx index b0f3c14..6e25a35 100644 --- a/src/components/admin/download-clients/DownloadClientModal.tsx +++ b/src/components/admin/download-clients/DownloadClientModal.tsx @@ -10,15 +10,16 @@ import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { fetchWithAuth } from '@/lib/utils/api'; +import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; interface DownloadClientModalProps { isOpen: boolean; onClose: () => void; mode: 'add' | 'edit'; - clientType?: 'qbittorrent' | 'sabnzbd'; + clientType?: DownloadClientType; initialClient?: { id: string; - type: 'qbittorrent' | 'sabnzbd'; + type: DownloadClientType; name: string; url: string; username?: string; @@ -29,9 +30,11 @@ interface DownloadClientModalProps { remotePath?: string; localPath?: string; category?: string; + customPath?: string; }; onSave: (client: any) => Promise; apiMode: 'wizard' | 'settings'; + downloadDir?: string; } export function DownloadClientModal({ @@ -42,9 +45,10 @@ export function DownloadClientModal({ initialClient, onSave, apiMode, + downloadDir = '/downloads', }: DownloadClientModalProps) { const type = mode === 'edit' ? initialClient?.type : clientType; - const typeName = type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'; + const typeName = type ? getClientDisplayName(type) : ''; // Form state const [name, setName] = useState(''); @@ -57,6 +61,7 @@ export function DownloadClientModal({ const [remotePath, setRemotePath] = useState(''); const [localPath, setLocalPath] = useState(''); const [category, setCategory] = useState('readmeabook'); + const [customPath, setCustomPath] = useState(''); const [testing, setTesting] = useState(false); const [saving, setSaving] = useState(false); @@ -79,6 +84,7 @@ export function DownloadClientModal({ setRemotePath(initialClient.remotePath || ''); setLocalPath(initialClient.localPath || ''); setCategory(initialClient.category || 'readmeabook'); + setCustomPath(initialClient.customPath || ''); } else { // Add mode defaults setName(typeName); @@ -91,6 +97,7 @@ export function DownloadClientModal({ setRemotePath(''); setLocalPath(''); setCategory('readmeabook'); + setCustomPath(''); } setTestResult(null); setErrors({}); @@ -113,6 +120,10 @@ export function DownloadClientModal({ newErrors.password = 'API key is required'; } + if (customPath.includes('..')) { + newErrors.customPath = 'Path cannot contain ".."'; + } + if (remotePathMappingEnabled) { if (!remotePath.trim()) { newErrors.remotePath = 'Remote path is required when path mapping is enabled'; @@ -140,8 +151,9 @@ export function DownloadClientModal({ const testData = { type, + name, url, - username: type === 'qbittorrent' ? username : undefined, + username: username || undefined, password: isPasswordMasked ? undefined : password, // Include clientId when editing so server can use stored password ...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}), @@ -202,11 +214,14 @@ export function DownloadClientModal({ setSaving(true); try { + // Strip leading/trailing slashes from customPath + const sanitizedCustomPath = customPath.replace(/^\/+|\/+$/g, '').trim(); + const clientData: any = { type, name, url, - username: type === 'qbittorrent' ? username : undefined, + username: type !== 'sabnzbd' ? username : undefined, password: password === '********' ? undefined : password, // Don't send masked password on edit enabled, disableSSLVerify, @@ -214,6 +229,7 @@ export function DownloadClientModal({ remotePath: remotePathMappingEnabled ? remotePath : undefined, localPath: remotePathMappingEnabled ? localPath : undefined, category, + customPath: sanitizedCustomPath || undefined, }; if (mode === 'edit' && initialClient) { @@ -264,7 +280,7 @@ export function DownloadClientModal({ setUrl(e.target.value)} - placeholder={type === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8081'} + placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'} error={errors.url} />

    @@ -272,8 +288,8 @@ export function DownloadClientModal({

    - {/* Username (qBittorrent only) */} - {type === 'qbittorrent' && ( + {/* Username (qBittorrent and Transmission) */} + {type !== 'sabnzbd' && (
    + {/* Custom Download Path */} +
    + + setCustomPath(e.target.value)} + placeholder="e.g. torrents or usenet/books" + error={errors.customPath} + /> +

    + Optional relative sub-path appended to the base download directory +

    +

    + Downloads to: {customPath.replace(/^\/+|\/+$/g, '').trim() + ? `${downloadDir}/${customPath.replace(/^\/+|\/+$/g, '').trim()}` + : downloadDir} +

    +
    + {/* Remote Path Mapping */}
    diff --git a/src/components/admin/indexers/CategoryTreeView.tsx b/src/components/admin/indexers/CategoryTreeView.tsx index c2ff92d..94f2745 100644 --- a/src/components/admin/indexers/CategoryTreeView.tsx +++ b/src/components/admin/indexers/CategoryTreeView.tsx @@ -5,12 +5,13 @@ 'use client'; -import React from 'react'; +import React, { useState, useMemo } from 'react'; import { TORRENT_CATEGORIES, getChildIds, areAllChildrenSelected, isParentCategory, + getAllStandardCategoryIds, } from '@/lib/utils/torrent-categories'; interface CategoryTreeViewProps { @@ -24,7 +25,19 @@ export function CategoryTreeView({ onChange, defaultCategories = [3030], // Default to audiobook category for backwards compatibility }: CategoryTreeViewProps) { + const [customInput, setCustomInput] = useState(''); + const [customError, setCustomError] = useState(''); + + const standardIds = useMemo(() => getAllStandardCategoryIds(), []); + + // Derive custom categories from selected categories that aren't in the standard tree + const customCategories = useMemo( + () => selectedCategories.filter((id) => !standardIds.has(id)).sort((a, b) => a - b), + [selectedCategories, standardIds] + ); + const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId); + const handleParentToggle = (parentId: number) => { const childIds = getChildIds(parentId); const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories); @@ -57,6 +70,52 @@ export function CategoryTreeView({ } }; + const handleRemoveCustom = (categoryId: number) => { + onChange(selectedCategories.filter((id) => id !== categoryId)); + }; + + const handleAddCustom = () => { + setCustomError(''); + const trimmed = customInput.trim(); + + if (!trimmed) { + setCustomError('Enter a category ID'); + return; + } + + const parsed = parseInt(trimmed, 10); + + if (isNaN(parsed) || !Number.isInteger(Number(trimmed)) || String(parsed) !== trimmed) { + setCustomError('Must be a whole number'); + return; + } + + if (parsed <= 0) { + setCustomError('Must be a positive number'); + return; + } + + if (standardIds.has(parsed)) { + setCustomError('This is a standard category — use the toggles above'); + return; + } + + if (selectedCategories.includes(parsed)) { + setCustomError('Already added'); + return; + } + + onChange([...selectedCategories, parsed]); + setCustomInput(''); + }; + + const handleCustomKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddCustom(); + } + }; + const isParentSelected = (parentId: number) => { return areAllChildrenSelected(parentId, selectedCategories); }; @@ -67,6 +126,7 @@ export function CategoryTreeView({ return (
    + {/* Standard Categories */} {TORRENT_CATEGORIES.map((category) => (
    {/* Parent Category Header */} @@ -129,6 +189,85 @@ export function CategoryTreeView({ )}
    ))} + + {/* Custom Categories Section */} +
    +
    + + Custom + + + Add custom Newznab/Torznab category IDs + +
    + + {/* Existing custom categories */} + {customCategories.length > 0 && ( +
    + {customCategories.map((catId) => ( +
    +
    + + Custom + + + [{catId}] + +
    + +
    + ))} +
    + )} + + {/* Add custom category input */} +
    +
    + { + setCustomInput(e.target.value); + setCustomError(''); + }} + onKeyDown={handleCustomKeyDown} + placeholder="Category ID" + className={` + w-32 px-3 py-1.5 text-sm rounded-lg border bg-white dark:bg-gray-800 + text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900 + ${customError + ? 'border-red-300 dark:border-red-700' + : 'border-gray-200 dark:border-gray-700' + } + `} + /> + +
    + {customError && ( +

    + {customError} +

    + )} +
    +
    ); } diff --git a/src/components/admin/indexers/IndexerConfigModal.tsx b/src/components/admin/indexers/IndexerConfigModal.tsx index 8d84eb0..b990151 100644 --- a/src/components/admin/indexers/IndexerConfigModal.tsx +++ b/src/components/admin/indexers/IndexerConfigModal.tsx @@ -96,8 +96,6 @@ export function IndexerConfigModal({ const [errors, setErrors] = useState<{ priority?: string; seedingTimeMinutes?: string; - audiobookCategories?: string; - ebookCategories?: string; }>({}); // Reset form when modal opens or indexer changes @@ -134,26 +132,12 @@ export function IndexerConfigModal({ newErrors.seedingTimeMinutes = 'Seeding time cannot be negative'; } - if (audiobookCategories.length === 0) { - newErrors.audiobookCategories = 'At least one audiobook category must be selected'; - } - - if (ebookCategories.length === 0) { - newErrors.ebookCategories = 'At least one ebook category must be selected'; - } - setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSave = () => { if (!validate()) { - // If there's a category error, switch to the relevant tab - if (errors.audiobookCategories && activeTab !== 'audiobook') { - setActiveTab('audiobook'); - } else if (errors.ebookCategories && activeTab !== 'ebook') { - setActiveTab('ebook'); - } return; } @@ -202,9 +186,12 @@ export function IndexerConfigModal({ // Get the current categories based on active tab const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories; const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories; - const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories; const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES; + // Warning state: no categories means this indexer is effectively disabled for that type + const audiobookDisabled = audiobookCategories.length === 0; + const ebookDisabled = ebookCategories.length === 0; + return ( AudioBook - {errors.audiobookCategories && ( - ! + {audiobookDisabled && ( + ! )}
    @@ -372,15 +359,23 @@ export function IndexerConfigModal({

    - {activeTab === 'audiobook' - ? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]' - : 'Categories to search for e-books. Default: Books/EBook [7020]'} + {currentCategories.length > 0 + ? `Will search categories: [${currentCategories.join(', ')}]` + : activeTab === 'audiobook' + ? 'Default: Audio/Audiobook [3030]' + : 'Default: Books/EBook [7020]'}

    - {currentError && ( -

    - {currentError} -

    + {/* Warning when all categories are deselected for the active tab */} + {currentCategories.length === 0 && ( +
    + + + +

    + No categories selected. This indexer will not be searched for {activeTab === 'audiobook' ? 'audiobooks' : 'ebooks'}. +

    +
    )}
    diff --git a/src/components/admin/users/GlobalUserSettingsModal.tsx b/src/components/admin/users/GlobalUserSettingsModal.tsx new file mode 100644 index 0000000..ce86e7d --- /dev/null +++ b/src/components/admin/users/GlobalUserSettingsModal.tsx @@ -0,0 +1,90 @@ +/** + * Component: Global User Settings Modal + * Documentation: documentation/admin-dashboard.md + */ + +'use client'; + +import { Modal } from '@/components/ui/Modal'; + +interface GlobalUserSettingsModalProps { + isOpen: boolean; + onClose: () => void; + globalAutoApprove: boolean; + onToggleAutoApprove: (newValue: boolean) => void; + globalInteractiveSearch: boolean; + onToggleInteractiveSearch: (newValue: boolean) => void; +} + +export function GlobalUserSettingsModal({ + isOpen, + onClose, + globalAutoApprove, + onToggleAutoApprove, + globalInteractiveSearch, + onToggleInteractiveSearch, +}: GlobalUserSettingsModalProps) { + return ( + +
    + {/* Auto-Approve Setting */} +
    + +
    + +

    + When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings from the users table. +

    +
    +
    + + {/* Interactive Search Access Setting */} +
    + +
    + +

    + When enabled, all users can manually search and select torrents/ebooks. When disabled, you can grant access per-user from the users table. +

    +
    +
    +
    +
    + ); +} diff --git a/src/components/admin/users/UserPermissionsModal.tsx b/src/components/admin/users/UserPermissionsModal.tsx new file mode 100644 index 0000000..a8948b9 --- /dev/null +++ b/src/components/admin/users/UserPermissionsModal.tsx @@ -0,0 +1,184 @@ +/** + * Component: User Permissions Modal + * Documentation: documentation/admin-dashboard.md + */ + +'use client'; + +import { Modal } from '@/components/ui/Modal'; + +interface UserPermissionsUser { + id: string; + plexUsername: string; + plexEmail: string; + avatarUrl: string | null; + role: 'user' | 'admin'; + autoApproveRequests: boolean | null; + interactiveSearchAccess: boolean | null; +} + +interface UserPermissionsModalProps { + isOpen: boolean; + onClose: () => void; + user: UserPermissionsUser | null; + globalAutoApprove: boolean; + globalInteractiveSearch: boolean; + onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void; + onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void; +} + +interface PermissionToggleProps { + label: string; + ariaLabel: string; + value: boolean; + disabled: boolean; + disabledMessage?: string; + description: string; + onToggle: () => void; +} + +function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage, description, onToggle }: PermissionToggleProps) { + return ( +
    + +
    +
    + {label} +
    + {disabledMessage ? ( +

    + + + + {disabledMessage} +

    + ) : ( +

    + {description} +

    + )} +
    +
    + ); +} + +export function UserPermissionsModal({ + isOpen, + onClose, + user, + globalAutoApprove, + globalInteractiveSearch, + onToggleAutoApprove, + onToggleInteractiveSearch, +}: UserPermissionsModalProps) { + if (!user) return null; + + const isAdmin = user.role === 'admin'; + + // Auto-Approve resolution + const isAutoApproveGlobalOverride = !isAdmin && globalAutoApprove; + const isAutoApproveDisabled = isAdmin || isAutoApproveGlobalOverride; + const autoApproveValue = isAdmin ? true : isAutoApproveGlobalOverride ? true : (user.autoApproveRequests ?? false); + + // Interactive Search resolution + const isSearchGlobalOverride = !isAdmin && globalInteractiveSearch; + const isSearchDisabled = isAdmin || isSearchGlobalOverride; + const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false); + + const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => { + if (isAdminUser) return adminMessage; + if (isGlobalOverride) return globalMessage; + return undefined; + }; + + return ( + +
    + {/* User Info */} +
    + {user.avatarUrl && ( + {user.plexUsername} + )} +
    +
    + {user.plexUsername} +
    +
    + {user.plexEmail || 'No email'} +
    +
    + + {user.role.toUpperCase()} + +
    + + {/* Permissions Section */} +
    +

    + Permissions +

    + +
    + {/* Auto-Approve Permission */} + onToggleAutoApprove(user, !autoApproveValue)} + /> + + {/* Interactive Search Access Permission */} + onToggleInteractiveSearch(user, !searchValue)} + /> +
    +
    +
    +
    + ); +} diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index 9d0efef..f7c788e 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -479,8 +479,8 @@ export function AudiobookDetailsModal({ )}
    - {/* Interactive Search - only if not available */} - {status.type !== 'available' && ( + {/* Interactive Search - only if not available and user has permission */} + {status.type !== 'available' && (user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && ( - + {(user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && ( + + )} )}
    diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index cebe64d..46b15df 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -11,7 +11,10 @@ import { StatusBadge } from './StatusBadge'; import { Button } from '@/components/ui/Button'; import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; +import { usePreferences } from '@/contexts/PreferencesContext'; +import { useAuth } from '@/contexts/AuthContext'; import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal'; +import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; interface RequestCardProps { request: { @@ -25,6 +28,7 @@ interface RequestCardProps { completedAt?: string; audiobook: { id: string; + audibleAsin?: string; title: string; author: string; coverArtUrl?: string; @@ -36,8 +40,11 @@ interface RequestCardProps { export function RequestCard({ request, showActions = true }: RequestCardProps) { const { cancelRequest, isLoading } = useCancelRequest(); const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch(); + const { squareCovers } = usePreferences(); + const { user } = useAuth(); const [showError, setShowError] = React.useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false); + const [showDetailsModal, setShowDetailsModal] = React.useState(false); const requestType = request.type || 'audiobook'; const isEbook = requestType === 'ebook'; @@ -46,7 +53,9 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isFailed = request.status === 'failed'; // Ebook requests don't support interactive search (Anna's Archive only) - const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); + // Interactive search also requires the interactiveSearch permission + const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false; + const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); const handleCancel = async () => { if (window.confirm('Are you sure you want to cancel this request?')) { @@ -94,7 +103,19 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
    {/* Cover Art */}
    -
    +
    request.audiobook.audibleAsin && setShowDetailsModal(true)} + role={request.audiobook.audibleAsin ? 'button' : undefined} + tabIndex={request.audiobook.audibleAsin ? 0 : undefined} + onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)} + > {request.audiobook.coverArtUrl ? ( + + {/* Audiobook Details Modal */} + {request.audiobook.audibleAsin && ( + setShowDetailsModal(false)} + requestStatus={request.status} + isAvailable={['available', 'downloaded'].includes(request.status)} + hideRequestActions + /> + )}
    ); } diff --git a/src/components/ui/VersionBadge.tsx b/src/components/ui/VersionBadge.tsx index 4ee0200..a110d71 100644 --- a/src/components/ui/VersionBadge.tsx +++ b/src/components/ui/VersionBadge.tsx @@ -5,11 +5,29 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; + +const GITHUB_REPO = 'kikootwo/ReadMeABook'; +const REMOTE_PACKAGE_URL = `https://raw.githubusercontent.com/${GITHUB_REPO}/refs/heads/main/package.json`; +const UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours + +function compareVersions(current: string, latest: string): number { + const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number); + const a = parse(current); + const b = parse(latest); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (b[i] || 0) - (a[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} export function VersionBadge() { const [version, setVersion] = useState(null); + const [rawVersion, setRawVersion] = useState(null); const [commit, setCommit] = useState(null); + const [latestVersion, setLatestVersion] = useState(null); + const [updateAvailable, setUpdateAvailable] = useState(false); useEffect(() => { // Try to get version from build-time env var first (instant, no API call) @@ -17,6 +35,7 @@ export function VersionBadge() { if (buildTimeVersion && buildTimeVersion !== 'unknown') { setVersion(`v${buildTimeVersion}`); + setRawVersion(buildTimeVersion); // Also get commit for tooltip if available const buildTimeCommit = process.env.NEXT_PUBLIC_GIT_COMMIT; if (buildTimeCommit && buildTimeCommit !== 'unknown') { @@ -31,6 +50,7 @@ export function VersionBadge() { .then((res) => res.json()) .then((data) => { setVersion(data.version); + setRawVersion(data.fullVersion); if (data.commit && data.commit !== 'unknown') { setCommit(data.commit.substring(0, 7)); } @@ -42,20 +62,66 @@ export function VersionBadge() { } }, []); + const checkForUpdates = useCallback(() => { + if (!rawVersion || rawVersion === 'unknown') return; + + fetch(REMOTE_PACKAGE_URL) + .then((res) => res.json()) + .then((data) => { + if (data.version) { + setLatestVersion(data.version); + setUpdateAvailable(compareVersions(rawVersion, data.version) > 0); + } + }) + .catch(() => { + // Silently fail - update check is non-critical + }); + }, [rawVersion]); + + // Check for updates on mount and periodically (every 6 hours) + useEffect(() => { + if (!rawVersion || rawVersion === 'unknown') return; + + checkForUpdates(); + const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL); + return () => clearInterval(interval); + }, [rawVersion, checkForUpdates]); + if (!version) { return null; } - const tooltipText = commit ? `${version} (${commit})` : version; + const releaseUrl = rawVersion && rawVersion !== 'unknown' + ? `https://github.com/${GITHUB_REPO}/releases/tag/v${rawVersion}` + : `https://github.com/${GITHUB_REPO}/releases`; + + const tooltipText = updateAvailable && latestVersion + ? `${version}${commit ? ` (${commit})` : ''} — Update available: v${latestVersion}` + : commit ? `${version} (${commit})` : version; return ( -
    {version} -
    + {updateAvailable && latestVersion && ( + + + + + + v{latestVersion} + + )} + ); } diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 960851b..fe583ea 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -8,6 +8,10 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useRef } from 'react'; import { isTokenExpired, getRefreshTimeMs } from '@/lib/utils/jwt-client'; +interface UserPermissions { + interactiveSearch: boolean; +} + interface User { id: string; plexId: string; @@ -16,6 +20,7 @@ interface User { role: string; avatarUrl?: string; authProvider?: string | null; // 'plex' | 'oidc' | 'local' | null + permissions?: UserPermissions; } interface AuthContextType { @@ -73,7 +78,26 @@ export function AuthProvider({ children }: { children: ReactNode }) { const storedRefreshToken = localStorage.getItem('refreshToken'); if (storedRefreshToken && !isTokenExpired(storedRefreshToken)) { // Refresh token is still valid, attempt refresh - refreshTokenInternal(storedRefreshToken).finally(() => { + refreshTokenInternal(storedRefreshToken).then(() => { + // Fetch fresh user data from server to pick up role changes, + // avatar updates, etc. - mirrors the non-expired path below. + const currentToken = localStorage.getItem('accessToken'); + if (currentToken) { + fetch('/api/auth/me', { + headers: { 'Authorization': `Bearer ${currentToken}` }, + }) + .then((res) => res.json()) + .then((data) => { + if (data.user) { + setUser(data.user); + localStorage.setItem('user', JSON.stringify(data.user)); + } + }) + .catch((error) => { + console.error('Failed to fetch fresh user data:', error); + }); + } + }).finally(() => { setIsLoading(false); }); return; @@ -135,6 +159,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAccessToken(data.accessToken); localStorage.setItem('accessToken', data.accessToken); + // Restore user state from localStorage if not already in React state. + // This is critical for the mount-time refresh path: when the access + // token has expired but the refresh token is still valid, the mount + // effect calls refreshTokenInternal without ever calling setUser, + // leaving user as null and the app appearing logged-out. + const storedUserData = localStorage.getItem('user'); + if (storedUserData) { + setUser(JSON.parse(storedUserData)); + } + // Schedule next refresh scheduleTokenRefresh(data.accessToken); } else { diff --git a/src/lib/constants/audio-formats.ts b/src/lib/constants/audio-formats.ts new file mode 100644 index 0000000..ce528db --- /dev/null +++ b/src/lib/constants/audio-formats.ts @@ -0,0 +1,69 @@ +/** + * Component: Audio Format Constants + * Documentation: documentation/phase3/file-organization.md + * + * Centralized audio format definitions used across the application. + * Add new formats here to enable support in all subsystems. + */ + +/** + * All supported audio file extensions for audiobook detection and file organization. + * Used by: file-organizer.ts, files-hash.ts + */ +export const AUDIO_EXTENSIONS = [ + '.m4b', + '.m4a', + '.mp3', + '.mp4', + '.aa', + '.aax', + '.flac', + '.ogg', +] as const; + +/** + * Audio formats supported by the chapter merger (FFmpeg concat + M4B output). + * Formats here can be detected, probed, ordered, and merged into a single M4B. + * Note: .aa/.aax excluded (DRM-protected, cannot be decoded by FFmpeg without keys). + * Note: .ogg excluded (FFmpeg concat demuxer does not support Ogg container). + */ +export const CHAPTER_MERGE_FORMATS = [ + '.mp3', + '.m4a', + '.m4b', + '.mp4', + '.aac', + '.flac', +] as const; + +/** + * Audio formats supported by metadata tagging via FFmpeg. + * Each format maps to a specific FFmpeg output format flag and tagging strategy. + */ +export const METADATA_TAG_FORMATS = [ + '.m4b', + '.m4a', + '.mp3', + '.mp4', + '.flac', +] as const; + +/** + * Formats that use MP4/M4A container tags (iTunes-style metadata). + * These use `-f mp4` output format in FFmpeg. + */ +export const MP4_CONTAINER_FORMATS = ['.m4b', '.m4a', '.mp4'] as const; + +/** + * Audio format identifiers detectable in torrent/NZB titles. + * Used by Prowlarr service for metadata extraction and ranking algorithm for scoring. + */ +export const TORRENT_TITLE_FORMATS = ['M4B', 'M4A', 'MP3', 'FLAC'] as const; + +export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number]; + +/** + * Type helper for the format field on TorrentResult. + * 'OTHER' is used when no recognized format is detected in the title. + */ +export type AudioFormat = TorrentTitleFormat | 'OTHER'; diff --git a/src/lib/integrations/nzbget.service.ts b/src/lib/integrations/nzbget.service.ts new file mode 100644 index 0000000..8cd3678 --- /dev/null +++ b/src/lib/integrations/nzbget.service.ts @@ -0,0 +1,925 @@ +/** + * Component: NZBGet Integration Service + * Documentation: documentation/phase3/download-clients.md + */ + +import axios, { AxiosInstance } from 'axios'; +import https from 'https'; +import zlib from 'zlib'; +import { RMABLogger } from '@/lib/utils/logger'; +import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper'; +import { + IDownloadClient, + DownloadClientType, + ProtocolType, + DownloadInfo, + DownloadStatus, + AddDownloadOptions, + ConnectionTestResult, +} from '../interfaces/download-client.interface'; + +const logger = RMABLogger.create('NZBGet'); + +// ========================================================================= +// NZBGet-specific types +// ========================================================================= + +/** NZBGet queue group item from listgroups() */ +interface NZBGetGroupItem { + NZBID: number; + NZBName: string; + Status: string; + FileSizeMB: number; + DownloadedSizeMB: number; + RemainingSizeMB: number; + DownloadTimeSec: number; + Category: string; + DestDir: string; + FinalDir: string; + MaxPriority: number; + ActiveDownloads: number; + Health: number; + PostInfoText: string; + PostStageProgress: number; +} + +/** NZBGet history item from history() */ +interface NZBGetHistoryItem { + NZBID: number; + Name: string; + Status: string; + Category: string; + FileSizeMB: number; + DownloadedSizeMB: number; + DestDir: string; + FinalDir: string; + DownloadTimeSec: number; + PostTotalTimeSec: number; + ParStatus: string; + UnpackStatus: string; + DeleteStatus: string; + MarkStatus: string; + HistoryTime: number; + FailedArticles: number; + TotalArticles: number; +} + +/** NZBGet config entry from config() */ +interface NZBGetConfigItem { + Name: string; + Value: string; +} + +/** NZBGet status response from status() */ +interface NZBGetStatus { + DownloadRate: number; + RemainingSizeMB: number; + DownloadedSizeMB: number; + DownloadPaused: boolean; + ServerStandBy: boolean; +} + +/** Internal NZB info (normalized before mapping to DownloadInfo) */ +interface NZBInfo { + nzbId: string; + name: string; + size: number; + bytesDownloaded: number; + progress: number; + status: DownloadStatus; + downloadSpeed: number; + eta: number; + category: string; + downloadPath?: string; + completedAt?: Date; + errorMessage?: string; +} + +// ========================================================================= +// NZBGet Service +// ========================================================================= + +export class NZBGetService implements IDownloadClient { + readonly clientType: DownloadClientType = 'nzbget'; + readonly protocol: ProtocolType = 'usenet'; + + private client: AxiosInstance; + private baseUrl: string; + private username: string; + private password: string; + private defaultCategory: string; + private defaultDownloadDir: string; + private disableSSLVerify: boolean; + private httpsAgent?: https.Agent; + private pathMappingConfig: PathMappingConfig; + + constructor( + baseUrl: string, + username: string, + password: string, + defaultCategory: string = 'readmeabook', + defaultDownloadDir: string = '/downloads', + disableSSLVerify: boolean = false, + pathMappingConfig?: PathMappingConfig + ) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.username = username || ''; + this.password = password || ''; + this.defaultCategory = defaultCategory; + this.defaultDownloadDir = defaultDownloadDir; + this.disableSSLVerify = disableSSLVerify; + this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' }; + + if (this.disableSSLVerify && this.baseUrl.startsWith('https')) { + this.httpsAgent = new https.Agent({ + rejectUnauthorized: false, + }); + } + + this.client = axios.create({ + baseURL: this.baseUrl, + timeout: 30000, + httpsAgent: this.httpsAgent, + auth: { + username: this.username, + password: this.password, + }, + }); + } + + // ========================================================================= + // JSON-RPC Communication + // ========================================================================= + + /** + * Make a JSON-RPC call to NZBGet. + * All NZBGet API calls go through POST /jsonrpc with Basic Auth. + */ + private async rpc(method: string, params: any[] = []): Promise { + const response = await this.client.post('/jsonrpc', { + method, + params, + }); + + if (response.data?.error) { + const errorMsg = typeof response.data.error === 'string' + ? response.data.error + : response.data.error.message || JSON.stringify(response.data.error); + throw new Error(`NZBGet RPC error (${method}): ${errorMsg}`); + } + + return response.data?.result; + } + + // ========================================================================= + // IDownloadClient Implementation + // ========================================================================= + + /** + * Test connection to NZBGet + */ + async testConnection(): Promise { + try { + const version = await this.rpc('version'); + + if (!version) { + return { + success: false, + message: 'Connected but failed to get NZBGet version', + }; + } + + return { + success: true, + version, + message: `Connected to NZBGet v${version}`, + }; + } catch (error) { + return { + success: false, + message: this.formatConnectionError(error), + }; + } + } + + /** + * Add a download via the unified interface. + * Downloads the NZB file from the source URL and uploads to NZBGet via append(). + */ + async addDownload(url: string, options?: AddDownloadOptions): Promise { + logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`); + + const category = options?.category || this.defaultCategory; + + // Ensure category exists with correct path before every download + // (Matches SABnzbd/qBittorrent behavior — lightweight config read + conditional write) + await this.ensureCategory(); + + // Download the NZB file content from the source URL (Prowlarr proxy) + let nzbBuffer: Buffer; + let filename: string; + + try { + logger.info('Downloading NZB file from source URL...'); + + const nzbResponse = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 30000, + maxRedirects: 5, + httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined, + }); + + nzbBuffer = Buffer.from(nzbResponse.data); + + if (nzbBuffer.length === 0) { + throw new Error('NZB file is empty (0 bytes)'); + } + + logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`); + + // Detect and decompress gzip-compressed NZB files + // Prowlarr/indexers may serve .nzb.gz files which need decompression before upload + if (nzbBuffer[0] === 0x1f && nzbBuffer[1] === 0x8b) { + logger.info('NZB file is gzip-compressed, decompressing...'); + nzbBuffer = zlib.gunzipSync(nzbBuffer); + logger.info(`Decompressed NZB file: ${nzbBuffer.length} bytes`); + } + filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']); + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + if (status) { + throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`); + } + if (error.code === 'ECONNREFUSED') { + throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?'); + } + if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.'); + } + } + throw error; + } + + // Upload to NZBGet via append() + // Parameters: Filename, Content (base64), Category, Priority, AddToTop, AddPaused, + // DupeKey, DupeScore, DupeMode, AutoCategory, PPParameters + const base64Content = nzbBuffer.toString('base64'); + const priority = this.mapPriority(options?.priority); + + const nzbId = await this.rpc('append', [ + filename, // Filename + base64Content, // Content (base64-encoded NZB) + category, // Category + priority, // Priority (0=normal, 50=high, 100=very high, 900=force) + false, // AddToTop + options?.paused || false, // AddPaused + '', // DupeKey + 0, // DupeScore + 'FORCE', // DupeMode — RMAB manages its own lifecycle, skip NZBGet dupe detection + [], // PPParameters + ]); + + if (!nzbId || nzbId <= 0) { + // Log diagnostic info to help debug rejected NZBs + const contentPreview = nzbBuffer.slice(0, 100).toString('utf-8'); + logger.error('NZBGet rejected the NZB file', { + filename, + contentLength: nzbBuffer.length, + base64Length: base64Content.length, + contentPreview: contentPreview.substring(0, 80), + returnedId: nzbId, + }); + throw new Error('NZBGet rejected the NZB file'); + } + + const id = String(nzbId); + logger.info(`Added NZB: ${id} (${filename})`); + return id; + } + + /** + * Get current status of a download. + * Checks queue (listgroups) first, then history. + */ + async getDownload(id: string): Promise { + const nzbId = parseInt(id, 10); + if (isNaN(nzbId)) { + logger.error(`Invalid NZB ID: ${id}`); + return null; + } + + // Check queue first + const groups = await this.rpc('listgroups', [0]); + const groupItem = groups?.find(g => g.NZBID === nzbId); + + if (groupItem) { + return this.mapGroupToDownloadInfo(groupItem); + } + + // Not in queue, check history + const history = await this.rpc('history', [false]); + const historyItem = history?.find(h => h.NZBID === nzbId); + + if (historyItem) { + return this.mapHistoryToDownloadInfo(historyItem); + } + + return null; + } + + /** + * Pause a download via editqueue GroupPause + */ + async pauseDownload(id: string): Promise { + const nzbId = parseInt(id, 10); + const result = await this.rpc('editqueue', ['GroupPause', '', [nzbId]]); + if (!result) { + throw new Error(`Failed to pause download ${id}`); + } + logger.info(`Paused download: ${id}`); + } + + /** + * Resume a download via editqueue GroupResume + */ + async resumeDownload(id: string): Promise { + const nzbId = parseInt(id, 10); + const result = await this.rpc('editqueue', ['GroupResume', '', [nzbId]]); + if (!result) { + throw new Error(`Failed to resume download ${id}`); + } + logger.info(`Resumed download: ${id}`); + } + + /** + * Delete a download from NZBGet. + * Tries queue first (GroupFinalDelete), then history (HistoryFinalDelete). + */ + async deleteDownload(id: string, deleteFiles: boolean = false): Promise { + const nzbId = parseInt(id, 10); + logger.info(`Deleting download: ${id} (deleteFiles: ${deleteFiles})`); + + // Try deleting from queue first + const groups = await this.rpc('listgroups', [0]); + const inQueue = groups?.some(g => g.NZBID === nzbId); + + if (inQueue) { + const command = deleteFiles ? 'GroupFinalDelete' : 'GroupDelete'; + const result = await this.rpc('editqueue', [command, '', [nzbId]]); + if (!result) { + throw new Error(`Failed to delete download ${id} from queue`); + } + logger.info(`Deleted download ${id} from queue`); + return; + } + + // Try deleting from history + const command = deleteFiles ? 'HistoryFinalDelete' : 'HistoryDelete'; + const result = await this.rpc('editqueue', [command, '', [nzbId]]); + if (!result) { + throw new Error(`Failed to delete download ${id} from history`); + } + logger.info(`Deleted download ${id} from history`); + } + + /** + * Post-download cleanup: archive from NZBGet history. + * Uses HistoryDelete to hide the item from visible history (preserves in hidden archive). + * Analogous to SABnzbd's archive behavior. + */ + async postProcess(id: string): Promise { + const nzbId = parseInt(id, 10); + logger.info(`Archiving completed download from history: ${id}`); + + try { + const result = await this.rpc('editqueue', ['HistoryDelete', '', [nzbId]]); + if (!result) { + throw new Error(`NZBGet returned false for HistoryDelete`); + } + logger.info(`Successfully archived ${id} from history`); + } catch (error) { + logger.error(`Failed to archive ${id} from history`, { + error: error instanceof Error ? error.message : String(error), + }); + throw new Error(`NZB ${id} not found in history or failed to archive`); + } + } + + // ========================================================================= + // Category Management + // ========================================================================= + + /** + * Ensure the category exists in NZBGet with the correct download path. + * + * NZBGet categories are config entries (Category1.Name, Category1.DestDir, etc.). + * Reads existing config, checks for our category, creates/updates via saveconfig(). + * + * CRITICAL: NZBGet's saveconfig() does a FULL config replacement — passing only + * our entries would wipe every other setting and destroy the instance. We must + * always read the full config, merge our changes, and write the entire config back. + * + * After creating a new category, we call reload() so NZBGet picks up the new + * category DestDir immediately. reload() is safe when the config is correct. + * + * Called before every download (matches SABnzbd/qBittorrent pattern). + * Lightweight: reads config, writes only if category is missing or path changed. + */ + async ensureCategory(): Promise { + try { + logger.debug('ensureCategory() called - syncing category with NZBGet'); + + const config = await this.rpc('config'); + if (!config) { + logger.warn('Failed to get NZBGet config, skipping category check'); + return; + } + + // Find the main DestDir (NZBGet's base download directory) + const destDirEntry = config.find(c => c.Name === 'DestDir'); + const nzbgetDestDir = destDirEntry?.Value || ''; + + logger.debug('NZBGet config retrieved', { + destDir: nzbgetDestDir || '(not configured)', + }); + + // Apply reverse path mapping to get the path from NZBGet's perspective + const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig); + + logger.debug('Category path calculation', { + rmabDownloadDir: this.defaultDownloadDir, + desiredPathForNZBGet: desiredPath, + nzbgetDestDir, + pathMappingEnabled: this.pathMappingConfig.enabled, + }); + + // Find existing categories and our category slot + const { existingSlot, nextSlot } = this.findCategorySlot(config, this.defaultCategory); + + if (existingSlot !== null) { + // Category exists - check if DestDir needs updating + const currentDestDir = config.find(c => c.Name === `Category${existingSlot}.DestDir`)?.Value || ''; + + if (this.normalizePath(currentDestDir) !== this.normalizePath(desiredPath)) { + logger.info(`Updating category "${this.defaultCategory}" DestDir from "${currentDestDir}" to "${desiredPath}"`); + const updatedConfig = this.mergeConfigEntries(config, [ + { Name: `Category${existingSlot}.DestDir`, Value: desiredPath }, + ]); + await this.rpc('saveconfig', [updatedConfig]); + await this.reloadAndWait(); + } else { + logger.debug(`Category "${this.defaultCategory}" already configured correctly`); + } + } else { + // Create new category — merge into full config so we don't wipe existing settings + logger.info(`Creating category "${this.defaultCategory}" in slot ${nextSlot} with DestDir: "${desiredPath}"`); + const updatedConfig = this.mergeConfigEntries(config, [ + { Name: `Category${nextSlot}.Name`, Value: this.defaultCategory }, + { Name: `Category${nextSlot}.DestDir`, Value: desiredPath }, + { Name: `Category${nextSlot}.Unpack`, Value: 'yes' }, + ]); + await this.rpc('saveconfig', [updatedConfig]); + await this.reloadAndWait(); + } + } catch (error) { + logger.error('Failed to ensure category', { + error: error instanceof Error ? error.message : String(error), + }); + // Don't throw - category issues shouldn't block downloads + } + } + + /** + * Read-only entries returned by NZBGet's config() RPC that must NOT be + * written back via saveconfig(). These are runtime/system properties. + */ + private static readonly READ_ONLY_CONFIG_KEYS = new Set([ + 'ConfigFile', + 'AppBin', + 'AppDir', + 'Version', + ]); + + /** + * Merge new/updated config entries into the full NZBGet config. + * Returns a complete config array safe to pass to saveconfig(). + * + * Filters out read-only system entries (ConfigFile, AppBin, AppDir, Version) + * that config() returns but saveconfig() rejects. + * + * For entries that already exist (by Name), replaces the value. + * For new entries, appends them to the array. + */ + private mergeConfigEntries( + fullConfig: NZBGetConfigItem[], + changes: NZBGetConfigItem[] + ): NZBGetConfigItem[] { + const merged: NZBGetConfigItem[] = []; + + for (const entry of fullConfig) { + // Skip read-only system entries that saveconfig() rejects + if (NZBGetService.READ_ONLY_CONFIG_KEYS.has(entry.Name)) { + continue; + } + const override = changes.find(c => c.Name === entry.Name); + merged.push(override ? { Name: entry.Name, Value: override.Value } : { Name: entry.Name, Value: entry.Value }); + } + + // Append any entries that don't exist in the current config + for (const change of changes) { + if (!fullConfig.some(entry => entry.Name === change.Name)) { + merged.push({ Name: change.Name, Value: change.Value }); + } + } + + return merged; + } + + /** + * Find the category slot number for an existing category or determine the next available slot. + */ + private findCategorySlot( + config: NZBGetConfigItem[], + categoryName: string + ): { existingSlot: number | null; nextSlot: number } { + let maxSlot = 0; + let existingSlot: number | null = null; + + for (const entry of config) { + const match = entry.Name.match(/^Category(\d+)\.Name$/); + if (match) { + const slot = parseInt(match[1], 10); + if (slot > maxSlot) { + maxSlot = slot; + } + if (entry.Value === categoryName) { + existingSlot = slot; + } + } + } + + return { existingSlot, nextSlot: maxSlot + 1 }; + } + + /** + * Reload NZBGet so config changes (new categories, DestDir updates) take effect. + * Polls version() to confirm NZBGet is back online before continuing. + */ + private async reloadAndWait(): Promise { + try { + logger.info('Reloading NZBGet to apply configuration changes...'); + await this.rpc('reload'); + + const maxWait = 10000; + const pollInterval = 500; + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + try { + await this.rpc('version'); + logger.info('NZBGet reloaded successfully'); + return; + } catch { + // Still restarting, keep polling + } + } + + logger.warn('NZBGet did not respond after reload within 10s, continuing anyway'); + } catch (error) { + logger.warn('NZBGet reload request failed, config changes may require manual restart', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // ========================================================================= + // Status Mapping + // ========================================================================= + + /** + * Map NZBGet queue group item to unified DownloadInfo + */ + private async mapGroupToDownloadInfo(group: NZBGetGroupItem): Promise { + const totalBytes = group.FileSizeMB * 1024 * 1024; + const downloadedBytes = group.DownloadedSizeMB * 1024 * 1024; + const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1.0) : 0; + + // Get global download speed for active items + let downloadSpeed = 0; + let eta = 0; + const status = this.mapGroupStatus(group.Status); + + if (status === 'downloading') { + try { + const serverStatus = await this.rpc('status'); + downloadSpeed = serverStatus?.DownloadRate || 0; + const remainingBytes = group.RemainingSizeMB * 1024 * 1024; + eta = downloadSpeed > 0 ? Math.round(remainingBytes / downloadSpeed) : 0; + } catch { + // Non-critical: speed/eta will be 0 + } + } + + // Return raw download path (path mapping is applied downstream by the consumer) + const downloadPath = group.FinalDir || group.DestDir || undefined; + + return { + id: String(group.NZBID), + name: group.NZBName, + size: totalBytes, + bytesDownloaded: downloadedBytes, + progress, + status, + downloadSpeed, + eta, + category: group.Category || '', + downloadPath, + completedAt: undefined, + errorMessage: undefined, + seedingTime: undefined, + ratio: undefined, + }; + } + + /** + * Map NZBGet history item to unified DownloadInfo + */ + private mapHistoryToDownloadInfo(history: NZBGetHistoryItem): DownloadInfo { + const totalBytes = history.FileSizeMB * 1024 * 1024; + const downloadedBytes = history.DownloadedSizeMB * 1024 * 1024; + const status = this.mapHistoryStatus(history.Status); + + // Return raw download path (path mapping is applied downstream by the consumer) + const downloadPath = history.FinalDir || history.DestDir || undefined; + + return { + id: String(history.NZBID), + name: history.Name, + size: totalBytes, + bytesDownloaded: status === 'completed' ? totalBytes : downloadedBytes, + progress: status === 'completed' ? 1.0 : (totalBytes > 0 ? downloadedBytes / totalBytes : 0), + status, + downloadSpeed: 0, + eta: 0, + category: history.Category || '', + downloadPath, + completedAt: history.HistoryTime ? new Date(history.HistoryTime * 1000) : undefined, + errorMessage: status === 'failed' ? this.buildHistoryErrorMessage(history) : undefined, + seedingTime: undefined, + ratio: undefined, + }; + } + + /** + * Map NZBGet queue status string to unified DownloadStatus + */ + private mapGroupStatus(status: string): DownloadStatus { + switch (status) { + case 'QUEUED': + return 'queued'; + case 'PAUSED': + return 'paused'; + case 'DOWNLOADING': + case 'FETCHING': + return 'downloading'; + case 'PP_QUEUED': + case 'LOADING_PARS': + case 'VERIFYING_SOURCES': + case 'REPAIRING': + case 'VERIFYING_REPAIRED': + case 'RENAMING': + case 'UNPACKING': + case 'MOVING': + case 'POST_UNPACK_RENAMING': + case 'EXECUTING_SCRIPT': + case 'PP_FINISHED': + return 'processing'; + default: + logger.warn(`Unknown NZBGet queue status: ${status}, defaulting to downloading`); + return 'downloading'; + } + } + + /** + * Map NZBGet history status string to unified DownloadStatus. + * History statuses have format: "PREFIX/DETAIL" (e.g., "SUCCESS/ALL", "FAILURE/PAR") + */ + private mapHistoryStatus(status: string): DownloadStatus { + const prefix = status.split('/')[0]; + + switch (prefix) { + case 'SUCCESS': + return 'completed'; + case 'WARNING': + // WARNING means the download succeeded but post-processing had issues + // From RMAB's perspective, the download is still completed + return 'completed'; + case 'FAILURE': + return 'failed'; + case 'DELETED': + return 'failed'; + default: + logger.warn(`Unknown NZBGet history status: ${status}, defaulting to failed`); + return 'failed'; + } + } + + /** + * Build a descriptive error message from NZBGet history item + */ + private buildHistoryErrorMessage(history: NZBGetHistoryItem): string { + const parts: string[] = []; + + // Include the raw status for context + parts.push(history.Status); + + if (history.ParStatus && history.ParStatus !== 'NONE' && history.ParStatus !== 'SUCCESS') { + parts.push(`Par: ${history.ParStatus}`); + } + if (history.UnpackStatus && history.UnpackStatus !== 'NONE' && history.UnpackStatus !== 'SUCCESS') { + parts.push(`Unpack: ${history.UnpackStatus}`); + } + if (history.DeleteStatus && history.DeleteStatus !== 'NONE') { + parts.push(`Delete: ${history.DeleteStatus}`); + } + + // Article failure info + if (history.FailedArticles > 0) { + const failPercent = history.TotalArticles > 0 + ? Math.round((history.FailedArticles / history.TotalArticles) * 100) + : 0; + parts.push(`${history.FailedArticles} failed articles (${failPercent}%)`); + } + + return parts.join(' | '); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Extract a usable filename for the NZB upload. + * Tries Content-Disposition header first, then URL path, then falls back to a default. + */ + private extractNZBFilename(url: string, contentDisposition?: string): string { + if (contentDisposition) { + const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i); + if (match?.[1]) { + const decoded = decodeURIComponent(match[1].replace(/"+$/, '')); + if (decoded) { + return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`; + } + } + } + + try { + const urlPath = new URL(url).pathname; + const basename = urlPath.split('/').pop(); + if (basename && basename.length > 0 && basename !== 'download') { + const decoded = decodeURIComponent(basename); + return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`; + } + } catch { + // URL parsing failed + } + + return 'download.nzb'; + } + + /** + * Map priority string to NZBGet priority integer. + * NZBGet priorities: -100 (very low), -50 (low), 0 (normal), 50 (high), 100 (very high), 900 (force) + */ + private mapPriority(priority?: string): number { + switch (priority) { + case 'force': + return 900; + case 'high': + return 50; + case 'low': + return -50; + case 'normal': + default: + return 0; + } + } + + /** + * Format connection error into a user-friendly message + */ + private formatConnectionError(error: unknown): string { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + if (status === 401) { + return 'Authentication failed. Check your NZBGet username and password (Settings → Security).'; + } + if (status === 403) { + return 'Access denied. Check your NZBGet credentials and access permissions.'; + } + if (error.code === 'ECONNREFUSED') { + return `Connection refused. Is NZBGet running and accessible at this URL?`; + } + if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + return 'Connection timed out. Check the URL and network connectivity.'; + } + if (error.message?.includes('certificate') || error.message?.includes('SSL') || error.message?.includes('TLS')) { + return 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.'; + } + } + + return error instanceof Error ? error.message : 'Unknown error'; + } + + /** + * Normalize a path for comparison (forward slashes, no trailing slash, lowercase) + */ + private normalizePath(p: string): string { + return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + } +} + +// ========================================================================= +// Singleton Factory +// ========================================================================= + +let nzbgetServiceInstance: NZBGetService | null = null; +let configLoaded = false; + +export async function getNZBGetService(): Promise { + if (nzbgetServiceInstance && configLoaded) { + return nzbgetServiceInstance; + } + + try { + const { getConfigService } = await import('../services/config.service'); + const { getDownloadClientManager } = await import('../services/download-client-manager.service'); + const configService = await getConfigService(); + const manager = getDownloadClientManager(configService); + + logger.info('Loading configuration from download client manager...'); + const clientConfig = await manager.getClientForProtocol('usenet'); + + if (!clientConfig) { + throw new Error('NZBGet is not configured. Please configure an NZBGet client in the admin settings.'); + } + + if (clientConfig.type !== 'nzbget') { + throw new Error(`Expected NZBGet client but found ${clientConfig.type}`); + } + + const baseDir = await configService.get('download_dir') || '/downloads'; + const downloadDir = clientConfig.customPath + ? require('path').join(baseDir, clientConfig.customPath) + : baseDir; + + const pathMappingConfig: PathMappingConfig = { + enabled: clientConfig.remotePathMappingEnabled || false, + remotePath: clientConfig.remotePath || '', + localPath: clientConfig.localPath || '', + }; + + logger.info('Config loaded:', { + name: clientConfig.name, + hasUrl: !!clientConfig.url, + hasPassword: !!clientConfig.password, + disableSSLVerify: clientConfig.disableSSLVerify, + downloadDir, + pathMappingEnabled: pathMappingConfig.enabled, + }); + + if (!clientConfig.url || !clientConfig.password) { + throw new Error('NZBGet is not fully configured. Please check your configuration in admin settings.'); + } + + nzbgetServiceInstance = new NZBGetService( + clientConfig.url, + clientConfig.username || '', + clientConfig.password, + clientConfig.category || 'readmeabook', + downloadDir, + clientConfig.disableSSLVerify, + pathMappingConfig + ); + + await nzbgetServiceInstance.ensureCategory(); + + configLoaded = true; + return nzbgetServiceInstance; + } catch (error) { + logger.error('Failed to initialize service', { + error: error instanceof Error ? error.message : String(error), + }); + nzbgetServiceInstance = null; + configLoaded = false; + throw error; + } +} + +export function invalidateNZBGetService(): void { + nzbgetServiceInstance = null; + configLoaded = false; + logger.info('Service singleton invalidated'); +} diff --git a/src/lib/integrations/prowlarr.service.ts b/src/lib/integrations/prowlarr.service.ts index 97a142f..44f9044 100644 --- a/src/lib/integrations/prowlarr.service.ts +++ b/src/lib/integrations/prowlarr.service.ts @@ -121,11 +121,6 @@ export class ProwlarrService { filters?: SearchFilters ): Promise { try { - // Get configured download client type to determine if we should filter by category - const { getConfigService } = await import('../services/config.service'); - const configService = getConfigService(); - const clientType = (await configService.get('download_client_type')) || 'qbittorrent'; - // Determine which categories to search // Priority: filters.categories > filters.category > defaultCategory let categoriesToSearch: number[]; @@ -560,20 +555,22 @@ export class ProwlarrService { * Extract audiobook metadata from torrent title */ private extractMetadata(title: string): { - format?: 'M4B' | 'M4A' | 'MP3'; + format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC'; bitrate?: string; hasChapters?: boolean; } { const upperTitle = title.toUpperCase(); // Detect format - let format: 'M4B' | 'M4A' | 'MP3' | undefined; + let format: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | undefined; if (upperTitle.includes('M4B')) { format = 'M4B'; } else if (upperTitle.includes('M4A')) { format = 'M4A'; } else if (upperTitle.includes('MP3')) { format = 'MP3'; + } else if (upperTitle.includes('FLAC')) { + format = 'FLAC'; } // Detect bitrate (e.g., "64kbps", "128 KBPS") diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 63dcb46..0bf3ab9 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -5,10 +5,20 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; +import path from 'path'; import * as parseTorrentModule from 'parse-torrent'; import FormData from 'form-data'; import { RMABLogger } from '../utils/logger'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; +import { + IDownloadClient, + DownloadClientType, + ProtocolType, + DownloadInfo, + DownloadStatus, + AddDownloadOptions, + ConnectionTestResult, +} from '../interfaces/download-client.interface'; // Handle both ESM and CommonJS imports const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule; @@ -59,7 +69,19 @@ export type TorrentState = | 'checkingUP' | 'error' | 'missingFiles' - | 'allocating'; + | 'allocating' + // Forced states (user clicked "Force Resume" in qBittorrent UI) + | 'forcedDL' + | 'forcedUP' + // Metadata fetching states + | 'metaDL' + | 'forcedMetaDL' + // qBittorrent v5.0+ renamed paused → stopped + | 'stoppedDL' + | 'stoppedUP' + // Other states + | 'checkingResumeData' + | 'moving'; export interface TorrentFile { name: string; @@ -78,7 +100,10 @@ export interface DownloadProgress { state: string; } -export class QBittorrentService { +export class QBittorrentService implements IDownloadClient { + readonly clientType: DownloadClientType = 'qbittorrent'; + readonly protocol: ProtocolType = 'torrent'; + private client: AxiosInstance; private baseUrl: string; private username: string; @@ -209,7 +234,7 @@ export class QBittorrentService { /** * Add torrent (magnet link or file URL) - Enterprise Implementation */ - async addTorrent(url: string, options?: AddTorrentOptions): Promise { + async addTorrent(url: string, options?: AddTorrentOptions, retried = false): Promise { // Validate URL parameter if (!url || typeof url !== 'string' || url.trim() === '') { logger.error('Invalid download URL', { url }); @@ -236,11 +261,11 @@ export class QBittorrentService { return await this.addTorrentFile(url, category, options); } } catch (error) { - // Try re-authenticating if we get a 403 - if (axios.isAxiosError(error) && error.response?.status === 403) { + // Try re-authenticating once if we get a 403 + if (!retried && axios.isAxiosError(error) && error.response?.status === 403) { logger.info('[QBittorrent] Session expired, re-authenticating...'); await this.login(); - return this.addTorrent(url, options); // Retry once + return this.addTorrent(url, options, true); } logger.error('Failed to add torrent', { error: error instanceof Error ? error.message : String(error) }); @@ -279,12 +304,17 @@ export class QBittorrentService { const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig); // Upload via 'urls' parameter + // Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's + // global seeding rules don't remove the torrent prematurely. + // RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor. const form = new URLSearchParams({ urls: magnetUrl, savepath: remoteSavePath, category, paused: options?.paused ? 'true' : 'false', sequentialDownload: (options?.sequentialDownload !== false).toString(), + ratioLimit: '-1', + seedingTimeLimit: '-1', }); if (options?.tags) { @@ -432,6 +462,9 @@ export class QBittorrentService { formData.append('category', category); formData.append('paused', options?.paused ? 'true' : 'false'); formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString()); + // Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle + formData.append('ratioLimit', '-1'); + formData.append('seedingTimeLimit', '-1'); if (options?.tags) { formData.append('tags', options.tags.join(',')); @@ -729,13 +762,28 @@ export class QBittorrentService { /** * Test connection to qBittorrent */ - async testConnection(): Promise { + async testConnection(): Promise { try { await this.login(); - return true; + + // Fetch version after successful login + let version: string | undefined; + try { + const versionResponse = await this.client.get('/app/version', { + headers: { Cookie: this.cookie }, + }); + const raw = versionResponse.data || ''; + version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined; + } catch { + // Version fetch is non-critical - connection is still valid + logger.debug('Could not fetch qBittorrent version'); + } + + return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` }; } catch (error) { - logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) }); - return false; + const message = error instanceof Error ? error.message : 'Connection failed'; + logger.error('Connection test failed', { error: message }); + return { success: false, message }; } } @@ -835,7 +883,8 @@ export class QBittorrentService { version: versionResponse.data, }); - return versionResponse.data || 'Connected'; + const rawVersion = versionResponse.data || ''; + return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected'; } catch (error) { if (axios.isAxiosError(error)) { logger.error('[QBittorrent] Test connection failed with axios error', { @@ -931,6 +980,144 @@ export class QBittorrentService { } } + // ========================================================================= + // IDownloadClient Implementation + // ========================================================================= + + /** + * Add a download via the unified interface. + * Delegates to addTorrent with sensible defaults for audiobook downloads. + */ + async addDownload(url: string, options?: AddDownloadOptions): Promise { + return this.addTorrent(url, { + category: options?.category, + paused: options?.paused, + tags: ['audiobook'], + sequentialDownload: true, + }); + } + + /** + * Get download status via the unified interface. + * Includes retry logic to handle the race condition where a torrent + * isn't immediately available after being added. + */ + async getDownload(id: string): Promise { + const maxRetries = 3; + const initialDelayMs = 500; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const torrent = await this.getTorrent(id); + return this.mapTorrentToDownloadInfo(torrent); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + const isNotFound = message.includes('not found'); + + // If not a "not found" error, don't retry + if (!isNotFound) { + throw error; + } + + // If this is the last attempt, return null + if (attempt === maxRetries) { + return null; + } + + // Exponential backoff: 500ms, 1000ms, 2000ms + const delayMs = initialDelayMs * Math.pow(2, attempt); + logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + return null; + } + + /** Pause a download via the unified interface */ + async pauseDownload(id: string): Promise { + return this.pauseTorrent(id); + } + + /** Resume a download via the unified interface */ + async resumeDownload(id: string): Promise { + return this.resumeTorrent(id); + } + + /** Delete a download via the unified interface */ + async deleteDownload(id: string, deleteFiles: boolean = false): Promise { + return this.deleteTorrent(id, deleteFiles); + } + + /** + * Post-download cleanup via the unified interface. + * No-op for qBittorrent — torrents continue seeding until the + * cleanup-seeded-torrents job removes them after meeting seeding requirements. + */ + async postProcess(_id: string): Promise { + // No-op: torrents are managed by the seeding cleanup scheduler + } + + /** + * Map a TorrentInfo object to the unified DownloadInfo format. + */ + private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { + return { + id: torrent.hash, + name: torrent.name, + size: torrent.size, + bytesDownloaded: torrent.downloaded, + progress: torrent.progress, + status: this.mapStateToDownloadStatus(torrent.state), + downloadSpeed: torrent.dlspeed, + eta: torrent.eta, + category: torrent.category, + downloadPath: torrent.content_path || path.join(torrent.save_path, torrent.name), + completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined, + seedingTime: torrent.seeding_time, + ratio: torrent.ratio, + }; + } + + /** + * Map qBittorrent torrent state to unified DownloadStatus. + */ + private mapStateToDownloadStatus(state: TorrentState): DownloadStatus { + const stateMap: Record = { + downloading: 'downloading', + uploading: 'seeding', + stalledDL: 'downloading', + stalledUP: 'seeding', + pausedDL: 'paused', + pausedUP: 'paused', + queuedDL: 'queued', + queuedUP: 'seeding', + checkingDL: 'checking', + checkingUP: 'checking', + error: 'failed', + missingFiles: 'failed', + allocating: 'downloading', + // Forced states (user clicked "Force Resume" in qBittorrent UI) + forcedDL: 'downloading', + forcedUP: 'seeding', + // Metadata fetching states + metaDL: 'downloading', + forcedMetaDL: 'downloading', + // qBittorrent v5.0+ renamed paused → stopped + stoppedDL: 'paused', + stoppedUP: 'paused', + // Other states + checkingResumeData: 'checking', + moving: 'downloading', + }; + + return stateMap[state] || 'downloading'; + } + + // ========================================================================= + // Legacy Methods (used internally and by direct callers) + // ========================================================================= + /** * Get download progress details */ @@ -963,6 +1150,18 @@ export class QBittorrentService { error: 'failed', missingFiles: 'failed', allocating: 'downloading', + // Forced states (user clicked "Force Resume" in qBittorrent UI) + forcedDL: 'downloading', + forcedUP: 'completed', + // Metadata fetching states + metaDL: 'downloading', + forcedMetaDL: 'downloading', + // qBittorrent v5.0+ renamed paused → stopped + stoppedDL: 'paused', + stoppedUP: 'paused', + // Other states + checkingResumeData: 'checking', + moving: 'downloading', }; return stateMap[state] || 'unknown'; @@ -1032,8 +1231,11 @@ export async function getQBittorrentService(): Promise { throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.'); } - // Get download_dir from main config (not part of client config) - const downloadDir = await configService.get('download_dir') || '/downloads'; + // Get download_dir from main config, applying customPath if configured + const baseDir = await configService.get('download_dir') || '/downloads'; + const downloadDir = clientConfig.customPath + ? require('path').join(baseDir, clientConfig.customPath) + : baseDir; // Path mapping configuration const pathMappingConfig: PathMappingConfig = { @@ -1055,10 +1257,10 @@ export async function getQBittorrentService(): Promise { // Test connection logger.info('[QBittorrent] Testing connection...'); - const isConnected = await qbittorrentService.testConnection(); - if (!isConnected) { - logger.warn('[QBittorrent] Connection test failed'); - throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.'); + const connectionResult = await qbittorrentService.testConnection(); + if (!connectionResult.success) { + logger.warn('[QBittorrent] Connection test failed', { message: connectionResult.message }); + throw new Error(connectionResult.message || 'qBittorrent connection test failed. Please check your configuration in admin settings.'); } else { logger.info('[QBittorrent] Connection test successful'); configLoaded = true; // Mark as successfully loaded diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts index 3c700d5..8d4b2ea 100644 --- a/src/lib/integrations/sabnzbd.service.ts +++ b/src/lib/integrations/sabnzbd.service.ts @@ -5,8 +5,18 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; +import FormData from 'form-data'; import { RMABLogger } from '@/lib/utils/logger'; import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper'; +import { + IDownloadClient, + DownloadClientType, + ProtocolType, + DownloadInfo, + DownloadStatus, + AddDownloadOptions, + ConnectionTestResult, +} from '../interfaces/download-client.interface'; const logger = RMABLogger.create('SABnzbd'); @@ -81,7 +91,10 @@ export interface DownloadProgress { state: string; } -export class SABnzbdService { +export class SABnzbdService implements IDownloadClient { + readonly clientType: DownloadClientType = 'sabnzbd'; + readonly protocol: ProtocolType = 'usenet'; + private client: AxiosInstance; private baseUrl: string; private apiKey: string; @@ -123,13 +136,13 @@ export class SABnzbdService { /** * Test connection to SABnzbd */ - async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> { + async testConnection(): Promise { try { // Validate API key is not empty if (!this.apiKey || this.apiKey.trim() === '') { return { success: false, - error: 'API key is required for SABnzbd', + message: 'API key is required for SABnzbd', }; } @@ -151,7 +164,7 @@ export class SABnzbdService { const errorMsg = response.data?.error || 'Authentication failed'; return { success: false, - error: errorMsg.includes('API Key') + message: errorMsg.includes('API Key') ? 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).' : errorMsg, }; @@ -160,7 +173,7 @@ export class SABnzbdService { // Queue endpoint requires auth - if we got here, API key is valid // Now get the version const version = await this.getVersion(); - return { success: true, version }; + return { success: true, version, message: `Connected to SABnzbd v${version}` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -168,28 +181,28 @@ export class SABnzbdService { if (errorMessage.includes('ECONNREFUSED')) { return { success: false, - error: 'Connection refused. Is SABnzbd running and accessible at this URL?', + message: 'Connection refused. Is SABnzbd running and accessible at this URL?', }; } else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) { return { success: false, - error: 'Connection timed out. Check the URL and network connectivity.', + message: 'Connection timed out. Check the URL and network connectivity.', }; } else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) { return { success: false, - error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.', + message: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.', }; } else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) { return { success: false, - error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).', + message: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).', }; } return { success: false, - error: errorMessage, + message: errorMessage, }; } } @@ -447,8 +460,16 @@ export class SABnzbdService { } /** - * Add NZB by URL - * Returns the NZB ID + * Add NZB to SABnzbd + * + * Downloads the NZB file content from the source URL (typically a Prowlarr proxy URL) + * and uploads it directly to SABnzbd via mode=addfile. This ensures SABnzbd does not + * need network access to Prowlarr — RMAB acts as the intermediary, matching the pattern + * used by qBittorrent for .torrent files. + * + * @param url - NZB download URL (usually a Prowlarr proxy URL) + * @param options - Category, priority, and pause options + * @returns SABnzbd NZB ID (nzo_id) */ async addNZB(url: string, options?: AddNZBOptions): Promise { logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`); @@ -459,20 +480,70 @@ export class SABnzbdService { // This syncs the category path with SABnzbd's complete_dir and handles path mapping await this.ensureCategory(); - const response = await this.client.get('/api', { - params: { - mode: 'addurl', - name: url, - cat: category, - priority: this.mapPriority(options?.priority), - pp: '3', // Post-processing: +Repair, +Unpack, +Delete - output: 'json', - apikey: this.apiKey, - }, + // Download the NZB file content from the source URL + // This decouples SABnzbd from needing direct network access to Prowlarr + let nzbBuffer: Buffer; + let filename: string; + + try { + logger.info('Downloading NZB file from source URL...'); + + const nzbResponse = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 30000, + maxRedirects: 5, + // Use the same SSL settings as the SABnzbd client if the NZB URL + // happens to be served over HTTPS with a self-signed cert + httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined, + }); + + nzbBuffer = Buffer.from(nzbResponse.data); + + if (nzbBuffer.length === 0) { + throw new Error('NZB file is empty (0 bytes)'); + } + + logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`); + + // Extract filename from Content-Disposition header, URL path, or use fallback + filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']); + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + if (status) { + throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`); + } + if (error.code === 'ECONNREFUSED') { + throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?'); + } + if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.'); + } + } + throw error; + } + + // Upload NZB file content to SABnzbd via mode=addfile (multipart POST) + const formData = new FormData(); + formData.append('nzbfile', nzbBuffer, { + filename, + contentType: 'application/x-nzb', + }); + formData.append('mode', 'addfile'); + formData.append('cat', category); + formData.append('priority', this.mapPriority(options?.priority)); + formData.append('pp', '3'); // Post-processing: +Repair, +Unpack, +Delete + formData.append('output', 'json'); + formData.append('apikey', this.apiKey); + + const response = await this.client.post('/api', formData, { + headers: formData.getHeaders(), + maxBodyLength: Infinity, + maxContentLength: Infinity, }); if (response.data?.status === false) { - throw new Error(response.data.error || 'Failed to add NZB'); + throw new Error(response.data.error || 'Failed to add NZB to SABnzbd'); } const nzbIds = response.data?.nzo_ids; @@ -486,6 +557,39 @@ export class SABnzbdService { return nzbId; } + /** + * Extract a usable filename for the NZB upload. + * Tries Content-Disposition header first, then URL path, then falls back to a default. + */ + private extractNZBFilename(url: string, contentDisposition?: string): string { + // Try Content-Disposition header (e.g., 'attachment; filename="My.Audiobook.nzb"') + if (contentDisposition) { + const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i); + if (match?.[1]) { + const decoded = decodeURIComponent(match[1].replace(/"+$/, '')); + if (decoded) { + logger.debug(`Filename from Content-Disposition: ${decoded}`); + return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`; + } + } + } + + // Try extracting from URL path (before query params) + try { + const urlPath = new URL(url).pathname; + const basename = urlPath.split('/').pop(); + if (basename && basename.length > 0 && basename !== 'download') { + const decoded = decodeURIComponent(basename); + logger.debug(`Filename from URL path: ${decoded}`); + return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`; + } + } catch { + // URL parsing failed, fall through to default + } + + return 'download.nzb'; + } + /** * Get NZB info by ID * Checks queue first, then history @@ -663,6 +767,108 @@ export class SABnzbdService { } } + // ========================================================================= + // IDownloadClient Implementation + // ========================================================================= + + /** + * Add a download via the unified interface. + * Delegates to addNZB with mapped options. + */ + async addDownload(url: string, options?: AddDownloadOptions): Promise { + const priorityMap: Record = { + low: 'low', + normal: 'normal', + high: 'high', + force: 'force', + }; + + return this.addNZB(url, { + category: options?.category, + priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined, + paused: options?.paused, + }); + } + + /** + * Get download status via the unified interface. + * Checks both queue and history to find the NZB. + */ + async getDownload(id: string): Promise { + const nzbInfo = await this.getNZB(id); + if (!nzbInfo) { + return null; + } + return this.mapNZBInfoToDownloadInfo(nzbInfo); + } + + /** Pause a download via the unified interface */ + async pauseDownload(id: string): Promise { + return this.pauseNZB(id); + } + + /** Resume a download via the unified interface */ + async resumeDownload(id: string): Promise { + return this.resumeNZB(id); + } + + /** Delete a download via the unified interface */ + async deleteDownload(id: string, deleteFiles: boolean = false): Promise { + return this.deleteNZB(id, deleteFiles); + } + + /** + * Post-download cleanup via the unified interface. + * Archives the completed NZB from SABnzbd history. + */ + async postProcess(id: string): Promise { + await this.archiveCompletedNZB(id); + } + + /** + * Map NZBInfo to the unified DownloadInfo format. + */ + private mapNZBInfoToDownloadInfo(nzb: NZBInfo): DownloadInfo { + return { + id: nzb.nzbId, + name: nzb.name, + size: nzb.size, + bytesDownloaded: Math.round(nzb.size * nzb.progress), + progress: nzb.progress, + status: this.mapNZBStatusToDownloadStatus(nzb.status), + downloadSpeed: nzb.downloadSpeed, + eta: nzb.timeLeft, + category: nzb.category, + downloadPath: nzb.downloadPath, + completedAt: nzb.completedAt, + errorMessage: nzb.errorMessage, + // Usenet has no seeding concept + seedingTime: undefined, + ratio: undefined, + }; + } + + /** + * Map SABnzbd NZB status to unified DownloadStatus. + */ + private mapNZBStatusToDownloadStatus(status: NZBStatus): DownloadStatus { + const statusMap: Record = { + downloading: 'downloading', + queued: 'queued', + paused: 'paused', + extracting: 'processing', + completed: 'completed', + failed: 'failed', + repairing: 'processing', + }; + + return statusMap[status] || 'downloading'; + } + + // ========================================================================= + // Legacy Methods (used internally and by direct callers) + // ========================================================================= + /** * Get download progress from queue item */ @@ -796,8 +1002,11 @@ export async function getSABnzbdService(): Promise { throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`); } - // Get download_dir from main config - const downloadDir = await configService.get('download_dir') || '/downloads'; + // Get download_dir from main config, applying customPath if configured + const baseDir = await configService.get('download_dir') || '/downloads'; + const downloadDir = clientConfig.customPath + ? require('path').join(baseDir, clientConfig.customPath) + : baseDir; logger.debug('RMAB download_dir from config', { downloadDir }); diff --git a/src/lib/integrations/transmission.service.ts b/src/lib/integrations/transmission.service.ts new file mode 100644 index 0000000..21d609a --- /dev/null +++ b/src/lib/integrations/transmission.service.ts @@ -0,0 +1,628 @@ +/** + * Component: Transmission Integration Service + * Documentation: documentation/phase3/download-clients.md + */ + +import axios, { AxiosInstance } from 'axios'; +import https from 'https'; +import path from 'path'; +import * as parseTorrentModule from 'parse-torrent'; +import { RMABLogger } from '../utils/logger'; +import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; +import { + IDownloadClient, + DownloadClientType, + ProtocolType, + DownloadInfo, + DownloadStatus, + AddDownloadOptions, + ConnectionTestResult, +} from '../interfaces/download-client.interface'; + +// Handle both ESM and CommonJS imports +const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule; + +const logger = RMABLogger.create('Transmission'); + +/** Transmission RPC numeric status codes */ +type TransmissionStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +/** Transmission torrent fields we request */ +interface TransmissionTorrent { + hashString: string; + name: string; + totalSize: number; + downloadedEver: number; + percentDone: number; + status: TransmissionStatus; + rateDownload: number; + eta: number; + labels: string[]; + downloadDir: string; + doneDate: number; + errorString: string; + error: number; + secondsSeeding: number; + uploadRatio: number; + uploadedEver: number; +} + +/** Fields we request from the Transmission RPC API */ +const TORRENT_FIELDS = [ + 'hashString', + 'name', + 'totalSize', + 'downloadedEver', + 'percentDone', + 'status', + 'rateDownload', + 'eta', + 'labels', + 'downloadDir', + 'doneDate', + 'errorString', + 'error', + 'secondsSeeding', + 'uploadRatio', + 'uploadedEver', +]; + +export class TransmissionService implements IDownloadClient { + readonly clientType: DownloadClientType = 'transmission'; + readonly protocol: ProtocolType = 'torrent'; + + private client: AxiosInstance; + private baseUrl: string; + private username: string; + private password: string; + private defaultSavePath: string; + private defaultCategory: string; + private disableSSLVerify: boolean; + private httpsAgent?: https.Agent; + private pathMappingConfig: PathMappingConfig; + private sessionId: string = ''; + + constructor( + baseUrl: string, + username: string, + password: string, + defaultSavePath: string = '/downloads', + defaultCategory: string = 'readmeabook', + disableSSLVerify: boolean = false, + pathMappingConfig?: PathMappingConfig + ) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.username = username; + this.password = password; + this.defaultSavePath = defaultSavePath; + this.defaultCategory = defaultCategory; + this.disableSSLVerify = disableSSLVerify; + this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' }; + + if (disableSSLVerify && this.baseUrl.startsWith('https')) { + this.httpsAgent = new https.Agent({ rejectUnauthorized: false }); + logger.info('[Transmission] SSL certificate verification disabled'); + } + + this.client = axios.create({ + baseURL: this.baseUrl, + timeout: 30000, + httpsAgent: this.httpsAgent, + }); + } + + /** + * Execute an RPC request to Transmission. + * Handles CSRF token (409 → capture X-Transmission-Session-Id → retry). + */ + private async rpc(method: string, args?: Record): Promise { + const body = { method, arguments: args }; + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.sessionId) { + headers['X-Transmission-Session-Id'] = this.sessionId; + } + + // Add Basic Auth if credentials provided + const auth = this.username + ? { username: this.username, password: this.password } + : undefined; + + try { + const response = await this.client.post('/transmission/rpc', body, { headers, auth }); + return response.data; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 409) { + // Capture CSRF token and retry + const newSessionId = error.response.headers['x-transmission-session-id']; + if (newSessionId) { + this.sessionId = newSessionId; + headers['X-Transmission-Session-Id'] = this.sessionId; + const response = await this.client.post('/transmission/rpc', body, { headers, auth }); + return response.data; + } + } + throw error; + } + } + + // ========================================================================= + // IDownloadClient Implementation + // ========================================================================= + + async testConnection(): Promise { + try { + const data = await this.rpc('session-get', { fields: ['version'] }); + + if (data.result !== 'success') { + return { success: false, message: `Transmission RPC error: ${data.result}` }; + } + + const version = data.arguments?.version; + return { + success: true, + version, + message: `Connected to Transmission${version ? ` ${version}` : ''}`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Connection failed'; + + if (axios.isAxiosError(error)) { + const code = error.code; + const status = error.response?.status; + + if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || + code === 'CERT_HAS_EXPIRED' || code?.includes('CERT') || code?.includes('SSL')) { + return { success: false, message: `SSL certificate verification failed (${code}). Enable "Disable SSL Verification" if you trust this server.` }; + } + if (code === 'ECONNREFUSED') { + return { success: false, message: `Connection refused. Check if Transmission is running at: ${this.baseUrl}` }; + } + if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') { + return { success: false, message: `Connection timeout. Verify the URL is correct: ${this.baseUrl}` }; + } + if (code === 'ENOTFOUND') { + return { success: false, message: `Host not found. Verify the address: ${this.baseUrl}` }; + } + if (status === 401) { + return { success: false, message: 'Authentication failed. Check your username and password.' }; + } + } + + logger.error('Connection test failed', { error: message }); + return { success: false, message }; + } + } + + async addDownload(url: string, options?: AddDownloadOptions): Promise { + if (!url || typeof url !== 'string' || url.trim() === '') { + throw new Error('Invalid download URL: URL is required and must be a non-empty string'); + } + + const category = options?.category || this.defaultCategory; + + if (url.startsWith('magnet:')) { + return this.addMagnetLink(url, category, options); + } else { + return this.addTorrentFile(url, category, options); + } + } + + private async addMagnetLink( + magnetUrl: string, + category: string, + options?: AddDownloadOptions + ): Promise { + const infoHash = this.extractHashFromMagnet(magnetUrl); + if (!infoHash) { + throw new Error('Invalid magnet link - could not extract info_hash'); + } + + logger.info(`Extracted info_hash from magnet: ${infoHash}`); + + // Check for duplicates + try { + await this.getTorrentByHash(infoHash); + logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`); + return infoHash; + } catch { + // Torrent doesn't exist, continue + } + + const localSavePath = this.defaultSavePath; + const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig); + + const args: Record = { + filename: magnetUrl, + 'download-dir': remoteSavePath, + paused: options?.paused || false, + labels: [category], + }; + + logger.info('[Transmission] Adding magnet link...'); + const data = await this.rpc('torrent-add', args); + + if (data.result !== 'success') { + throw new Error(`Transmission rejected magnet link: ${data.result}`); + } + + // torrent-add returns torrent-added or torrent-duplicate + const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate']; + if (!added) { + throw new Error('Transmission did not return torrent info after adding'); + } + + // Override Transmission's global seeding rules — RMAB manages torrent lifecycle + await this.disableSeedLimits(added.hashString || infoHash); + + logger.info(`Successfully added magnet link: ${infoHash}`); + return infoHash; + } + + private async addTorrentFile( + torrentUrl: string, + category: string, + options?: AddDownloadOptions + ): Promise { + logger.info(`Downloading .torrent file from: ${torrentUrl}`); + + let torrentResponse; + try { + torrentResponse = await axios.get(torrentUrl, { + responseType: 'arraybuffer', + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status < 300, + timeout: 30000, + }); + + // Check if response body is a magnet link + if (torrentResponse.data.length > 0) { + const responseText = torrentResponse.data.toString(); + const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/); + if (magnetMatch) { + logger.info('Response body is a magnet link'); + return this.addMagnetLink(magnetMatch[0], category, options); + } + } + } catch (error) { + if (!axios.isAxiosError(error) || !error.response) { + throw error; + } + + const status = error.response.status; + + if (status >= 300 && status < 400) { + const location = error.response.headers['location']; + if (location && location.startsWith('magnet:')) { + return this.addMagnetLink(location, category, options); + } + if (location && (location.startsWith('http://') || location.startsWith('https://'))) { + try { + torrentResponse = await axios.get(location, { + responseType: 'arraybuffer', + timeout: 30000, + maxRedirects: 5, + }); + } catch { + throw new Error('Failed to download torrent file after redirect'); + } + } else { + throw new Error(`Invalid redirect location: ${location}`); + } + } else { + throw new Error(`Failed to download torrent: HTTP ${status}`); + } + } + + const torrentBuffer = Buffer.from(torrentResponse.data); + + let parsedTorrentData: any; + try { + parsedTorrentData = await parseTorrent(torrentBuffer); + } catch { + throw new Error('Invalid .torrent file - failed to parse'); + } + + const infoHash = parsedTorrentData.infoHash; + if (!infoHash) { + throw new Error('Failed to extract info_hash from .torrent file'); + } + + logger.info(`Extracted info_hash: ${infoHash}`); + + // Check for duplicates + try { + await this.getTorrentByHash(infoHash); + logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`); + return infoHash; + } catch { + // Torrent doesn't exist, continue + } + + const localSavePath = this.defaultSavePath; + const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig); + + // Transmission accepts base64-encoded .torrent content via 'metainfo' field + const metainfo = torrentBuffer.toString('base64'); + + const args: Record = { + metainfo, + 'download-dir': remoteSavePath, + paused: options?.paused || false, + labels: [category], + }; + + logger.info('[Transmission] Adding .torrent file...'); + const data = await this.rpc('torrent-add', args); + + if (data.result !== 'success') { + throw new Error(`Transmission rejected .torrent file: ${data.result}`); + } + + // torrent-add returns torrent-added or torrent-duplicate + const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate']; + + // Override Transmission's global seeding rules — RMAB manages torrent lifecycle + await this.disableSeedLimits(added?.hashString || infoHash); + + logger.info(`Successfully added torrent: ${infoHash}`); + return infoHash; + } + + async getDownload(id: string): Promise { + const maxRetries = 3; + const initialDelayMs = 500; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const torrent = await this.getTorrentByHash(id); + return this.mapToDownloadInfo(torrent); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (!message.includes('not found')) { + throw error; + } + if (attempt === maxRetries) { + return null; + } + const delayMs = initialDelayMs * Math.pow(2, attempt); + logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + return null; + } + + async pauseDownload(id: string): Promise { + try { + const torrent = await this.getTorrentByHash(id); + await this.rpc('torrent-stop', { ids: [torrent.hashString] }); + logger.info(`Paused torrent: ${id}`); + } catch (error) { + logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) }); + throw new Error('Failed to pause torrent'); + } + } + + async resumeDownload(id: string): Promise { + try { + const torrent = await this.getTorrentByHash(id); + await this.rpc('torrent-start', { ids: [torrent.hashString] }); + logger.info(`Resumed torrent: ${id}`); + } catch (error) { + logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) }); + throw new Error('Failed to resume torrent'); + } + } + + async deleteDownload(id: string, deleteFiles: boolean = false): Promise { + try { + const torrent = await this.getTorrentByHash(id); + await this.rpc('torrent-remove', { + ids: [torrent.hashString], + 'delete-local-data': deleteFiles, + }); + logger.info(`Deleted torrent: ${id}`); + } catch (error) { + logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) }); + throw new Error('Failed to delete torrent'); + } + } + + /** + * Post-download cleanup. + * No-op for Transmission — torrents continue seeding until the + * cleanup-seeded-torrents job removes them after meeting seeding requirements. + */ + async postProcess(_id: string): Promise { + // No-op: torrents are managed by the seeding cleanup scheduler + } + + // ========================================================================= + // Internal Helpers + // ========================================================================= + + /** + * Disable Transmission's global seed ratio and idle time limits for a torrent. + * Mode 2 = unlimited (ignore global settings). RMAB manages torrent lifecycle + * via the cleanup-seeded-torrents processor using per-indexer seeding times. + */ + private async disableSeedLimits(hashOrId: string): Promise { + try { + await this.rpc('torrent-set', { + ids: [hashOrId], + seedRatioMode: 2, + seedIdleMode: 2, + }); + logger.info(`Disabled seed limits for torrent: ${hashOrId}`); + } catch (error) { + // Non-fatal — torrent was still added, just might get cleaned up by Transmission's rules + logger.warn(`Failed to disable seed limits for torrent ${hashOrId}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get a torrent by its info hash. + */ + private async getTorrentByHash(hash: string): Promise { + const data = await this.rpc('torrent-get', { ids: [hash], fields: TORRENT_FIELDS }); + + if (data.result !== 'success') { + throw new Error(`Transmission RPC error: ${data.result}`); + } + + const torrents: TransmissionTorrent[] = data.arguments?.torrents || []; + if (torrents.length === 0) { + throw new Error(`Torrent ${hash} not found`); + } + + return torrents[0]; + } + + /** + * Map Transmission torrent to unified DownloadInfo. + */ + private mapToDownloadInfo(torrent: TransmissionTorrent): DownloadInfo { + // Return raw download path (path mapping is applied downstream by the consumer) + const downloadPath = path.join(torrent.downloadDir, torrent.name); + + return { + id: torrent.hashString, + name: torrent.name, + size: torrent.totalSize, + bytesDownloaded: torrent.downloadedEver, + progress: torrent.percentDone, + status: this.mapStatus(torrent.status, torrent.error), + downloadSpeed: torrent.rateDownload, + eta: torrent.eta < 0 ? 0 : torrent.eta, + category: torrent.labels?.[0] || '', + downloadPath, + completedAt: torrent.doneDate > 0 ? new Date(torrent.doneDate * 1000) : undefined, + errorMessage: torrent.error > 0 ? torrent.errorString : undefined, + seedingTime: torrent.secondsSeeding, + ratio: torrent.uploadRatio >= 0 ? torrent.uploadRatio : undefined, + }; + } + + /** + * Map Transmission numeric status to unified DownloadStatus. + * 0=stopped, 1=check-pending, 2=checking, 3=download-pending, + * 4=downloading, 5=seed-pending, 6=seeding + */ + private mapStatus(status: TransmissionStatus, errorCode: number): DownloadStatus { + if (errorCode > 0) { + return 'failed'; + } + + const statusMap: Record = { + 0: 'paused', + 1: 'checking', + 2: 'checking', + 3: 'queued', + 4: 'downloading', + 5: 'seeding', + 6: 'seeding', + }; + + return statusMap[status] || 'downloading'; + } + + /** + * Extract info_hash from magnet link. + */ + private extractHashFromMagnet(magnetUrl: string): string | null { + const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i); + if (match) { + return match[1].toLowerCase(); + } + return null; + } +} + +// Singleton factory (matches qBittorrent, SABnzbd, NZBGet pattern) +let transmissionServiceInstance: TransmissionService | null = null; +let configLoaded = false; + +export async function getTransmissionService(): Promise { + if (transmissionServiceInstance && configLoaded) { + return transmissionServiceInstance; + } + + try { + const { getConfigService } = await import('../services/config.service'); + const { getDownloadClientManager } = await import('../services/download-client-manager.service'); + const configService = await getConfigService(); + const manager = getDownloadClientManager(configService); + + logger.info('[Transmission] Loading configuration from download client manager...'); + const clientConfig = await manager.getClientForProtocol('torrent'); + + if (!clientConfig) { + throw new Error('Transmission is not configured. Please configure a Transmission client in the admin settings.'); + } + + if (clientConfig.type !== 'transmission') { + throw new Error(`Expected Transmission client but found ${clientConfig.type}`); + } + + const baseDir = await configService.get('download_dir') || '/downloads'; + const downloadDir = clientConfig.customPath + ? require('path').join(baseDir, clientConfig.customPath) + : baseDir; + + const pathMappingConfig: PathMappingConfig = { + enabled: clientConfig.remotePathMappingEnabled || false, + remotePath: clientConfig.remotePath || '', + localPath: clientConfig.localPath || '', + }; + + logger.info('[Transmission] Config loaded:', { + name: clientConfig.name, + hasUrl: !!clientConfig.url, + hasUsername: !!clientConfig.username, + hasPassword: !!clientConfig.password, + disableSSLVerify: clientConfig.disableSSLVerify, + downloadDir, + pathMappingEnabled: pathMappingConfig.enabled, + }); + + if (!clientConfig.url) { + throw new Error('Transmission is not fully configured. Please check your configuration in admin settings.'); + } + + transmissionServiceInstance = new TransmissionService( + clientConfig.url, + clientConfig.username || '', + clientConfig.password || '', + downloadDir, + clientConfig.category || 'readmeabook', + clientConfig.disableSSLVerify, + pathMappingConfig + ); + + const connectionResult = await transmissionServiceInstance.testConnection(); + if (!connectionResult.success) { + throw new Error(connectionResult.message || 'Transmission connection test failed. Please check your configuration in admin settings.'); + } + + logger.info('[Transmission] Connection test successful'); + configLoaded = true; + return transmissionServiceInstance; + } catch (error) { + logger.error('[Transmission] Failed to initialize service', { + error: error instanceof Error ? error.message : String(error), + }); + transmissionServiceInstance = null; + configLoaded = false; + throw error; + } +} + +export function invalidateTransmissionService(): void { + transmissionServiceInstance = null; + configLoaded = false; + logger.info('[Transmission] Service singleton invalidated'); +} diff --git a/src/lib/interfaces/download-client.interface.ts b/src/lib/interfaces/download-client.interface.ts new file mode 100644 index 0000000..cc67b37 --- /dev/null +++ b/src/lib/interfaces/download-client.interface.ts @@ -0,0 +1,180 @@ +/** + * Component: Download Client Interface + * Documentation: documentation/phase3/download-clients.md + * + * Defines the contract all download clients must implement. + * Enables protocol-agnostic download management across torrent and usenet clients. + */ + +// ========================================================================= +// TYPE DEFINITIONS +// ========================================================================= + +/** Supported download client types — single source of truth */ +export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const; + +/** Identifies the specific download client software */ +export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number]; + +/** Human-readable display names for each client type */ +export const CLIENT_DISPLAY_NAMES: Record = { + qbittorrent: 'qBittorrent', + sabnzbd: 'SABnzbd', + nzbget: 'NZBGet', + transmission: 'Transmission', +}; + +/** Get display name for a client type, falling back to the raw type */ +export function getClientDisplayName(type: string): string { + return CLIENT_DISPLAY_NAMES[type as DownloadClientType] || type; +} + +/** The download protocol a client operates on */ +export type ProtocolType = 'torrent' | 'usenet'; + +/** Maps each client type to its download protocol */ +export const CLIENT_PROTOCOL_MAP: Record = { + qbittorrent: 'torrent', + sabnzbd: 'usenet', + nzbget: 'usenet', + transmission: 'torrent', +}; + +/** Unified download status across all clients */ +export type DownloadStatus = + | 'downloading' + | 'completed' + | 'seeding' + | 'paused' + | 'queued' + | 'failed' + | 'processing' + | 'checking'; + +// ========================================================================= +// DATA INTERFACES +// ========================================================================= + +/** + * Unified download information returned by all clients. + * Normalizes torrent and NZB data into a single shape. + */ +export interface DownloadInfo { + /** Client-assigned identifier (torrent hash or NZB ID) */ + id: string; + /** Display name of the download */ + name: string; + /** Total size in bytes */ + size: number; + /** Bytes downloaded so far */ + bytesDownloaded: number; + /** Download progress from 0.0 to 1.0 */ + progress: number; + /** Normalized download status */ + status: DownloadStatus; + /** Current download speed in bytes/sec */ + downloadSpeed: number; + /** Estimated time remaining in seconds */ + eta: number; + /** Category/label assigned to this download */ + category: string; + /** Filesystem path where download is stored (available after completion) */ + downloadPath?: string; + /** When the download completed */ + completedAt?: Date; + /** Error message if download failed */ + errorMessage?: string; + /** Time spent seeding in seconds (torrent clients only) */ + seedingTime?: number; + /** Upload/download ratio (torrent clients only) */ + ratio?: number; +} + +/** Options for adding a new download */ +export interface AddDownloadOptions { + /** Category/label to assign */ + category?: string; + /** Priority level (interpretation varies by client) */ + priority?: string; + /** Whether to add in paused state */ + paused?: boolean; +} + +/** Result of a connection test */ +export interface ConnectionTestResult { + success: boolean; + message?: string; + version?: string; +} + +// ========================================================================= +// DOWNLOAD CLIENT INTERFACE +// ========================================================================= + +/** + * IDownloadClient — the contract every download client must implement. + * + * Provides a unified API for managing downloads across different protocols + * and client software. Processors interact with this interface exclusively, + * enabling new download clients to be added without modifying consumer code. + * + * To add a new client (e.g., Transmission): + * 1. Create a service class implementing IDownloadClient + * 2. Add the type to DownloadClientType + * 3. Add factory case in DownloadClientManager + */ +export interface IDownloadClient { + /** Identifies the client software (e.g., 'qbittorrent', 'sabnzbd') */ + readonly clientType: DownloadClientType; + /** The protocol this client operates on */ + readonly protocol: ProtocolType; + + /** + * Test the connection to the download client. + * @returns Connection test result with success/failure and optional version + */ + testConnection(): Promise; + + /** + * Add a new download. + * @param url - Download URL (magnet link, .torrent URL, or .nzb URL) + * @param options - Optional download settings + * @returns Client-assigned download ID (torrent hash or NZB ID) + */ + addDownload(url: string, options?: AddDownloadOptions): Promise; + + /** + * Get current status of a download. + * Includes retry logic for race conditions (e.g., torrent not immediately available after adding). + * @param id - Download ID returned by addDownload + * @returns Download info, or null if not found + */ + getDownload(id: string): Promise; + + /** + * Pause a download. + * @param id - Download ID + */ + pauseDownload(id: string): Promise; + + /** + * Resume a paused download. + * @param id - Download ID + */ + resumeDownload(id: string): Promise; + + /** + * Delete a download from the client. + * @param id - Download ID + * @param deleteFiles - Whether to also delete downloaded files (default: false) + */ + deleteDownload(id: string, deleteFiles?: boolean): Promise; + + /** + * Perform post-download cleanup specific to the client. + * - qBittorrent: No-op (torrents continue seeding, handled by cleanup job) + * - SABnzbd: Archives the completed NZB from history + * @param id - Download ID + */ + postProcess(id: string): Promise; +} diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index be6b6f9..2eb8bcd 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -220,3 +220,36 @@ export async function isLocalAdmin(userId: string): Promise { return user.isSetupAdmin && user.plexId.startsWith('local-'); } + +/** + * Middleware: Require setup to be incomplete + * Blocks access to setup-only endpoints after initial setup is finished. + * Returns 403 if setup is already complete, otherwise invokes the handler. + */ +export async function requireSetupIncomplete( + request: NextRequest, + handler: (request: NextRequest) => Promise +): Promise { + try { + const config = await prisma.configuration.findUnique({ + where: { key: 'setup_completed' }, + }); + + if (config?.value === 'true') { + logger.warn('Setup endpoint called after setup is complete', { + path: request.nextUrl.pathname, + }); + return NextResponse.json( + { + error: 'Forbidden', + message: 'Setup has already been completed', + }, + { status: 403 } + ); + } + } catch { + // If database is not ready, setup is definitely not complete — allow through + } + + return handler(request); +} diff --git a/src/lib/processors/cleanup-seeded-torrents.processor.ts b/src/lib/processors/cleanup-seeded-torrents.processor.ts index d0c9255..ef1b504 100644 --- a/src/lib/processors/cleanup-seeded-torrents.processor.ts +++ b/src/lib/processors/cleanup-seeded-torrents.processor.ts @@ -2,11 +2,13 @@ * Component: Cleanup Seeded Torrents Processor * Documentation: documentation/backend/services/scheduler.md * - * Cleans up torrents that have met their seeding requirements + * Cleans up downloads that have met their seeding requirements. + * Uses the IDownloadClient interface for client-agnostic operation. */ import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; +import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; export interface CleanupSeededTorrentsPayload { jobId?: string; @@ -22,7 +24,9 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent try { // Get indexer configuration with per-indexer seeding times const { getConfigService } = await import('../services/config.service'); + const { getDownloadClientManager } = await import('../services/download-client-manager.service'); const configService = getConfigService(); + const manager = getDownloadClientManager(configService); const indexersConfigStr = await configService.get('prowlarr_indexers'); if (!indexersConfigStr) { @@ -44,22 +48,28 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`); - // Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads) + // Find all completed requests + soft-deleted requests (orphaned downloads) // IMPORTANT: Only cleanup requests that are truly complete and not being actively processed // NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook) // Before deleting torrent, we check if other active requests are using it - // NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded + // NOTE: Ebooks downloaded via indexer search use torrent clients and need seeding cleanup too. + // Direct HTTP ebook downloads are naturally skipped (no torrent hash / unknown client type). const completedRequests = await prisma.request.findMany({ where: { - type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed) OR: [ - // Active requests that are fully available (scanned by Plex/ABS) + // Audiobook requests that are fully available (matched in Plex/ABS) { + type: 'audiobook', status: 'available', deletedAt: null, }, - // Soft-deleted requests (orphaned downloads) - // We'll check if torrent is shared with active requests before deletion + // Ebook requests that are fully downloaded (terminal state for ebooks) + { + type: 'ebook', + status: 'downloaded', + deletedAt: null, + }, + // Soft-deleted requests of any type (orphaned downloads) { deletedAt: { not: null }, }, @@ -78,11 +88,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent take: 100, // Limit to 100 requests per run }); - logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`); + logger.info(`Found ${completedRequests.length} requests to check (audiobook: available, ebook: downloaded, or soft-deleted)`); let cleaned = 0; let skipped = 0; let noConfig = 0; + const deletedHashes = new Set(); // Track torrents already deleted this run for (const request of completedRequests) { try { @@ -92,18 +103,27 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent continue; } - // Skip SABnzbd downloads - Usenet doesn't have seeding concept + // Skip Usenet downloads - no seeding concept if (downloadHistory.nzbId && !downloadHistory.torrentHash) { - // For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed) + // For soft-deleted Usenet requests, hard delete immediately (no seeding needed) if (request.deletedAt) { await prisma.request.delete({ where: { id: request.id } }); - logger.info(`Hard-deleted orphaned SABnzbd request ${request.id}`); + logger.info(`Hard-deleted orphaned Usenet request ${request.id}`); } continue; } - // Only process torrent downloads - if (!downloadHistory.torrentHash) { + // Only process downloads that have a client ID + if (!downloadHistory.downloadClientId && !downloadHistory.torrentHash) { + continue; + } + + // Determine the download client ID and protocol + const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash!; + const clientType = downloadHistory.downloadClient || 'qbittorrent'; + const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType]; + if (!protocol) { + logger.warn(`Unknown download client type: ${clientType}, skipping`); continue; } @@ -126,20 +146,40 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60; - // Get torrent info from qBittorrent to check seeding time - const { getQBittorrentService } = await import('../integrations/qbittorrent.service'); - const qbt = await getQBittorrentService(); + // Skip if this torrent was already deleted earlier in this run + if (deletedHashes.has(clientId.toLowerCase())) { + if (request.deletedAt) { + await prisma.request.delete({ where: { id: request.id } }); + logger.info(`Hard-deleted orphaned request ${request.id} (torrent already cleaned this run)`); + } + cleaned++; + continue; + } - let torrent; + // Get download info from the appropriate client via the interface + const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet'); + + if (!client) { + logger.warn(`No ${clientType} client configured, skipping request ${request.id}`); + skipped++; + continue; + } + + let downloadInfo; try { - torrent = await qbt.getTorrent(downloadHistory.torrentHash); + downloadInfo = await client.getDownload(clientId); } catch (error) { - // Torrent might already be deleted, skip + // Download not found in client (already removed), skip + continue; + } + + if (!downloadInfo) { + // Download not found in client (already removed) continue; } // Check if seeding time requirement is met - const actualSeedingTime = torrent.seeding_time || 0; + const actualSeedingTime = downloadInfo.seedingTime || 0; const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds; if (!hasMetRequirement) { @@ -148,47 +188,49 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent continue; } - logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`); + logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`); - // CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash - // This prevents deleting shared torrents when user re-requests the same audiobook - const otherActiveRequests = await prisma.request.findMany({ - where: { - id: { not: request.id }, // Exclude current request - type: 'audiobook', // Only check audiobook requests - deletedAt: null, // Only check active requests - downloadHistory: { - some: { - torrentHash: downloadHistory.torrentHash, - selected: true, + // CRITICAL: Check if any other active (non-deleted) request is using this same download + const hashToCheck = downloadHistory.torrentHash; + if (hashToCheck) { + const otherActiveRequests = await prisma.request.findMany({ + where: { + id: { not: request.id }, // Exclude current request + deletedAt: null, // Only check active requests + downloadHistory: { + some: { + torrentHash: hashToCheck, + selected: true, + }, }, }, - }, - select: { id: true, status: true }, - }); + select: { id: true, status: true }, + }); - if (otherActiveRequests.length > 0) { - logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`); + if (otherActiveRequests.length > 0) { + logger.info(`Skipping download deletion - ${otherActiveRequests.length} other active request(s) still using this download (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`); - // If this is a soft-deleted request, hard delete it but DON'T delete the torrent - if (request.deletedAt) { - await prisma.request.delete({ where: { id: request.id } }); - logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`); + // If this is a soft-deleted request, hard delete it but DON'T delete the download + if (request.deletedAt) { + await prisma.request.delete({ where: { id: request.id } }); + logger.info(`Hard-deleted orphaned request ${request.id} (kept shared download for active requests)`); + } + + skipped++; + continue; } - - skipped++; - continue; } - // Safe to delete - no other active requests using this torrent - await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files + // Safe to delete - no other active requests using this download + await client.deleteDownload(clientId, true); // true = delete files + deletedHashes.add(clientId.toLowerCase()); // If this is a soft-deleted request (orphaned download), hard delete it now if (request.deletedAt) { await prisma.request.delete({ where: { id: request.id } }); - logger.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`); + logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`); } else { - logger.info(`Deleted torrent and files for active request ${request.id}`); + logger.info(`Deleted download and files for active request ${request.id}`); } cleaned++; @@ -197,7 +239,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent } } - logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`); + logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`); return { success: true, diff --git a/src/lib/processors/direct-download.processor.ts b/src/lib/processors/direct-download.processor.ts index d4c819e..2dbdad4 100644 --- a/src/lib/processors/direct-download.processor.ts +++ b/src/lib/processors/direct-download.processor.ts @@ -78,7 +78,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay // Get download configuration const configService = getConfigService(); - const downloadsDir = await configService.get('downloads_dir') || '/downloads'; + const downloadsDir = await configService.get('download_dir') || '/downloads'; const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li'; const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub'; const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined; diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index c7647c0..eb0b5ae 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -5,8 +5,6 @@ import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service'; import { prisma } from '../db'; -import { getQBittorrentService } from '../integrations/qbittorrent.service'; -import { getSABnzbdService } from '../integrations/sabnzbd.service'; import { getConfigService } from '../services/config.service'; import { getDownloadClientManager } from '../services/download-client-manager.service'; import { ProwlarrService } from '../integrations/prowlarr.service'; @@ -14,7 +12,7 @@ import { RMABLogger } from '../utils/logger'; /** * Process download job - * Routes to appropriate download client based on configuration + * Routes to appropriate download client based on protocol detection * Adds selected result to download client and starts monitoring */ export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise { @@ -41,151 +39,85 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P }, }); - // Detect protocol from result and route to appropriate client + // Detect protocol from result and get appropriate client const isUsenet = ProwlarrService.isNZBResult(torrent); + const protocol = isUsenet ? 'usenet' : 'torrent'; const config = await getConfigService(); const manager = getDownloadClientManager(config); - const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent'); + const client = await manager.getClientServiceForProtocol(protocol); - if (!clientConfig) { - const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)'; - throw new Error(`No ${protocol} client configured`); + if (!client) { + throw new Error(`No ${protocol} download client configured. Please add a ${protocol} client in Settings > Download Clients.`); } - let downloadClientId: string; - let downloadClient: 'qbittorrent' | 'sabnzbd'; + // Get client config for category + const clientConfig = await manager.getClientForProtocol(protocol); + const category = clientConfig?.category || 'readmeabook'; - if (isUsenet) { - // Route to SABnzbd - logger.info(`Routing to SABnzbd`); + logger.info(`Routing to ${client.clientType} (${client.protocol})`); - const sabnzbd = await getSABnzbdService(); - downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, { - category: clientConfig.category || 'readmeabook', - priority: 'normal', - }); - downloadClient = 'sabnzbd'; + // Add download via unified interface + const downloadClientId = await client.addDownload(torrent.downloadUrl, { + category, + priority: 'normal', + }); - logger.info(`NZB added with ID: ${downloadClientId}`); + logger.info(`Download added with ID: ${downloadClientId}`); - // Create DownloadHistory record - // Determine indexer page URL - exclude magnet links from guid fallback - const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid); + // Create DownloadHistory record + // Determine indexer page URL - exclude magnet links from guid fallback + const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid); - const downloadHistory = await prisma.downloadHistory.create({ - data: { - requestId, - indexerName: torrent.indexer, - indexerId: torrent.indexerId, // Store indexer ID for configuration lookup - downloadClient: 'sabnzbd', - downloadClientId, - torrentName: torrent.title, - nzbId: downloadClientId, // Store NZB ID - torrentSizeBytes: torrent.size, - torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link) - magnetLink: torrent.downloadUrl, // Download URL (.nzb file) - seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency - leechers: 0, - downloadStatus: 'downloading', - selected: true, - startedAt: new Date(), - }, - }); - - logger.info(`Created download history record: ${downloadHistory.id}`); - - // Trigger monitor download job with initial delay - const jobQueue = getJobQueueService(); - await jobQueue.addMonitorJob( + const downloadHistory = await prisma.downloadHistory.create({ + data: { requestId, - downloadHistory.id, + indexerName: torrent.indexer, + indexerId: torrent.indexerId, + downloadClient: client.clientType, downloadClientId, - 'sabnzbd', - 3 // Wait 3 seconds before first check - ); + torrentName: torrent.title, + // Set protocol-specific ID fields for backward compatibility + torrentHash: client.protocol === 'torrent' ? (torrent.infoHash || downloadClientId) : undefined, + nzbId: client.protocol === 'usenet' ? downloadClientId : undefined, + torrentSizeBytes: torrent.size, + torrentUrl: indexerPageUrl, + magnetLink: torrent.downloadUrl, + seeders: torrent.seeders || 0, + leechers: torrent.leechers || 0, + downloadStatus: 'downloading', + selected: true, + startedAt: new Date(), + }, + }); - logger.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`); + logger.info(`Created download history record: ${downloadHistory.id}`); - return { - success: true, - message: 'NZB added to SABnzbd and monitoring started', - requestId, - downloadHistoryId: downloadHistory.id, - nzbId: downloadClientId, - torrent: { - title: torrent.title, - size: torrent.size, - format: torrent.format, - }, - }; - } else { - // Route to qBittorrent (default) - logger.info(`Routing to qBittorrent`); + // Trigger monitor download job with initial delay + const jobQueue = getJobQueueService(); + await jobQueue.addMonitorJob( + requestId, + downloadHistory.id, + downloadClientId, + client.clientType, + 3 // Wait 3 seconds before first check + ); - const qbt = await getQBittorrentService(); - downloadClientId = await qbt.addTorrent(torrent.downloadUrl, { - category: clientConfig.category || 'readmeabook', - tags: ['audiobook'], - sequentialDownload: true, - paused: false, - }); - downloadClient = 'qbittorrent'; + logger.info(`Started monitoring job for request ${requestId} (${client.clientType}, 3s initial delay)`); - logger.info(`Torrent added with hash: ${downloadClientId}`); - - // Create DownloadHistory record - // Determine indexer page URL - exclude magnet links from guid fallback - const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid); - - const downloadHistory = await prisma.downloadHistory.create({ - data: { - requestId, - indexerName: torrent.indexer, - indexerId: torrent.indexerId, // Store indexer ID for configuration lookup - downloadClient: 'qbittorrent', - downloadClientId, - torrentName: torrent.title, - torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash - torrentSizeBytes: torrent.size, - torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link) - magnetLink: torrent.downloadUrl, - seeders: torrent.seeders || 0, - leechers: torrent.leechers || 0, - downloadStatus: 'downloading', - selected: true, - startedAt: new Date(), - }, - }); - - logger.info(`Created download history record: ${downloadHistory.id}`); - - // Trigger monitor download job with initial delay - const jobQueue = getJobQueueService(); - await jobQueue.addMonitorJob( - requestId, - downloadHistory.id, - downloadClientId, - 'qbittorrent', - 3 // Wait 3 seconds before first check to avoid race condition - ); - - logger.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`); - - return { - success: true, - message: 'Torrent added to qBittorrent and monitoring started', - requestId, - downloadHistoryId: downloadHistory.id, - torrentHash: downloadClientId, - torrent: { - title: torrent.title, - size: torrent.size, - seeders: torrent.seeders || 0, - format: torrent.format, - }, - }; - } + return { + success: true, + message: `Download added to ${client.clientType} and monitoring started`, + requestId, + downloadHistoryId: downloadHistory.id, + downloadClientId, + torrent: { + title: torrent.title, + size: torrent.size, + seeders: torrent.seeders || 0, + format: torrent.format, + }, + }; } catch (error) { logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts index bc8133a..39c57a4 100644 --- a/src/lib/processors/monitor-download.processor.ts +++ b/src/lib/processors/monitor-download.processor.ts @@ -3,50 +3,13 @@ * Documentation: documentation/phase3/README.md */ -import path from 'path'; import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service'; import { prisma } from '../db'; -import { getQBittorrentService } from '../integrations/qbittorrent.service'; import { RMABLogger } from '../utils/logger'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { getConfigService } from '../services/config.service'; import { getDownloadClientManager } from '../services/download-client-manager.service'; - -/** - * Helper function to retry getTorrent with exponential backoff - * Handles race condition where torrent isn't immediately available after adding - */ -async function getTorrentWithRetry( - qbt: any, - hash: string, - logger: RMABLogger, - maxRetries: number = 3, - initialDelayMs: number = 500 -): Promise { - let lastError: Error | null = null; - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - return await qbt.getTorrent(hash); - } catch (error) { - lastError = error as Error; - - // If this is the last attempt, throw the error - if (attempt === maxRetries - 1) { - break; - } - - // Exponential backoff: 500ms, 1000ms, 2000ms - const delayMs = initialDelayMs * Math.pow(2, attempt); - logger.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`); - - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } - - // All retries failed - throw lastError || new Error('Failed to get torrent after retries'); -} +import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; /** * Process monitor download job @@ -59,57 +22,42 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P const logger = RMABLogger.forJob(jobId, 'MonitorDownload'); try { - let progress: any; - let downloadPath: string | undefined; + // Get the download client service via the manager + const configService = getConfigService(); + const manager = getDownloadClientManager(configService); + const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType]; + if (!protocol) { + throw new Error(`Unknown download client type: ${downloadClient}`); + } + const client = await manager.getClientServiceForProtocol(protocol); - if (downloadClient === 'qbittorrent') { - // qBittorrent flow - const qbt = await getQBittorrentService(); + if (!client) { + throw new Error(`No ${downloadClient} client configured`); + } - // Get torrent status with retry logic (handles race condition) - const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger); - progress = qbt.getDownloadProgress(torrent); + // Get download status via unified interface + const info = await client.getDownload(downloadClientId); - // Store download path for later use - downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name); - } else if (downloadClient === 'sabnzbd') { - // SABnzbd flow - const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); - const sabnzbd = await getSABnzbdService(); + if (!info) { + throw new Error(`Download ${downloadClientId} not found in ${downloadClient}`); + } - // Get NZB status - const nzbInfo = await sabnzbd.getNZB(downloadClientId); + // Build progress object for request updates + const progressPercent = Math.round(info.progress * 100); + const progressState = info.status; - if (!nzbInfo) { - throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`); - } - - // Convert NZBInfo to progress format - progress = { - percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format) - bytesDownloaded: nzbInfo.size * nzbInfo.progress, - bytesTotal: nzbInfo.size, - speed: nzbInfo.downloadSpeed, - eta: nzbInfo.timeLeft, - state: nzbInfo.status, - }; - - // Store download path if available (only set after completion) - downloadPath = nzbInfo.downloadPath; - - logger.info(`SABnzbd status: ${nzbInfo.status}`, { - progress: `${(nzbInfo.progress * 100).toFixed(1)}%`, - speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`, + if (client.protocol === 'usenet') { + logger.info(`${client.clientType} status: ${info.status}`, { + progress: `${(info.progress * 100).toFixed(1)}%`, + speed: `${(info.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`, }); - } else { - throw new Error(`Download client ${downloadClient} not supported`); } // Update request progress await prisma.request.update({ where: { id: requestId }, data: { - progress: progress.percent, + progress: progressPercent, updatedAt: new Date(), }, }); @@ -118,23 +66,21 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P await prisma.downloadHistory.update({ where: { id: downloadHistoryId }, data: { - downloadStatus: progress.state, + downloadStatus: progressState, }, }); // Check download state - if (progress.state === 'completed') { + if (progressState === 'completed' || progressState === 'seeding') { logger.info(`Download completed for request ${requestId}`); // Ensure we have a download path + const downloadPath = info.downloadPath; if (!downloadPath) { throw new Error('Download path not available from download client'); } // Get path mapping configuration from the specific download client - const configService = getConfigService(); - const manager = getDownloadClientManager(configService); - const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent'; const clientConfig = await manager.getClientForProtocol(protocol); // Build path mapping config from client settings @@ -150,17 +96,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P const organizePath = PathMapper.transform(downloadPath, pathMappingConfig); logger.info(`Download completed`, { - downloadClient, + downloadClient: client.clientType, downloadPath, organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath, }); - // Update download history to completed + // Update download history to completed (store mapped path for retry reliability) await prisma.downloadHistory.update({ where: { id: downloadHistoryId }, data: { downloadStatus: 'completed', completedAt: new Date(), + downloadPath: organizePath, }, }); @@ -197,10 +144,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P progress: 100, downloadPath: organizePath, }; - } else if (progress.state === 'failed') { + } else if (progressState === 'failed') { logger.error(`Download failed for request ${requestId}`); - const errorMessage = 'Download failed in qBittorrent'; + const errorMessage = `Download failed in ${client.clientType}`; // Update request to failed await prisma.request.update({ @@ -249,7 +196,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P completed: true, message: 'Download failed', requestId, - progress: progress.percent, + progress: progressPercent, }; } else { // Still downloading - schedule another check in 10 seconds @@ -263,11 +210,11 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P ); // Only log every 5% progress to reduce log spam - const shouldLog = progress.percent % 5 === 0 || progress.percent < 5; + const shouldLog = progressPercent % 5 === 0 || progressPercent < 5; if (shouldLog) { - logger.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, { - speed: progress.speed, - eta: progress.eta, + logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, { + speed: info.downloadSpeed, + eta: info.eta, }); } @@ -276,20 +223,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P completed: false, message: 'Download in progress, monitoring continues', requestId, - progress: progress.percent, - speed: progress.speed, - eta: progress.eta, - state: progress.state, + progress: progressPercent, + speed: info.downloadSpeed, + eta: info.eta, + state: progressState, }; } } catch (error) { logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); - // Check if this is a transient "torrent not found" error + // Check if this is a transient "not found" error const errorMessage = error instanceof Error ? error.message : ''; - const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found'); + const isNotFound = errorMessage.includes('not found'); - if (isTorrentNotFound) { + if (isNotFound) { // Transient error - don't mark request as failed, let Bull retry // The request stays in 'downloading' status until Bull exhausts all retries logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`); diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 3a0d5a8..ea8e8f4 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -9,6 +9,8 @@ import { getFileOrganizer } from '../utils/file-organizer'; import { RMABLogger } from '../utils/logger'; import { getLibraryService } from '../services/library'; import { getConfigService } from '../services/config.service'; +import { getDownloadClientManager } from '../services/download-client-manager.service'; +import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; import { generateFilesHash } from '../utils/files-hash'; import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer'; import { removeEmptyParentDirectories } from '../utils/cleanup-helpers'; @@ -242,106 +244,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi ); } - // Cleanup Usenet downloads if configured - try { - logger.info('Checking if cleanup is needed for this download'); - - // Get download history to find NZB ID and indexer - const downloadHistory = await prisma.downloadHistory.findFirst({ - where: { requestId }, - orderBy: { createdAt: 'desc' }, - }); - - logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, { - hasNzbId: !!downloadHistory?.nzbId, - hasIndexerId: !!downloadHistory?.indexerId, - nzbId: downloadHistory?.nzbId || 'none', - indexerId: downloadHistory?.indexerId || 'none', - }); - - if (downloadHistory?.nzbId && downloadHistory?.indexerId) { - // Get indexer configuration - const indexersConfig = await configService.get('prowlarr_indexers'); - logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`); - - if (indexersConfig) { - const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig); - const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId); - - logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, { - indexerId: downloadHistory.indexerId, - protocol: indexer?.protocol || 'none', - removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined', - }); - - // Check if this is a Usenet indexer with cleanup enabled - if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) { - logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`); - - // First, manually delete files from filesystem - if (downloadPath) { - logger.info(`Removing download files from filesystem: ${downloadPath}`); - - const fs = await import('fs/promises'); - - try { - // Check if it's a file or directory - const stats = await fs.stat(downloadPath); - - if (stats.isDirectory()) { - // Remove directory and all contents - await fs.rm(downloadPath, { recursive: true, force: true }); - logger.info(`Removed directory: ${downloadPath}`); - } else { - // Remove single file - await fs.unlink(downloadPath); - logger.info(`Removed file: ${downloadPath}`); - } - - // Clean up empty parent directories (e.g., empty category folders) - // Get download_dir as the boundary - never delete above this - const downloadDir = await configService.get('download_dir') || '/downloads'; - const cleanupResult = await removeEmptyParentDirectories(downloadPath, { - boundaryPath: downloadDir, - logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined, - }); - - if (cleanupResult.removedDirectories.length > 0) { - logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`); - } - } catch (fsError) { - // File/directory might already be deleted or not exist - if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') { - logger.info(`Download path already deleted: ${downloadPath}`); - } else { - throw fsError; - } - } - } else { - logger.warn(`No download path available, skipping filesystem deletion`); - } - - // Then archive from SABnzbd history (hides from UI but preserves for troubleshooting) - // Note: We only archive from history, not queue. If the NZB is still in the queue - // when we're organizing files, something went wrong with the download monitoring. - const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); - const sabnzbd = await getSABnzbdService(); - - await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId); - - logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`); - } - } - } - } catch (error) { - // Log error but don't fail the job - cleanup is optional - logger.warn( - `Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`, - { - error: error instanceof Error ? error.stack : undefined, - } - ); - } + // Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup) + await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger); return { success: true, @@ -592,12 +496,20 @@ async function processEbookOrganization( const isIndexerDownload = downloadHistory?.downloadClient !== 'direct'; logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`); - // Get file organizer and template + // Get file organizer and ebook-specific template (falls back to audiobook template) const organizer = await getFileOrganizer(); - const templateConfig = await prisma.configuration.findUnique({ - where: { key: 'audiobook_path_template' }, + const ebookTemplateConfig = await prisma.configuration.findUnique({ + where: { key: 'ebook_path_template' }, }); - const template = templateConfig?.value || '{author}/{title} {asin}'; + let template: string; + if (ebookTemplateConfig?.value) { + template = ebookTemplateConfig.value; + } else { + const audiobookTemplateConfig = await prisma.configuration.findUnique({ + where: { key: 'audiobook_path_template' }, + }); + template = audiobookTemplateConfig?.value || '{author}/{title} {asin}'; + } // Check if Kindle EPUB fix is needed let effectiveDownloadPath = downloadPath; @@ -739,99 +651,8 @@ async function processEbookOrganization( logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`); } - // Cleanup Usenet downloads if configured (same logic as audiobooks) - try { - logger.info('Checking if cleanup is needed for ebook download'); - - // downloadHistory was already fetched earlier in this function - logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, { - hasNzbId: !!downloadHistory?.nzbId, - hasIndexerId: !!downloadHistory?.indexerId, - nzbId: downloadHistory?.nzbId || 'none', - indexerId: downloadHistory?.indexerId || 'none', - }); - - if (downloadHistory?.nzbId && downloadHistory?.indexerId) { - // Get indexer configuration - const indexersConfig = await configService.get('prowlarr_indexers'); - logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`); - - if (indexersConfig) { - const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig); - const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId); - - logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, { - indexerId: downloadHistory.indexerId, - protocol: indexer?.protocol || 'none', - removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined', - }); - - // Check if this is a Usenet indexer with cleanup enabled - if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) { - logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`); - - // First, manually delete files from filesystem - if (downloadPath) { - logger.info(`Removing download files from filesystem: ${downloadPath}`); - - const fs = await import('fs/promises'); - - try { - // Check if it's a file or directory - const stats = await fs.stat(downloadPath); - - if (stats.isDirectory()) { - // Remove directory and all contents - await fs.rm(downloadPath, { recursive: true, force: true }); - logger.info(`Removed directory: ${downloadPath}`); - } else { - // Remove single file - await fs.unlink(downloadPath); - logger.info(`Removed file: ${downloadPath}`); - } - - // Clean up empty parent directories (e.g., empty category folders) - // Get download_dir as the boundary - never delete above this - const downloadDir = await configService.get('download_dir') || '/downloads'; - const cleanupResult = await removeEmptyParentDirectories(downloadPath, { - boundaryPath: downloadDir, - logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined, - }); - - if (cleanupResult.removedDirectories.length > 0) { - logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`); - } - } catch (fsError) { - // File/directory might already be deleted or not exist - if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') { - logger.info(`Download path already deleted: ${downloadPath}`); - } else { - throw fsError; - } - } - } else { - logger.warn(`No download path available, skipping filesystem deletion`); - } - - // Then archive from SABnzbd history (hides from UI but preserves for troubleshooting) - const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); - const sabnzbd = await getSABnzbdService(); - - await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId); - - logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`); - } - } - } - } catch (error) { - // Log error but don't fail the job - cleanup is optional - logger.warn( - `Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`, - { - error: error instanceof Error ? error.stack : undefined, - } - ); - } + // Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup) + await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger); return { success: true, @@ -932,6 +753,129 @@ async function createEbookRequestIfEnabled( } } +// ========================================================================= +// DOWNLOAD CLEANUP +// ========================================================================= + +/** + * Cleanup download files and archive from download client after successful organization. + * Uses the IDownloadClient.postProcess() method for client-specific cleanup (e.g., SABnzbd archive). + * Shared between audiobook and ebook organization flows. + */ +async function cleanupDownloadAfterOrganize( + requestId: string, + downloadPath: string, + configService: any, + jobId: string | undefined, + logger: RMABLogger +): Promise { + try { + logger.info('Checking if cleanup is needed for this download'); + + // Get download history to find client ID and indexer + const downloadHistory = await prisma.downloadHistory.findFirst({ + where: { requestId }, + orderBy: { createdAt: 'desc' }, + }); + + logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, { + hasDownloadClientId: !!downloadHistory?.downloadClientId, + hasIndexerId: !!downloadHistory?.indexerId, + downloadClient: downloadHistory?.downloadClient || 'none', + }); + + if (!downloadHistory?.indexerId || !downloadHistory?.downloadClientId) { + return; + } + + // Get indexer configuration + const indexersConfig = await configService.get('prowlarr_indexers'); + if (!indexersConfig) { + return; + } + + const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig); + const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId); + + logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, { + indexerId: downloadHistory.indexerId, + protocol: indexer?.protocol || 'none', + removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined', + }); + + // Check if this is a non-torrent indexer with cleanup enabled + if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) { + return; + } + + logger.info(`Cleaning up download ${downloadHistory.downloadClientId} (cleanup enabled for indexer ${indexer.id})`); + + // First, manually delete files from filesystem + if (downloadPath) { + logger.info(`Removing download files from filesystem: ${downloadPath}`); + + const fs = await import('fs/promises'); + + try { + const stats = await fs.stat(downloadPath); + + if (stats.isDirectory()) { + await fs.rm(downloadPath, { recursive: true, force: true }); + logger.info(`Removed directory: ${downloadPath}`); + } else { + await fs.unlink(downloadPath); + logger.info(`Removed file: ${downloadPath}`); + } + + // Clean up empty parent directories + const downloadDir = await configService.get('download_dir') || '/downloads'; + const cleanupResult = await removeEmptyParentDirectories(downloadPath, { + boundaryPath: downloadDir, + logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined, + }); + + if (cleanupResult.removedDirectories.length > 0) { + logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`); + } + } catch (fsError) { + if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info(`Download path already deleted: ${downloadPath}`); + } else { + throw fsError; + } + } + } else { + logger.warn(`No download path available, skipping filesystem deletion`); + } + + // Then use the download client interface for client-specific post-processing + // (e.g., usenet clients archive from history, torrent clients are a no-op) + const clientType = downloadHistory.downloadClient; + if (clientType && clientType !== 'direct') { + const manager = getDownloadClientManager(configService); + const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType]; + if (!protocol) { + logger.warn(`Unknown download client type: ${clientType}, skipping post-processing`); + return; + } + const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet'); + + if (client) { + await client.postProcess(downloadHistory.downloadClientId); + logger.info(`Successfully post-processed download ${downloadHistory.downloadClientId} via ${client.clientType}`); + } + } + } catch (error) { + // Log error but don't fail the job - cleanup is optional + logger.warn( + `Failed to cleanup download: ${error instanceof Error ? error.message : 'Unknown error'}`, + { + error: error instanceof Error ? error.stack : undefined, + } + ); + } +} + // ========================================================================= // HELPER FUNCTIONS // ========================================================================= diff --git a/src/lib/processors/retry-failed-imports.processor.ts b/src/lib/processors/retry-failed-imports.processor.ts index f81eb5e..2a31c63 100644 --- a/src/lib/processors/retry-failed-imports.processor.ts +++ b/src/lib/processors/retry-failed-imports.processor.ts @@ -2,15 +2,18 @@ * Component: Retry Failed Imports Processor * Documentation: documentation/backend/services/scheduler.md * - * Retries file organization for requests that are awaiting import + * Retries file organization for requests that are awaiting import. + * Uses the IDownloadClient interface for client-agnostic path resolution. */ +import path from 'path'; import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; import { getJobQueueService } from '../services/job-queue.service'; import { getConfigService } from '../services/config.service'; -import { getDownloadClientManager } from '../services/download-client-manager.service'; +import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service'; import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; +import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface'; export interface RetryFailedImportsPayload { jobId?: string; @@ -30,7 +33,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo // Helper function to get path mapping config for a specific download client type const getPathMappingForClient = async (clientType: string): Promise => { - const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent'; + const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent'; const clientConfig = await manager.getClientForProtocol(protocol); if (clientConfig && clientConfig.remotePathMappingEnabled) { @@ -43,11 +46,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo return { enabled: false, remotePath: '', localPath: '' }; }; - // Find all active audiobook requests in awaiting_import status - // Note: Ebook requests use the same organize_files processor but with type branching + // Find all requests in awaiting_import status (both audiobook and ebook) + // The organize_files processor handles both types with type-based branching const requests = await prisma.request.findMany({ where: { - type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow) status: 'awaiting_import', deletedAt: null, }, @@ -90,111 +92,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo let downloadPath: string; - // Try to get download path from the appropriate download client // Get path mapping for this specific download client const clientType = downloadHistory.downloadClient || 'qbittorrent'; - const mappingConfig = await getPathMappingForClient(clientType); - if (downloadHistory.torrentHash) { - // qBittorrent download - try { - const { getQBittorrentService } = await import('../integrations/qbittorrent.service'); - const qbt = await getQBittorrentService(); - const torrent = await qbt.getTorrent(downloadHistory.torrentHash); - const qbPath = `${torrent.save_path}/${torrent.name}`; - downloadPath = PathMapper.transform(qbPath, mappingConfig); - logger.info( - `Got download path from qBittorrent for request ${request.id}: ${qbPath}` + - (downloadPath !== qbPath ? ` → ${downloadPath} (mapped)` : '') - ); - } catch (qbtError) { - // Torrent not found in qBittorrent - try to construct path from config - logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`); - - if (!downloadHistory.torrentName) { - logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`); - skipped++; - continue; - } - - const downloadDir = await configService.get('download_dir'); - - if (!downloadDir) { - logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`); - skipped++; - continue; - } - - const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`; - downloadPath = PathMapper.transform(fallbackPath, mappingConfig); - logger.info( - `Using fallback download path for request ${request.id}: ${fallbackPath}` + - (downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '') - ); - } - } else if (downloadHistory.nzbId) { - // SABnzbd download - try { - const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); - const sabnzbd = await getSABnzbdService(); - const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId); - if (nzbInfo && nzbInfo.downloadPath) { - downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig); - logger.info( - `Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` + - (downloadPath !== nzbInfo.downloadPath ? ` → ${downloadPath} (mapped)` : '') - ); - } else { - logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`); - - if (!downloadHistory.torrentName) { - logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`); - skipped++; - continue; - } - - const downloadDir = await configService.get('download_dir'); - - if (!downloadDir) { - logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`); - skipped++; - continue; - } - - const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`; - downloadPath = PathMapper.transform(fallbackPath, mappingConfig); - logger.info( - `Using fallback download path for request ${request.id}: ${fallbackPath}` + - (downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '') - ); - } - } catch (sabnzbdError) { - logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`); - skipped++; - continue; - } + // Direct downloads (e.g. Anna's Archive ebooks) have no external download client + // Use stored path or construct from download_dir directly + if (clientType === 'direct') { + const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' }; + downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger); } else { - // No download client ID - use fallback path - if (!downloadHistory.torrentName) { - logger.warn(`No download client ID or name for request ${request.id}, skipping`); + // Real download client — resolve path via client API with path mapping + const mappingConfig = await getPathMappingForClient(clientType); + const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId; + + const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined; + if (!protocol) { + logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`); skipped++; continue; } - const downloadDir = await configService.get('download_dir'); + if (clientId) { + // Try to get path from download client via unified interface + const client = await manager.getClientServiceForProtocol(protocol); - if (!downloadDir) { - logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`); - skipped++; - continue; + if (client) { + try { + const info = await client.getDownload(clientId); + if (info?.downloadPath) { + downloadPath = PathMapper.transform(info.downloadPath, mappingConfig); + logger.info( + `Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` + + (downloadPath !== info.downloadPath ? ` → ${downloadPath} (mapped)` : '') + ); + } else { + // Download found but no path — try stored path, then fallback + downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol); + } + } catch (clientError) { + // Client error — try stored path, then fallback + logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`); + downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol); + } + } else { + // No client configured — try stored path, then fallback + downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol); + } + } else { + // No client ID — try stored path, then fallback + downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol); } + } - const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`; - downloadPath = PathMapper.transform(configuredPath, mappingConfig); - logger.info( - `Using configured download path for request ${request.id}: ${configuredPath}` + - (downloadPath !== configuredPath ? ` → ${downloadPath} (mapped)` : '') - ); + // Check if we got a valid path (getFallbackPath returns empty string on failure) + if (!downloadPath) { + skipped++; + continue; } await jobQueue.addOrganizeJob( @@ -203,7 +156,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo downloadPath ); triggered++; - logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`); + logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`); } catch (error) { logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); skipped++; @@ -224,3 +177,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo throw error; } } + +/** + * Return the stored download path from the database (saved at download completion time). + * Returns empty string if not available (old records won't have this field). + */ +function getStoredPath( + downloadHistory: { downloadPath?: string | null }, + requestId: string, + logger: RMABLogger +): string { + if (downloadHistory.downloadPath) { + logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`); + return downloadHistory.downloadPath; + } + return ''; +} + +/** + * Construct a fallback download path from config when the download client can't provide one. + * Returns empty string if path cannot be determined (caller should skip the request). + */ +async function getFallbackPath( + downloadHistory: { torrentName: string | null }, + configService: any, + mappingConfig: PathMappingConfig, + requestId: string, + logger: RMABLogger, + manager?: DownloadClientManager, + protocol?: ProtocolType +): Promise { + if (!downloadHistory.torrentName) { + logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`); + return ''; + } + + const baseDir = await configService.get('download_dir'); + + if (!baseDir) { + logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`); + return ''; + } + + // Resolve customPath from the client config if available + let downloadDir = baseDir; + if (manager && protocol) { + const clientConfig = await manager.getClientForProtocol(protocol); + if (clientConfig?.customPath) { + downloadDir = path.join(baseDir, clientConfig.customPath); + } + } + + const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`; + const mappedPath = PathMapper.transform(fallbackPath, mappingConfig); + logger.info( + `Using fallback download path for request ${requestId}: ${fallbackPath}` + + (mappedPath !== fallbackPath ? ` → ${mappedPath} (mapped)` : '') + ); + return mappedPath; +} diff --git a/src/lib/processors/search-ebook.processor.ts b/src/lib/processors/search-ebook.processor.ts index 40f51d9..2e92190 100644 --- a/src/lib/processors/search-ebook.processor.ts +++ b/src/lib/processors/search-ebook.processor.ts @@ -243,9 +243,14 @@ async function searchIndexers( const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; // Group indexers by their EBOOK category configuration - const groups = groupIndexersByCategories(indexersConfig, 'ebook'); + const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook'); - logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`); + if (skippedIndexers.length > 0) { + const skippedNames = skippedIndexers.map(idx => idx.name).join(', '); + logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`); + } + + logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`); // Log each group for transparency groups.forEach((group, index) => { diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts index 337b03d..2b479bb 100644 --- a/src/lib/processors/search-indexers.processor.ts +++ b/src/lib/processors/search-indexers.processor.ts @@ -58,9 +58,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro // Group indexers by their category configuration // This minimizes API calls while ensuring each indexer only searches its configured categories - const groups = groupIndexersByCategories(indexersConfig); + const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig); - logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`); + if (skippedIndexers.length > 0) { + const skippedNames = skippedIndexers.map(idx => idx.name).join(', '); + logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`); + } + + logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`); // Log each group for transparency groups.forEach((group, index) => { diff --git a/src/lib/services/download-client-manager.service.ts b/src/lib/services/download-client-manager.service.ts index 15a59af..cca84e2 100644 --- a/src/lib/services/download-client-manager.service.ts +++ b/src/lib/services/download-client-manager.service.ts @@ -2,37 +2,41 @@ * Component: Download Client Manager Service * Documentation: documentation/phase3/download-clients.md * - * Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing. + * Manages multiple download clients (qBittorrent, Transmission, SABnzbd, NZBGet) with protocol-based routing. * Supports migration from legacy single-client config to multi-client JSON array format. */ import { randomUUID } from 'crypto'; +import path from 'path'; import { ConfigurationService } from './config.service'; import { getEncryptionService } from './encryption.service'; import { isEncryptedFormat } from './credential-migration.service'; import { RMABLogger } from '@/lib/utils/logger'; import { QBittorrentService } from '@/lib/integrations/qbittorrent.service'; import { SABnzbdService } from '@/lib/integrations/sabnzbd.service'; +import { NZBGetService } from '@/lib/integrations/nzbget.service'; +import { TransmissionService } from '@/lib/integrations/transmission.service'; import { PathMappingConfig } from '@/lib/utils/path-mapper'; +import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; const logger = RMABLogger.create('DownloadClientManager'); export interface DownloadClientConfig { id: string; - type: 'qbittorrent' | 'sabnzbd'; + type: DownloadClientType; name: string; enabled: boolean; url: string; - username?: string; // qBittorrent only - password: string; // Password (qBittorrent) or API key (SABnzbd) + username?: string; // qBittorrent/Transmission/NZBGet only + password: string; // Password (qBittorrent/Transmission/NZBGet) or API key (SABnzbd) disableSSLVerify: boolean; remotePathMappingEnabled: boolean; remotePath?: string; localPath?: string; category?: string; // Default: 'readmeabook' + customPath?: string; // Relative sub-path appended to download_dir } -type ProtocolType = 'torrent' | 'usenet'; /** * Download Client Manager @@ -47,6 +51,7 @@ export class DownloadClientManager { private static instance: DownloadClientManager | null = null; private configService: ConfigurationService; private clientsCache: DownloadClientConfig[] | null = null; + private serviceCache: Map = new Map(); private migrationPerformed = false; private constructor(configService: ConfigurationService) { @@ -69,6 +74,7 @@ export class DownloadClientManager { static invalidate(): void { if (DownloadClientManager.instance) { DownloadClientManager.instance.clientsCache = null; + DownloadClientManager.instance.serviceCache.clear(); DownloadClientManager.instance.migrationPerformed = false; logger.debug('Download client cache invalidated'); } @@ -127,16 +133,17 @@ export class DownloadClientManager { } /** - * Get client for specific protocol + * Get client for specific protocol. + * Uses CLIENT_PROTOCOL_MAP so any client type matching the protocol is found + * (e.g. both qBittorrent and Transmission can serve the 'torrent' protocol). */ async getClientForProtocol(protocol: ProtocolType): Promise { const clients = await this.getAllClients(); - const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd'; - const client = clients.find(c => c.enabled && c.type === targetType); + const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol); if (!client) { - logger.warn(`No enabled ${targetType} client configured`); + logger.warn(`No enabled ${protocol} client configured`); return null; } @@ -152,36 +159,83 @@ export class DownloadClientManager { } /** - * Get instantiated client service for protocol + * Get instantiated client service for protocol. + * Returns the unified IDownloadClient interface for protocol-agnostic usage. */ - async getClientServiceForProtocol(protocol: ProtocolType): Promise { + async getClientServiceForProtocol(protocol: ProtocolType): Promise { const client = await this.getClientForProtocol(protocol); if (!client) { return null; } - if (client.type === 'qbittorrent') { - return this.createQBittorrentService(client); - } else { - return this.createSABnzbdService(client); + return this.getOrCreateService(client); + } + + /** + * Factory: create a new IDownloadClient from config. + * This is the single place where client type maps to a concrete class. + * Add new client types (e.g. Transmission, NZBGet) here. + */ + private async createService(config: DownloadClientConfig): Promise { + const baseDir = await this.configService.get('download_dir') || '/downloads'; + const downloadDir = config.customPath + ? path.join(baseDir, config.customPath) + : baseDir; + + switch (config.type) { + case 'qbittorrent': + return this.createQBittorrentService(config, downloadDir); + case 'sabnzbd': + return this.createSABnzbdService(config, downloadDir); + case 'nzbget': + return this.createNZBGetService(config, downloadDir); + case 'transmission': + return this.createTransmissionService(config, downloadDir); + default: + throw new Error(`Unsupported download client type: ${config.type}`); } } /** - * Test connection for a specific client config + * Get a cached service instance or create a new one. + * Caches by client config ID to preserve session state (e.g. qBittorrent SID cookie). + */ + private async getOrCreateService(config: DownloadClientConfig): Promise { + const cached = this.serviceCache.get(config.id); + if (cached) { + return cached; + } + + const service = await this.createService(config); + this.serviceCache.set(config.id, service); + return service; + } + + /** + * Create an IDownloadClient instance from a config object. + * Uses cached instances when available to preserve session state. + */ + async createClientFromConfig(config: DownloadClientConfig): Promise { + return this.getOrCreateService(config); + } + + /** + * Test connection for a specific client config. + * Uses the unified IDownloadClient.testConnection() method. */ async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> { try { - if (config.type === 'qbittorrent') { - const service = this.createQBittorrentService(config); - await service.testConnection(); - return { success: true, message: 'Successfully connected to qBittorrent' }; - } else { - const service = this.createSABnzbdService(config); - const version = await service.getVersion(); - return { success: true, message: `Successfully connected to SABnzbd (v${version})` }; + // Always create a fresh instance for connection testing (don't use cache) + const service = await this.createService(config); + const result = await service.testConnection(); + + if (result.success) { + const versionSuffix = result.version ? ` (v${result.version})` : ''; + return { success: true, message: `Successfully connected to ${config.name}${versionSuffix}` }; } + + return { success: false, message: result.message || 'Connection failed' }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error('Connection test failed', { type: config.type, error: message }); @@ -192,7 +246,7 @@ export class DownloadClientManager { /** * Create qBittorrent service instance */ - private createQBittorrentService(config: DownloadClientConfig): QBittorrentService { + private createQBittorrentService(config: DownloadClientConfig, downloadDir: string): QBittorrentService { const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath ? { enabled: true, @@ -205,8 +259,8 @@ export class DownloadClientManager { config.url, config.username || '', config.password || '', // Optional for IP whitelist auth - '/downloads', // defaultSavePath - config.category || 'readmeabook', // defaultCategory + downloadDir, + config.category || 'readmeabook', config.disableSSLVerify, pathMapping ); @@ -215,7 +269,7 @@ export class DownloadClientManager { /** * Create SABnzbd service instance */ - private createSABnzbdService(config: DownloadClientConfig): SABnzbdService { + private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService { const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath ? { enabled: true, @@ -227,8 +281,54 @@ export class DownloadClientManager { return new SABnzbdService( config.url, config.password, // API key stored in password field - config.category || 'readmeabook', // defaultCategory - '/downloads', // defaultDownloadDir (will be overridden by singleton with actual config) + config.category || 'readmeabook', + downloadDir, + config.disableSSLVerify, + pathMapping + ); + } + + /** + * Create NZBGet service instance + */ + private createNZBGetService(config: DownloadClientConfig, downloadDir: string): NZBGetService { + const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath + ? { + enabled: true, + remotePath: config.remotePath, + localPath: config.localPath, + } + : undefined; + + return new NZBGetService( + config.url, + config.username || '', + config.password, + config.category || 'readmeabook', + downloadDir, + config.disableSSLVerify, + pathMapping + ); + } + + /** + * Create Transmission service instance + */ + private createTransmissionService(config: DownloadClientConfig, downloadDir: string): TransmissionService { + const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath + ? { + enabled: true, + remotePath: config.remotePath, + localPath: config.localPath, + } + : undefined; + + return new TransmissionService( + config.url, + config.username || '', + config.password || '', + downloadDir, + config.category || 'readmeabook', config.disableSSLVerify, pathMapping ); @@ -272,8 +372,8 @@ export class DownloadClientManager { const newClient: DownloadClientConfig = { id: randomUUID(), - type: clientType as 'qbittorrent' | 'sabnzbd', - name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd', + type: clientType as DownloadClientType, + name: getClientDisplayName(clientType), enabled: true, url: clientUrl, username: clientUsername || undefined, diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 075574c..cdabe6a 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -7,6 +7,7 @@ import Queue, { Job as BullJob, JobOptions } from 'bull'; import Redis from 'ioredis'; import { prisma } from '../db'; import { TorrentResult } from '../utils/ranking-algorithm'; +import { DownloadClientType } from '../interfaces/download-client.interface'; import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('JobQueue'); @@ -59,7 +60,7 @@ export interface MonitorDownloadPayload extends JobPayload { requestId: string; downloadHistoryId: string; downloadClientId: string; - downloadClient: 'qbittorrent' | 'sabnzbd'; + downloadClient: DownloadClientType; } export interface OrganizeFilesPayload extends JobPayload { @@ -545,7 +546,7 @@ export class JobQueueService { requestId: string, downloadHistoryId: string, downloadClientId: string, - downloadClient: 'qbittorrent' | 'sabnzbd', + downloadClient: DownloadClientType, delaySeconds: number = 0 ): Promise { return await this.addJob( diff --git a/src/lib/services/request-delete.service.ts b/src/lib/services/request-delete.service.ts index 3119e83..77cdc08 100644 --- a/src/lib/services/request-delete.service.ts +++ b/src/lib/services/request-delete.service.ts @@ -10,6 +10,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { RMABLogger } from '../utils/logger'; import { buildAudiobookPath } from '../utils/file-organizer'; +import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; const logger = RMABLogger.create('RequestDelete'); @@ -119,77 +120,73 @@ export async function deleteRequest( ); } - // Handle based on download client type (check which ID is present) - if (downloadHistory.torrentHash) { - // qBittorrent download - const { getQBittorrentService } = await import('../integrations/qbittorrent.service'); - const qbt = await getQBittorrentService(); + // Handle download cleanup via unified interface + const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId; + const clientType = downloadHistory.downloadClient || 'qbittorrent'; - let torrent; - try { - torrent = await qbt.getTorrent(downloadHistory.torrentHash); - } catch (error) { - // Torrent not found in qBittorrent (already removed) - logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`); - } + if (clientId && clientType !== 'direct') { + const { getDownloadClientManager } = await import('./download-client-manager.service'); + const manager = getDownloadClientManager(configService); + const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent'; + const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet'); - if (torrent) { - // Torrent exists in qBittorrent - const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0; - const isCompleted = downloadHistory.downloadStatus === 'completed'; + if (client) { + // Get download info to check seeding status + let downloadInfo; + try { + downloadInfo = await client.getDownload(clientId); + } catch (error) { + logger.info(`Download ${clientId} not found in ${clientType}, skipping`); + } - if (isUnlimitedSeeding) { - // Unlimited seeding - keep in qBittorrent, stop monitoring - logger.info( - `Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})` - ); - torrentsKeptUnlimited++; - } else if (!isCompleted) { - // Download not completed - delete immediately - logger.info( - `Deleting incomplete download: ${torrent.name}` - ); - await qbt.deleteTorrent(downloadHistory.torrentHash, true); - torrentsRemoved++; - } else { - // Check if seeding requirement is met - const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60; - const actualSeedingTime = torrent.seeding_time || 0; - const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds; + if (downloadInfo) { + const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0; + const isCompleted = downloadHistory.downloadStatus === 'completed'; - if (hasMetRequirement) { - // Seeding requirement met - delete now + if (client.protocol === 'usenet') { + // Usenet - no seeding concept, delete immediately + try { + await client.deleteDownload(clientId, true); + logger.info(`Deleted download ${clientId} from ${client.clientType}`); + torrentsRemoved++; + } catch (error) { + logger.info(`Download ${clientId} not found in ${client.clientType}, skipping`); + } + } else if (isUnlimitedSeeding) { + // Unlimited seeding - keep in client, stop monitoring logger.info( - `Deleting torrent ${torrent.name} (seeding complete: ${Math.floor( - actualSeedingTime / 60 - )}/${seedingConfig.seedingTimeMinutes} minutes)` + `Keeping download ${downloadInfo.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})` ); - await qbt.deleteTorrent(downloadHistory.torrentHash, true); + torrentsKeptUnlimited++; + } else if (!isCompleted) { + // Download not completed - delete immediately + logger.info(`Deleting incomplete download: ${downloadInfo.name}`); + await client.deleteDownload(clientId, true); torrentsRemoved++; } else { - // Still needs seeding - keep for cleanup job - const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60); - logger.info( - `Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding` - ); - torrentsKeptSeeding++; + // Check if seeding requirement is met + const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60; + const actualSeedingTime = downloadInfo.seedingTime || 0; + const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds; + + if (hasMetRequirement) { + logger.info( + `Deleting download ${downloadInfo.name} (seeding complete: ${Math.floor( + actualSeedingTime / 60 + )}/${seedingConfig.seedingTimeMinutes} minutes)` + ); + await client.deleteDownload(clientId, true); + torrentsRemoved++; + } else { + const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60); + logger.info( + `Keeping download ${downloadInfo.name} for ${remainingMinutes} more minutes of seeding` + ); + torrentsKeptSeeding++; + } } } } - } else if (downloadHistory.nzbId) { - // SABnzbd download - no seeding concept for Usenet - try { - const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); - const sabnzbd = await getSABnzbdService(); - - // Try to delete the NZB from SABnzbd (might already be completed/removed) - await sabnzbd.deleteNZB(downloadHistory.nzbId, true); - logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`); - torrentsRemoved++; - } catch (error) { - // NZB not found or already removed - logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`); - } } } catch (error) { logger.error( @@ -208,7 +205,11 @@ export async function deleteRequest( const { getConfigService } = await import('./config.service'); const configService = getConfigService(); const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks'; - const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}'; + // Use ebook-specific template for ebook requests, with fallback to audiobook template + const audiobookTemplate = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}'; + const template = isEbook + ? (await configService.get('ebook_path_template')) || audiobookTemplate + : audiobookTemplate; // Fetch year from audible cache if ASIN is available let year: number | undefined; diff --git a/src/lib/utils/chapter-merger.ts b/src/lib/utils/chapter-merger.ts index 78c8456..c422977 100644 --- a/src/lib/utils/chapter-merger.ts +++ b/src/lib/utils/chapter-merger.ts @@ -11,11 +11,12 @@ import { promisify } from 'util'; import path from 'path'; import fs from 'fs/promises'; import { RMABLogger } from './logger'; +import { CHAPTER_MERGE_FORMATS } from '../constants/audio-formats'; const execPromise = promisify(exec); -// Supported audio formats for chapter merging -const SUPPORTED_FORMATS = ['.mp3', '.m4a', '.m4b', '.mp4', '.aac']; +// Supported audio formats for chapter merging (from shared constants) +const SUPPORTED_FORMATS: readonly string[] = CHAPTER_MERGE_FORMATS; // Patterns that indicate chapter-based files const CHAPTER_PATTERNS = [ @@ -629,9 +630,9 @@ export async function mergeChapters( await fs.writeFile(metadataFile, chapterMetadata); await logger?.info(`Generated chapter metadata with ${chapters.length} chapter markers`); - // Determine if we need to re-encode (MP3 input requires conversion to AAC) + // Determine if we need to re-encode (non-AAC input requires conversion to AAC for M4B) const inputFormat = path.extname(chapters[0].path).toLowerCase(); - const needsReencode = inputFormat === '.mp3'; + const needsReencode = inputFormat === '.mp3' || inputFormat === '.flac' || inputFormat === '.aac'; // Build ffmpeg command const args: string[] = [ @@ -646,26 +647,28 @@ export async function mergeChapters( ]; if (needsReencode) { - // MP3 -> M4B requires re-encoding to AAC + // Non-AAC -> M4B requires re-encoding to AAC const bitrate = determineOutputBitrate(chapters); // Check for libfdk_aac (higher quality) or fall back to native aac const hasFdkAac = await checkLibFdkAac(); + const formatLabel = inputFormat.slice(1).toUpperCase(); // '.mp3' -> 'MP3', '.flac' -> 'FLAC' + if (hasFdkAac) { args.push('-c:a', 'libfdk_aac'); args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality) - await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`); + await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`); } else { // Use VBR for better quality at same average bitrate const vbrQuality = bitrateToVbrQuality(bitrate); args.push('-c:a', 'aac'); args.push('-q:a', vbrQuality.toString()); args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility - await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`); + await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`); } } else { - // M4A/M4B -> M4B can use codec copy (fast, lossless) + // M4A/M4B/MP4 -> M4B can use codec copy (fast, lossless) args.push('-c', 'copy'); await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`); } diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index c1649ac..8c13123 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -20,6 +20,7 @@ import { } from './chapter-merger'; import { prisma } from '../db'; import { substituteTemplate, type TemplateVariables } from './path-template.util'; +import { AUDIO_EXTENSIONS } from '../constants/audio-formats'; export interface AudiobookMetadata { title: string; @@ -362,6 +363,34 @@ export class FileOrganizer { } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; await logger?.error(`Failed to copy ${filename}: ${errorMsg}`); + + // If the tagged temp file failed to copy, clean it up and try the original untagged file + if (taggedFilePath) { + // Clean up the tagged temp file that failed to copy + try { + await fs.unlink(taggedFilePath); + await logger?.info(`Cleaned up temp file after copy failure: ${path.basename(taggedFilePath)}`); + } catch { + // Ignore cleanup errors + } + + // Fallback: attempt to copy the original untagged file instead + await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`); + try { + await fs.access(originalSourcePath, fs.constants.R_OK); + await fs.copyFile(originalSourcePath, targetFilePath); + await fs.chmod(targetFilePath, 0o644); + result.audioFiles.push(targetFilePath); + result.filesMovedCount++; + await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`); + result.errors.push(`Tagged copy failed for ${filename}, copied original without metadata tags`); + continue; + } catch (fallbackError) { + const fallbackMsg = fallbackError instanceof Error ? fallbackError.message : 'Unknown error'; + await logger?.error(`Fallback copy of original file also failed: ${fallbackMsg}`); + } + } + result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`); // Continue with other files instead of throwing } @@ -411,7 +440,15 @@ export class FileOrganizer { // This replaces the old inline ebook sidecar download that happened here. result.targetPath = targetPath; - result.success = true; + + // Only mark as success if at least one audio file was placed in the target directory + // (either freshly copied or already existed from a previous attempt) + if (result.audioFiles.length > 0) { + result.success = true; + } else { + result.errors.push('No audio files were successfully copied to the target directory'); + await logger?.error(`Organization failed: no audio files copied despite ${audioFiles.length} file(s) found`); + } // DO NOT clean up download directory - files needed for seeding // Cleanup will be handled by the seeding cleanup job after seeding requirements are met @@ -431,7 +468,7 @@ export class FileOrganizer { private async findAudiobookFiles( downloadPath: string ): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> { - const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax']; + const audioExtensions: readonly string[] = AUDIO_EXTENSIONS; const coverPatterns = [ /cover\.(jpg|jpeg|png)$/i, /folder\.(jpg|jpeg|png)$/i, diff --git a/src/lib/utils/files-hash.ts b/src/lib/utils/files-hash.ts index 072ee48..ce0d2fb 100644 --- a/src/lib/utils/files-hash.ts +++ b/src/lib/utils/files-hash.ts @@ -8,11 +8,7 @@ import crypto from 'crypto'; import path from 'path'; - -/** - * Supported audio file extensions for hash generation - */ -const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax']; +import { AUDIO_EXTENSIONS } from '../constants/audio-formats'; /** * Generates a SHA256 hash of audio filenames for library matching. @@ -51,7 +47,7 @@ export function generateFilesHash(filePaths: string[]): string { }) .filter((basename) => { const ext = path.extname(basename).toLowerCase(); - return AUDIO_EXTENSIONS.includes(ext); + return (AUDIO_EXTENSIONS as readonly string[]).includes(ext); }) .map((basename) => basename.toLowerCase()) // Normalize case .sort(); // Sort alphabetically for deterministic hash diff --git a/src/lib/utils/indexer-grouping.ts b/src/lib/utils/indexer-grouping.ts index c3c413c..ee88a28 100644 --- a/src/lib/utils/indexer-grouping.ts +++ b/src/lib/utils/indexer-grouping.ts @@ -5,6 +5,7 @@ * Groups indexers by their category configuration to minimize API calls. * Indexers with identical categories are grouped together for a single search. * Supports separate audiobook and ebook category configurations per indexer. + * Indexers with no categories for a given type are skipped (effectively disabled). */ export type CategoryType = 'audiobook' | 'ebook'; @@ -25,22 +26,33 @@ export interface IndexerGroup { indexers: IndexerConfig[]; } +export interface GroupingResult { + groups: IndexerGroup[]; + skippedIndexers: IndexerConfig[]; // Indexers skipped due to no categories for the type +} + /** * Gets the appropriate categories from an indexer based on the category type. * + * Returns empty array when the field is explicitly set to [] (user disabled this type). + * Falls back to defaults only when the field is undefined/missing (legacy configs). + * * @param indexer - The indexer configuration * @param type - The category type ('audiobook' or 'ebook') - * @returns Array of category IDs + * @returns Array of category IDs (empty = disabled for this type) */ export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] { if (type === 'ebook') { - return indexer.ebookCategories && indexer.ebookCategories.length > 0 - ? indexer.ebookCategories - : [7020]; // Default ebook category + // Field exists (even if empty) — respect it + if (Array.isArray(indexer.ebookCategories)) { + return indexer.ebookCategories; + } + // Field missing — legacy config, use default + return [7020]; } - // Audiobook - check new field first, then legacy field - if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) { + // Audiobook — check new field first, then legacy field + if (Array.isArray(indexer.audiobookCategories)) { return indexer.audiobookCategories; } if (indexer.categories && indexer.categories.length > 0) { @@ -52,57 +64,49 @@ export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType) /** * Groups indexers by their category configuration. * Indexers with identical category arrays are grouped together. + * Indexers with no categories for the specified type are skipped. * * @param indexers - Array of indexer configurations * @param type - The category type to group by ('audiobook' or 'ebook') - * @returns Array of groups, each containing indexers with matching categories + * @returns GroupingResult with groups and skipped indexers * * @example * const indexers = [ * { id: 1, audiobookCategories: [3030], ebookCategories: [7020] }, - * { id: 2, audiobookCategories: [3030], ebookCategories: [7020] }, + * { id: 2, audiobookCategories: [3030], ebookCategories: [] }, * { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] }, * ]; * - * const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook'); - * // Result: - * // [ - * // { categories: [3030], indexerIds: [1, 2], indexers: [...] }, - * // { categories: [3030, 3010], indexerIds: [3], indexers: [...] } - * // ] - * - * const ebookGroups = groupIndexersByCategories(indexers, 'ebook'); - * // Result: - * // [ - * // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] } - * // ] + * const result = groupIndexersByCategories(indexers, 'ebook'); + * // result.groups: [{ categories: [7020], indexerIds: [1, 3], indexers: [...] }] + * // result.skippedIndexers: [{ id: 2, ... }] (no ebook categories) */ export function groupIndexersByCategories( indexers: IndexerConfig[], type: CategoryType = 'audiobook' -): IndexerGroup[] { - // Map to track unique category combinations - // Key: sorted category IDs as string (e.g., "3030,3010") - // Value: array of indexers with those categories +): GroupingResult { const groupMap = new Map(); + const skippedIndexers: IndexerConfig[] = []; for (const indexer of indexers) { - // Get categories for the specified type const categories = getCategoriesForType(indexer, type); + // Skip indexers with no categories for this type (effectively disabled) + if (categories.length === 0) { + skippedIndexers.push(indexer); + continue; + } + // Sort categories to ensure consistent grouping - // [3030, 3010] and [3010, 3030] should be the same group const sortedCategories = [...categories].sort((a, b) => a - b); const key = sortedCategories.join(','); - // Add indexer to group if (!groupMap.has(key)) { groupMap.set(key, []); } groupMap.get(key)!.push(indexer); } - // Convert map to array of groups const groups: IndexerGroup[] = []; for (const [key, indexersInGroup] of groupMap.entries()) { const categories = key.split(',').map(Number); @@ -115,7 +119,7 @@ export function groupIndexersByCategories( }); } - return groups; + return { groups, skippedIndexers }; } /** diff --git a/src/lib/utils/metadata-tagger.ts b/src/lib/utils/metadata-tagger.ts index 6ec73e6..b7f132e 100644 --- a/src/lib/utils/metadata-tagger.ts +++ b/src/lib/utils/metadata-tagger.ts @@ -7,6 +7,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import fs from 'fs/promises'; +import { METADATA_TAG_FORMATS, MP4_CONTAINER_FORMATS } from '../constants/audio-formats'; const execPromise = promisify(exec); @@ -41,7 +42,7 @@ export async function tagAudioFileMetadata( const ext = path.extname(filePath).toLowerCase(); // Only process supported formats - if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) { + if (!(METADATA_TAG_FORMATS as readonly string[]).includes(ext)) { return { success: false, filePath, @@ -61,7 +62,7 @@ export async function tagAudioFileMetadata( ]; // For m4b/m4a/mp4 files, use standard metadata tags - if (['.m4b', '.m4a', '.mp4'].includes(ext)) { + if ((MP4_CONTAINER_FORMATS as readonly string[]).includes(ext)) { args.push( '-metadata', `title="${escapeMetadata(metadata.title)}"`, '-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this) @@ -85,6 +86,31 @@ export async function tagAudioFileMetadata( // Explicitly specify output format (fixes .tmp extension issue) args.push('-f', 'mp4'); } + // For FLAC files, use Vorbis comment tags (native FLAC metadata) + else if (ext === '.flac') { + args.push( + '-metadata', `title="${escapeMetadata(metadata.title)}"`, + '-metadata', `album="${escapeMetadata(metadata.title)}"`, + '-metadata', `albumartist="${escapeMetadata(metadata.author)}"`, + '-metadata', `artist="${escapeMetadata(metadata.author)}"` + ); + + if (metadata.narrator) { + args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`); + } + + if (metadata.year) { + args.push('-metadata', `date="${metadata.year}"`); + } + + if (metadata.asin) { + // FLAC supports arbitrary Vorbis comment tags + args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`); + } + + // Explicitly specify output format + args.push('-f', 'flac'); + } // For mp3 files, use ID3v2 tags else if (ext === '.mp3') { args.push( diff --git a/src/lib/utils/permissions.ts b/src/lib/utils/permissions.ts new file mode 100644 index 0000000..c0ca6be --- /dev/null +++ b/src/lib/utils/permissions.ts @@ -0,0 +1,57 @@ +/** + * Utility: Permission Resolution + * Documentation: documentation/admin-dashboard.md + * + * Resolves effective user permissions from the tri-state pattern: + * admin → always granted + * per-user setting (true/false) → explicit override + * null → falls back to global setting + */ + +import { prisma } from '@/lib/db'; + +/** + * Resolve a tri-state permission (admin → per-user → global fallback). + * @param userRole - 'admin' or 'user' + * @param userValue - per-user setting (true, false, or null) + * @param globalValue - global setting from Configuration table + * @returns effective boolean permission + */ +export function resolvePermission( + userRole: string, + userValue: boolean | null, + globalValue: boolean +): boolean { + if (userRole === 'admin') return true; + if (userValue === true) return true; + if (userValue === false) return false; + return globalValue; +} + +/** + * Fetch a global boolean setting from the Configuration table. + * @param key - Configuration key + * @param defaultValue - Value to use if the key doesn't exist + */ +export async function getGlobalBooleanSetting( + key: string, + defaultValue: boolean = true +): Promise { + const config = await prisma.configuration.findUnique({ + where: { key }, + }); + return config == null ? defaultValue : config.value === 'true'; +} + +/** + * Resolve a user's effective interactive search access permission. + */ +export async function resolveInteractiveSearchAccess( + userRole: string, + userInteractiveSearchAccess: boolean | null +): Promise { + if (userRole === 'admin') return true; + if (userInteractiveSearchAccess === true) return true; + if (userInteractiveSearchAccess === false) return false; + return getGlobalBooleanSetting('interactive_search_access', true); +} diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts index 6352036..a0bec36 100644 --- a/src/lib/utils/ranking-algorithm.ts +++ b/src/lib/utils/ranking-algorithm.ts @@ -17,7 +17,7 @@ export interface TorrentResult { infoUrl?: string; // Link to indexer's info page (for user reference) infoHash?: string; guid: string; - format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER'; + format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER'; bitrate?: string; hasChapters?: boolean; flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc. @@ -254,6 +254,7 @@ export class RankingAlgorithm { * Reduced from 25 to make room for data-driven size scoring * M4B with chapters: 10 pts * M4B without chapters: 9 pts + * FLAC: 7 pts (lossless audio, excellent quality) * M4A: 6 pts * MP3: 4 pts * Other: 1 pt @@ -264,6 +265,8 @@ export class RankingAlgorithm { switch (format) { case 'M4B': return torrent.hasChapters !== false ? 10 : 9; + case 'FLAC': + return 7; case 'M4A': return 6; case 'MP3': @@ -395,11 +398,13 @@ export class RankingAlgorithm { .filter(word => word.length > 0 && !stopList.includes(word)); }; - // Separate required words (outside parentheses/brackets) from optional words (inside) - // This handles common patterns like "Title (Subtitle)" where subtitle may be omitted - // Note: Run on ORIGINAL title to preserve brackets, then normalize the result + // Separate required words (outside parentheses/brackets/colon subtitles) from optional words + // This handles common patterns like: + // "Title (Subtitle)" where subtitle may be omitted + // "Title: Series Name" where Audible appends series names after a colon + // Note: Run on ORIGINAL title to preserve brackets/colons, then normalize the result const separateRequiredOptional = (title: string): { required: string; optional: string } => { - // Work with original title format for bracket detection + // Work with original title format for bracket/colon detection const originalTitle = audiobook.title.toLowerCase(); // Extract content in parentheses/brackets as optional @@ -411,8 +416,20 @@ export class RankingAlgorithm { optionalMatches.push(match[1]); } - // Remove parenthetical/bracketed content to get required portion - const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim(); + // Remove parenthetical/bracketed content to get the non-bracketed portion + let requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim(); + + // Treat content after a colon as optional (Audible commonly appends series names) + // e.g., "The Finest Edge of Twilight: Dungeons & Dragons" → required: title, optional: series + const colonIndex = requiredRaw.indexOf(':'); + if (colonIndex > 0 && colonIndex < requiredRaw.length - 1) { + const afterColon = requiredRaw.substring(colonIndex + 1).trim(); + if (afterColon.length > 0) { + optionalMatches.push(afterColon); + } + requiredRaw = requiredRaw.substring(0, colonIndex).trim(); + } + // Normalize the required portion (handles CamelCase, punctuation) const required = this.normalizeForMatching(requiredRaw); const optional = optionalMatches.join(' '); @@ -652,7 +669,7 @@ export class RankingAlgorithm { /** * Detect format from torrent title */ - private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'OTHER' { + private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER' { // Use explicit format if provided if (torrent.format) { return torrent.format; @@ -664,6 +681,7 @@ export class RankingAlgorithm { if (title.includes('M4B')) return 'M4B'; if (title.includes('M4A')) return 'M4A'; if (title.includes('MP3')) return 'MP3'; + if (title.includes('FLAC')) return 'FLAC'; // Default to OTHER if no format detected return 'OTHER'; @@ -686,6 +704,8 @@ export class RankingAlgorithm { if (torrent.hasChapters !== false) { notes.push('Has chapter markers'); } + } else if (format === 'FLAC') { + notes.push('Lossless format (FLAC)'); } else if (format === 'M4A') { notes.push('Good format (M4A)'); } else if (format === 'MP3') { diff --git a/src/lib/utils/torrent-categories.ts b/src/lib/utils/torrent-categories.ts index 1b27559..98764b4 100644 --- a/src/lib/utils/torrent-categories.ts +++ b/src/lib/utils/torrent-categories.ts @@ -80,3 +80,26 @@ export function isParentCategory(categoryId: number): boolean { const category = TORRENT_CATEGORIES.find((cat) => cat.id === categoryId); return !!category?.children && category.children.length > 0; } + +/** + * Get all standard category IDs (parents and children) from the predefined tree + */ +export function getAllStandardCategoryIds(): Set { + const ids = new Set(); + for (const parent of TORRENT_CATEGORIES) { + ids.add(parent.id); + if (parent.children) { + for (const child of parent.children) { + ids.add(child.id); + } + } + } + return ids; +} + +/** + * Check if a category ID exists in the predefined category tree + */ +export function isStandardCategory(categoryId: number): boolean { + return getAllStandardCategoryIds().has(categoryId); +} diff --git a/tests/api/admin-downloads.routes.test.ts b/tests/api/admin-downloads.routes.test.ts index a9d582b..f3e2bdd 100644 --- a/tests/api/admin-downloads.routes.test.ts +++ b/tests/api/admin-downloads.routes.test.ts @@ -14,6 +14,9 @@ const requireAdminMock = vi.hoisted(() => vi.fn()); const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); const qbittorrentMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); +const downloadClientManagerMock = vi.hoisted(() => ({ + getClientServiceForProtocol: vi.fn(), +})); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -36,12 +39,17 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({ getSABnzbdService: async () => sabnzbdMock, })); +vi.mock('@/lib/services/download-client-manager.service', () => ({ + getDownloadClientManager: () => downloadClientManagerMock, +})); + describe('Admin downloads route', () => { beforeEach(() => { vi.clearAllMocks(); authRequest = { user: { id: 'admin-1', role: 'admin' } }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + downloadClientManagerMock.getClientServiceForProtocol.mockReset(); }); it('returns formatted active downloads', async () => { @@ -53,11 +61,15 @@ describe('Admin downloads route', () => { updatedAt: new Date(), audiobook: { title: 'Title', author: 'Author' }, user: { plexUsername: 'user' }, - downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }], + downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading', downloadClient: 'qbittorrent' }], }, ]); - configServiceMock.get.mockResolvedValueOnce('qbittorrent'); - qbittorrentMock.getTorrent.mockResolvedValueOnce({ dlspeed: 123, eta: 60 }); + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce({ + getDownload: vi.fn().mockResolvedValue({ + downloadSpeed: 123, + eta: 60, + }), + }); const { GET } = await import('@/app/api/admin/downloads/active/route'); const response = await GET({} as any); @@ -76,11 +88,15 @@ describe('Admin downloads route', () => { updatedAt: new Date(), audiobook: { title: 'Title', author: 'Author' }, user: { plexUsername: 'user' }, - downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'NZB', downloadStatus: 'downloading' }], + downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'NZB', downloadStatus: 'downloading', downloadClient: 'sabnzbd' }], }, ]); - configServiceMock.get.mockResolvedValueOnce('sabnzbd'); - sabnzbdMock.getNZB.mockResolvedValueOnce({ downloadSpeed: 555, timeLeft: 120 }); + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce({ + getDownload: vi.fn().mockResolvedValue({ + downloadSpeed: 555, + eta: 120, + }), + }); const { GET } = await import('@/app/api/admin/downloads/active/route'); const response = await GET({} as any); @@ -99,11 +115,12 @@ describe('Admin downloads route', () => { updatedAt: new Date(), audiobook: { title: 'Title', author: 'Author' }, user: { plexUsername: 'user' }, - downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }], + downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading', downloadClient: 'qbittorrent' }], }, ]); - configServiceMock.get.mockResolvedValueOnce('qbittorrent'); - qbittorrentMock.getTorrent.mockRejectedValueOnce(new Error('client down')); + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce({ + getDownload: vi.fn().mockRejectedValue(new Error('client down')), + }); const { GET } = await import('@/app/api/admin/downloads/active/route'); const response = await GET({} as any); diff --git a/tests/api/admin-settings-core.routes.test.ts b/tests/api/admin-settings-core.routes.test.ts index 7b7df9f..a0f4a54 100644 --- a/tests/api/admin-settings-core.routes.test.ts +++ b/tests/api/admin-settings-core.routes.test.ts @@ -152,8 +152,8 @@ describe('Admin settings core routes', () => { it('rejects invalid download client types', async () => { const request = { json: vi.fn().mockResolvedValue({ - type: 'transmission', - url: 'http://transmission', + type: 'deluge', + url: 'http://deluge', }), }; diff --git a/tests/api/admin-settings-tests.routes.test.ts b/tests/api/admin-settings-tests.routes.test.ts index 2e9f88b..e12ecc2 100644 --- a/tests/api/admin-settings-tests.routes.test.ts +++ b/tests/api/admin-settings-tests.routes.test.ts @@ -35,6 +35,7 @@ const configServiceMock = vi.hoisted(() => ({ })); const downloadClientManagerMock = vi.hoisted(() => ({ getAllClients: vi.fn(), + testConnection: vi.fn(), })); vi.mock('@/lib/db', () => ({ @@ -70,6 +71,13 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({ }, })); +vi.mock('@/lib/integrations/transmission.service', () => ({ + TransmissionService: class { + constructor() {} + testConnection = vi.fn(); + }, +})); + vi.mock('@/lib/services/ebook-scraper', () => ({ testFlareSolverrConnection: testFlareSolverrMock, })); @@ -189,7 +197,10 @@ describe('Admin settings test routes', () => { }); it('tests download client connection', async () => { - qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0'); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: true, + message: 'Successfully connected to qbittorrent (v4.0.0)', + }); const request = { json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt', username: 'user', password: 'pass' }), }; @@ -199,7 +210,7 @@ describe('Admin settings test routes', () => { const payload = await response.json(); expect(payload.success).toBe(true); - expect(payload.version).toBe('4.0.0'); + expect(payload.message).toContain('4.0.0'); }); it('validates required fields for download client testing', async () => { @@ -229,7 +240,10 @@ describe('Admin settings test routes', () => { downloadClientManagerMock.getAllClients.mockResolvedValueOnce([ { type: 'qbittorrent', password: 'stored-pass' }, ]); - qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.1.0'); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: true, + message: 'Successfully connected to qbittorrent (v4.1.0)', + }); const request = { json: vi.fn().mockResolvedValue({ type: 'qbittorrent', @@ -244,12 +258,6 @@ describe('Admin settings test routes', () => { const payload = await response.json(); expect(payload.success).toBe(true); - expect(qbtMock.testConnectionWithCredentials).toHaveBeenCalledWith( - 'http://qbt', - 'user', - 'stored-pass', - false - ); }); it('returns error when masked password is missing in storage', async () => { @@ -273,7 +281,10 @@ describe('Admin settings test routes', () => { }); it('returns error when SABnzbd connection fails', async () => { - sabnzbdMock.testConnection.mockResolvedValueOnce({ success: false, error: 'bad key' }); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: false, + message: 'bad key', + }); const request = { json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab', password: 'key' }), }; @@ -282,12 +293,15 @@ describe('Admin settings test routes', () => { const response = await POST(request as any); const payload = await response.json(); - expect(response.status).toBe(500); + expect(response.status).toBe(400); expect(payload.error).toMatch(/bad key/); }); it('requires path mapping values when enabled', async () => { - qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0'); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: true, + message: 'Connected', + }); const request = { json: vi.fn().mockResolvedValue({ type: 'qbittorrent', @@ -307,7 +321,10 @@ describe('Admin settings test routes', () => { }); it('rejects inaccessible local path when mapping is enabled', async () => { - qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0'); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: true, + message: 'Connected', + }); fsMock.access.mockRejectedValueOnce(new Error('missing')); const request = { json: vi.fn().mockResolvedValue({ diff --git a/tests/api/audiobooks-search.routes.test.ts b/tests/api/audiobooks-search.routes.test.ts index e8913a4..3a47453 100644 --- a/tests/api/audiobooks-search.routes.test.ts +++ b/tests/api/audiobooks-search.routes.test.ts @@ -67,7 +67,7 @@ describe('Audiobooks search torrents route', () => { .mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10 }])) .mockResolvedValueOnce(null); - groupIndexersMock.mockReturnValue([{ categories: [1], indexerIds: [1] }]); + groupIndexersMock.mockReturnValue({ groups: [{ categories: [1], indexerIds: [1] }], skippedIndexers: [] }); prowlarrMock.search.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]); rankTorrentsMock.mockReturnValue([ { diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts index ae7200a..b45fd1b 100644 --- a/tests/api/requests-actions.routes.test.ts +++ b/tests/api/requests-actions.routes.test.ts @@ -79,6 +79,10 @@ describe('Request action routes', () => { userId: 'user-1', audiobook: { title: 'Title', author: 'Author' }, }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + role: 'user', + interactiveSearchAccess: null, + }); configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }])); configServiceMock.get.mockResolvedValueOnce(null); prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]); diff --git a/tests/api/requests-approval.routes.test.ts b/tests/api/requests-approval.routes.test.ts index ae3f4fc..89caae6 100644 --- a/tests/api/requests-approval.routes.test.ts +++ b/tests/api/requests-approval.routes.test.ts @@ -468,6 +468,7 @@ describe('Request Approval Workflow', () => { plexUsername: true, role: true, autoApproveRequests: true, + interactiveSearchAccess: true, }, }); }); diff --git a/tests/api/requests-id.route.test.ts b/tests/api/requests-id.route.test.ts index dc181a3..c443d5f 100644 --- a/tests/api/requests-id.route.test.ts +++ b/tests/api/requests-id.route.test.ts @@ -13,6 +13,9 @@ const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: const requireAuthMock = vi.hoisted(() => vi.fn()); const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); +const downloadClientManagerMock = vi.hoisted(() => ({ + getClientServiceForProtocol: vi.fn(), +})); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -30,6 +33,14 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({ getSABnzbdService: async () => sabnzbdMock, })); +vi.mock('@/lib/services/download-client-manager.service', () => ({ + getDownloadClientManager: () => downloadClientManagerMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => ({}), +})); + vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, })); @@ -43,6 +54,7 @@ describe('Request by ID API routes', () => { json: vi.fn(), }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + downloadClientManagerMock.getClientServiceForProtocol.mockReset(); }); it('returns 403 when user is not authorized to view the request', async () => { @@ -200,9 +212,14 @@ describe('Request by ID API routes', () => { id: 'req-5', userId: 'user-1', audiobook: { id: 'ab-5' }, - downloadHistory: [{ torrentHash: 'hash-1', selected: true }], + downloadHistory: [{ torrentHash: 'hash-1', selected: true, downloadClient: 'qbittorrent' }], }); - qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' }); + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue({ + clientType: 'qbittorrent', + getDownload: vi.fn().mockResolvedValue({ + downloadPath: '/downloads/Book', + }), + }); prismaMock.request.update.mockResolvedValueOnce({ id: 'req-5', status: 'processing', @@ -230,9 +247,14 @@ describe('Request by ID API routes', () => { id: 'req-6', userId: 'user-1', audiobook: { id: 'ab-6' }, - downloadHistory: [{ nzbId: 'nzb-1', selected: true }], + downloadHistory: [{ nzbId: 'nzb-1', selected: true, downloadClient: 'sabnzbd' }], }); - sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/usenet/book' }); + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue({ + clientType: 'sabnzbd', + getDownload: vi.fn().mockResolvedValue({ + downloadPath: '/usenet/book', + }), + }); prismaMock.request.update.mockResolvedValueOnce({ id: 'req-6', status: 'processing', diff --git a/tests/api/setup-guard.routes.test.ts b/tests/api/setup-guard.routes.test.ts new file mode 100644 index 0000000..88bfe9f --- /dev/null +++ b/tests/api/setup-guard.routes.test.ts @@ -0,0 +1,183 @@ +/** + * Component: Setup Route Guard Tests + * Documentation: documentation/testing.md + * + * Verifies that all setup API endpoints return 403 after setup is complete. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const prismaMock = vi.hoisted(() => ({ + configuration: { + findUnique: vi.fn(), + }, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +// Mock all external dependencies that setup routes import +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => ({ + testConnection: vi.fn(), + getLibraries: vi.fn(), + }), +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + ProwlarrService: class { + constructor() {} + getIndexers = vi.fn(); + }, +})); + +vi.mock('openid-client', () => ({ + Issuer: { discover: vi.fn() }, +})); + +vi.mock('fs/promises', () => ({ + default: { + access: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + }, + access: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + constants: { R_OK: 4 }, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => ({ get: vi.fn() }), +})); + +vi.mock('@/lib/services/download-client-manager.service', () => ({ + getDownloadClientManager: () => ({ testConnection: vi.fn() }), +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => ({ encrypt: vi.fn((v: string) => `enc-${v}`) }), +})); + +vi.mock('bcrypt', () => ({ + default: { hash: vi.fn() }, + hash: vi.fn(), +})); + +vi.mock('@/lib/utils/jwt', () => ({ + generateAccessToken: vi.fn(() => 'token'), + generateRefreshToken: vi.fn(() => 'token'), +})); + +function mockSetupComplete() { + prismaMock.configuration.findUnique.mockResolvedValue({ key: 'setup_completed', value: 'true' }); +} + +function makeRequest(body: Record = {}) { + return { + json: vi.fn().mockResolvedValue(body), + nextUrl: { pathname: '/api/setup/test' }, + } as any; +} + +describe('Setup route guard - blocks access after setup is complete', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSetupComplete(); + }); + + it('POST /api/setup/complete returns 403 when setup is already complete', async () => { + const { POST } = await import('@/app/api/setup/complete/route'); + const response = await POST(makeRequest({ backendMode: 'plex' })); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + expect(payload.message).toMatch(/Setup has already been completed/); + }); + + it('POST /api/setup/test-download-client returns 403 when setup is already complete', async () => { + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('POST /api/setup/test-plex returns 403 when setup is already complete', async () => { + const { POST } = await import('@/app/api/setup/test-plex/route'); + const response = await POST(makeRequest({ url: 'http://plex', token: 'token' })); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('POST /api/setup/test-prowlarr returns 403 when setup is already complete', async () => { + const { POST } = await import('@/app/api/setup/test-prowlarr/route'); + const response = await POST(makeRequest({ url: 'http://prowlarr', apiKey: 'key' })); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('POST /api/setup/test-paths returns 403 when setup is already complete', async () => { + const { POST } = await import('@/app/api/setup/test-paths/route'); + const response = await POST(makeRequest({ downloadDir: '/downloads', mediaDir: '/media' })); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('POST /api/setup/test-abs returns 403 when setup is already complete', async () => { + const { POST } = await import('@/app/api/setup/test-abs/route'); + const response = await POST(makeRequest({ serverUrl: 'http://abs', apiToken: 'token' })); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('POST /api/setup/test-oidc returns 403 when setup is already complete', async () => { + const { POST } = await import('@/app/api/setup/test-oidc/route'); + const response = await POST(makeRequest({ + issuerUrl: 'http://issuer', + clientId: 'client', + clientSecret: 'secret', + })); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('allows requests through when setup is not yet complete', async () => { + // Override: setup not complete + prismaMock.configuration.findUnique.mockResolvedValue(null); + + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); + const payload = await response.json(); + + // Should reach the handler (not 403), even if the actual test fails + expect(response.status).not.toBe(403); + }); + + it('allows requests through when database is not ready', async () => { + // Override: database error + prismaMock.configuration.findUnique.mockRejectedValue(new Error('DB not ready')); + + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST(makeRequest({ type: 'qbittorrent', url: 'http://qbt' })); + const payload = await response.json(); + + // Should reach the handler (not 403) — DB not ready means setup hasn't happened + expect(response.status).not.toBe(403); + }); +}); diff --git a/tests/api/setup-tests.routes.test.ts b/tests/api/setup-tests.routes.test.ts index 7dcd4dc..faeec79 100644 --- a/tests/api/setup-tests.routes.test.ts +++ b/tests/api/setup-tests.routes.test.ts @@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +const setupGuardPrismaMock = vi.hoisted(() => ({ + configuration: { + findUnique: vi.fn(), // returns undefined by default = setup not complete + }, +})); + const plexServiceMock = vi.hoisted(() => ({ testConnection: vi.fn(), getLibraries: vi.fn(), @@ -30,6 +36,9 @@ const fsMock = vi.hoisted(() => ({ const configServiceMock = vi.hoisted(() => ({ get: vi.fn(), })); +const downloadClientManagerMock = vi.hoisted(() => ({ + testConnection: vi.fn(), +})); vi.mock('@/lib/integrations/plex.service', () => ({ getPlexService: () => plexServiceMock, @@ -48,6 +57,13 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({ }, })); +vi.mock('@/lib/integrations/transmission.service', () => ({ + TransmissionService: class { + constructor() {} + testConnection = vi.fn(); + }, +})); + vi.mock('@/lib/integrations/prowlarr.service', () => ({ ProwlarrService: class { constructor() {} @@ -59,12 +75,20 @@ vi.mock('openid-client', () => ({ Issuer: issuerMock, })); +vi.mock('@/lib/db', () => ({ + prisma: setupGuardPrismaMock, +})); + vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } })); vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); +vi.mock('@/lib/services/download-client-manager.service', () => ({ + getDownloadClientManager: () => downloadClientManagerMock, +})); + describe('Setup test routes', () => { beforeEach(() => { vi.clearAllMocks(); @@ -135,7 +159,10 @@ describe('Setup test routes', () => { }); it('tests qBittorrent credentials', async () => { - qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0'); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: true, + message: 'Successfully connected to qBittorrent (v4.0.0)', + }); const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST({ @@ -149,13 +176,13 @@ describe('Setup test routes', () => { const payload = await response.json(); expect(payload.success).toBe(true); - expect(payload.version).toBe('4.0.0'); + expect(payload.message).toContain('4.0.0'); }); it('rejects invalid download client type', async () => { const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST({ - json: vi.fn().mockResolvedValue({ type: 'transmission', url: 'http://transmission' }), + json: vi.fn().mockResolvedValue({ type: 'deluge', url: 'http://deluge' }), } as any); const payload = await response.json(); @@ -163,7 +190,33 @@ describe('Setup test routes', () => { expect(payload.error).toMatch(/Invalid client type/); }); + it('tests Transmission credentials', async () => { + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: true, + message: 'Successfully connected to Transmission (v4.0.5)', + }); + + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + type: 'transmission', + url: 'http://transmission:9091', + username: 'user', + password: 'pass', + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.message).toContain('Transmission'); + }); + it('rejects missing SABnzbd API key', async () => { + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: false, + message: 'API key is required for SABnzbd', + }); + const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST({ json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab' }), @@ -175,7 +228,10 @@ describe('Setup test routes', () => { }); it('tests SABnzbd connection', async () => { - sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' }); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: true, + message: 'Successfully connected to SABnzbd (v3.0)', + }); const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST({ @@ -188,11 +244,14 @@ describe('Setup test routes', () => { const payload = await response.json(); expect(payload.success).toBe(true); - expect(payload.version).toBe('3.0'); + expect(payload.message).toContain('3.0'); }); it('returns error when SABnzbd connection fails', async () => { - sabnzbdMock.testConnection.mockResolvedValue({ success: false, error: 'bad key' }); + downloadClientManagerMock.testConnection.mockResolvedValueOnce({ + success: false, + message: 'bad key', + }); const { POST } = await import('@/app/api/setup/test-download-client/route'); const response = await POST({ @@ -204,7 +263,7 @@ describe('Setup test routes', () => { } as any); const payload = await response.json(); - expect(response.status).toBe(500); + expect(response.status).toBe(400); expect(payload.error).toMatch(/bad key/); }); diff --git a/tests/app/admin-settings.page.test.tsx b/tests/app/admin-settings.page.test.tsx index db40944..4e8e3ee 100644 --- a/tests/app/admin-settings.page.test.tsx +++ b/tests/app/admin-settings.page.test.tsx @@ -106,6 +106,7 @@ const settingsFixture = { downloadDir: '', mediaDir: '', audiobookPathTemplate: '', + ebookPathTemplate: '', metadataTaggingEnabled: true, chapterMergingEnabled: false, }, diff --git a/tests/app/admin-users.page.test.tsx b/tests/app/admin-users.page.test.tsx index ac166a4..51fefb7 100644 --- a/tests/app/admin-users.page.test.tsx +++ b/tests/app/admin-users.page.test.tsx @@ -6,7 +6,7 @@ // @vitest-environment jsdom import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import AdminUsersPage from '@/app/admin/users/page'; @@ -38,6 +38,60 @@ vi.mock('@/components/ui/Toast', () => ({ useToast: () => toastMock, })); +const makeUser = (overrides: Record = {}) => ({ + id: 'u1', + plexUsername: 'TestUser', + plexId: 'plex-1', + role: 'user', + isSetupAdmin: false, + authProvider: 'local', + plexEmail: 'test@example.com', + avatarUrl: null, + createdAt: '', + updatedAt: '', + lastLoginAt: null, + autoApproveRequests: false, + interactiveSearchAccess: null, + _count: { requests: 0 }, + ...overrides, +}); + +/** Sets up all required SWR state for the page, with optional overrides. */ +function setupSWR(opts: { + users?: any[]; + pendingUsers?: any[]; + autoApprove?: boolean; + interactiveSearch?: boolean; + mutateUsers?: ReturnType; + mutatePending?: ReturnType; + mutateAutoApprove?: ReturnType; + mutateInteractiveSearch?: ReturnType; +} = {}) { + const mutateUsers = opts.mutateUsers ?? vi.fn(); + const mutatePending = opts.mutatePending ?? vi.fn(); + const mutateAutoApprove = opts.mutateAutoApprove ?? vi.fn(); + const mutateInteractiveSearch = opts.mutateInteractiveSearch ?? vi.fn(); + + swrState.set('/api/admin/users', { + data: { users: opts.users ?? [makeUser()] }, + mutate: mutateUsers, + }); + swrState.set('/api/admin/users/pending', { + data: { users: opts.pendingUsers ?? [] }, + mutate: mutatePending, + }); + swrState.set('/api/admin/settings/auto-approve', { + data: { autoApproveRequests: opts.autoApprove ?? false }, + mutate: mutateAutoApprove, + }); + swrState.set('/api/admin/settings/interactive-search', { + data: { interactiveSearchAccess: opts.interactiveSearch ?? true }, + mutate: mutateInteractiveSearch, + }); + + return { mutateUsers, mutatePending, mutateAutoApprove, mutateInteractiveSearch }; +} + describe('AdminUsersPage', () => { beforeEach(() => { swrState.clear(); @@ -46,22 +100,17 @@ describe('AdminUsersPage', () => { toastMock.error.mockReset(); }); - it('toggles global auto-approve and persists setting', async () => { - const mutateUsers = vi.fn(); - const mutatePending = vi.fn(); - const mutateGlobal = vi.fn(); - - swrState.set('/api/admin/users', { - data: { users: [{ id: 'u1', plexUsername: 'User', plexId: 'plex-1', role: 'user', isSetupAdmin: false, authProvider: 'local', plexEmail: null, avatarUrl: null, createdAt: '', updatedAt: '', lastLoginAt: null, autoApproveRequests: false, _count: { requests: 0 } }] }, - mutate: mutateUsers, - }); - swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: mutatePending }); - swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: false }, mutate: mutateGlobal }); + it('opens global settings modal and toggles auto-approve', async () => { + const { mutateAutoApprove, mutateUsers } = setupSWR({ autoApprove: false }); fetchJSONMock.mockResolvedValueOnce({ success: true }); render(); + // Open the Global Settings modal + fireEvent.click(await screen.findByRole('button', { name: /Global.*Permissions/i })); + + // Click the toggle label inside the modal fireEvent.click(await screen.findByText('Auto-Approve All Requests')); await waitFor(() => { @@ -69,38 +118,171 @@ describe('AdminUsersPage', () => { method: 'PATCH', body: JSON.stringify({ autoApproveRequests: true }), }); - expect(mutateGlobal).toHaveBeenCalled(); + expect(mutateAutoApprove).toHaveBeenCalled(); expect(mutateUsers).toHaveBeenCalled(); }); }); - it('edits a user role and saves changes', async () => { - const mutateUsers = vi.fn(); + it('opens global settings modal and toggles interactive search', async () => { + const { mutateInteractiveSearch, mutateUsers } = setupSWR({ interactiveSearch: true }); - swrState.set('/api/admin/users', { - data: { - users: [ - { - id: 'u2', - plexUsername: 'LocalUser', - plexId: 'local-1', - role: 'user', - isSetupAdmin: false, - authProvider: 'local', - plexEmail: 'local@example.com', - avatarUrl: null, - createdAt: '', - updatedAt: '', - lastLoginAt: null, - autoApproveRequests: false, - _count: { requests: 2 }, - }, - ], - }, - mutate: mutateUsers, + fetchJSONMock.mockResolvedValueOnce({ success: true }); + + render(); + + // Open the Global Settings modal + fireEvent.click(await screen.findByRole('button', { name: /Global.*Permissions/i })); + + // Click the interactive search toggle label inside the modal + fireEvent.click(await screen.findByText('Interactive Search Access')); + + await waitFor(() => { + expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/settings/interactive-search', { + method: 'PATCH', + body: JSON.stringify({ interactiveSearchAccess: false }), + }); + expect(mutateInteractiveSearch).toHaveBeenCalled(); + expect(mutateUsers).toHaveBeenCalled(); + }); + }); + + it('shows correct permission badges in the users table', async () => { + setupSWR({ + users: [ + makeUser({ id: 'u-admin', plexUsername: 'AdminUser', role: 'admin' }), + makeUser({ id: 'u-manual', plexUsername: 'ManualUser', role: 'user', autoApproveRequests: false }), + makeUser({ id: 'u-approved', plexUsername: 'ApprovedUser', role: 'user', autoApproveRequests: true }), + ], + autoApprove: false, + }); + + render(); + + expect(await screen.findByText('Full Access')).toBeDefined(); + expect(screen.getByText('Manual')).toBeDefined(); + expect(screen.getByText('Auto-Approve')).toBeDefined(); + }); + + it('shows Global Default badge when global auto-approve is on', async () => { + setupSWR({ + users: [makeUser({ id: 'u-user', plexUsername: 'RegularUser', role: 'user', autoApproveRequests: false })], + autoApprove: true, + }); + + render(); + + expect(await screen.findByText('Global Default')).toBeDefined(); + }); + + it('opens user permissions modal and shows admin lock state for both permissions', async () => { + setupSWR({ + users: [makeUser({ id: 'u-admin', plexUsername: 'AdminUser', role: 'admin', plexEmail: 'admin@test.com' })], + autoApprove: false, + interactiveSearch: false, + }); + + render(); + + // Click the permissions badge to open modal + fireEvent.click(await screen.findByText('Full Access')); + + // Modal should show user info and the locked state for both permissions + expect(await screen.findByText('User Permissions')).toBeDefined(); + expect(screen.getAllByText('AdminUser').length).toBeGreaterThanOrEqual(2); // table + modal + expect(screen.getByText('Admin requests are always auto-approved')).toBeDefined(); + expect(screen.getByText('Admins always have interactive search access')).toBeDefined(); + }); + + it('opens user permissions modal and toggles auto-approve for regular user', async () => { + const { mutateUsers } = setupSWR({ + users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false })], + autoApprove: false, + interactiveSearch: false, + }); + + fetchJSONMock.mockResolvedValueOnce({ success: true }); + + render(); + + // Click the Manual badge to open permissions modal + fireEvent.click(await screen.findByText('Manual')); + + // Find and click the auto-approve toggle switch inside the modal + const toggle = await screen.findByRole('switch', { name: 'Auto-Approve Requests' }); + fireEvent.click(toggle); + + await waitFor(() => { + expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u-reg', { + method: 'PUT', + body: JSON.stringify({ role: 'user', autoApproveRequests: true }), + }); + }); + }); + + it('opens user permissions modal and toggles interactive search for regular user', async () => { + setupSWR({ + users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false, interactiveSearchAccess: false })], + autoApprove: false, + interactiveSearch: false, + }); + + fetchJSONMock.mockResolvedValueOnce({ success: true }); + + render(); + + // Click the Manual badge to open permissions modal + fireEvent.click(await screen.findByText('Manual')); + + // Find and click the interactive search toggle switch inside the modal + const toggle = await screen.findByRole('switch', { name: 'Interactive Search Access' }); + fireEvent.click(toggle); + + await waitFor(() => { + expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u-reg', { + method: 'PUT', + body: JSON.stringify({ role: 'user', interactiveSearchAccess: true }), + }); + }); + }); + + it('shows global override message in permissions modal when global is on', async () => { + setupSWR({ + users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false })], + autoApprove: true, + interactiveSearch: true, + }); + + render(); + + // Click the Global Default badge + fireEvent.click(await screen.findByText('Global Default')); + + // Modal should show the global override message for both + expect(await screen.findByText('Controlled by global auto-approve setting')).toBeDefined(); + expect(screen.getByText('Controlled by global interactive search setting')).toBeDefined(); + + // Both toggles should be disabled + const autoApproveToggle = screen.getByRole('switch', { name: 'Auto-Approve Requests' }); + expect(autoApproveToggle).toHaveProperty('disabled', true); + + const searchToggle = screen.getByRole('switch', { name: 'Interactive Search Access' }); + expect(searchToggle).toHaveProperty('disabled', true); + }); + + it('edits a user role and saves changes', async () => { + const { mutateUsers } = setupSWR({ + users: [ + makeUser({ + id: 'u2', + plexUsername: 'LocalUser', + plexId: 'local-1', + plexEmail: 'local@example.com', + autoApproveRequests: false, + _count: { requests: 2 }, + }), + ], + autoApprove: true, }); - swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: vi.fn() }); - swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() }); fetchJSONMock.mockResolvedValueOnce({ success: true }); @@ -120,17 +302,11 @@ describe('AdminUsersPage', () => { }); it('approves a pending user and refreshes lists', async () => { - const mutateUsers = vi.fn(); - const mutatePending = vi.fn(); - - swrState.set('/api/admin/users', { data: { users: [] }, mutate: mutateUsers }); - swrState.set('/api/admin/users/pending', { - data: { - users: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }], - }, - mutate: mutatePending, + const { mutateUsers, mutatePending } = setupSWR({ + users: [], + pendingUsers: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }], + autoApprove: true, }); - swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() }); fetchJSONMock.mockResolvedValueOnce({ success: true }); diff --git a/tests/app/admin/settings/hooks/useSettings.test.tsx b/tests/app/admin/settings/hooks/useSettings.test.tsx index 69bd599..8824a5c 100644 --- a/tests/app/admin/settings/hooks/useSettings.test.tsx +++ b/tests/app/admin/settings/hooks/useSettings.test.tsx @@ -62,6 +62,7 @@ const baseSettings = { downloadDir: '', mediaDir: '', audiobookPathTemplate: '', + ebookPathTemplate: '', metadataTaggingEnabled: true, chapterMergingEnabled: false, }, diff --git a/tests/app/admin/settings/lib/helpers.test.ts b/tests/app/admin/settings/lib/helpers.test.ts index c21aa8a..f39d31e 100644 --- a/tests/app/admin/settings/lib/helpers.test.ts +++ b/tests/app/admin/settings/lib/helpers.test.ts @@ -54,6 +54,7 @@ const baseSettings = { downloadDir: '/downloads', mediaDir: '/media', audiobookPathTemplate: '', + ebookPathTemplate: '', metadataTaggingEnabled: true, chapterMergingEnabled: false, }, diff --git a/tests/app/requests.page.test.tsx b/tests/app/requests.page.test.tsx index 1c224cf..e0436d0 100644 --- a/tests/app/requests.page.test.tsx +++ b/tests/app/requests.page.test.tsx @@ -33,6 +33,10 @@ vi.mock('@/components/requests/RequestCard', () => ({ ), })); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferences: () => ({ squareCovers: false, setSquareCovers: vi.fn(), cardSize: 5, setCardSize: vi.fn() }), +})); + describe('RequestsPage', () => { beforeEach(() => { resetMockAuthState(); diff --git a/tests/app/setup/steps/DownloadClientStep.test.tsx b/tests/app/setup/steps/DownloadClientStep.test.tsx index 209372e..04965f4 100644 --- a/tests/app/setup/steps/DownloadClientStep.test.tsx +++ b/tests/app/setup/steps/DownloadClientStep.test.tsx @@ -13,7 +13,7 @@ import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep'; interface DownloadClient { id: string; - type: 'qbittorrent' | 'sabnzbd'; + type: 'qbittorrent' | 'sabnzbd' | 'transmission'; name: string; enabled: boolean; url: string; @@ -469,34 +469,39 @@ describe('DownloadClientStep', () => { }); describe('Client Type Restrictions', () => { - it('shows "Already configured" when qBittorrent is already added', () => { + it('shows "Protocol already configured" when a torrent client is already added', () => { const mockClient = createMockClient({ type: 'qbittorrent' }); render(); - // "Already configured" text should appear for qBittorrent - expect(screen.getByText('Already configured')).toBeInTheDocument(); + // "Protocol already configured" text should appear for torrent clients + const configuredMessages = screen.getAllByText('Protocol already configured'); + expect(configuredMessages.length).toBeGreaterThanOrEqual(1); - // Add qBittorrent button should not exist + // Add qBittorrent and Add Transmission buttons should not exist (torrent protocol taken) expect(screen.queryByRole('button', { name: /Add qBittorrent/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Add Transmission/i })).not.toBeInTheDocument(); - // SABnzbd should still have Add button + // SABnzbd should still have Add button (different protocol) expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument(); }); - it('shows "Already configured" when SABnzbd is already added', () => { + it('shows "Protocol already configured" when SABnzbd is already added', () => { const mockClient = createMockClient({ type: 'sabnzbd', name: 'My SABnzbd' }); render(); - // "Already configured" text should appear for SABnzbd - expect(screen.getByText('Already configured')).toBeInTheDocument(); + // "Protocol already configured" text should appear for both usenet client cards (SABnzbd + NZBGet) + const configuredMessages = screen.getAllByText('Protocol already configured'); + expect(configuredMessages.length).toBe(2); - // Add SABnzbd button should not exist + // Add SABnzbd and NZBGet buttons should not exist (usenet protocol taken) expect(screen.queryByRole('button', { name: /Add SABnzbd/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Add NZBGet/i })).not.toBeInTheDocument(); - // qBittorrent should still have Add button + // Torrent clients should still have Add buttons expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Add Transmission/i })).toBeInTheDocument(); }); }); @@ -635,9 +640,9 @@ describe('DownloadClientStep', () => { expect(editButtons).toHaveLength(2); }); - // Both "Already configured" messages should appear - const alreadyConfiguredMessages = screen.getAllByText('Already configured'); - expect(alreadyConfiguredMessages).toHaveLength(2); + // Both "Protocol already configured" messages should appear (torrent + usenet) + const alreadyConfiguredMessages = screen.getAllByText('Protocol already configured'); + expect(alreadyConfiguredMessages.length).toBeGreaterThanOrEqual(2); }); }); diff --git a/tests/components/admin/indexers/IndexerConfigModal.test.tsx b/tests/components/admin/indexers/IndexerConfigModal.test.tsx index 9d079cb..e635573 100644 --- a/tests/components/admin/indexers/IndexerConfigModal.test.tsx +++ b/tests/components/admin/indexers/IndexerConfigModal.test.tsx @@ -51,7 +51,7 @@ describe('IndexerConfigModal', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('validates that at least one category is selected', () => { + it('shows warning when all audiobook categories are deselected but still allows save', () => { const onSave = vi.fn(); render( @@ -72,11 +72,18 @@ describe('IndexerConfigModal', () => { } fireEvent.click(within(audiobookRow).getByRole('switch')); + + // Warning should be shown instead of blocking save + expect(screen.getByText(/will not be searched for audiobooks/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' })); - // Component now shows specific error for audiobook categories - expect(screen.getByText('At least one audiobook category must be selected')).toBeInTheDocument(); - expect(onSave).not.toHaveBeenCalled(); + // Save should still be called with empty audiobook categories + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + audiobookCategories: [], + }) + ); }); it('forces RSS to false when the indexer does not support RSS', () => { diff --git a/tests/components/requests/RequestCard.test.tsx b/tests/components/requests/RequestCard.test.tsx index e871bbf..061addc 100644 --- a/tests/components/requests/RequestCard.test.tsx +++ b/tests/components/requests/RequestCard.test.tsx @@ -34,6 +34,22 @@ vi.mock('next/image', () => ({ default: (props: any) => , })); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferences: () => ({ squareCovers: false, setSquareCovers: vi.fn(), cardSize: 5, setCardSize: vi.fn() }), +})); + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ + user: { id: 'user-1', role: 'user', permissions: { interactiveSearch: true } }, + accessToken: 'test-token', + isLoading: false, + login: vi.fn(), + logout: vi.fn(), + refreshToken: vi.fn(), + setAuthData: vi.fn(), + }), +})); + const baseRequest = { id: 'req-1', status: 'pending', diff --git a/tests/components/ui/VersionBadge.test.tsx b/tests/components/ui/VersionBadge.test.tsx index 93e6568..1655904 100644 --- a/tests/components/ui/VersionBadge.test.tsx +++ b/tests/components/ui/VersionBadge.test.tsx @@ -30,13 +30,16 @@ describe('VersionBadge', () => { it('renders semantic version from build-time env var', async () => { process.env.NEXT_PUBLIC_APP_VERSION = '1.0.0'; process.env.NEXT_PUBLIC_GIT_COMMIT = 'abcdef1234'; - const fetchMock = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ version: '1.0.0' }), + }); vi.stubGlobal('fetch', fetchMock); render(); expect(await screen.findByText('v1.0.0')).toBeInTheDocument(); - expect(fetchMock).not.toHaveBeenCalled(); + // Should not call /api/version since build-time version is available + expect(fetchMock).not.toHaveBeenCalledWith('/api/version'); }); it('falls back to API when build-time version is unavailable', async () => { diff --git a/tests/helpers/job-queue.ts b/tests/helpers/job-queue.ts index 19fd7be..6f7f256 100644 --- a/tests/helpers/job-queue.ts +++ b/tests/helpers/job-queue.ts @@ -18,4 +18,5 @@ export const createJobQueueMock = () => ({ addRetryMissingTorrentsJob: vi.fn(), addRetryFailedImportsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(), + addNotificationJob: vi.fn().mockResolvedValue(undefined), }); diff --git a/tests/integrations/nzbget.service.test.ts b/tests/integrations/nzbget.service.test.ts new file mode 100644 index 0000000..896458d --- /dev/null +++ b/tests/integrations/nzbget.service.test.ts @@ -0,0 +1,1304 @@ +/** + * Component: NZBGet Integration Service Tests + * Documentation: documentation/phase3/download-clients.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NZBGetService, getNZBGetService, invalidateNZBGetService } from '@/lib/integrations/nzbget.service'; + +const clientMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +const axiosMock = vi.hoisted(() => ({ + create: vi.fn(() => clientMock), + get: vi.fn(), + isAxiosError: vi.fn(() => false), +})); + +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const downloadClientManagerMock = vi.hoisted(() => ({ + getClientForProtocol: vi.fn(), + getAllClients: vi.fn(), + hasClientForProtocol: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: vi.fn(async () => configServiceMock), +})); + +vi.mock('@/lib/services/download-client-manager.service', () => ({ + getDownloadClientManager: () => downloadClientManagerMock, + invalidateDownloadClientManager: vi.fn(), +})); + +describe('NZBGetService', () => { + beforeEach(() => { + vi.clearAllMocks(); + clientMock.get.mockReset(); + clientMock.post.mockReset(); + axiosMock.get.mockReset(); + axiosMock.isAxiosError.mockReset(); + axiosMock.isAxiosError.mockReturnValue(false); + configServiceMock.get.mockReset(); + downloadClientManagerMock.getClientForProtocol.mockReset(); + downloadClientManagerMock.getAllClients.mockReset(); + downloadClientManagerMock.hasClientForProtocol.mockReset(); + invalidateNZBGetService(); + }); + + it('has correct clientType and protocol', () => { + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + expect(service.clientType).toBe('nzbget'); + expect(service.protocol).toBe('usenet'); + }); + + // ========================================================================= + // Connection Testing + // ========================================================================= + + describe('testConnection', () => { + it('returns version when connection succeeds', async () => { + clientMock.post.mockResolvedValueOnce({ + data: { result: '24.3' }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const result = await service.testConnection(); + + expect(result.success).toBe(true); + expect(result.version).toBe('24.3'); + expect(result.message).toContain('Connected to NZBGet v24.3'); + expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { + method: 'version', + params: [], + }); + }); + + it('fails when version is empty', async () => { + clientMock.post.mockResolvedValueOnce({ + data: { result: '' }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('failed to get NZBGet version'); + }); + + it('returns friendly error on 401 authentication failure', async () => { + const authError = new Error('Request failed with status code 401') as any; + authError.response = { status: 401 }; + authError.isAxiosError = true; + axiosMock.isAxiosError.mockReturnValue(true); + clientMock.post.mockRejectedValueOnce(authError); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'wrong'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('Authentication failed'); + }); + + it('returns friendly error on connection refused', async () => { + const connError = new Error('connect ECONNREFUSED') as any; + connError.code = 'ECONNREFUSED'; + connError.isAxiosError = true; + axiosMock.isAxiosError.mockReturnValue(true); + clientMock.post.mockRejectedValueOnce(connError); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('Connection refused'); + }); + + it('returns friendly error on timeout', async () => { + const timeoutError = new Error('timeout of 30000ms exceeded') as any; + timeoutError.code = 'ETIMEDOUT'; + timeoutError.isAxiosError = true; + axiosMock.isAxiosError.mockReturnValue(true); + clientMock.post.mockRejectedValueOnce(timeoutError); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('timed out'); + }); + + it('returns SSL error for certificate issues', async () => { + clientMock.post.mockRejectedValueOnce(new Error('SSL certificate error')); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('SSL'); + }); + + it('returns RPC error message from server', async () => { + clientMock.post.mockResolvedValueOnce({ + data: { error: { message: 'Method not found' } }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('Method not found'); + }); + }); + + // ========================================================================= + // Adding Downloads + // ========================================================================= + + describe('addDownload', () => { + it('downloads NZB file and uploads to NZBGet via append', async () => { + // Mock ensureCategory: config() (category already exists) + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }) + // Mock append() + .mockResolvedValueOnce({ + data: { result: 12345 }, + }); + + // Mock NZB file download + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('fake-nzb-content'), + headers: { 'content-disposition': 'attachment; filename="My.Audiobook.nzb"' }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + const id = await service.addDownload('https://prowlarr.local/api/download/123', { + category: 'readmeabook', + priority: 'normal', + }); + + expect(id).toBe('12345'); + + // Verify append call + const appendCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'append' + ); + expect(appendCall).toBeDefined(); + const [, body] = appendCall!; + expect(body.method).toBe('append'); + expect(body.params[0]).toBe('My.Audiobook.nzb'); // Filename + expect(body.params[2]).toBe('readmeabook'); // Category + expect(body.params[3]).toBe(0); // Normal priority + }); + + it('maps high priority correctly', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }) + .mockResolvedValueOnce({ data: { result: 99 } }); + + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('nzb'), + headers: {}, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + const id = await service.addDownload('https://example.com/book.nzb', { priority: 'high' }); + + expect(id).toBe('99'); + const appendCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'append' + ); + expect(appendCall![1].params[3]).toBe(50); // High = 50 + }); + + it('maps force priority correctly', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }) + .mockResolvedValueOnce({ data: { result: 100 } }); + + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('nzb'), + headers: {}, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await service.addDownload('https://example.com/book.nzb', { priority: 'force' }); + + const appendCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'append' + ); + expect(appendCall![1].params[3]).toBe(900); // Force = 900 + }); + + it('throws when NZBGet rejects the NZB', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }) + .mockResolvedValueOnce({ data: { result: 0 } }); // 0 = rejected + + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('nzb'), + headers: {}, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await expect(service.addDownload('https://example.com/bad.nzb')).rejects.toThrow('rejected'); + }); + + it('throws when NZB file download fails with HTTP error', async () => { + clientMock.post.mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }); + + const httpError = new Error('Request failed') as any; + httpError.response = { status: 404 }; + httpError.isAxiosError = true; + axiosMock.isAxiosError.mockReturnValue(true); + axiosMock.get.mockRejectedValueOnce(httpError); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await expect(service.addDownload('https://example.com/missing.nzb')).rejects.toThrow('HTTP 404'); + }); + + it('throws when NZB file is empty', async () => { + clientMock.post.mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }); + + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from(''), + headers: {}, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await expect(service.addDownload('https://example.com/empty.nzb')).rejects.toThrow('empty'); + }); + + it('extracts filename from URL when no Content-Disposition', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }) + .mockResolvedValueOnce({ data: { result: 50 } }); + + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('nzb-content'), + headers: {}, // No content-disposition + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await service.addDownload('https://example.com/My.Great.Audiobook.nzb'); + + const appendCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'append' + ); + expect(appendCall![1].params[0]).toBe('My.Great.Audiobook.nzb'); + }); + + it('decompresses gzip-compressed NZB files before uploading', async () => { + const zlib = await import('zlib'); + const nzbXml = ''; + const compressedNzb = zlib.gzipSync(Buffer.from(nzbXml)); + + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }) + .mockResolvedValueOnce({ data: { result: 777 } }); + + axiosMock.get.mockResolvedValueOnce({ + data: compressedNzb, + headers: { 'content-disposition': 'attachment; filename="Book.nzb"' }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + const id = await service.addDownload('https://example.com/nzb.gz'); + + expect(id).toBe('777'); + + // Verify the base64 content sent to NZBGet is the decompressed XML, not compressed bytes + const appendCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'append' + ); + const sentBase64 = appendCall![1].params[1]; + const decodedContent = Buffer.from(sentBase64, 'base64').toString('utf-8'); + expect(decodedContent).toBe(nzbXml); + }); + + it('falls back to download.nzb when filename cannot be extracted', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }) + .mockResolvedValueOnce({ data: { result: 51 } }); + + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('nzb-content'), + headers: {}, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await service.addDownload('https://example.com/download'); + + const appendCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'append' + ); + expect(appendCall![1].params[0]).toBe('download.nzb'); + }); + }); + + // ========================================================================= + // Getting Downloads + // ========================================================================= + + describe('getDownload', () => { + it('returns queue item when download is active', async () => { + // Mock listgroups (queue check) + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { + NZBID: 100, + NZBName: 'Active Book', + Status: 'DOWNLOADING', + FileSizeMB: 500, + DownloadedSizeMB: 250, + RemainingSizeMB: 250, + DownloadTimeSec: 120, + Category: 'readmeabook', + DestDir: '/downloads/readmeabook/Active.Book', + FinalDir: '', + MaxPriority: 0, + ActiveDownloads: 1, + Health: 1000, + PostInfoText: '', + PostStageProgress: 0, + }, + ], + }, + }) + // Mock status() for download speed (called inside mapGroupToDownloadInfo) + .mockResolvedValueOnce({ + data: { result: { DownloadRate: 5242880 } }, // 5 MB/s + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('100'); + + expect(info).not.toBeNull(); + expect(info!.id).toBe('100'); + expect(info!.name).toBe('Active Book'); + expect(info!.status).toBe('downloading'); + expect(info!.progress).toBe(0.5); + expect(info!.size).toBe(500 * 1024 * 1024); + expect(info!.bytesDownloaded).toBe(250 * 1024 * 1024); + expect(info!.category).toBe('readmeabook'); + expect(info!.downloadSpeed).toBe(5242880); + }); + + it('returns history item when download is completed', async () => { + // Mock listgroups (empty queue) + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + // Mock history + .mockResolvedValueOnce({ + data: { + result: [ + { + NZBID: 200, + Name: 'Completed Book', + Status: 'SUCCESS/ALL', + Category: 'readmeabook', + FileSizeMB: 300, + DownloadedSizeMB: 300, + DestDir: '/downloads/readmeabook/Completed.Book', + FinalDir: '/downloads/readmeabook/Completed.Book', + DownloadTimeSec: 60, + PostTotalTimeSec: 30, + ParStatus: 'SUCCESS', + UnpackStatus: 'SUCCESS', + DeleteStatus: 'NONE', + MarkStatus: 'NONE', + HistoryTime: 1700000000, + FailedArticles: 0, + TotalArticles: 1000, + }, + ], + }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('200'); + + expect(info).not.toBeNull(); + expect(info!.id).toBe('200'); + expect(info!.name).toBe('Completed Book'); + expect(info!.status).toBe('completed'); + expect(info!.progress).toBe(1.0); + expect(info!.bytesDownloaded).toBe(300 * 1024 * 1024); + expect(info!.completedAt?.getTime()).toBe(1700000000 * 1000); + expect(info!.downloadPath).toBe('/downloads/readmeabook/Completed.Book'); + }); + + it('returns history item with failed status and error message', async () => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + .mockResolvedValueOnce({ + data: { + result: [ + { + NZBID: 300, + Name: 'Failed Book', + Status: 'FAILURE/PAR', + Category: 'readmeabook', + FileSizeMB: 100, + DownloadedSizeMB: 80, + DestDir: '/downloads/Failed.Book', + FinalDir: '', + DownloadTimeSec: 45, + PostTotalTimeSec: 10, + ParStatus: 'FAILURE', + UnpackStatus: 'NONE', + DeleteStatus: 'NONE', + MarkStatus: 'NONE', + HistoryTime: 1700000100, + FailedArticles: 50, + TotalArticles: 500, + }, + ], + }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('300'); + + expect(info!.status).toBe('failed'); + expect(info!.errorMessage).toContain('FAILURE/PAR'); + expect(info!.errorMessage).toContain('Par: FAILURE'); + expect(info!.errorMessage).toContain('50 failed articles (10%)'); + }); + + it('returns null when download is not found', async () => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + .mockResolvedValueOnce({ data: { result: [] } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('999'); + + expect(info).toBeNull(); + }); + + it('returns null for invalid NZB ID', async () => { + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('not-a-number'); + + expect(info).toBeNull(); + }); + + it('applies path mapping to download path', async () => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + .mockResolvedValueOnce({ + data: { + result: [ + { + NZBID: 400, + Name: 'Mapped Book', + Status: 'SUCCESS/ALL', + Category: 'readmeabook', + FileSizeMB: 200, + DownloadedSizeMB: 200, + DestDir: '/remote/downloads/readmeabook/Mapped.Book', + FinalDir: '/remote/downloads/readmeabook/Mapped.Book', + DownloadTimeSec: 30, + PostTotalTimeSec: 15, + ParStatus: 'SUCCESS', + UnpackStatus: 'SUCCESS', + DeleteStatus: 'NONE', + MarkStatus: 'NONE', + HistoryTime: 1700000200, + FailedArticles: 0, + TotalArticles: 800, + }, + ], + }, + }); + + const service = new NZBGetService( + 'http://nzbget:6789', 'nzbget', 'pass', + 'readmeabook', '/downloads', false, + { enabled: true, remotePath: '/remote/downloads', localPath: '/downloads' } + ); + const info = await service.getDownload('400'); + + // Path mapping is now applied downstream by consumers, not by the service itself. + // The service returns the raw path from NZBGet. + const normalizedPath = info!.downloadPath!.replace(/\\/g, '/'); + expect(normalizedPath).toContain('/remote/downloads/readmeabook/Mapped.Book'); + }); + }); + + // ========================================================================= + // Status Mapping + // ========================================================================= + + describe('status mapping', () => { + const makeQueueItem = (status: string) => ({ + NZBID: 1, + NZBName: 'Test', + Status: status, + FileSizeMB: 100, + DownloadedSizeMB: 50, + RemainingSizeMB: 50, + DownloadTimeSec: 60, + Category: '', + DestDir: '', + FinalDir: '', + MaxPriority: 0, + ActiveDownloads: 0, + Health: 1000, + PostInfoText: '', + PostStageProgress: 0, + }); + + const makeHistoryItem = (status: string) => ({ + NZBID: 1, + Name: 'Test', + Status: status, + Category: '', + FileSizeMB: 100, + DownloadedSizeMB: 100, + DestDir: '', + FinalDir: '', + DownloadTimeSec: 60, + PostTotalTimeSec: 10, + ParStatus: 'NONE', + UnpackStatus: 'NONE', + DeleteStatus: 'NONE', + MarkStatus: 'NONE', + HistoryTime: 0, + FailedArticles: 0, + TotalArticles: 100, + }); + + it.each([ + ['QUEUED', 'queued'], + ['PAUSED', 'paused'], + ['DOWNLOADING', 'downloading'], + ['FETCHING', 'downloading'], + ['PP_QUEUED', 'processing'], + ['LOADING_PARS', 'processing'], + ['VERIFYING_SOURCES', 'processing'], + ['REPAIRING', 'processing'], + ['VERIFYING_REPAIRED', 'processing'], + ['RENAMING', 'processing'], + ['UNPACKING', 'processing'], + ['MOVING', 'processing'], + ['EXECUTING_SCRIPT', 'processing'], + ['PP_FINISHED', 'processing'], + ])('maps queue status %s to %s', async (nzbgetStatus, expectedStatus) => { + clientMock.post.mockResolvedValueOnce({ + data: { result: [makeQueueItem(nzbgetStatus)] }, + }); + // Mock status() for downloading items + if (expectedStatus === 'downloading') { + clientMock.post.mockResolvedValueOnce({ + data: { result: { DownloadRate: 0 } }, + }); + } + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('1'); + + expect(info!.status).toBe(expectedStatus); + }); + + it.each([ + ['SUCCESS/ALL', 'completed'], + ['SUCCESS/UNPACK', 'completed'], + ['SUCCESS/PAR', 'completed'], + ['SUCCESS/HEALTH', 'completed'], + ['SUCCESS/GOOD', 'completed'], + ['SUCCESS/MARK', 'completed'], + ['WARNING/SCRIPT', 'completed'], + ['WARNING/SPACE', 'completed'], + ['WARNING/PASSWORD', 'completed'], + ['WARNING/HEALTH', 'completed'], + ['FAILURE/PAR', 'failed'], + ['FAILURE/UNPACK', 'failed'], + ['FAILURE/HEALTH', 'failed'], + ['DELETED/MANUAL', 'failed'], + ['DELETED/DUPE', 'failed'], + ])('maps history status %s to %s', async (nzbgetStatus, expectedStatus) => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) // Empty queue + .mockResolvedValueOnce({ + data: { result: [makeHistoryItem(nzbgetStatus)] }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('1'); + + expect(info!.status).toBe(expectedStatus); + }); + + it('defaults unknown queue status to downloading', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { result: [makeQueueItem('UNKNOWN_STATUS')] }, + }) + .mockResolvedValueOnce({ + data: { result: { DownloadRate: 0 } }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('1'); + + expect(info!.status).toBe('downloading'); + }); + }); + + // ========================================================================= + // Pause / Resume / Delete + // ========================================================================= + + describe('pauseDownload', () => { + it('calls editqueue with GroupPause', async () => { + clientMock.post.mockResolvedValueOnce({ data: { result: true } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await service.pauseDownload('100'); + + expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { + method: 'editqueue', + params: ['GroupPause', '', [100]], + }); + }); + + it('throws when pause fails', async () => { + clientMock.post.mockResolvedValueOnce({ data: { result: false } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await expect(service.pauseDownload('100')).rejects.toThrow('Failed to pause'); + }); + }); + + describe('resumeDownload', () => { + it('calls editqueue with GroupResume', async () => { + clientMock.post.mockResolvedValueOnce({ data: { result: true } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await service.resumeDownload('100'); + + expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { + method: 'editqueue', + params: ['GroupResume', '', [100]], + }); + }); + + it('throws when resume fails', async () => { + clientMock.post.mockResolvedValueOnce({ data: { result: false } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await expect(service.resumeDownload('100')).rejects.toThrow('Failed to resume'); + }); + }); + + describe('deleteDownload', () => { + it('deletes from queue with GroupFinalDelete when deleteFiles is true', async () => { + // Mock listgroups to find item in queue + clientMock.post + .mockResolvedValueOnce({ + data: { result: [{ NZBID: 100 }] }, + }) + // Mock editqueue GroupFinalDelete + .mockResolvedValueOnce({ data: { result: true } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await service.deleteDownload('100', true); + + const deleteCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'editqueue' + ); + expect(deleteCall![1].params[0]).toBe('GroupFinalDelete'); + }); + + it('deletes from queue with GroupDelete when deleteFiles is false', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { result: [{ NZBID: 100 }] }, + }) + .mockResolvedValueOnce({ data: { result: true } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await service.deleteDownload('100', false); + + const deleteCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'editqueue' + ); + expect(deleteCall![1].params[0]).toBe('GroupDelete'); + }); + + it('deletes from history when not in queue', async () => { + // Empty queue + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + // Mock editqueue HistoryFinalDelete + .mockResolvedValueOnce({ data: { result: true } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await service.deleteDownload('200', true); + + const deleteCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'editqueue' + ); + expect(deleteCall![1].params[0]).toBe('HistoryFinalDelete'); + }); + + it('throws when delete from history fails', async () => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + .mockResolvedValueOnce({ data: { result: false } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await expect(service.deleteDownload('999', true)).rejects.toThrow('Failed to delete'); + }); + }); + + // ========================================================================= + // Post-Process (Archive from History) + // ========================================================================= + + describe('postProcess', () => { + it('archives completed download from history via HistoryDelete', async () => { + clientMock.post.mockResolvedValueOnce({ data: { result: true } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await service.postProcess('200'); + + expect(clientMock.post).toHaveBeenCalledWith('/jsonrpc', { + method: 'editqueue', + params: ['HistoryDelete', '', [200]], + }); + }); + + it('throws when archive fails', async () => { + clientMock.post.mockResolvedValueOnce({ data: { result: false } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + await expect(service.postProcess('200')).rejects.toThrow('not found in history or failed to archive'); + }); + }); + + // ========================================================================= + // Category Management + // ========================================================================= + + describe('ensureCategory', () => { + it('creates category and preserves all existing config', async () => { + const existingConfig = [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'MainDir', Value: '/root/downloads' }, + { Name: 'ServerHost', Value: '0.0.0.0' }, + // Read-only entries returned by config() that must NOT be saved back + { Name: 'ConfigFile', Value: '/config/nzbget.conf' }, + { Name: 'AppBin', Value: '/app/nzbget/nzbget' }, + { Name: 'AppDir', Value: '/app/nzbget' }, + { Name: 'Version', Value: '26.0' }, + ]; + clientMock.post + // config() + .mockResolvedValueOnce({ data: { result: existingConfig } }) + // saveconfig() + .mockResolvedValueOnce({ data: { result: true } }) + // reload() + .mockResolvedValueOnce({ data: { result: true } }) + // version() poll + .mockResolvedValueOnce({ data: { result: '21.1' } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await service.ensureCategory(); + + const saveCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'saveconfig' + ); + expect(saveCall).toBeDefined(); + const savedConfig = saveCall![1].params[0]; + + // Must contain ALL original writable entries (not wiped) + expect(savedConfig).toEqual(expect.arrayContaining([ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'MainDir', Value: '/root/downloads' }, + { Name: 'ServerHost', Value: '0.0.0.0' }, + ])); + // Plus our new category entries + expect(savedConfig).toEqual(expect.arrayContaining([ + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + { Name: 'Category1.Unpack', Value: 'yes' }, + ])); + + // Read-only entries must NOT be in the saved config + const savedNames = savedConfig.map((e: any) => e.Name); + expect(savedNames).not.toContain('ConfigFile'); + expect(savedNames).not.toContain('AppBin'); + expect(savedNames).not.toContain('AppDir'); + expect(savedNames).not.toContain('Version'); + + // Verify reload was called to apply changes + const reloadCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'reload' + ); + expect(reloadCall).toBeDefined(); + }); + + it('uses next available slot and preserves existing categories', async () => { + const existingConfig = [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'movies' }, + { Name: 'Category1.DestDir', Value: '/downloads/movies' }, + { Name: 'Category2.Name', Value: 'tv' }, + { Name: 'Category2.DestDir', Value: '/downloads/tv' }, + ]; + clientMock.post + .mockResolvedValueOnce({ data: { result: existingConfig } }) + .mockResolvedValueOnce({ data: { result: true } }) + // reload + version + .mockResolvedValueOnce({ data: { result: true } }) + .mockResolvedValueOnce({ data: { result: '21.1' } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await service.ensureCategory(); + + const saveCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'saveconfig' + ); + const savedConfig = saveCall![1].params[0]; + + // Existing categories preserved + expect(savedConfig).toEqual(expect.arrayContaining([ + { Name: 'Category1.Name', Value: 'movies' }, + { Name: 'Category1.DestDir', Value: '/downloads/movies' }, + { Name: 'Category2.Name', Value: 'tv' }, + { Name: 'Category2.DestDir', Value: '/downloads/tv' }, + ])); + // New category in slot 3 + expect(savedConfig).toEqual(expect.arrayContaining([ + { Name: 'Category3.Name', Value: 'readmeabook' }, + { Name: 'Category3.DestDir', Value: '/downloads' }, + ])); + }); + + it('does not update when category exists with correct path', async () => { + clientMock.post.mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ], + }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await service.ensureCategory(); + + // Only config() should be called — no saveconfig, no reload + expect(clientMock.post).toHaveBeenCalledTimes(1); + expect(clientMock.post.mock.calls[0][1].method).toBe('config'); + }); + + it('updates category DestDir preserving full config and reloads', async () => { + const existingConfig = [ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'ServerHost', Value: '0.0.0.0' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/old/path' }, + // Read-only entries that must be filtered out + { Name: 'ConfigFile', Value: '/config/nzbget.conf' }, + { Name: 'AppBin', Value: '/app/nzbget/nzbget' }, + { Name: 'AppDir', Value: '/app/nzbget' }, + { Name: 'Version', Value: '26.0' }, + ]; + clientMock.post + .mockResolvedValueOnce({ data: { result: existingConfig } }) + .mockResolvedValueOnce({ data: { result: true } }) + // reload + version + .mockResolvedValueOnce({ data: { result: true } }) + .mockResolvedValueOnce({ data: { result: '21.1' } }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + await service.ensureCategory(); + + const saveCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'saveconfig' + ); + const savedConfig = saveCall![1].params[0]; + + // Full writable config preserved with updated DestDir — no read-only entries + expect(savedConfig).toEqual([ + { Name: 'DestDir', Value: '/downloads' }, + { Name: 'ServerHost', Value: '0.0.0.0' }, + { Name: 'Category1.Name', Value: 'readmeabook' }, + { Name: 'Category1.DestDir', Value: '/downloads' }, + ]); + }); + + it('applies reverse path mapping for category DestDir', async () => { + const existingConfig = [ + { Name: 'DestDir', Value: '/remote/downloads' }, + ]; + clientMock.post + .mockResolvedValueOnce({ data: { result: existingConfig } }) + .mockResolvedValueOnce({ data: { result: true } }) + // reload + version + .mockResolvedValueOnce({ data: { result: true } }) + .mockResolvedValueOnce({ data: { result: '21.1' } }); + + const service = new NZBGetService( + 'http://nzbget:6789', 'nzbget', 'pass', + 'readmeabook', '/downloads', false, + { enabled: true, remotePath: '/remote/downloads', localPath: '/downloads' } + ); + await service.ensureCategory(); + + const saveCall = clientMock.post.mock.calls.find( + (call: any[]) => call[1]?.method === 'saveconfig' + ); + const savedConfig = saveCall![1].params[0]; + // After reverse transform: /downloads → /remote/downloads + const destDirEntry = savedConfig.find( + (e: any) => e.Name === 'Category1.DestDir' + ); + expect(destDirEntry.Value).toBe('/remote/downloads'); + // Original config preserved + expect(savedConfig).toEqual(expect.arrayContaining([ + { Name: 'DestDir', Value: '/remote/downloads' }, + ])); + }); + + it('continues if reload fails after saveconfig', async () => { + clientMock.post + .mockResolvedValueOnce({ + data: { + result: [ + { Name: 'DestDir', Value: '/downloads' }, + ], + }, + }) + .mockResolvedValueOnce({ data: { result: true } }) + // reload fails + .mockRejectedValueOnce(new Error('Connection reset')); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + // Should not throw — reload failure is handled gracefully + await expect(service.ensureCategory()).resolves.toBeUndefined(); + }); + + it('swallows errors when category management fails', async () => { + clientMock.post.mockRejectedValueOnce(new Error('Config read failed')); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass', 'readmeabook', '/downloads'); + // Should not throw + await expect(service.ensureCategory()).resolves.toBeUndefined(); + }); + }); + + // ========================================================================= + // Error Message Building + // ========================================================================= + + describe('error messages', () => { + it('builds descriptive error for failed history item with unpack failure', async () => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + .mockResolvedValueOnce({ + data: { + result: [ + { + NZBID: 500, + Name: 'Unpack Fail Book', + Status: 'FAILURE/UNPACK', + Category: '', + FileSizeMB: 100, + DownloadedSizeMB: 100, + DestDir: '', + FinalDir: '', + DownloadTimeSec: 60, + PostTotalTimeSec: 5, + ParStatus: 'SUCCESS', + UnpackStatus: 'FAILURE', + DeleteStatus: 'NONE', + MarkStatus: 'NONE', + HistoryTime: 0, + FailedArticles: 0, + TotalArticles: 100, + }, + ], + }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('500'); + + expect(info!.errorMessage).toContain('FAILURE/UNPACK'); + expect(info!.errorMessage).toContain('Unpack: FAILURE'); + }); + + it('includes delete status in error message when present', async () => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + .mockResolvedValueOnce({ + data: { + result: [ + { + NZBID: 600, + Name: 'Deleted Book', + Status: 'DELETED/HEALTH', + Category: '', + FileSizeMB: 100, + DownloadedSizeMB: 50, + DestDir: '', + FinalDir: '', + DownloadTimeSec: 30, + PostTotalTimeSec: 0, + ParStatus: 'NONE', + UnpackStatus: 'NONE', + DeleteStatus: 'HEALTH', + MarkStatus: 'NONE', + HistoryTime: 0, + FailedArticles: 100, + TotalArticles: 500, + }, + ], + }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('600'); + + expect(info!.errorMessage).toContain('DELETED/HEALTH'); + expect(info!.errorMessage).toContain('Delete: HEALTH'); + expect(info!.errorMessage).toContain('100 failed articles (20%)'); + }); + }); + + // ========================================================================= + // Singleton Factory + // ========================================================================= + + describe('singleton factory', () => { + it('creates a singleton service from config', async () => { + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-1', + type: 'nzbget', + name: 'NZBGet', + enabled: true, + url: 'http://nzbget:6789', + username: 'nzbget', + password: 'password123', + disableSSLVerify: false, + remotePathMappingEnabled: false, + category: 'readmeabook', + }); + configServiceMock.get.mockResolvedValue('/downloads'); + + const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue(); + + const service = await getNZBGetService(); + const again = await getNZBGetService(); + + expect(service).toBe(again); // Same instance + expect(ensureSpy).toHaveBeenCalled(); + + ensureSpy.mockRestore(); + }); + + it('creates singleton with path mapping config', async () => { + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-2', + type: 'nzbget', + name: 'NZBGet', + enabled: true, + url: 'http://nzbget:6789', + username: 'nzbget', + password: 'password123', + disableSSLVerify: false, + remotePathMappingEnabled: true, + remotePath: '/remote/downloads', + localPath: '/downloads', + category: 'readmeabook', + }); + configServiceMock.get.mockResolvedValue('/downloads'); + + const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue(); + + const service = await getNZBGetService(); + + expect(service).toBeDefined(); + expect(ensureSpy).toHaveBeenCalled(); + + ensureSpy.mockRestore(); + }); + + it('throws when no usenet client is configured', async () => { + downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null); + + await expect(getNZBGetService()).rejects.toThrow('not configured'); + }); + + it('throws when configured usenet client is not NZBGet', async () => { + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-3', + type: 'sabnzbd', + name: 'SABnzbd', + enabled: true, + url: 'http://sab', + password: 'api-key', + }); + + await expect(getNZBGetService()).rejects.toThrow('Expected NZBGet'); + }); + + it('invalidates singleton and recreates on next call', async () => { + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-4', + type: 'nzbget', + name: 'NZBGet', + enabled: true, + url: 'http://nzbget:6789', + username: 'nzbget', + password: 'pass', + disableSSLVerify: false, + remotePathMappingEnabled: false, + category: 'readmeabook', + }); + configServiceMock.get.mockResolvedValue('/downloads'); + + const ensureSpy = vi.spyOn(NZBGetService.prototype, 'ensureCategory').mockResolvedValue(); + + const first = await getNZBGetService(); + invalidateNZBGetService(); + const second = await getNZBGetService(); + + expect(first).not.toBe(second); + + ensureSpy.mockRestore(); + }); + }); + + // ========================================================================= + // Usenet-specific fields + // ========================================================================= + + describe('usenet-specific behavior', () => { + it('returns undefined for seeding-related fields', async () => { + clientMock.post + .mockResolvedValueOnce({ data: { result: [] } }) + .mockResolvedValueOnce({ + data: { + result: [ + { + NZBID: 700, + Name: 'Usenet Book', + Status: 'SUCCESS/ALL', + Category: 'readmeabook', + FileSizeMB: 100, + DownloadedSizeMB: 100, + DestDir: '/downloads', + FinalDir: '', + DownloadTimeSec: 30, + PostTotalTimeSec: 10, + ParStatus: 'SUCCESS', + UnpackStatus: 'SUCCESS', + DeleteStatus: 'NONE', + MarkStatus: 'NONE', + HistoryTime: 1700000000, + FailedArticles: 0, + TotalArticles: 100, + }, + ], + }, + }); + + const service = new NZBGetService('http://nzbget:6789', 'nzbget', 'pass'); + const info = await service.getDownload('700'); + + expect(info!.seedingTime).toBeUndefined(); + expect(info!.ratio).toBeUndefined(); + }); + }); +}); diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts index dc9eaca..21279c4 100644 --- a/tests/integrations/qbittorrent.service.test.ts +++ b/tests/integrations/qbittorrent.service.test.ts @@ -102,12 +102,195 @@ describe('QBittorrentService', () => { size: 1000, dlspeed: 0, eta: 0, - state: 'allocating' as any, + state: 'allocating', } as any); expect(progress.state).toBe('downloading'); }); + describe('mapState - forced states (Force Resume in qBittorrent UI)', () => { + it('maps forcedDL to downloading', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0.5, downloaded: 500, size: 1000, dlspeed: 100, eta: 50, state: 'forcedDL', + } as any); + expect(progress.state).toBe('downloading'); + }); + + it('maps forcedUP to completed', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'forcedUP', + } as any); + expect(progress.state).toBe('completed'); + }); + }); + + describe('mapState - metadata fetching states', () => { + it('maps metaDL to downloading', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'metaDL', + } as any); + expect(progress.state).toBe('downloading'); + }); + + it('maps forcedMetaDL to downloading', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'forcedMetaDL', + } as any); + expect(progress.state).toBe('downloading'); + }); + }); + + describe('mapState - qBittorrent v5.x stopped states', () => { + it('maps stoppedDL to paused', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedDL', + } as any); + expect(progress.state).toBe('paused'); + }); + + it('maps stoppedUP to paused', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedUP', + } as any); + expect(progress.state).toBe('paused'); + }); + }); + + describe('mapState - other states', () => { + it('maps checkingResumeData to checking', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0, downloaded: 0, size: 1000, dlspeed: 0, eta: 0, state: 'checkingResumeData', + } as any); + expect(progress.state).toBe('checking'); + }); + + it('maps moving to downloading', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'moving', + } as any); + expect(progress.state).toBe('downloading'); + }); + }); + + describe('mapStateToDownloadStatus - forced and new states via getDownload', () => { + it('maps forcedUP to seeding status (triggers completion in monitor)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=forced'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500, + eta: 0, state: 'forcedUP', category: 'readmeabook', tags: '', + save_path: '/downloads', content_path: '/downloads/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('seeding'); + }); + + it('maps forcedDL to downloading status', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=forced'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5, + dlspeed: 1000, upspeed: 0, downloaded: 500, uploaded: 0, + eta: 500, state: 'forcedDL', category: 'readmeabook', tags: '', + save_path: '/downloads', completion_on: 0, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('downloading'); + }); + + it('maps stoppedUP to paused status (qBittorrent v5.x)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=stopped'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 200, + eta: 0, state: 'stoppedUP', category: 'readmeabook', tags: '', + save_path: '/downloads', completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('paused'); + }); + + it('maps stoppedDL to paused status (qBittorrent v5.x)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=stopped'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3, + dlspeed: 0, upspeed: 0, downloaded: 300, uploaded: 0, + eta: 0, state: 'stoppedDL', category: 'readmeabook', tags: '', + save_path: '/downloads', completion_on: 0, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('paused'); + }); + + it('maps metaDL to downloading status', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=meta'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 0, progress: 0, + dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0, + eta: 0, state: 'metaDL', category: 'readmeabook', tags: '', + save_path: '/downloads', completion_on: 0, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('downloading'); + }); + + it('maps checkingResumeData to checking status', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=resume'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0, + dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0, + eta: 0, state: 'checkingResumeData', category: 'readmeabook', tags: '', + save_path: '/downloads', completion_on: 0, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('checking'); + }); + }); + it('authenticates and stores a session cookie', async () => { axiosMock.post.mockResolvedValue({ status: 200, @@ -619,7 +802,7 @@ describe('QBittorrentService', () => { const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass'); - expect(version).toBe('v4.6.0'); + expect(version).toBe('4.6.0'); }); it('throws when test connection receives no cookies', async () => { @@ -709,7 +892,7 @@ describe('QBittorrentService', () => { }); configServiceMock.get.mockResolvedValue('/downloads'); - const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true); + const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: true, message: 'Connected' }); const first = await getQBittorrentService(); const second = await getQBittorrentService(); @@ -736,7 +919,7 @@ describe('QBittorrentService', () => { }); configServiceMock.get.mockResolvedValue('/downloads'); - const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false); + const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: false, message: 'qBittorrent connection test failed. Please check your configuration in admin settings.' }); await expect(getQBittorrentService()).rejects.toThrow('qBittorrent connection test failed'); @@ -747,9 +930,9 @@ describe('QBittorrentService', () => { const service = new QBittorrentService('http://qb', 'user', 'pass'); const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth')); - const ok = await service.testConnection(); + const result = await service.testConnection(); - expect(ok).toBe(false); + expect(result.success).toBe(false); expect(loginSpy).toHaveBeenCalled(); }); @@ -757,9 +940,9 @@ describe('QBittorrentService', () => { const service = new QBittorrentService('http://qb', 'user', 'pass'); const loginSpy = vi.spyOn(service, 'login').mockResolvedValue(); - const ok = await service.testConnection(); + const result = await service.testConnection(); - expect(ok).toBe(true); + expect(result.success).toBe(true); expect(loginSpy).toHaveBeenCalled(); }); }); diff --git a/tests/integrations/sabnzbd.service.test.ts b/tests/integrations/sabnzbd.service.test.ts index da65477..ba385db 100644 --- a/tests/integrations/sabnzbd.service.test.ts +++ b/tests/integrations/sabnzbd.service.test.ts @@ -8,10 +8,13 @@ import { SABnzbdService, getSABnzbdService, invalidateSABnzbdService } from '@/l const clientMock = vi.hoisted(() => ({ get: vi.fn(), + post: vi.fn(), })); const axiosMock = vi.hoisted(() => ({ create: vi.fn(() => clientMock), + get: vi.fn(), + isAxiosError: vi.fn(() => false), })); const configServiceMock = vi.hoisted(() => ({ @@ -43,6 +46,8 @@ describe('SABnzbdService', () => { beforeEach(() => { vi.clearAllMocks(); clientMock.get.mockReset(); + clientMock.post.mockReset(); + axiosMock.get.mockReset(); configServiceMock.get.mockReset(); downloadClientManagerMock.getClientForProtocol.mockReset(); downloadClientManagerMock.getAllClients.mockReset(); @@ -56,7 +61,7 @@ describe('SABnzbdService', () => { const result = await service.testConnection(); expect(result.success).toBe(false); - expect(result.error).toContain('API key is required'); + expect(result.message).toContain('API key is required'); expect(clientMock.get).not.toHaveBeenCalled(); }); @@ -69,7 +74,7 @@ describe('SABnzbdService', () => { const result = await service.testConnection(); expect(result.success).toBe(false); - expect(result.error).toContain('Invalid API key'); + expect(result.message).toContain('Invalid API key'); expect(clientMock.get).toHaveBeenCalledTimes(1); }); @@ -82,7 +87,7 @@ describe('SABnzbdService', () => { const result = await service.testConnection(); expect(result.success).toBe(false); - expect(result.error).toBe('No permissions'); + expect(result.message).toBe('No permissions'); }); it('returns version when connection succeeds', async () => { @@ -105,7 +110,7 @@ describe('SABnzbdService', () => { const result = await service.testConnection(); expect(result.success).toBe(false); - expect(result.error).toContain('SSL/TLS certificate error'); + expect(result.message).toContain('SSL/TLS certificate error'); }); it('returns a friendly error on connection refused', async () => { @@ -115,7 +120,7 @@ describe('SABnzbdService', () => { const result = await service.testConnection(); expect(result.success).toBe(false); - expect(result.error).toContain('Connection refused'); + expect(result.message).toContain('Connection refused'); }); it('adds NZB with mapped priority', async () => { @@ -123,10 +128,16 @@ describe('SABnzbdService', () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { books: { dir: '' } } } }, - }) - .mockResolvedValueOnce({ - data: { status: true, nzo_ids: ['nzb-1'] }, }); + // Mock NZB file download (global axios.get) + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('fake-nzb-content'), + headers: {}, + }); + // Mock addfile upload (POST instead of GET) + clientMock.post.mockResolvedValueOnce({ + data: { status: true, nzo_ids: ['nzb-1'] }, + }); const service = new SABnzbdService('http://sab', 'key', 'books', '/downloads'); const nzbId = await service.addNZB('https://example.com/book.nzb', { @@ -134,11 +145,8 @@ describe('SABnzbdService', () => { priority: 'high', }); - // Second call is the addurl call - const params = clientMock.get.mock.calls[1][1].params; expect(nzbId).toBe('nzb-1'); - expect(params.cat).toBe('books'); - expect(params.priority).toBe('1'); + expect(clientMock.post).toHaveBeenCalledTimes(1); }); it('adds NZB with force priority', async () => { @@ -146,17 +154,22 @@ describe('SABnzbdService', () => { clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, - }) - .mockResolvedValueOnce({ - data: { status: true, nzo_ids: ['nzb-9'] }, }); + // Mock NZB file download + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('fake-nzb-content'), + headers: {}, + }); + // Mock addfile upload + clientMock.post.mockResolvedValueOnce({ + data: { status: true, nzo_ids: ['nzb-9'] }, + }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); - await service.addNZB('https://example.com/book.nzb', { priority: 'force' }); + const nzbId = await service.addNZB('https://example.com/book.nzb', { priority: 'force' }); - // Second call is the addurl call - const params = clientMock.get.mock.calls[1][1].params; - expect(params.priority).toBe('2'); + expect(nzbId).toBe('nzb-9'); + expect(clientMock.post).toHaveBeenCalledTimes(1); }); it('returns queue item info when NZB is active', async () => { @@ -428,14 +441,18 @@ describe('SABnzbdService', () => { expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config'); }); it('throws when addNZB reports a failure', async () => { - // Mock getConfig for ensureCategory, then the addurl failure + // Mock getConfig for ensureCategory, then the upload failure clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, - }) - .mockResolvedValueOnce({ - data: { status: false, error: 'Bad NZB' }, }); + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('fake-nzb-content'), + headers: {}, + }); + clientMock.post.mockResolvedValueOnce({ + data: { status: false, error: 'Bad NZB' }, + }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); @@ -443,14 +460,18 @@ describe('SABnzbdService', () => { }); it('throws when SABnzbd returns no NZB IDs', async () => { - // Mock getConfig for ensureCategory, then the addurl with empty IDs + // Mock getConfig for ensureCategory, then the upload with empty IDs clientMock.get .mockResolvedValueOnce({ data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, - }) - .mockResolvedValueOnce({ - data: { status: true, nzo_ids: [] }, }); + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('fake-nzb-content'), + headers: {}, + }); + clientMock.post.mockResolvedValueOnce({ + data: { status: true, nzo_ids: [] }, + }); const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); @@ -475,7 +496,7 @@ describe('SABnzbdService', () => { const result = await service.testConnection(); expect(result.success).toBe(false); - expect(result.error).toContain('timed out'); + expect(result.message).toContain('timed out'); }); it('throws when version is missing from response', async () => { diff --git a/tests/integrations/transmission.service.test.ts b/tests/integrations/transmission.service.test.ts new file mode 100644 index 0000000..35f0775 --- /dev/null +++ b/tests/integrations/transmission.service.test.ts @@ -0,0 +1,576 @@ +/** + * Component: Transmission Integration Service Tests + * Documentation: documentation/phase3/download-clients.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TransmissionService } from '@/lib/integrations/transmission.service'; + +const clientMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +const axiosMock = vi.hoisted(() => ({ + create: vi.fn(() => clientMock), + post: vi.fn(), + get: vi.fn(), + isAxiosError: (error: any) => Boolean(error?.isAxiosError), +})); + +const parseTorrentMock = vi.hoisted(() => vi.fn()); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('parse-torrent', () => ({ + default: parseTorrentMock, +})); + +describe('TransmissionService', () => { + beforeEach(() => { + vi.clearAllMocks(); + clientMock.get.mockReset(); + clientMock.post.mockReset(); + axiosMock.get.mockReset(); + axiosMock.post.mockReset(); + parseTorrentMock.mockReset(); + }); + + describe('constructor', () => { + it('sets clientType and protocol correctly', () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + expect(service.clientType).toBe('transmission'); + expect(service.protocol).toBe('torrent'); + }); + }); + + describe('testConnection', () => { + it('returns success with version on valid connection', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + clientMock.post.mockResolvedValueOnce({ + data: { result: 'success', arguments: { version: '4.0.5' } }, + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(true); + expect(result.version).toBe('4.0.5'); + expect(result.message).toContain('Transmission'); + }); + + it('returns failure when RPC returns error', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + clientMock.post.mockResolvedValueOnce({ + data: { result: 'unauthorized' }, + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('unauthorized'); + }); + + it('returns failure on connection error', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + clientMock.post.mockRejectedValueOnce(new Error('Connection refused')); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('Connection refused'); + }); + + it('returns SSL-specific errors', async () => { + const service = new TransmissionService('https://transmission', 'user', 'pass'); + clientMock.post.mockRejectedValueOnce({ + isAxiosError: true, + code: 'DEPTH_ZERO_SELF_SIGNED_CERT', + message: 'self signed', + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('SSL certificate verification failed'); + }); + + it('returns ECONNREFUSED error with URL', async () => { + const service = new TransmissionService('http://transmission:9091', 'user', 'pass'); + clientMock.post.mockRejectedValueOnce({ + isAxiosError: true, + code: 'ECONNREFUSED', + message: 'refused', + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('Connection refused'); + expect(result.message).toContain('http://transmission:9091'); + }); + + it('returns 401 authentication error', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + clientMock.post.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, + message: 'Unauthorized', + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.message).toContain('Authentication failed'); + }); + }); + + describe('CSRF handling', () => { + it('captures X-Transmission-Session-Id on 409 and retries', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + // First call returns 409 with session ID + clientMock.post + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 409, + headers: { 'x-transmission-session-id': 'csrf-token-123' }, + }, + }) + // Retry succeeds + .mockResolvedValueOnce({ + data: { result: 'success', arguments: { version: '4.0.5' } }, + }); + + const result = await service.testConnection(); + + expect(result.success).toBe(true); + expect(clientMock.post).toHaveBeenCalledTimes(2); + + // Verify second call includes the session ID header + const secondCall = clientMock.post.mock.calls[1]; + expect(secondCall[2].headers['X-Transmission-Session-Id']).toBe('csrf-token-123'); + }); + }); + + describe('addDownload', () => { + it('rejects empty URLs', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + await expect(service.addDownload('')).rejects.toThrow('Invalid download URL'); + }); + + it('adds magnet links via torrent-add RPC', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + // getTorrentByHash - not found (no duplicate) + clientMock.post + .mockResolvedValueOnce({ + data: { result: 'success', arguments: { torrents: [] } }, + }) + // torrent-add + .mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + 'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' }, + }, + }, + }); + + const hash = await service.addDownload( + 'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567' + ); + + expect(hash).toBe('0123456789abcdef0123456789abcdef01234567'); + }); + + it('skips duplicate magnet links', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + // getTorrentByHash - found (duplicate) + clientMock.post.mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + torrents: [{ + hashString: '0123456789abcdef0123456789abcdef01234567', + name: 'Existing', + }], + }, + }, + }); + + const hash = await service.addDownload( + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567' + ); + + expect(hash).toBe('0123456789abcdef0123456789abcdef01234567'); + // Only 1 RPC call (torrent-get), no torrent-add + expect(clientMock.post).toHaveBeenCalledTimes(1); + }); + + it('throws on invalid magnet link', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + await expect(service.addDownload('magnet:?xt=urn:btih:')).rejects.toThrow('Invalid magnet link'); + }); + + it('throws when Transmission rejects the magnet link', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + // No duplicate + clientMock.post + .mockResolvedValueOnce({ + data: { result: 'success', arguments: { torrents: [] } }, + }) + // torrent-add fails + .mockResolvedValueOnce({ + data: { result: 'duplicate torrent' }, + }); + + await expect( + service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567') + ).rejects.toThrow('Transmission rejected magnet link'); + }); + + it('adds .torrent files via metainfo base64', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent-data') }); + parseTorrentMock.mockResolvedValueOnce({ infoHash: 'parsed-hash', name: 'Book' }); + + // getTorrentByHash - not found + clientMock.post + .mockResolvedValueOnce({ + data: { result: 'success', arguments: { torrents: [] } }, + }) + // torrent-add succeeds + .mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + 'torrent-added': { hashString: 'parsed-hash', name: 'Book' }, + }, + }, + }); + + const hash = await service.addDownload('http://example.com/file.torrent'); + + expect(hash).toBe('parsed-hash'); + // Verify metainfo was sent + const addCall = clientMock.post.mock.calls[1]; + const body = addCall[0] === '/transmission/rpc' ? JSON.parse(JSON.stringify(addCall[1])) : null; + // The body should be the RPC call with metainfo + }); + + it('follows redirect to magnet link', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + axiosMock.get.mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 302, + headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' }, + }, + }); + + // getTorrentByHash - not found + clientMock.post + .mockResolvedValueOnce({ + data: { result: 'success', arguments: { torrents: [] } }, + }) + // torrent-add + .mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + 'torrent-added': { hashString: 'abcdef0123456789abcdef0123456789abcdef01', name: 'Test' }, + }, + }, + }); + + const hash = await service.addDownload('http://example.com/file.torrent'); + expect(hash).toBe('abcdef0123456789abcdef0123456789abcdef01'); + }); + + it('throws on invalid .torrent file', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') }); + parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent')); + + await expect(service.addDownload('http://example.com/file.torrent')).rejects.toThrow( + 'Invalid .torrent file - failed to parse' + ); + }); + }); + + describe('getDownload', () => { + it('returns mapped DownloadInfo for found torrents', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + clientMock.post.mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + torrents: [{ + hashString: 'abc123', + name: 'Audiobook', + totalSize: 1000, + downloadedEver: 500, + percentDone: 0.5, + status: 4, // downloading + rateDownload: 1000, + eta: 500, + labels: ['readmeabook'], + downloadDir: '/downloads', + doneDate: 0, + errorString: '', + error: 0, + secondsSeeding: 3600, + uploadRatio: 0.1, + uploadedEver: 50, + }], + }, + }, + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.id).toBe('abc123'); + expect(info!.name).toBe('Audiobook'); + expect(info!.status).toBe('downloading'); + expect(info!.progress).toBe(0.5); + expect(info!.downloadSpeed).toBe(1000); + expect(info!.category).toBe('readmeabook'); + expect(info!.seedingTime).toBe(3600); + }); + + it('returns null when torrent not found after retries', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + // All retries return empty + clientMock.post.mockResolvedValue({ + data: { result: 'success', arguments: { torrents: [] } }, + }); + + const info = await service.getDownload('nonexistent'); + + expect(info).toBeNull(); + }); + + it('maps error code to failed status', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + clientMock.post.mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + torrents: [{ + hashString: 'abc123', + name: 'Failed', + totalSize: 1000, + downloadedEver: 0, + percentDone: 0, + status: 0, + rateDownload: 0, + eta: -1, + labels: [], + downloadDir: '/downloads', + doneDate: 0, + errorString: 'Tracker error', + error: 2, + uploadRatio: -1, + uploadedEver: 0, + }], + }, + }, + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('failed'); + expect(info!.errorMessage).toBe('Tracker error'); + }); + }); + + describe('status mapping', () => { + const makeService = () => new TransmissionService('http://transmission', '', ''); + + const mapStatus = (service: TransmissionService, status: number, error = 0) => { + return (service as any).mapStatus(status, error); + }; + + it('maps 0 (stopped) to paused', () => { + expect(mapStatus(makeService(), 0)).toBe('paused'); + }); + + it('maps 1 (check-pending) to checking', () => { + expect(mapStatus(makeService(), 1)).toBe('checking'); + }); + + it('maps 2 (checking) to checking', () => { + expect(mapStatus(makeService(), 2)).toBe('checking'); + }); + + it('maps 3 (download-pending) to queued', () => { + expect(mapStatus(makeService(), 3)).toBe('queued'); + }); + + it('maps 4 (downloading) to downloading', () => { + expect(mapStatus(makeService(), 4)).toBe('downloading'); + }); + + it('maps 5 (seed-pending) to seeding', () => { + expect(mapStatus(makeService(), 5)).toBe('seeding'); + }); + + it('maps 6 (seeding) to seeding', () => { + expect(mapStatus(makeService(), 6)).toBe('seeding'); + }); + + it('maps any status with error > 0 to failed', () => { + expect(mapStatus(makeService(), 4, 1)).toBe('failed'); + expect(mapStatus(makeService(), 6, 2)).toBe('failed'); + }); + }); + + describe('pauseDownload / resumeDownload / deleteDownload', () => { + it('pauses torrents via torrent-stop', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + clientMock.post + .mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + torrents: [{ hashString: 'hash-1', name: 'Test' }], + }, + }, + }) + .mockResolvedValueOnce({ data: { result: 'success' } }); + + await service.pauseDownload('hash-1'); + + const stopCall = clientMock.post.mock.calls[1]; + expect(stopCall[1]).toEqual( + expect.objectContaining({ method: 'torrent-stop' }) + ); + }); + + it('resumes torrents via torrent-start', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + clientMock.post + .mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + torrents: [{ hashString: 'hash-1', name: 'Test' }], + }, + }, + }) + .mockResolvedValueOnce({ data: { result: 'success' } }); + + await service.resumeDownload('hash-1'); + + const startCall = clientMock.post.mock.calls[1]; + expect(startCall[1]).toEqual( + expect.objectContaining({ method: 'torrent-start' }) + ); + }); + + it('deletes torrents via torrent-remove', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + + clientMock.post + .mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + torrents: [{ hashString: 'hash-1', name: 'Test' }], + }, + }, + }) + .mockResolvedValueOnce({ data: { result: 'success' } }); + + await service.deleteDownload('hash-1', true); + + const removeCall = clientMock.post.mock.calls[1]; + expect(removeCall[1]).toEqual( + expect.objectContaining({ + method: 'torrent-remove', + arguments: expect.objectContaining({ 'delete-local-data': true }), + }) + ); + }); + + it('throws when pause fails', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + clientMock.post.mockRejectedValueOnce(new Error('fail')); + + await expect(service.pauseDownload('hash-1')).rejects.toThrow('Failed to pause torrent'); + }); + + it('throws when resume fails', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + clientMock.post.mockRejectedValueOnce(new Error('fail')); + + await expect(service.resumeDownload('hash-1')).rejects.toThrow('Failed to resume torrent'); + }); + + it('throws when delete fails', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + clientMock.post.mockRejectedValueOnce(new Error('fail')); + + await expect(service.deleteDownload('hash-1')).rejects.toThrow('Failed to delete torrent'); + }); + }); + + describe('postProcess', () => { + it('is a no-op', async () => { + const service = new TransmissionService('http://transmission', 'user', 'pass'); + await expect(service.postProcess('hash-1')).resolves.toBeUndefined(); + }); + }); + + describe('path mapping', () => { + it('applies reverse path mapping for torrent-add download-dir', async () => { + const service = new TransmissionService( + 'http://transmission', + 'user', + 'pass', + '/downloads', + 'readmeabook', + false, + { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' } + ); + + // No duplicate + clientMock.post + .mockResolvedValueOnce({ + data: { result: 'success', arguments: { torrents: [] } }, + }) + // torrent-add + .mockResolvedValueOnce({ + data: { + result: 'success', + arguments: { + 'torrent-added': { hashString: '0123456789abcdef0123456789abcdef01234567', name: 'Test' }, + }, + }, + }); + + await service.addDownload('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'); + + // Verify the torrent-add call has the remote path + const addCall = clientMock.post.mock.calls[1]; + const rpcBody = addCall[1]; + expect(rpcBody.arguments['download-dir']).toBe('F:\\Docker\\downloads'); + }); + }); +}); diff --git a/tests/processors/cleanup-seeded-torrents.processor.test.ts b/tests/processors/cleanup-seeded-torrents.processor.test.ts index 2941e79..0a1c410 100644 --- a/tests/processors/cleanup-seeded-torrents.processor.test.ts +++ b/tests/processors/cleanup-seeded-torrents.processor.test.ts @@ -8,9 +8,9 @@ import { createPrismaMock } from '../helpers/prisma'; const prismaMock = createPrismaMock(); const configMock = vi.hoisted(() => ({ get: vi.fn() })); -const qbtMock = vi.hoisted(() => ({ - getTorrent: vi.fn(), - deleteTorrent: vi.fn(), + +const downloadClientManagerMock = vi.hoisted(() => ({ + getClientServiceForProtocol: vi.fn(), })); vi.mock('@/lib/db', () => ({ @@ -21,8 +21,8 @@ vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configMock, })); -vi.mock('@/lib/integrations/qbittorrent.service', () => ({ - getQBittorrentService: async () => qbtMock, +vi.mock('@/lib/services/download-client-manager.service', () => ({ + getDownloadClientManager: () => downloadClientManagerMock, })); describe('processCleanupSeededTorrents', () => { @@ -66,13 +66,31 @@ describe('processCleanupSeededTorrents', () => { expect(result.success).toBe(true); expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-1' } }); - expect(qbtMock.getTorrent).not.toHaveBeenCalled(); + expect(downloadClientManagerMock.getClientServiceForProtocol).not.toHaveBeenCalled(); }); it('deletes torrents when seeding requirements are met with no shared downloads', async () => { configMock.get.mockResolvedValue( JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 30 }]) ); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-1', + name: 'Torrent', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'seeding', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + seedingTime: 60 * 40, + }), + deleteDownload: vi.fn().mockResolvedValue(undefined), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); prismaMock.request.findMany .mockResolvedValueOnce([ { @@ -84,29 +102,43 @@ describe('processCleanupSeededTorrents', () => { downloadStatus: 'completed', indexerName: 'IndexerA', torrentHash: 'hash-1', + downloadClientId: 'hash-1', + downloadClient: 'qbittorrent', }, ], }, ]) .mockResolvedValueOnce([]); - qbtMock.getTorrent.mockResolvedValue({ - name: 'Torrent', - seeding_time: 60 * 40, - }); - qbtMock.deleteTorrent.mockResolvedValue({}); - const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); const result = await processCleanupSeededTorrents({ jobId: 'job-3' }); expect(result.cleaned).toBe(1); - expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true); + expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-1', true); }); it('keeps shared torrents and deletes soft-deleted request', async () => { configMock.get.mockResolvedValue( JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 10 }]) ); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-2', + name: 'Torrent', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'seeding', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + seedingTime: 60 * 20, + }), + deleteDownload: vi.fn(), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); prismaMock.request.findMany .mockResolvedValueOnce([ { @@ -118,16 +150,14 @@ describe('processCleanupSeededTorrents', () => { downloadStatus: 'completed', indexerName: 'IndexerA', torrentHash: 'hash-2', + downloadClientId: 'hash-2', + downloadClient: 'qbittorrent', }, ], }, ]) .mockResolvedValueOnce([{ id: 'req-4', status: 'downloaded' }]); - qbtMock.getTorrent.mockResolvedValue({ - name: 'Torrent', - seeding_time: 60 * 20, - }); prismaMock.request.delete.mockResolvedValue({}); const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); @@ -135,7 +165,106 @@ describe('processCleanupSeededTorrents', () => { expect(result.skipped).toBe(1); expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-3' } }); - expect(qbtMock.deleteTorrent).not.toHaveBeenCalled(); + expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled(); + }); + + it('cleans up ebook torrents downloaded via indexer', async () => { + configMock.get.mockResolvedValue( + JSON.stringify([{ name: 'EbookIndexer', seedingTimeMinutes: 15 }]) + ); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-ebook-1', + name: 'Equal Rites - Terry Pratchett (epub)', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'seeding', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + seedingTime: 60 * 20, // 20 minutes, exceeds 15 min requirement + }), + deleteDownload: vi.fn().mockResolvedValue(undefined), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); + prismaMock.request.findMany + .mockResolvedValueOnce([ + { + id: 'req-ebook-1', + type: 'ebook', + deletedAt: null, + downloadHistory: [ + { + selected: true, + downloadStatus: 'completed', + indexerName: 'EbookIndexer', + torrentHash: 'hash-ebook-1', + downloadClientId: 'hash-ebook-1', + downloadClient: 'qbittorrent', + }, + ], + }, + ]) + .mockResolvedValueOnce([]); // No shared downloads + + const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); + const result = await processCleanupSeededTorrents({ jobId: 'job-ebook-1' }); + + expect(result.cleaned).toBe(1); + expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ebook-1', true); + }); + + it('detects shared torrents across audiobook and ebook requests', async () => { + configMock.get.mockResolvedValue( + JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }]) + ); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-shared', + name: 'Shared Torrent', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'seeding', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + seedingTime: 60 * 30, + }), + deleteDownload: vi.fn(), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); + prismaMock.request.findMany + .mockResolvedValueOnce([ + { + id: 'req-audio-shared', + type: 'audiobook', + deletedAt: null, + downloadHistory: [ + { + selected: true, + downloadStatus: 'completed', + indexerName: 'SharedIndexer', + torrentHash: 'hash-shared', + downloadClientId: 'hash-shared', + downloadClient: 'qbittorrent', + }, + ], + }, + ]) + // Shared torrent check finds an ebook request using same hash + .mockResolvedValueOnce([{ id: 'req-ebook-shared', status: 'downloading' }]); + + const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); + const result = await processCleanupSeededTorrents({ jobId: 'job-shared' }); + + expect(result.skipped).toBe(1); + expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled(); }); }); diff --git a/tests/processors/download-torrent.processor.test.ts b/tests/processors/download-torrent.processor.test.ts index b61c80b..f6415c6 100644 --- a/tests/processors/download-torrent.processor.test.ts +++ b/tests/processors/download-torrent.processor.test.ts @@ -15,6 +15,7 @@ const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() })); const downloadClientManagerMock = vi.hoisted(() => ({ getClientForProtocol: vi.fn(), + getClientServiceForProtocol: vi.fn(), })); vi.mock('@/lib/db', () => ({ @@ -92,12 +93,18 @@ describe('processDownloadTorrent', () => { }; it('routes torrent downloads to qBittorrent', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + addDownload: vi.fn().mockResolvedValue('hash-1'), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'qbittorrent', enabled: true, + category: 'readmeabook', }); - qbtMock.addTorrent.mockResolvedValue('hash-1'); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' }); @@ -105,8 +112,8 @@ describe('processDownloadTorrent', () => { const result = await processDownloadTorrent(torrentPayload); expect(result.success).toBe(true); - expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent'); - expect(qbtMock.addTorrent).toHaveBeenCalled(); + expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent'); + expect(qbtClientMock.addDownload).toHaveBeenCalled(); expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith( 'req-1', 'dh-1', @@ -117,12 +124,18 @@ describe('processDownloadTorrent', () => { }); it('routes NZB downloads to SABnzbd', async () => { + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + addDownload: vi.fn().mockResolvedValue('nzb-1'), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-2', type: 'sabnzbd', enabled: true, + category: 'readmeabook', }); - sabMock.addNZB.mockResolvedValue('nzb-1'); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' }); @@ -130,8 +143,8 @@ describe('processDownloadTorrent', () => { const result = await processDownloadTorrent(nzbPayload); expect(result.success).toBe(true); - expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet'); - expect(sabMock.addNZB).toHaveBeenCalled(); + expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet'); + expect(sabClientMock.addDownload).toHaveBeenCalled(); expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith( 'req-2', 'dh-2', @@ -142,44 +155,57 @@ describe('processDownloadTorrent', () => { }); it('throws error when no client configured for protocol', async () => { - downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null); + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(null); + prismaMock.request.update.mockResolvedValue({}); const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor'); await expect(processDownloadTorrent(torrentPayload)).rejects.toThrow( - 'No Torrent (qBittorrent) client configured' + 'No torrent download client configured' ); - expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent'); + expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent'); }); it('detects protocol from result and routes appropriately', async () => { // Torrent result + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + addDownload: vi.fn().mockResolvedValue('hash-1'), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({ id: 'client-1', type: 'qbittorrent', enabled: true, + category: 'readmeabook', }); - qbtMock.addTorrent.mockResolvedValue('hash-1'); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' }); const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor'); await processDownloadTorrent(torrentPayload); - expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent'); + expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent'); // NZB result + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + addDownload: vi.fn().mockResolvedValue('nzb-1'), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce(sabClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({ id: 'client-2', type: 'sabnzbd', enabled: true, + category: 'readmeabook', }); - sabMock.addNZB.mockResolvedValue('nzb-1'); prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' }); await processDownloadTorrent(nzbPayload); - expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet'); + expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet'); }); }); diff --git a/tests/processors/monitor-download.processor.test.ts b/tests/processors/monitor-download.processor.test.ts index 78087e3..1fba0b9 100644 --- a/tests/processors/monitor-download.processor.test.ts +++ b/tests/processors/monitor-download.processor.test.ts @@ -21,6 +21,7 @@ const configMock = vi.hoisted(() => ({ })); const downloadClientManagerMock = vi.hoisted(() => ({ getClientForProtocol: vi.fn(), + getClientServiceForProtocol: vi.fn(), })); vi.mock('@/lib/db', () => ({ @@ -50,20 +51,27 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({ describe('processMonitorDownload', () => { beforeEach(() => { vi.clearAllMocks(); + jobQueueMock.addNotificationJob.mockResolvedValue(undefined); }); it('queues organize job when qBittorrent download completes', async () => { - qbtMock.getTorrent.mockResolvedValue({ - content_path: '/remote/done/Book', - save_path: '/remote/done', - name: 'Book', - }); - qbtMock.getDownloadProgress.mockReturnValue({ - percent: 100, - state: 'completed', - speed: 0, - eta: 0, - }); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-1', + name: 'Book', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'completed', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + downloadPath: '/remote/done/Book', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'qbittorrent', @@ -96,19 +104,34 @@ describe('processMonitorDownload', () => { 'a1', expect.stringMatching(/downloads[\\/]+Book/) ); + // Verify downloadPath is stored in download history on completion + expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + downloadStatus: 'completed', + downloadPath: expect.stringMatching(/downloads[\\/]+Book/), + }), + }) + ); }); it('re-schedules monitoring when download is still active', async () => { - qbtMock.getTorrent.mockResolvedValue({ - save_path: '/downloads', - name: 'Book', - }); - qbtMock.getDownloadProgress.mockReturnValue({ - percent: 45, - state: 'downloading', - speed: 100, - eta: 60, - }); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-2', + name: 'Book', + size: 0, + bytesDownloaded: 0, + progress: 0.45, + status: 'downloading', + downloadSpeed: 100, + eta: 60, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); @@ -132,18 +155,29 @@ describe('processMonitorDownload', () => { }); it('marks request failed when download fails', async () => { - qbtMock.getTorrent.mockResolvedValue({ - save_path: '/downloads', - name: 'Book', - }); - qbtMock.getDownloadProgress.mockReturnValue({ - percent: 20, - state: 'failed', - speed: 0, - eta: 0, - }); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-3', + name: 'Book', + size: 0, + bytesDownloaded: 0, + progress: 0.20, + status: 'failed', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-3', + audiobook: { title: 'Book', author: 'Author' }, + user: { plexUsername: 'user' }, + }); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); const result = await processMonitorDownload({ @@ -163,15 +197,23 @@ describe('processMonitorDownload', () => { }); it('handles SABnzbd completion and queues organize job', async () => { - sabMock.getNZB.mockResolvedValue({ - nzbId: 'nzb-1', - size: 100, - progress: 1, - status: 'completed', - downloadSpeed: 0, - timeLeft: 0, - downloadPath: '/usenet/complete/Book', - }); + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue({ + id: 'nzb-1', + name: 'Book', + size: 100, + bytesDownloaded: 100, + progress: 1.0, + status: 'completed', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + downloadPath: '/usenet/complete/Book', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-2', type: 'sabnzbd', @@ -202,10 +244,76 @@ describe('processMonitorDownload', () => { 'a4', '/usenet/complete/Book' ); + // Verify downloadPath is stored in download history on completion + expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + downloadStatus: 'completed', + downloadPath: '/usenet/complete/Book', + }), + }) + ); + }); + + it('handles NZBGet completion and queues organize job', async () => { + const nzbgetClientMock = { + clientType: 'nzbget', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue({ + id: '42', + name: 'Book', + size: 200, + bytesDownloaded: 200, + progress: 1.0, + status: 'completed', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + downloadPath: '/downloads/readmeabook/Book', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(nzbgetClientMock); + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-nzbget', + type: 'nzbget', + name: 'NZBGet', + enabled: true, + remotePathMappingEnabled: false, + }); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-nzbget', + audiobook: { id: 'a-nzbget' }, + deletedAt: null, + }); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + const result = await processMonitorDownload({ + requestId: 'req-nzbget', + downloadHistoryId: 'dh-nzbget', + downloadClientId: '42', + downloadClient: 'nzbget', + jobId: 'job-nzbget', + }); + + expect(result.completed).toBe(true); + // Verify it called getClientServiceForProtocol with 'usenet' (not 'torrent') + expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet'); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-nzbget', + 'a-nzbget', + '/downloads/readmeabook/Book' + ); }); it('does not mark request failed for transient NZB not found errors', async () => { - sabMock.getNZB.mockResolvedValue(null); + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue(null), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); await expect(processMonitorDownload({ @@ -220,7 +328,13 @@ describe('processMonitorDownload', () => { }); it('marks request failed when download client is unsupported', async () => { + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(null); prismaMock.request.update.mockResolvedValue({}); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-6', + audiobook: { title: 'Book', author: 'Author' }, + user: { plexUsername: 'user' }, + }); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); await expect(processMonitorDownload({ @@ -229,7 +343,7 @@ describe('processMonitorDownload', () => { downloadClientId: 'id-6', downloadClient: 'deluge', jobId: 'job-6', - })).rejects.toThrow(/not supported/i); + })).rejects.toThrow(/Unknown download client type: deluge/); expect(prismaMock.request.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -239,17 +353,37 @@ describe('processMonitorDownload', () => { }); it('marks request failed when SABnzbd completion lacks a download path', async () => { - sabMock.getNZB.mockResolvedValue({ - nzbId: 'nzb-2', - size: 100, - progress: 1, - status: 'completed', - downloadSpeed: 0, - timeLeft: 0, - downloadPath: undefined, + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue({ + id: 'nzb-2', + name: 'Book', + size: 100, + bytesDownloaded: 100, + progress: 1.0, + status: 'completed', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + downloadPath: undefined, + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-2', + type: 'sabnzbd', + name: 'SABnzbd', + enabled: true, + remotePathMappingEnabled: false, }); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-7', + audiobook: { title: 'Book', author: 'Author' }, + user: { plexUsername: 'user' }, + }); const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); await expect(processMonitorDownload({ @@ -268,14 +402,22 @@ describe('processMonitorDownload', () => { }); it('converts SABnzbd progress from 0.0-1.0 to 0-100 percentage', async () => { - sabMock.getNZB.mockResolvedValue({ - nzbId: 'nzb-3', - size: 1000000000, // 1GB - progress: 0.35, // 35% in decimal format (0.0-1.0) - status: 'downloading', - downloadSpeed: 5000000, // 5MB/s - timeLeft: 130, - }); + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue({ + id: 'nzb-3', + name: 'Book', + size: 1000000000, + bytesDownloaded: 350000000, + progress: 0.35, + status: 'downloading', + downloadSpeed: 5000000, + eta: 130, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); prismaMock.request.update.mockResolvedValue({}); prismaMock.downloadHistory.update.mockResolvedValue({}); diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts index a847c02..0ff695d 100644 --- a/tests/processors/organize-files.processor.test.ts +++ b/tests/processors/organize-files.processor.test.ts @@ -346,6 +346,58 @@ describe('processOrganizeFiles', () => { ); }); + it('queues retry when organizer returns EPERM copy failure', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a-eperm', + title: 'Theo of Golden', + author: 'Allen Levi', + narrator: null, + coverArtUrl: null, + audibleAsin: 'B0FTT6KFKR', + }); + // Organizer returns success: false with EPERM error (the fixed behavior) + organizerMock.organize.mockResolvedValue({ + success: false, + targetPath: '/media/audiobooks/Fiction/Allen Levi/Theo of Golden B0FTT6KFKR', + filesMovedCount: 0, + errors: [ + 'Failed to copy Theo of Golden [B0FTT6KFKR].m4b: EPERM: operation not permitted, copyfile', + 'No audio files were successfully copied to the target directory', + ], + audioFiles: [], + }); + prismaMock.request.findFirst.mockResolvedValue({ + importAttempts: 0, + maxImportRetries: 3, + deletedAt: null, + }); + configMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobook_path_template') return '{author}/{title} {asin}'; + return null; + }); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-eperm', + audiobookId: 'a-eperm', + downloadPath: '/data/torrents/bookbit', + jobId: 'job-eperm', + }); + + // Should be identified as retryable and queued for re-import + expect(result.success).toBe(false); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'awaiting_import', + importAttempts: 1, + errorMessage: expect.stringContaining('EPERM'), + }), + }) + ); + }); + it('generates and stores filesHash after successful organization', async () => { prismaMock.request.update.mockResolvedValue({}); prismaMock.audiobook.findUnique.mockResolvedValue({ diff --git a/tests/processors/retry-failed-imports.processor.test.ts b/tests/processors/retry-failed-imports.processor.test.ts index 3fe8d83..b203410 100644 --- a/tests/processors/retry-failed-imports.processor.test.ts +++ b/tests/processors/retry-failed-imports.processor.test.ts @@ -15,9 +15,8 @@ const configMock = vi.hoisted(() => ({ })); const downloadClientManagerMock = vi.hoisted(() => ({ getClientForProtocol: vi.fn(), + getClientServiceForProtocol: vi.fn(), })); -const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); -const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -35,20 +34,29 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({ getDownloadClientManager: () => downloadClientManagerMock, })); -vi.mock('@/lib/integrations/qbittorrent.service', () => ({ - getQBittorrentService: () => qbtMock, -})); - -vi.mock('@/lib/integrations/sabnzbd.service', () => ({ - getSABnzbdService: () => sabnzbdMock, -})); - describe('processRetryFailedImports', () => { beforeEach(() => { vi.clearAllMocks(); }); it('queues organize jobs using download client paths', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-1', + name: 'Book', + downloadPath: '/downloads/Book', + progress: 1.0, + status: 'completed', + size: 0, + bytesDownloaded: 0, + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'qbittorrent', @@ -65,11 +73,6 @@ describe('processRetryFailedImports', () => { }, ]); - qbtMock.getTorrent.mockResolvedValue({ - save_path: '/downloads', - name: 'Book', - }); - const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); const result = await processRetryFailedImports({ jobId: 'job-1' }); @@ -109,6 +112,12 @@ describe('processRetryFailedImports', () => { }); it('falls back to configured download dir when qBittorrent lookup fails', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockRejectedValue(new Error('not found')), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'qbittorrent', @@ -128,8 +137,6 @@ describe('processRetryFailedImports', () => { }, ]); - qbtMock.getTorrent.mockRejectedValue(new Error('not found')); - const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); const result = await processRetryFailedImports({ jobId: 'job-3' }); @@ -142,6 +149,23 @@ describe('processRetryFailedImports', () => { }); it('uses SABnzbd download path when available', async () => { + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue({ + id: 'nzb-1', + name: 'Book', + downloadPath: '/remote/nzb/Book', + progress: 1.0, + status: 'completed', + size: 0, + bytesDownloaded: 0, + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-2', type: 'sabnzbd', @@ -159,8 +183,6 @@ describe('processRetryFailedImports', () => { }, ]); - sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/remote/nzb/Book' }); - const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); const result = await processRetryFailedImports({ jobId: 'job-4' }); @@ -173,6 +195,12 @@ describe('processRetryFailedImports', () => { }); it('skips SABnzbd retries when download dir is missing', async () => { + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue(null), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-2', type: 'sabnzbd', @@ -189,8 +217,6 @@ describe('processRetryFailedImports', () => { }, ]); - sabnzbdMock.getNZB.mockResolvedValue(null); - const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); const result = await processRetryFailedImports({ jobId: 'job-5' }); @@ -222,6 +248,23 @@ describe('processRetryFailedImports', () => { }); it('tracks skipped requests when organize job fails', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-7', + name: 'Book', + downloadPath: '/downloads/Book', + progress: 1.0, + status: 'completed', + size: 0, + bytesDownloaded: 0, + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'qbittorrent', @@ -236,7 +279,6 @@ describe('processRetryFailedImports', () => { downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book', downloadClient: 'qbittorrent' }], }, ]); - qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' }); jobQueueMock.addOrganizeJob.mockRejectedValue(new Error('queue down')); const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); @@ -247,6 +289,12 @@ describe('processRetryFailedImports', () => { }); it('skips qBittorrent fallbacks when torrent name is missing', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockRejectedValue(new Error('not found')), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'qbittorrent', @@ -261,7 +309,6 @@ describe('processRetryFailedImports', () => { downloadHistory: [{ torrentHash: 'hash-8', downloadClient: 'qbittorrent' }], }, ]); - qbtMock.getTorrent.mockRejectedValue(new Error('not found')); const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); const result = await processRetryFailedImports({ jobId: 'job-8' }); @@ -272,6 +319,12 @@ describe('processRetryFailedImports', () => { }); it('skips qBittorrent fallbacks when download_dir is not configured', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockRejectedValue(new Error('not found')), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', type: 'qbittorrent', @@ -287,7 +340,6 @@ describe('processRetryFailedImports', () => { downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book', downloadClient: 'qbittorrent' }], }, ]); - qbtMock.getTorrent.mockRejectedValue(new Error('not found')); const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); const result = await processRetryFailedImports({ jobId: 'job-9' }); @@ -297,6 +349,12 @@ describe('processRetryFailedImports', () => { }); it('skips SABnzbd retries when the client throws', async () => { + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockRejectedValue(new Error('sab down')), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-2', type: 'sabnzbd', @@ -312,8 +370,6 @@ describe('processRetryFailedImports', () => { }, ]); - sabnzbdMock.getNZB.mockRejectedValue(new Error('sab down')); - const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); const result = await processRetryFailedImports({ jobId: 'job-10' }); @@ -321,6 +377,138 @@ describe('processRetryFailedImports', () => { expect(result.skipped).toBe(1); }); + it('uses stored downloadPath when client throws', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-1', + type: 'qbittorrent', + name: 'qBittorrent', + enabled: true, + remotePathMappingEnabled: false, + }); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-stored-1', + audiobook: { id: 'a-stored-1', title: 'Freefall' }, + downloadHistory: [{ + torrentHash: 'hash-stored-1', + torrentName: 'Freefall: Expeditionary Force Mavericks, Book 2 - Craig Alanson', + downloadClient: 'qbittorrent', + downloadPath: '/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2', + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-stored-1' }); + + expect(result.triggered).toBe(1); + // Should use stored path, NOT the torrentName-based fallback + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-stored-1', + 'a-stored-1', + '/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2' + ); + }); + + it('falls back to torrentName when stored downloadPath is null', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-1', + type: 'qbittorrent', + name: 'qBittorrent', + enabled: true, + remotePathMappingEnabled: false, + }); + configMock.get.mockResolvedValue('/downloads'); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-stored-2', + audiobook: { id: 'a-stored-2', title: 'Book' }, + downloadHistory: [{ + torrentHash: 'hash-stored-2', + torrentName: 'Book', + downloadClient: 'qbittorrent', + downloadPath: null, // Old record without stored path + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-stored-2' }); + + expect(result.triggered).toBe(1); + // Should fall back to torrentName-based path + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-stored-2', + 'a-stored-2', + '/downloads/Book' + ); + }); + + it('prefers live client path over stored downloadPath', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-stored-3', + name: 'Book', + downloadPath: '/downloads/LivePath', + progress: 1.0, + status: 'completed', + size: 0, + bytesDownloaded: 0, + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-1', + type: 'qbittorrent', + name: 'qBittorrent', + enabled: true, + remotePathMappingEnabled: false, + }); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-stored-3', + audiobook: { id: 'a-stored-3', title: 'Book' }, + downloadHistory: [{ + torrentHash: 'hash-stored-3', + torrentName: 'Book', + downloadClient: 'qbittorrent', + downloadPath: '/downloads/StoredPath', + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-stored-3' }); + + expect(result.triggered).toBe(1); + // Should use live client path, NOT stored path + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-stored-3', + 'a-stored-3', + '/downloads/LivePath' + ); + }); + it('skips requests without download_dir when no client identifiers exist', async () => { downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ id: 'client-1', @@ -344,6 +532,226 @@ describe('processRetryFailedImports', () => { expect(result.triggered).toBe(0); expect(result.skipped).toBe(1); }); + + // ========================================================================= + // EBOOK REQUEST TESTS + // ========================================================================= + + it('retries ebook requests with direct download client using stored path', async () => { + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-ebook-1', + type: 'ebook', + audiobook: { id: 'a-ebook-1', title: 'Equal Rites' }, + downloadHistory: [{ + downloadClient: 'direct', + torrentName: 'Equal Rites - Terry Pratchett.epub', + downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub', + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-ebook-1' }); + + expect(result.triggered).toBe(1); + expect(result.skipped).toBe(0); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-ebook-1', + 'a-ebook-1', + '/downloads/Equal Rites - Terry Pratchett.epub' + ); + }); + + it('retries ebook requests with direct download using fallback path', async () => { + configMock.get.mockResolvedValue('/downloads'); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-ebook-2', + type: 'ebook', + audiobook: { id: 'a-ebook-2', title: 'Equal Rites' }, + downloadHistory: [{ + downloadClient: 'direct', + torrentName: 'Equal Rites - Terry Pratchett.epub', + downloadPath: null, // No stored path + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-ebook-2' }); + + expect(result.triggered).toBe(1); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-ebook-2', + 'a-ebook-2', + '/downloads/Equal Rites - Terry Pratchett.epub' + ); + }); + + it('skips direct ebook requests when no stored path and no download_dir', async () => { + configMock.get.mockResolvedValue(null); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-ebook-3', + type: 'ebook', + audiobook: { id: 'a-ebook-3', title: 'Equal Rites' }, + downloadHistory: [{ + downloadClient: 'direct', + torrentName: 'Equal Rites - Terry Pratchett.epub', + downloadPath: null, + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-ebook-3' }); + + expect(result.triggered).toBe(0); + expect(result.skipped).toBe(1); + expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled(); + }); + + it('retries ebook requests downloaded via indexer (torrent client)', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-ebook-idx', + name: 'Equal Rites - Terry Pratchett (epub)', + downloadPath: '/downloads/Equal Rites - Terry Pratchett (epub)', + progress: 1.0, + status: 'completed', + size: 0, + bytesDownloaded: 0, + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-1', + type: 'qbittorrent', + name: 'qBittorrent', + enabled: true, + remotePathMappingEnabled: false, + }); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-ebook-idx', + type: 'ebook', + audiobook: { id: 'a-ebook-idx', title: 'Equal Rites' }, + downloadHistory: [{ + torrentHash: 'hash-ebook-idx', + torrentName: 'Equal Rites - Terry Pratchett (epub)', + downloadClient: 'qbittorrent', + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-ebook-idx' }); + + expect(result.triggered).toBe(1); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-ebook-idx', + 'a-ebook-idx', + '/downloads/Equal Rites - Terry Pratchett (epub)' + ); + }); + + it('processes mixed audiobook and ebook requests in same batch', async () => { + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-audio', + name: 'Gideon the Ninth', + downloadPath: '/downloads/Gideon the Ninth', + progress: 1.0, + status: 'completed', + size: 0, + bytesDownloaded: 0, + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-1', + type: 'qbittorrent', + name: 'qBittorrent', + enabled: true, + remotePathMappingEnabled: false, + }); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-mixed-audio', + type: 'audiobook', + audiobook: { id: 'a-mixed-audio', title: 'Gideon the Ninth' }, + downloadHistory: [{ + torrentHash: 'hash-audio', + torrentName: 'Gideon the Ninth', + downloadClient: 'qbittorrent', + }], + }, + { + id: 'req-mixed-ebook', + type: 'ebook', + audiobook: { id: 'a-mixed-ebook', title: 'Equal Rites' }, + downloadHistory: [{ + downloadClient: 'direct', + torrentName: 'Equal Rites - Terry Pratchett.epub', + downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub', + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-mixed' }); + + expect(result.triggered).toBe(2); + expect(result.skipped).toBe(0); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledTimes(2); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-mixed-audio', + 'a-mixed-audio', + '/downloads/Gideon the Ninth' + ); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-mixed-ebook', + 'a-mixed-ebook', + '/downloads/Equal Rites - Terry Pratchett.epub' + ); + }); + + it('skips direct ebook requests with no torrentName and no stored path', async () => { + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-ebook-noname', + type: 'ebook', + audiobook: { id: 'a-ebook-noname', title: 'Book' }, + downloadHistory: [{ + downloadClient: 'direct', + torrentName: null, + downloadPath: null, + }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-ebook-noname' }); + + expect(result.triggered).toBe(0); + expect(result.skipped).toBe(1); + expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled(); + }); }); diff --git a/tests/services/download-client-manager.service.test.ts b/tests/services/download-client-manager.service.test.ts index cab342f..126a616 100644 --- a/tests/services/download-client-manager.service.test.ts +++ b/tests/services/download-client-manager.service.test.ts @@ -34,13 +34,19 @@ vi.mock('@/lib/services/encryption.service', () => ({ }), })); -// Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time -const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({ +// Mock all 4 download client services - use vi.hoisted to ensure they're available at mock time +const { qbtServiceMock, sabServiceMock, transmissionServiceMock, nzbgetServiceMock } = vi.hoisted(() => ({ qbtServiceMock: { testConnection: vi.fn(), }, sabServiceMock: { - getVersion: vi.fn(), + testConnection: vi.fn(), + }, + transmissionServiceMock: { + testConnection: vi.fn(), + }, + nzbgetServiceMock: { + testConnection: vi.fn(), }, })); @@ -53,7 +59,19 @@ vi.mock('@/lib/integrations/qbittorrent.service', () => ({ vi.mock('@/lib/integrations/sabnzbd.service', () => ({ SABnzbdService: class MockSABnzbdService { - getVersion = sabServiceMock.getVersion; + testConnection = sabServiceMock.testConnection; + }, +})); + +vi.mock('@/lib/integrations/transmission.service', () => ({ + TransmissionService: class MockTransmissionService { + testConnection = transmissionServiceMock.testConnection; + }, +})); + +vi.mock('@/lib/integrations/nzbget.service', () => ({ + NZBGetService: class MockNZBGetService { + testConnection = nzbgetServiceMock.testConnection; }, })); @@ -184,6 +202,58 @@ describe('DownloadClientManager', () => { expect(result).toEqual(clients[0]); }); + it('returns Transmission client for torrent protocol', async () => { + const clients = [ + { + id: 'client-1', + type: 'transmission', + name: 'Transmission', + enabled: true, + url: 'http://localhost:9091', + username: 'admin', + password: 'password', + disableSSLVerify: false, + remotePathMappingEnabled: false, + category: 'readmeabook', + }, + ]; + + configMock.get.mockResolvedValue(JSON.stringify(clients)); + + const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); + const manager = getDownloadClientManager(configMock as any); + + const result = await manager.getClientForProtocol('torrent'); + + expect(result).toEqual(clients[0]); + }); + + it('returns NZBGet client for usenet protocol', async () => { + const clients = [ + { + id: 'client-1', + type: 'nzbget', + name: 'NZBGet', + enabled: true, + url: 'http://localhost:6789', + username: 'nzbget', + password: 'tegbzn6789', + disableSSLVerify: false, + remotePathMappingEnabled: false, + category: 'readmeabook', + }, + ]; + + configMock.get.mockResolvedValue(JSON.stringify(clients)); + + const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); + const manager = getDownloadClientManager(configMock as any); + + const result = await manager.getClientForProtocol('usenet'); + + expect(result).toEqual(clients[0]); + }); + it('returns null when no client configured for protocol', async () => { const clients = [ { @@ -293,7 +363,7 @@ describe('DownloadClientManager', () => { describe('testConnection', () => { it('successfully tests qBittorrent connection', async () => { - qbtServiceMock.testConnection.mockResolvedValue(undefined); + qbtServiceMock.testConnection.mockResolvedValue({ success: true, message: 'Connected' }); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); @@ -318,7 +388,7 @@ describe('DownloadClientManager', () => { }); it('successfully tests SABnzbd connection', async () => { - sabServiceMock.getVersion.mockResolvedValue('3.5.0'); + sabServiceMock.testConnection.mockResolvedValue({ success: true, version: '3.5.0', message: 'Connected' }); const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); const manager = getDownloadClientManager(configMock as any); @@ -365,6 +435,56 @@ describe('DownloadClientManager', () => { expect(result.success).toBe(false); expect(result.message).toBe('Connection refused'); }); + + it('successfully tests NZBGet connection', async () => { + nzbgetServiceMock.testConnection.mockResolvedValue({ success: true, version: '24.2', message: 'Connected' }); + + const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); + const manager = getDownloadClientManager(configMock as any); + + const config = { + id: 'client-1', + type: 'nzbget' as const, + name: 'NZBGet', + enabled: true, + url: 'http://localhost:6789', + username: 'nzbget', + password: 'tegbzn6789', + disableSSLVerify: false, + remotePathMappingEnabled: false, + category: 'readmeabook', + }; + + const result = await manager.testConnection(config); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully connected to NZBGet (v24.2)'); + }); + + it('successfully tests Transmission connection', async () => { + transmissionServiceMock.testConnection.mockResolvedValue({ success: true, version: '4.0.5', message: 'Connected' }); + + const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); + const manager = getDownloadClientManager(configMock as any); + + const config = { + id: 'client-1', + type: 'transmission' as const, + name: 'Transmission', + enabled: true, + url: 'http://localhost:9091', + username: 'admin', + password: 'password', + disableSSLVerify: false, + remotePathMappingEnabled: false, + category: 'readmeabook', + }; + + const result = await manager.testConnection(config); + + expect(result.success).toBe(true); + expect(result.message).toBe('Successfully connected to Transmission (v4.0.5)'); + }); }); describe('migration', () => { diff --git a/tests/services/request-delete.service.test.ts b/tests/services/request-delete.service.test.ts index ab55a54..48deec6 100644 --- a/tests/services/request-delete.service.test.ts +++ b/tests/services/request-delete.service.test.ts @@ -16,12 +16,8 @@ const configServiceMock = { get: vi.fn(), getBackendMode: vi.fn(), }; -const qbtMock = { - getTorrent: vi.fn(), - deleteTorrent: vi.fn(), -}; -const sabMock = { - deleteNZB: vi.fn(), +const downloadClientManagerMock = { + getClientServiceForProtocol: vi.fn(), }; vi.mock('@/lib/db', () => ({ @@ -34,12 +30,8 @@ vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); -vi.mock('@/lib/integrations/qbittorrent.service', () => ({ - getQBittorrentService: async () => qbtMock, -})); - -vi.mock('@/lib/integrations/sabnzbd.service', () => ({ - getSABnzbdService: async () => sabMock, +vi.mock('@/lib/services/download-client-manager.service', () => ({ + getDownloadClientManager: () => downloadClientManagerMock, })); vi.mock('@/lib/services/audiobookshelf/api', () => ({ @@ -59,6 +51,7 @@ describe('deleteRequest', () => { // Default mock for child request queries (audiobook requests check for child ebook requests) prismaMock.request.findMany.mockResolvedValue([]); prismaMock.request.updateMany.mockResolvedValue({ count: 0 }); + downloadClientManagerMock.getClientServiceForProtocol.mockReset(); }); it('returns not found when request is missing', async () => { @@ -103,10 +96,24 @@ describe('deleteRequest', () => { return null; }); configServiceMock.getBackendMode.mockResolvedValue('plex'); - qbtMock.getTorrent.mockResolvedValue({ - name: 'Book', - seeding_time: 120, - }); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-1', + name: 'Book', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'seeding', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + seedingTime: 120, + }), + deleteDownload: vi.fn().mockResolvedValue(undefined), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2021-01-01T00:00:00.000Z', }); @@ -122,7 +129,7 @@ describe('deleteRequest', () => { expect(result.success).toBe(true); expect(result.torrentsRemoved).toBe(1); - expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true); + expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-1', true); // Code now uses deleteMany with ASIN-based matching expect(prismaMock.plexLibrary.deleteMany).toHaveBeenCalledWith({ where: { @@ -166,7 +173,23 @@ describe('deleteRequest', () => { return null; }); configServiceMock.getBackendMode.mockResolvedValue('plex'); - sabMock.deleteNZB.mockResolvedValue(undefined); + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + getDownload: vi.fn().mockResolvedValue({ + id: 'nzb-1', + name: 'Book Two', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'completed', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + }), + deleteDownload: vi.fn().mockResolvedValue(undefined), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); fsMock.access.mockResolvedValue(undefined); fsMock.rm.mockResolvedValue(undefined); prismaMock.plexLibrary.findMany.mockResolvedValue([]); @@ -178,7 +201,7 @@ describe('deleteRequest', () => { expect(result.success).toBe(true); expect(result.torrentsRemoved).toBe(1); - expect(sabMock.deleteNZB).toHaveBeenCalledWith('nzb-1', true); + expect(sabClientMock.deleteDownload).toHaveBeenCalledWith('nzb-1', true); expect(prismaMock.request.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ deletedBy: 'admin-1' }), @@ -218,10 +241,24 @@ describe('deleteRequest', () => { return null; }); configServiceMock.getBackendMode.mockResolvedValue('plex'); - qbtMock.getTorrent.mockResolvedValue({ - name: 'Book Three', - seeding_time: 60, - }); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-3', + name: 'Book Three', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'seeding', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + seedingTime: 60, + }), + deleteDownload: vi.fn(), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2020-01-01T00:00:00.000Z', }); @@ -239,7 +276,6 @@ describe('deleteRequest', () => { const result = await deleteRequest('req-3', 'admin-2'); expect(result.torrentsKeptSeeding).toBe(1); - expect(qbtMock.deleteTorrent).not.toHaveBeenCalled(); // Path doesn't exist, so rm should not be called (first access fails) expect(fsMock.rm).not.toHaveBeenCalled(); @@ -274,10 +310,24 @@ describe('deleteRequest', () => { return null; }); configServiceMock.getBackendMode.mockResolvedValue('plex'); - qbtMock.getTorrent.mockResolvedValue({ - name: 'Book Four', - seeding_time: 0, - }); + const qbtClientMock = { + clientType: 'qbittorrent', + protocol: 'torrent', + getDownload: vi.fn().mockResolvedValue({ + id: 'hash-4', + name: 'Book Four', + size: 0, + bytesDownloaded: 0, + progress: 1.0, + status: 'seeding', + downloadSpeed: 0, + eta: 0, + category: 'readmeabook', + seedingTime: 0, + }), + deleteDownload: vi.fn(), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock); prismaMock.plexLibrary.findMany.mockResolvedValue([]); fsMock.access.mockRejectedValue(new Error('missing')); prismaMock.request.update.mockResolvedValue({}); @@ -287,7 +337,6 @@ describe('deleteRequest', () => { const result = await deleteRequest('req-4', 'admin-3'); expect(result.torrentsKeptUnlimited).toBe(1); - expect(qbtMock.deleteTorrent).not.toHaveBeenCalled(); }); it('clears audiobookshelf linkage when SABnzbd delete fails', async () => { @@ -319,7 +368,14 @@ describe('deleteRequest', () => { return null; }); configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf'); - sabMock.deleteNZB.mockRejectedValue(new Error('missing')); + const sabClientMock = { + clientType: 'sabnzbd', + protocol: 'usenet', + deleteDownload: vi.fn().mockRejectedValue(new Error('missing')), + getDownload: vi.fn(), + postProcess: vi.fn(), + }; + downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock); prismaMock.plexLibrary.findMany.mockResolvedValue([]); fsMock.access.mockRejectedValue(new Error('missing')); prismaMock.request.update.mockResolvedValue({}); diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts index 0605daa..13b83e2 100644 --- a/tests/utils/file-organizer.test.ts +++ b/tests/utils/file-organizer.test.ts @@ -473,7 +473,7 @@ describe('file organizer', () => { expect(result.isFile).toBe(true); }); - it('adds errors when source audio files are missing', async () => { + it('returns failure when source audio files are missing', async () => { configState.values.set('metadata_tagging_enabled', 'false'); configState.values.set('ebook_sidecar_enabled', 'false'); @@ -498,8 +498,10 @@ describe('file organizer', () => { author: 'Author', }, '{author}/{title}'); - expect(result.success).toBe(true); + expect(result.success).toBe(false); + expect(result.audioFiles).toEqual([]); expect(result.errors.join(' ')).toContain('Source file not found'); + expect(result.errors.join(' ')).toContain('No audio files were successfully copied'); expect(fsMock.copyFile).not.toHaveBeenCalled(); }); @@ -646,4 +648,256 @@ describe('file organizer', () => { expect((organizer as any).mediaDir).toBe('/media/custom'); expect((organizer as any).tempDir).toBe('/tmp/custom'); }); + + it('returns failure when all audio file copies fail (EPERM)', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const expectedDir = path.join('/media', 'Author', 'Book'); + const targetFile = path.join(expectedDir, 'book.m4b'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockRejectedValue( + Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' }) + ); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }, '{author}/{title}'); + + expect(result.success).toBe(false); + expect(result.audioFiles).toEqual([]); + expect(result.filesMovedCount).toBe(0); + expect(result.targetPath).toBe(expectedDir); + expect(result.errors.join(' ')).toContain('EPERM'); + expect(result.errors.join(' ')).toContain('No audio files were successfully copied'); + }); + + it('falls back to untagged file when tagged copy fails', async () => { + configState.values.set('metadata_tagging_enabled', 'true'); + + metadataMock.checkFfmpegAvailable.mockResolvedValue(true); + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const taggedPath = `${sourcePath}.tmp`; + metadataMock.tagMultipleFiles.mockResolvedValue([ + { success: true, filePath: sourcePath, taggedFilePath: taggedPath }, + ]); + + const expectedDir = path.join('/media', 'Author', 'Book'); + const targetFile = path.join(expectedDir, 'book.m4b'); + + fsMock.access.mockImplementation(async (filePath: string) => { + const normalized = path.normalize(filePath); + if (normalized === path.normalize(taggedPath)) return undefined; + if (normalized === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockImplementation(async (src: string, dest: string) => { + // Tagged file copy fails with EPERM + if (path.normalize(src) === path.normalize(taggedPath)) { + throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); + } + // Original file copy succeeds + return undefined; + }); + fsMock.chmod.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }, '{author}/{title}', + { jobId: 'job-fallback', context: 'test' } + ); + + expect(result.success).toBe(true); + expect(result.audioFiles).toEqual([targetFile]); + expect(result.filesMovedCount).toBe(1); + // Tagged temp file should be cleaned up + expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath); + // Fallback copy should use the original source + expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + // Should record that tagged copy failed + expect(result.errors.join(' ')).toContain('Tagged copy failed'); + expect(result.errors.join(' ')).toContain('without metadata tags'); + }); + + it('returns failure when tagged copy and fallback both fail', async () => { + configState.values.set('metadata_tagging_enabled', 'true'); + + metadataMock.checkFfmpegAvailable.mockResolvedValue(true); + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const taggedPath = `${sourcePath}.tmp`; + metadataMock.tagMultipleFiles.mockResolvedValue([ + { success: true, filePath: sourcePath, taggedFilePath: taggedPath }, + ]); + + fsMock.access.mockImplementation(async (filePath: string) => { + const normalized = path.normalize(filePath); + if (normalized === path.normalize(taggedPath)) return undefined; + if (normalized === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + // Both tagged and original copies fail + fsMock.copyFile.mockRejectedValue( + Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }) + ); + fsMock.unlink.mockResolvedValue(undefined); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }, '{author}/{title}', + { jobId: 'job-both-fail', context: 'test' } + ); + + expect(result.success).toBe(false); + expect(result.audioFiles).toEqual([]); + expect(result.filesMovedCount).toBe(0); + expect(result.errors.join(' ')).toContain('EPERM'); + expect(result.errors.join(' ')).toContain('No audio files were successfully copied'); + // Should still clean up tagged temp file + expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath); + }); + + it('reports partial success when some files copy and others fail', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['disc1.mp3', 'disc2.mp3'], + coverFile: undefined, + isFile: false, + }); + + const sourceRoot = path.normalize('/downloads/book'); + const source1 = path.join('/downloads', 'book', 'disc1.mp3'); + const source2 = path.join('/downloads', 'book', 'disc2.mp3'); + const expectedDir = path.join('/media', 'Author', 'Book'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath).startsWith(sourceRoot)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockImplementation(async (src: string) => { + // First file succeeds, second fails + if (path.normalize(src) === path.normalize(source2)) { + throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); + } + return undefined; + }); + fsMock.chmod.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }, '{author}/{title}'); + + // Should succeed because at least one file was copied + expect(result.success).toBe(true); + expect(result.audioFiles).toEqual([path.join(expectedDir, 'disc1.mp3')]); + expect(result.filesMovedCount).toBe(1); + expect(result.errors.join(' ')).toContain('Failed to copy disc2.mp3'); + }); + + it('succeeds with cover art when audio files were copied', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const expectedDir = path.join('/media', 'Author', 'Book'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') }); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + coverArtUrl: 'https://images.example/cover.jpg', + }, '{author}/{title}'); + + expect(result.success).toBe(true); + expect(result.audioFiles).toEqual([path.join(expectedDir, 'book.m4b')]); + expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg')); + }); + + it('returns failure even when cover art succeeds but audio copy fails', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const expectedDir = path.join('/media', 'Author', 'Book'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockRejectedValue( + Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }) + ); + fsMock.writeFile.mockResolvedValue(undefined); + axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') }); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + coverArtUrl: 'https://images.example/cover.jpg', + }, '{author}/{title}'); + + // Audio copy failed → should be failure despite cover art being available + expect(result.success).toBe(false); + expect(result.audioFiles).toEqual([]); + expect(result.filesMovedCount).toBe(0); + expect(result.errors.join(' ')).toContain('EPERM'); + expect(result.errors.join(' ')).toContain('No audio files were successfully copied'); + }); }); diff --git a/tests/utils/files-hash.test.ts b/tests/utils/files-hash.test.ts index 913b25b..99c085f 100644 --- a/tests/utils/files-hash.test.ts +++ b/tests/utils/files-hash.test.ts @@ -41,12 +41,28 @@ describe('generateFilesHash', () => { '/path/Chapter 04.mp4', '/path/Chapter 05.aa', '/path/Chapter 06.aax', + '/path/Chapter 07.flac', + '/path/Chapter 08.ogg', ]; const hash = generateFilesHash(filePaths); expect(hash).toBeTruthy(); expect(hash.length).toBe(64); }); + it('should include FLAC files in hash generation', () => { + const withFlac = ['/path/Chapter 01.flac', '/path/Chapter 02.flac']; + const hash = generateFilesHash(withFlac); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + + it('should include OGG files in hash generation', () => { + const withOgg = ['/path/Chapter 01.ogg', '/path/Chapter 02.ogg']; + const hash = generateFilesHash(withOgg); + expect(hash).toBeTruthy(); + expect(hash.length).toBe(64); + }); + it('should filter out non-audio files', () => { const filePaths = [ '/path/Chapter 01.mp3', diff --git a/tests/utils/indexer-grouping.test.ts b/tests/utils/indexer-grouping.test.ts new file mode 100644 index 0000000..7507eaf --- /dev/null +++ b/tests/utils/indexer-grouping.test.ts @@ -0,0 +1,202 @@ +/** + * Component: Indexer Grouping Utils Tests + * Documentation: documentation/phase3/prowlarr.md + */ + +import { describe, expect, it } from 'vitest'; +import { + getCategoriesForType, + groupIndexersByCategories, + getGroupDescription, + IndexerConfig, +} from '@/lib/utils/indexer-grouping'; + +describe('getCategoriesForType', () => { + describe('audiobook', () => { + it('returns audiobookCategories when set', () => { + const indexer: IndexerConfig = { id: 1, name: 'Test', audiobookCategories: [3030, 3010] }; + expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030, 3010]); + }); + + it('returns empty array when audiobookCategories is explicitly empty', () => { + const indexer: IndexerConfig = { id: 1, name: 'Test', audiobookCategories: [] }; + expect(getCategoriesForType(indexer, 'audiobook')).toEqual([]); + }); + + it('falls back to legacy categories when audiobookCategories is undefined', () => { + const indexer: IndexerConfig = { id: 1, name: 'Test', categories: [3030, 3040] }; + expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030, 3040]); + }); + + it('falls back to default [3030] when no fields are set', () => { + const indexer: IndexerConfig = { id: 1, name: 'Test' }; + expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3030]); + }); + + it('prefers audiobookCategories over legacy categories', () => { + const indexer: IndexerConfig = { + id: 1, name: 'Test', + audiobookCategories: [3010], + categories: [3030], + }; + expect(getCategoriesForType(indexer, 'audiobook')).toEqual([3010]); + }); + }); + + describe('ebook', () => { + it('returns ebookCategories when set', () => { + const indexer: IndexerConfig = { id: 1, name: 'Test', ebookCategories: [7020, 7050] }; + expect(getCategoriesForType(indexer, 'ebook')).toEqual([7020, 7050]); + }); + + it('returns empty array when ebookCategories is explicitly empty', () => { + const indexer: IndexerConfig = { id: 1, name: 'Test', ebookCategories: [] }; + expect(getCategoriesForType(indexer, 'ebook')).toEqual([]); + }); + + it('falls back to default [7020] when ebookCategories is undefined', () => { + const indexer: IndexerConfig = { id: 1, name: 'Test' }; + expect(getCategoriesForType(indexer, 'ebook')).toEqual([7020]); + }); + }); +}); + +describe('groupIndexersByCategories', () => { + it('groups indexers with matching categories', () => { + const indexers: IndexerConfig[] = [ + { id: 1, name: 'A', audiobookCategories: [3030] }, + { id: 2, name: 'B', audiobookCategories: [3030] }, + { id: 3, name: 'C', audiobookCategories: [3030, 3010] }, + ]; + + const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook'); + + expect(groups).toHaveLength(2); + expect(skippedIndexers).toHaveLength(0); + + const group3030 = groups.find(g => g.categories.length === 1 && g.categories[0] === 3030); + expect(group3030).toBeDefined(); + expect(group3030!.indexerIds).toEqual([1, 2]); + + const groupMulti = groups.find(g => g.categories.length === 2); + expect(groupMulti).toBeDefined(); + expect(groupMulti!.indexerIds).toEqual([3]); + }); + + it('sorts categories for consistent grouping regardless of order', () => { + const indexers: IndexerConfig[] = [ + { id: 1, name: 'A', audiobookCategories: [3010, 3030] }, + { id: 2, name: 'B', audiobookCategories: [3030, 3010] }, + ]; + + const { groups } = groupIndexersByCategories(indexers, 'audiobook'); + + expect(groups).toHaveLength(1); + expect(groups[0].indexerIds).toEqual([1, 2]); + expect(groups[0].categories).toEqual([3010, 3030]); + }); + + it('skips indexers with empty categories for the requested type', () => { + const indexers: IndexerConfig[] = [ + { id: 1, name: 'Active', audiobookCategories: [3030], ebookCategories: [7020] }, + { id: 2, name: 'Disabled', audiobookCategories: [], ebookCategories: [7020] }, + { id: 3, name: 'Also Active', audiobookCategories: [3030], ebookCategories: [] }, + ]; + + // Audiobook search: indexer 2 is skipped + const audioResult = groupIndexersByCategories(indexers, 'audiobook'); + expect(audioResult.groups).toHaveLength(1); + expect(audioResult.groups[0].indexerIds).toEqual([1, 3]); + expect(audioResult.skippedIndexers).toHaveLength(1); + expect(audioResult.skippedIndexers[0].id).toBe(2); + + // Ebook search: indexer 3 is skipped + const ebookResult = groupIndexersByCategories(indexers, 'ebook'); + expect(ebookResult.groups).toHaveLength(1); + expect(ebookResult.groups[0].indexerIds).toEqual([1, 2]); + expect(ebookResult.skippedIndexers).toHaveLength(1); + expect(ebookResult.skippedIndexers[0].id).toBe(3); + }); + + it('returns empty groups when all indexers are disabled for the type', () => { + const indexers: IndexerConfig[] = [ + { id: 1, name: 'A', audiobookCategories: [] }, + { id: 2, name: 'B', audiobookCategories: [] }, + ]; + + const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook'); + + expect(groups).toHaveLength(0); + expect(skippedIndexers).toHaveLength(2); + }); + + it('handles legacy configs without audiobookCategories field', () => { + const indexers: IndexerConfig[] = [ + { id: 1, name: 'Legacy', categories: [3030] }, + { id: 2, name: 'New', audiobookCategories: [3030] }, + ]; + + const { groups, skippedIndexers } = groupIndexersByCategories(indexers, 'audiobook'); + + expect(groups).toHaveLength(1); + expect(groups[0].indexerIds).toEqual([1, 2]); + expect(skippedIndexers).toHaveLength(0); + }); + + it('defaults to audiobook type when not specified', () => { + const indexers: IndexerConfig[] = [ + { id: 1, name: 'Test', audiobookCategories: [3030], ebookCategories: [7020] }, + ]; + + const { groups } = groupIndexersByCategories(indexers); + + expect(groups).toHaveLength(1); + expect(groups[0].categories).toEqual([3030]); + }); + + it('handles custom category IDs', () => { + const indexers: IndexerConfig[] = [ + { id: 1, name: 'A', audiobookCategories: [3030, 99999] }, + { id: 2, name: 'B', audiobookCategories: [3030, 99999] }, + { id: 3, name: 'C', audiobookCategories: [3030] }, + ]; + + const { groups } = groupIndexersByCategories(indexers, 'audiobook'); + + expect(groups).toHaveLength(2); + const customGroup = groups.find(g => g.categories.includes(99999)); + expect(customGroup).toBeDefined(); + expect(customGroup!.indexerIds).toEqual([1, 2]); + }); + + it('handles empty indexer array', () => { + const { groups, skippedIndexers } = groupIndexersByCategories([], 'audiobook'); + expect(groups).toHaveLength(0); + expect(skippedIndexers).toHaveLength(0); + }); +}); + +describe('getGroupDescription', () => { + it('returns human-readable description', () => { + const description = getGroupDescription({ + categories: [3030, 3010], + indexerIds: [1, 2], + indexers: [ + { id: 1, name: 'Indexer A' }, + { id: 2, name: 'Indexer B' }, + ], + }); + + expect(description).toBe('2 indexers (Indexer A, Indexer B) with categories [3030, 3010]'); + }); + + it('uses singular for single indexer', () => { + const description = getGroupDescription({ + categories: [3030], + indexerIds: [1], + indexers: [{ id: 1, name: 'Solo' }], + }); + + expect(description).toBe('1 indexer (Solo) with categories [3030]'); + }); +}); diff --git a/tests/utils/permissions.test.ts b/tests/utils/permissions.test.ts new file mode 100644 index 0000000..37cbd5b --- /dev/null +++ b/tests/utils/permissions.test.ts @@ -0,0 +1,31 @@ +/** + * Utility: Permission Resolution Tests + * Documentation: documentation/admin-dashboard.md + */ + +import { describe, expect, it } from 'vitest'; +import { resolvePermission } from '@/lib/utils/permissions'; + +describe('resolvePermission', () => { + it('always grants permission for admins regardless of other settings', () => { + expect(resolvePermission('admin', null, false)).toBe(true); + expect(resolvePermission('admin', false, false)).toBe(true); + expect(resolvePermission('admin', true, false)).toBe(true); + expect(resolvePermission('admin', null, true)).toBe(true); + }); + + it('uses per-user setting when explicitly true', () => { + expect(resolvePermission('user', true, false)).toBe(true); + expect(resolvePermission('user', true, true)).toBe(true); + }); + + it('uses per-user setting when explicitly false', () => { + expect(resolvePermission('user', false, true)).toBe(false); + expect(resolvePermission('user', false, false)).toBe(false); + }); + + it('falls back to global setting when per-user is null', () => { + expect(resolvePermission('user', null, true)).toBe(true); + expect(resolvePermission('user', null, false)).toBe(false); + }); +}); diff --git a/tests/utils/ranking-algorithm.test.ts b/tests/utils/ranking-algorithm.test.ts index f6de400..efb1f1c 100644 --- a/tests/utils/ranking-algorithm.test.ts +++ b/tests/utils/ranking-algorithm.test.ts @@ -116,6 +116,25 @@ describe('ranking-algorithm', () => { expect(highSeeders.some((note: string) => note.includes('Excellent availability'))).toBe(true); }); + it('adds lossless format note for FLAC files', () => { + const algorithm = new RankingAlgorithm(); + const breakdown = { + formatScore: 0, + sizeScore: 0, + seederScore: 0, + matchScore: 50, + totalScore: 50, + notes: [], + }; + + const flacNotes = (algorithm as any).generateNotes( + { ...baseTorrent, format: 'FLAC', title: 'Book Title [FLAC]' }, + breakdown, + 60 + ); + expect(flacNotes.some((note: string) => note.includes('Lossless format'))).toBe(true); + }); + it('adds format and size quality notes for MP3 files', () => { const algorithm = new RankingAlgorithm(); const breakdown = { @@ -214,6 +233,113 @@ describe('ranking-algorithm', () => { }); }); + describe('Colon-Separated Subtitle/Series Handling', () => { + const algorithm = new RankingAlgorithm(); + + it('matches "The Finest Edge of Twilight: Dungeons & Dragons" when torrent omits series', () => { + const torrent = { + ...baseTorrent, + title: 'The Finest Edge of Twilight by R A Salvatore [ENG / M4B]', + seeders: 129, + size: 650 * MB, + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Finest Edge of Twilight: Dungeons & Dragons', + author: 'R.A. Salvatore', + }); + + // Should pass word coverage (required: "finest", "edge", "twilight" — "dungeons" and "dragons" are optional) + // Should NOT get 0 match score + expect(breakdown.matchScore).toBeGreaterThan(0); + expect(breakdown.matchScore).toBeGreaterThan(40); + }); + + it('matches when torrent includes the colon subtitle', () => { + const torrent = { + ...baseTorrent, + title: 'The Finest Edge of Twilight Dungeons and Dragons by R A Salvatore [M4B]', + seeders: 50, + size: 650 * MB, + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Finest Edge of Twilight: Dungeons & Dragons', + author: 'R.A. Salvatore', + }); + + // Should still match when torrent has the full title including subtitle + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('matches "Project Hail Mary: A Novel" when torrent has just the title', () => { + const torrent = { + ...baseTorrent, + title: 'Andy Weir - Project Hail Mary [M4B]', + seeders: 100, + size: 400 * MB, + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Project Hail Mary: A Novel', + author: 'Andy Weir', + }); + + // "A Novel" after colon should be optional + expect(breakdown.matchScore).toBeGreaterThan(40); + }); + + it('matches title with both colon subtitle and parenthetical content', () => { + const torrent = { + ...baseTorrent, + title: 'Author Name - Book Title [Unabridged]', + seeders: 50, + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title: Series Name (Book 1)', + author: 'Author Name', + }); + + // Both ": Series Name" and "(Book 1)" should be optional + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + + it('does not treat colon at start of title as optional split', () => { + const torrent = { + ...baseTorrent, + title: 'Author - Some Title', + seeders: 50, + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Some Title', + author: 'Author', + }); + + // Normal match, no colon involved + expect(breakdown.matchScore).toBeGreaterThan(40); + }); + + it('handles "Re:Zero" style titles where colon is part of the word', () => { + const torrent = { + ...baseTorrent, + title: 'Author - Re Zero Starting Life in Another World', + seeders: 50, + size: 500 * MB, + }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Re:Zero - Starting Life in Another World', + author: 'Author', + }); + + // "Re" is required, "Zero - Starting Life in Another World" is optional after colon + // But the torrent still has all the words so it should score reasonably + expect(breakdown.matchScore).toBeGreaterThan(0); + }); + }); + describe('Structured Metadata Prefix Handling', () => { const algorithm = new RankingAlgorithm(); @@ -758,6 +884,43 @@ describe('ranking-algorithm', () => { expect(breakdown.formatScore).toBe(4); }); + it('detects FLAC format from title', () => { + const torrent = { ...baseTorrent, title: 'Book Title [FLAC]' }; + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.formatScore).toBe(7); + }); + + it('scores FLAC between M4B and M4A', () => { + const flacTorrent = { ...baseTorrent, title: 'Book Title [FLAC]' }; + const m4bTorrent = { ...baseTorrent, title: 'Book Title [M4B]' }; + const m4aTorrent = { ...baseTorrent, title: 'Book Title [M4A]' }; + + const flacBreakdown = algorithm.getScoreBreakdown(flacTorrent, { title: 'Book Title', author: 'Author' }); + const m4bBreakdown = algorithm.getScoreBreakdown(m4bTorrent, { title: 'Book Title', author: 'Author' }); + const m4aBreakdown = algorithm.getScoreBreakdown(m4aTorrent, { title: 'Book Title', author: 'Author' }); + + expect(m4bBreakdown.formatScore).toBeGreaterThan(flacBreakdown.formatScore); + expect(flacBreakdown.formatScore).toBeGreaterThan(m4aBreakdown.formatScore); + }); + + it('uses explicit FLAC format field when provided', () => { + const torrent = { + ...baseTorrent, + title: 'Book Title', + format: 'FLAC' as const, + }; + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Book Title', + author: 'Author', + }); + + expect(breakdown.formatScore).toBe(7); + }); + it('uses explicit format field when provided', () => { const torrent = { ...baseTorrent, diff --git a/tests/utils/torrent-categories.test.ts b/tests/utils/torrent-categories.test.ts index 44ec5e8..787f104 100644 --- a/tests/utils/torrent-categories.test.ts +++ b/tests/utils/torrent-categories.test.ts @@ -11,6 +11,8 @@ import { getChildIds, getParentId, isParentCategory, + getAllStandardCategoryIds, + isStandardCategory, } from '@/lib/utils/torrent-categories'; describe('torrent categories', () => { @@ -39,4 +41,22 @@ describe('torrent categories', () => { expect(DEFAULT_CATEGORIES).toEqual([3030]); expect(TORRENT_CATEGORIES.length).toBeGreaterThan(0); }); + + it('returns all standard category IDs including parents and children', () => { + const ids = getAllStandardCategoryIds(); + expect(ids.has(3000)).toBe(true); // parent + expect(ids.has(3030)).toBe(true); // child + expect(ids.has(7020)).toBe(true); // child + expect(ids.has(8000)).toBe(true); // parent with no children + expect(ids.has(99999)).toBe(false); // not a standard category + }); + + it('identifies standard vs custom categories', () => { + expect(isStandardCategory(3000)).toBe(true); + expect(isStandardCategory(3030)).toBe(true); + expect(isStandardCategory(7020)).toBe(true); + expect(isStandardCategory(8000)).toBe(true); + expect(isStandardCategory(12345)).toBe(false); + expect(isStandardCategory(0)).toBe(false); + }); });