mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<DownloadClientType, ProtocolType> = {
|
||||
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 `<DownloadClientManagement mode="settings" />`
|
||||
|
||||
Replaced single-client form with `<DownloadClientManagement mode="wizard" />`
|
||||
|
||||
**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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "download_history" ADD COLUMN "download_path" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "interactive_search_access" BOOLEAN;
|
||||
@@ -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")
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface PathsSettings {
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
audiobookPathTemplate?: string;
|
||||
ebookPathTemplate?: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -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<TemplatePreview | null>(null);
|
||||
|
||||
// Update live preview whenever template changes
|
||||
// Live preview state for ebook template
|
||||
const [ebookPreview, setEbookPreview] = useState<TemplatePreview | null>(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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
@@ -74,7 +101,7 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Temporary location for torrent downloads (kept for seeding)
|
||||
Temporary location for downloads before they are organized into the media library
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -111,61 +138,24 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
Customize how audiobooks are organized within the media directory
|
||||
</p>
|
||||
|
||||
{/* Variable Reference Panel */}
|
||||
<div className="mt-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Available Variables
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview - Client-side validation */}
|
||||
{livePreview && !livePreview.isValid && (
|
||||
{/* Audiobook Validation Error */}
|
||||
{audiobookPreview && !audiobookPreview.isValid && (
|
||||
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
|
||||
<span className="flex-shrink-0 mt-0.5">✗</span>
|
||||
<div className="flex-1">
|
||||
<span>{livePreview.error || 'Invalid template format'}</span>
|
||||
<span>{audiobookPreview.error || 'Invalid template format'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Preview Examples - Show while editing */}
|
||||
{livePreview && livePreview.isValid && livePreview.previewPaths && (
|
||||
{/* Audiobook Preview Examples */}
|
||||
{audiobookPreview && audiobookPreview.isValid && audiobookPreview.previewPaths && (
|
||||
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Preview Examples
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{livePreview.previewPaths.map((preview, index) => (
|
||||
{audiobookPreview.previewPaths.map((preview, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
{paths.mediaDir || '/media/audiobooks'}/{preview}
|
||||
</div>
|
||||
@@ -175,6 +165,96 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ebook Organization Template */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Ebook Organization Template
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.ebookPathTemplate || '{author}/{title} {asin}'}
|
||||
onChange={(e) => updatePath('ebookPathTemplate', e.target.value)}
|
||||
placeholder="{author}/{title} {asin}"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updatePath('ebookPathTemplate', paths.audiobookPathTemplate || '{author}/{title} {asin}')}
|
||||
disabled={ebookMatchesAudiobook}
|
||||
className="whitespace-nowrap text-sm"
|
||||
>
|
||||
Match Audiobook
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Customize how ebooks are organized within the media directory
|
||||
</p>
|
||||
|
||||
{/* Ebook Validation Error */}
|
||||
{ebookPreview && !ebookPreview.isValid && (
|
||||
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
|
||||
<span className="flex-shrink-0 mt-0.5">✗</span>
|
||||
<div className="flex-1">
|
||||
<span>{ebookPreview.error || 'Invalid template format'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ebook Preview Examples */}
|
||||
{ebookPreview && ebookPreview.isValid && ebookPreview.previewPaths && (
|
||||
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Preview Examples
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{ebookPreview.previewPaths.map((preview, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
{paths.mediaDir || '/media/audiobooks'}/{preview}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable Reference Panel (shared for both templates) */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Available Variables
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Tagging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
|
||||
@@ -41,6 +41,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat
|
||||
downloadDir: paths.downloadDir,
|
||||
mediaDir: paths.mediaDir,
|
||||
audiobookPathTemplate: paths.audiobookPathTemplate,
|
||||
ebookPathTemplate: paths.ebookPathTemplate,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+131
-49
@@ -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<boolean>(false);
|
||||
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
|
||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@@ -287,40 +355,26 @@ function AdminUsersPageContent() {
|
||||
Manage user roles and permissions
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Global Auto-Approve Toggle */}
|
||||
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: globalAutoApprove ? '#3b82f6' : '#d1d5db' }}
|
||||
onClick={() => setGlobalSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${globalAutoApprove ? 'translate-x-6' : 'translate-x-1'}`}
|
||||
/>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>Global User Permissions</span>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
|
||||
className="block text-base font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-Approve All Requests
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings below.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,7 +457,7 @@ function AdminUsersPageContent() {
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Auto-Approve
|
||||
Permissions
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Requests
|
||||
@@ -471,31 +525,34 @@ function AdminUsersPageContent() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setPermissionsUserId(user.id)}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{user.role === 'admin' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Always On
|
||||
Full Access
|
||||
</span>
|
||||
) : globalAutoApprove ? (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Global Setting
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
Global Default
|
||||
</span>
|
||||
) : (user.autoApproveRequests ?? false) ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Auto-Approve
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleUserAutoApproveToggle(user, !(user.autoApproveRequests ?? false))}
|
||||
className="relative inline-flex h-5 w-10 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
|
||||
style={{ backgroundColor: (user.autoApproveRequests ?? false) ? '#3b82f6' : '#d1d5db' }}
|
||||
title={`Toggle auto-approve for ${user.plexUsername}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${(user.autoApproveRequests ?? false) ? 'translate-x-6' : 'translate-x-1'}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{user._count.requests}
|
||||
@@ -587,7 +644,7 @@ function AdminUsersPageContent() {
|
||||
<li>• <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
|
||||
<li>• <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
|
||||
<li>• <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
|
||||
<li>• <strong>Auto-Approve:</strong> 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.</li>
|
||||
<li>• <strong>Permissions:</strong> 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.</li>
|
||||
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
|
||||
<li>• <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
|
||||
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
|
||||
@@ -722,6 +779,31 @@ function AdminUsersPageContent() {
|
||||
isLoading={deleting}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* Global User Settings Modal */}
|
||||
<GlobalUserSettingsModal
|
||||
isOpen={globalSettingsOpen}
|
||||
onClose={() => setGlobalSettingsOpen(false)}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
onToggleAutoApprove={handleGlobalAutoApproveToggle}
|
||||
globalInteractiveSearch={globalInteractiveSearch}
|
||||
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
|
||||
/>
|
||||
|
||||
{/* User Permissions Modal */}
|
||||
<UserPermissionsModal
|
||||
isOpen={permissionsUser !== null}
|
||||
onClose={() => setPermissionsUserId(null)}
|
||||
user={permissionsUser}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
globalInteractiveSearch={globalInteractiveSearch}
|
||||
onToggleAutoApprove={(user, newValue) => {
|
||||
handleUserAutoApproveToggle(user as User, newValue);
|
||||
}}
|
||||
onToggleInteractiveSearch={(user, newValue) => {
|
||||
handleUserInteractiveSearchToggle(user as User, newValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function GET(request: NextRequest) {
|
||||
updatedAt: true,
|
||||
lastLoginAt: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
_count: {
|
||||
select: {
|
||||
requests: true,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<FilterStatus>('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"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
|
||||
<div className={cn(
|
||||
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
|
||||
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
|
||||
)}></div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
|
||||
@@ -495,6 +495,7 @@ export default function SetupWizard() {
|
||||
return (
|
||||
<DownloadClientStep
|
||||
downloadClients={state.downloadClients}
|
||||
downloadDir={state.downloadDir}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
|
||||
@@ -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
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +84,7 @@ export function DownloadClientStep({
|
||||
mode="wizard"
|
||||
initialClients={clients}
|
||||
onClientsChange={handleClientsChange}
|
||||
downloadDir={downloadDir}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -17,13 +17,13 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center p-4"
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center p-2 overflow-hidden"
|
||||
style={{ backgroundColor: '#f7f4f3' }}
|
||||
>
|
||||
<img
|
||||
src="/rmab_32x32.png"
|
||||
src="/RMAB_1024x1024_ICON.png"
|
||||
alt="ReadMeABook Logo"
|
||||
className="w-full h-full object-contain"
|
||||
className="w-full h-full object-contain relative top-[3px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,9 +57,9 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong className="text-gray-900 dark:text-gray-100">Plex Media Server</strong>
|
||||
<strong className="text-gray-900 dark:text-gray-100">Plex or Audiobookshelf</strong>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Your Plex server URL and authentication token
|
||||
Your media server URL and authentication credentials
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -79,7 +79,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
<div>
|
||||
<strong className="text-gray-900 dark:text-gray-100">Prowlarr</strong>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Indexer aggregator for searching torrents (URL and API key)
|
||||
Indexer aggregator for searching torrents and usenet (URL and API key)
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -98,10 +98,10 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
</svg>
|
||||
<div>
|
||||
<strong className="text-gray-900 dark:text-gray-100">
|
||||
qBittorrent or SABnzbd
|
||||
Download Client
|
||||
</strong>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Download client for torrents (qBittorrent) or Usenet/NZB (SABnzbd)
|
||||
qBittorrent, Transmission, SABnzbd, or NZBGet
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={client.url}>
|
||||
{displayUrl}
|
||||
</p>
|
||||
{client.customPath && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 truncate" title={`Custom path: ${client.customPath}`}>
|
||||
Path: {client.customPath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<DownloadClient[]>(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 (
|
||||
<div className="space-y-6">
|
||||
@@ -233,9 +261,9 @@ export function DownloadClientManagement({
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Add Download Client
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* qBittorrent Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
@@ -249,9 +277,9 @@ export function DownloadClientManagement({
|
||||
Torrent
|
||||
</span>
|
||||
</div>
|
||||
{hasQBittorrent ? (
|
||||
{hasTorrentClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Already configured
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -265,8 +293,39 @@ export function DownloadClientManagement({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transmission Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Transmission
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Torrent downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 font-medium">
|
||||
Torrent
|
||||
</span>
|
||||
</div>
|
||||
{hasTorrentClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAddClient('transmission')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add Transmission
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SABnzbd Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
@@ -280,9 +339,9 @@ export function DownloadClientManagement({
|
||||
Usenet
|
||||
</span>
|
||||
</div>
|
||||
{hasSABnzbd ? (
|
||||
{hasUsenetClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Already configured
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -295,6 +354,37 @@ export function DownloadClientManagement({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NZBGet Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
NZBGet
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Usenet/NZB downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium">
|
||||
Usenet
|
||||
</span>
|
||||
</div>
|
||||
{hasUsenetClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAddClient('nzbget')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add NZBGet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -338,6 +428,7 @@ export function DownloadClientManagement({
|
||||
initialClient={modalState.currentClient}
|
||||
onSave={handleSaveClient}
|
||||
apiMode={mode}
|
||||
downloadDir={resolvedDownloadDir}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -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<void>;
|
||||
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({
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -272,8 +288,8 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username (qBittorrent only) */}
|
||||
{type === 'qbittorrent' && (
|
||||
{/* Username (qBittorrent and Transmission) */}
|
||||
{type !== 'sabnzbd' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
@@ -290,13 +306,13 @@ export function DownloadClientModal({
|
||||
{/* Password / API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{type === 'qbittorrent' ? 'Password' : 'API Key'}
|
||||
{type === 'sabnzbd' ? 'API Key' : 'Password'}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={type === 'qbittorrent' ? 'Password' : 'API Key from SABnzbd Config > General'}
|
||||
placeholder={type === 'sabnzbd' ? 'API Key from SABnzbd Config > General' : 'Password'}
|
||||
error={errors.password}
|
||||
/>
|
||||
{type === 'sabnzbd' && (
|
||||
@@ -304,6 +320,11 @@ export function DownloadClientModal({
|
||||
Found in SABnzbd under Config → General → API Key
|
||||
</p>
|
||||
)}
|
||||
{type === 'nzbget' && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Configured in NZBGet under Settings → Security → ControlPassword
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSL Verification */}
|
||||
@@ -342,6 +363,27 @@ export function DownloadClientModal({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Custom Download Path */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Custom Download Path
|
||||
</label>
|
||||
<Input
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
placeholder="e.g. torrents or usenet/books"
|
||||
error={errors.customPath}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Optional relative sub-path appended to the base download directory
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
Downloads to: {customPath.replace(/^\/+|\/+$/g, '').trim()
|
||||
? `${downloadDir}/${customPath.replace(/^\/+|\/+$/g, '').trim()}`
|
||||
: downloadDir}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start mb-3">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
{/* Standard Categories */}
|
||||
{TORRENT_CATEGORIES.map((category) => (
|
||||
<div key={category.id} className="space-y-2">
|
||||
{/* Parent Category Header */}
|
||||
@@ -129,6 +189,85 @@ export function CategoryTreeView({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom Categories Section */}
|
||||
<div className="space-y-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 px-2 py-1">
|
||||
<span className="text-base font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
|
||||
Custom
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Add custom Newznab/Torznab category IDs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Existing custom categories */}
|
||||
{customCategories.length > 0 && (
|
||||
<div className="ml-4 space-y-2">
|
||||
{customCategories.map((catId) => (
|
||||
<div
|
||||
key={catId}
|
||||
className="flex items-center justify-between p-2.5 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Custom
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
[{catId}]
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCustom(catId)}
|
||||
className="text-xs px-2.5 py-1 rounded-md text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 border border-red-200 dark:border-red-800 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add custom category input */}
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={customInput}
|
||||
onChange={(e) => {
|
||||
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'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustom}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{customError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1.5">
|
||||
{customError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -342,8 +329,8 @@ export function IndexerConfigModal({
|
||||
}`}
|
||||
>
|
||||
AudioBook
|
||||
{errors.audiobookCategories && (
|
||||
<span className="ml-2 text-red-500">!</span>
|
||||
{audiobookDisabled && (
|
||||
<span className="ml-2 text-amber-500" title="No categories — disabled for audiobooks">!</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
@@ -356,8 +343,8 @@ export function IndexerConfigModal({
|
||||
}`}
|
||||
>
|
||||
EBook
|
||||
{errors.ebookCategories && (
|
||||
<span className="ml-2 text-red-500">!</span>
|
||||
{ebookDisabled && (
|
||||
<span className="ml-2 text-amber-500" title="No categories — disabled for ebooks">!</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -372,15 +359,23 @@ export function IndexerConfigModal({
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
{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]'}
|
||||
</p>
|
||||
|
||||
{currentError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{currentError}
|
||||
</p>
|
||||
{/* Warning when all categories are deselected for the active tab */}
|
||||
{currentCategories.length === 0 && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<svg className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
No categories selected. This indexer will not be searched for {activeTab === 'audiobook' ? 'audiobooks' : 'ebooks'}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
|
||||
<div className="space-y-6">
|
||||
{/* Auto-Approve Setting */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: globalAutoApprove ? '#3b82f6' : '#d1d5db' }}
|
||||
role="switch"
|
||||
aria-checked={globalAutoApprove}
|
||||
aria-label="Auto-Approve All Requests"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
globalAutoApprove ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
|
||||
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-Approve All Requests
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings from the users table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Access Setting */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: globalInteractiveSearch ? '#3b82f6' : '#d1d5db' }}
|
||||
role="switch"
|
||||
aria-checked={globalInteractiveSearch}
|
||||
aria-label="Interactive Search Access"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
globalInteractiveSearch ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
|
||||
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Interactive Search Access
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
When enabled, all users can manually search and select torrents/ebooks. When disabled, you can grant access per-user from the users table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-start gap-4 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!disabled) onToggle();
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5 ${
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||
disabled={disabled}
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||
value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</div>
|
||||
{disabledMessage ? (
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{disabledMessage}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="User Permissions" size="sm">
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
{user.avatarUrl && (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.plexUsername}
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`ml-auto px-2 py-0.5 text-xs font-semibold rounded-full ${
|
||||
isAdmin
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{user.role.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Permissions
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Auto-Approve Permission */}
|
||||
<PermissionToggle
|
||||
label="Auto-Approve Requests"
|
||||
ariaLabel="Auto-Approve Requests"
|
||||
value={autoApproveValue}
|
||||
disabled={isAutoApproveDisabled}
|
||||
disabledMessage={getDisabledMessage(
|
||||
isAdmin, isAutoApproveGlobalOverride,
|
||||
'Admin requests are always auto-approved',
|
||||
'Controlled by global auto-approve setting'
|
||||
)}
|
||||
description="When enabled, this user's requests are automatically processed without admin approval"
|
||||
onToggle={() => onToggleAutoApprove(user, !autoApproveValue)}
|
||||
/>
|
||||
|
||||
{/* Interactive Search Access Permission */}
|
||||
<PermissionToggle
|
||||
label="Interactive Search Access"
|
||||
ariaLabel="Interactive Search Access"
|
||||
value={searchValue}
|
||||
disabled={isSearchDisabled}
|
||||
disabledMessage={getDisabledMessage(
|
||||
isAdmin, isSearchGlobalOverride,
|
||||
'Admins always have interactive search access',
|
||||
'Controlled by global interactive search setting'
|
||||
)}
|
||||
description="When enabled, this user can manually search and select torrents and ebooks"
|
||||
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -479,8 +479,8 @@ export function AudiobookDetailsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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) && (
|
||||
<button
|
||||
onClick={handleInteractiveSearch}
|
||||
disabled={!user}
|
||||
@@ -513,15 +513,17 @@ export function AudiobookDetailsModal({
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInteractiveSearchEbook(true)}
|
||||
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
|
||||
title="Search Ebook Sources"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{(user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
|
||||
<button
|
||||
onClick={() => setShowInteractiveSearchEbook(true)}
|
||||
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
|
||||
title="Search Ebook Sources"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
|
||||
{/* Cover Art */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative w-16 h-24 sm:w-24 sm:h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700',
|
||||
squareCovers
|
||||
? 'w-16 sm:w-24 aspect-square'
|
||||
: 'w-16 sm:w-24 aspect-[2/3]',
|
||||
request.audiobook.audibleAsin && 'cursor-pointer hover:opacity-90 transition-opacity'
|
||||
)}
|
||||
onClick={() => 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 ? (
|
||||
<Image
|
||||
src={request.audiobook.coverArtUrl}
|
||||
@@ -277,6 +298,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
author: request.audiobook.author,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Audiobook Details Modal */}
|
||||
{request.audiobook.audibleAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={request.audiobook.audibleAsin}
|
||||
isOpen={showDetailsModal}
|
||||
onClose={() => setShowDetailsModal(false)}
|
||||
requestStatus={request.status}
|
||||
isAvailable={['available', 'downloaded'].includes(request.status)}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [rawVersion, setRawVersion] = useState<string | null>(null);
|
||||
const [commit, setCommit] = useState<string | null>(null);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(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 (
|
||||
<div
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm"
|
||||
<a
|
||||
href={updateAvailable && latestVersion
|
||||
? `https://github.com/${GITHUB_REPO}/releases/tag/v${latestVersion}`
|
||||
: releaseUrl
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm hover:shadow-md transition-shadow no-underline"
|
||||
title={tooltipText}
|
||||
>
|
||||
<span className="text-xs font-mono font-medium text-gray-700 dark:text-gray-300">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
{updateAvailable && latestVersion && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-mono font-medium text-amber-600 dark:text-amber-400">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-500 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
|
||||
</span>
|
||||
v{latestVersion}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Component: Audio Format Constants
|
||||
* Documentation: documentation/phase3/file-organization.md
|
||||
*
|
||||
* Centralized audio format definitions used across the application.
|
||||
* Add new formats here to enable support in all subsystems.
|
||||
*/
|
||||
|
||||
/**
|
||||
* All supported audio file extensions for audiobook detection and file organization.
|
||||
* Used by: file-organizer.ts, files-hash.ts
|
||||
*/
|
||||
export const AUDIO_EXTENSIONS = [
|
||||
'.m4b',
|
||||
'.m4a',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.aa',
|
||||
'.aax',
|
||||
'.flac',
|
||||
'.ogg',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Audio formats supported by the chapter merger (FFmpeg concat + M4B output).
|
||||
* Formats here can be detected, probed, ordered, and merged into a single M4B.
|
||||
* Note: .aa/.aax excluded (DRM-protected, cannot be decoded by FFmpeg without keys).
|
||||
* Note: .ogg excluded (FFmpeg concat demuxer does not support Ogg container).
|
||||
*/
|
||||
export const CHAPTER_MERGE_FORMATS = [
|
||||
'.mp3',
|
||||
'.m4a',
|
||||
'.m4b',
|
||||
'.mp4',
|
||||
'.aac',
|
||||
'.flac',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Audio formats supported by metadata tagging via FFmpeg.
|
||||
* Each format maps to a specific FFmpeg output format flag and tagging strategy.
|
||||
*/
|
||||
export const METADATA_TAG_FORMATS = [
|
||||
'.m4b',
|
||||
'.m4a',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.flac',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Formats that use MP4/M4A container tags (iTunes-style metadata).
|
||||
* These use `-f mp4` output format in FFmpeg.
|
||||
*/
|
||||
export const MP4_CONTAINER_FORMATS = ['.m4b', '.m4a', '.mp4'] as const;
|
||||
|
||||
/**
|
||||
* Audio format identifiers detectable in torrent/NZB titles.
|
||||
* Used by Prowlarr service for metadata extraction and ranking algorithm for scoring.
|
||||
*/
|
||||
export const TORRENT_TITLE_FORMATS = ['M4B', 'M4A', 'MP3', 'FLAC'] as const;
|
||||
|
||||
export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
|
||||
|
||||
/**
|
||||
* Type helper for the format field on TorrentResult.
|
||||
* 'OTHER' is used when no recognized format is detected in the title.
|
||||
*/
|
||||
export type AudioFormat = TorrentTitleFormat | 'OTHER';
|
||||
@@ -0,0 +1,925 @@
|
||||
/**
|
||||
* Component: NZBGet Integration Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import zlib from 'zlib';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||
import {
|
||||
IDownloadClient,
|
||||
DownloadClientType,
|
||||
ProtocolType,
|
||||
DownloadInfo,
|
||||
DownloadStatus,
|
||||
AddDownloadOptions,
|
||||
ConnectionTestResult,
|
||||
} from '../interfaces/download-client.interface';
|
||||
|
||||
const logger = RMABLogger.create('NZBGet');
|
||||
|
||||
// =========================================================================
|
||||
// NZBGet-specific types
|
||||
// =========================================================================
|
||||
|
||||
/** NZBGet queue group item from listgroups() */
|
||||
interface NZBGetGroupItem {
|
||||
NZBID: number;
|
||||
NZBName: string;
|
||||
Status: string;
|
||||
FileSizeMB: number;
|
||||
DownloadedSizeMB: number;
|
||||
RemainingSizeMB: number;
|
||||
DownloadTimeSec: number;
|
||||
Category: string;
|
||||
DestDir: string;
|
||||
FinalDir: string;
|
||||
MaxPriority: number;
|
||||
ActiveDownloads: number;
|
||||
Health: number;
|
||||
PostInfoText: string;
|
||||
PostStageProgress: number;
|
||||
}
|
||||
|
||||
/** NZBGet history item from history() */
|
||||
interface NZBGetHistoryItem {
|
||||
NZBID: number;
|
||||
Name: string;
|
||||
Status: string;
|
||||
Category: string;
|
||||
FileSizeMB: number;
|
||||
DownloadedSizeMB: number;
|
||||
DestDir: string;
|
||||
FinalDir: string;
|
||||
DownloadTimeSec: number;
|
||||
PostTotalTimeSec: number;
|
||||
ParStatus: string;
|
||||
UnpackStatus: string;
|
||||
DeleteStatus: string;
|
||||
MarkStatus: string;
|
||||
HistoryTime: number;
|
||||
FailedArticles: number;
|
||||
TotalArticles: number;
|
||||
}
|
||||
|
||||
/** NZBGet config entry from config() */
|
||||
interface NZBGetConfigItem {
|
||||
Name: string;
|
||||
Value: string;
|
||||
}
|
||||
|
||||
/** NZBGet status response from status() */
|
||||
interface NZBGetStatus {
|
||||
DownloadRate: number;
|
||||
RemainingSizeMB: number;
|
||||
DownloadedSizeMB: number;
|
||||
DownloadPaused: boolean;
|
||||
ServerStandBy: boolean;
|
||||
}
|
||||
|
||||
/** Internal NZB info (normalized before mapping to DownloadInfo) */
|
||||
interface NZBInfo {
|
||||
nzbId: string;
|
||||
name: string;
|
||||
size: number;
|
||||
bytesDownloaded: number;
|
||||
progress: number;
|
||||
status: DownloadStatus;
|
||||
downloadSpeed: number;
|
||||
eta: number;
|
||||
category: string;
|
||||
downloadPath?: string;
|
||||
completedAt?: Date;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// NZBGet Service
|
||||
// =========================================================================
|
||||
|
||||
export class NZBGetService implements IDownloadClient {
|
||||
readonly clientType: DownloadClientType = 'nzbget';
|
||||
readonly protocol: ProtocolType = 'usenet';
|
||||
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private username: string;
|
||||
private password: string;
|
||||
private defaultCategory: string;
|
||||
private defaultDownloadDir: string;
|
||||
private disableSSLVerify: boolean;
|
||||
private httpsAgent?: https.Agent;
|
||||
private pathMappingConfig: PathMappingConfig;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
defaultCategory: string = 'readmeabook',
|
||||
defaultDownloadDir: string = '/downloads',
|
||||
disableSSLVerify: boolean = false,
|
||||
pathMappingConfig?: PathMappingConfig
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username || '';
|
||||
this.password = password || '';
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.defaultDownloadDir = defaultDownloadDir;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
if (this.disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 30000,
|
||||
httpsAgent: this.httpsAgent,
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// JSON-RPC Communication
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Make a JSON-RPC call to NZBGet.
|
||||
* All NZBGet API calls go through POST /jsonrpc with Basic Auth.
|
||||
*/
|
||||
private async rpc<T = any>(method: string, params: any[] = []): Promise<T> {
|
||||
const response = await this.client.post('/jsonrpc', {
|
||||
method,
|
||||
params,
|
||||
});
|
||||
|
||||
if (response.data?.error) {
|
||||
const errorMsg = typeof response.data.error === 'string'
|
||||
? response.data.error
|
||||
: response.data.error.message || JSON.stringify(response.data.error);
|
||||
throw new Error(`NZBGet RPC error (${method}): ${errorMsg}`);
|
||||
}
|
||||
|
||||
return response.data?.result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IDownloadClient Implementation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Test connection to NZBGet
|
||||
*/
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const version = await this.rpc<string>('version');
|
||||
|
||||
if (!version) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Connected but failed to get NZBGet version',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version,
|
||||
message: `Connected to NZBGet v${version}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: this.formatConnectionError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a download via the unified interface.
|
||||
* Downloads the NZB file from the source URL and uploads to NZBGet via append().
|
||||
*/
|
||||
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
||||
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
|
||||
|
||||
const category = options?.category || this.defaultCategory;
|
||||
|
||||
// Ensure category exists with correct path before every download
|
||||
// (Matches SABnzbd/qBittorrent behavior — lightweight config read + conditional write)
|
||||
await this.ensureCategory();
|
||||
|
||||
// Download the NZB file content from the source URL (Prowlarr proxy)
|
||||
let nzbBuffer: Buffer;
|
||||
let filename: string;
|
||||
|
||||
try {
|
||||
logger.info('Downloading NZB file from source URL...');
|
||||
|
||||
const nzbResponse = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
||||
});
|
||||
|
||||
nzbBuffer = Buffer.from(nzbResponse.data);
|
||||
|
||||
if (nzbBuffer.length === 0) {
|
||||
throw new Error('NZB file is empty (0 bytes)');
|
||||
}
|
||||
|
||||
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
|
||||
|
||||
// Detect and decompress gzip-compressed NZB files
|
||||
// Prowlarr/indexers may serve .nzb.gz files which need decompression before upload
|
||||
if (nzbBuffer[0] === 0x1f && nzbBuffer[1] === 0x8b) {
|
||||
logger.info('NZB file is gzip-compressed, decompressing...');
|
||||
nzbBuffer = zlib.gunzipSync(nzbBuffer);
|
||||
logger.info(`Decompressed NZB file: ${nzbBuffer.length} bytes`);
|
||||
}
|
||||
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
if (status) {
|
||||
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||||
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Upload to NZBGet via append()
|
||||
// Parameters: Filename, Content (base64), Category, Priority, AddToTop, AddPaused,
|
||||
// DupeKey, DupeScore, DupeMode, AutoCategory, PPParameters
|
||||
const base64Content = nzbBuffer.toString('base64');
|
||||
const priority = this.mapPriority(options?.priority);
|
||||
|
||||
const nzbId = await this.rpc<number>('append', [
|
||||
filename, // Filename
|
||||
base64Content, // Content (base64-encoded NZB)
|
||||
category, // Category
|
||||
priority, // Priority (0=normal, 50=high, 100=very high, 900=force)
|
||||
false, // AddToTop
|
||||
options?.paused || false, // AddPaused
|
||||
'', // DupeKey
|
||||
0, // DupeScore
|
||||
'FORCE', // DupeMode — RMAB manages its own lifecycle, skip NZBGet dupe detection
|
||||
[], // PPParameters
|
||||
]);
|
||||
|
||||
if (!nzbId || nzbId <= 0) {
|
||||
// Log diagnostic info to help debug rejected NZBs
|
||||
const contentPreview = nzbBuffer.slice(0, 100).toString('utf-8');
|
||||
logger.error('NZBGet rejected the NZB file', {
|
||||
filename,
|
||||
contentLength: nzbBuffer.length,
|
||||
base64Length: base64Content.length,
|
||||
contentPreview: contentPreview.substring(0, 80),
|
||||
returnedId: nzbId,
|
||||
});
|
||||
throw new Error('NZBGet rejected the NZB file');
|
||||
}
|
||||
|
||||
const id = String(nzbId);
|
||||
logger.info(`Added NZB: ${id} (${filename})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status of a download.
|
||||
* Checks queue (listgroups) first, then history.
|
||||
*/
|
||||
async getDownload(id: string): Promise<DownloadInfo | null> {
|
||||
const nzbId = parseInt(id, 10);
|
||||
if (isNaN(nzbId)) {
|
||||
logger.error(`Invalid NZB ID: ${id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check queue first
|
||||
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
|
||||
const groupItem = groups?.find(g => g.NZBID === nzbId);
|
||||
|
||||
if (groupItem) {
|
||||
return this.mapGroupToDownloadInfo(groupItem);
|
||||
}
|
||||
|
||||
// Not in queue, check history
|
||||
const history = await this.rpc<NZBGetHistoryItem[]>('history', [false]);
|
||||
const historyItem = history?.find(h => h.NZBID === nzbId);
|
||||
|
||||
if (historyItem) {
|
||||
return this.mapHistoryToDownloadInfo(historyItem);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a download via editqueue GroupPause
|
||||
*/
|
||||
async pauseDownload(id: string): Promise<void> {
|
||||
const nzbId = parseInt(id, 10);
|
||||
const result = await this.rpc<boolean>('editqueue', ['GroupPause', '', [nzbId]]);
|
||||
if (!result) {
|
||||
throw new Error(`Failed to pause download ${id}`);
|
||||
}
|
||||
logger.info(`Paused download: ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a download via editqueue GroupResume
|
||||
*/
|
||||
async resumeDownload(id: string): Promise<void> {
|
||||
const nzbId = parseInt(id, 10);
|
||||
const result = await this.rpc<boolean>('editqueue', ['GroupResume', '', [nzbId]]);
|
||||
if (!result) {
|
||||
throw new Error(`Failed to resume download ${id}`);
|
||||
}
|
||||
logger.info(`Resumed download: ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a download from NZBGet.
|
||||
* Tries queue first (GroupFinalDelete), then history (HistoryFinalDelete).
|
||||
*/
|
||||
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
||||
const nzbId = parseInt(id, 10);
|
||||
logger.info(`Deleting download: ${id} (deleteFiles: ${deleteFiles})`);
|
||||
|
||||
// Try deleting from queue first
|
||||
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
|
||||
const inQueue = groups?.some(g => g.NZBID === nzbId);
|
||||
|
||||
if (inQueue) {
|
||||
const command = deleteFiles ? 'GroupFinalDelete' : 'GroupDelete';
|
||||
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
|
||||
if (!result) {
|
||||
throw new Error(`Failed to delete download ${id} from queue`);
|
||||
}
|
||||
logger.info(`Deleted download ${id} from queue`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try deleting from history
|
||||
const command = deleteFiles ? 'HistoryFinalDelete' : 'HistoryDelete';
|
||||
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
|
||||
if (!result) {
|
||||
throw new Error(`Failed to delete download ${id} from history`);
|
||||
}
|
||||
logger.info(`Deleted download ${id} from history`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-download cleanup: archive from NZBGet history.
|
||||
* Uses HistoryDelete to hide the item from visible history (preserves in hidden archive).
|
||||
* Analogous to SABnzbd's archive behavior.
|
||||
*/
|
||||
async postProcess(id: string): Promise<void> {
|
||||
const nzbId = parseInt(id, 10);
|
||||
logger.info(`Archiving completed download from history: ${id}`);
|
||||
|
||||
try {
|
||||
const result = await this.rpc<boolean>('editqueue', ['HistoryDelete', '', [nzbId]]);
|
||||
if (!result) {
|
||||
throw new Error(`NZBGet returned false for HistoryDelete`);
|
||||
}
|
||||
logger.info(`Successfully archived ${id} from history`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to archive ${id} from history`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new Error(`NZB ${id} not found in history or failed to archive`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Category Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Ensure the category exists in NZBGet with the correct download path.
|
||||
*
|
||||
* NZBGet categories are config entries (Category1.Name, Category1.DestDir, etc.).
|
||||
* Reads existing config, checks for our category, creates/updates via saveconfig().
|
||||
*
|
||||
* CRITICAL: NZBGet's saveconfig() does a FULL config replacement — passing only
|
||||
* our entries would wipe every other setting and destroy the instance. We must
|
||||
* always read the full config, merge our changes, and write the entire config back.
|
||||
*
|
||||
* After creating a new category, we call reload() so NZBGet picks up the new
|
||||
* category DestDir immediately. reload() is safe when the config is correct.
|
||||
*
|
||||
* Called before every download (matches SABnzbd/qBittorrent pattern).
|
||||
* Lightweight: reads config, writes only if category is missing or path changed.
|
||||
*/
|
||||
async ensureCategory(): Promise<void> {
|
||||
try {
|
||||
logger.debug('ensureCategory() called - syncing category with NZBGet');
|
||||
|
||||
const config = await this.rpc<NZBGetConfigItem[]>('config');
|
||||
if (!config) {
|
||||
logger.warn('Failed to get NZBGet config, skipping category check');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the main DestDir (NZBGet's base download directory)
|
||||
const destDirEntry = config.find(c => c.Name === 'DestDir');
|
||||
const nzbgetDestDir = destDirEntry?.Value || '';
|
||||
|
||||
logger.debug('NZBGet config retrieved', {
|
||||
destDir: nzbgetDestDir || '(not configured)',
|
||||
});
|
||||
|
||||
// Apply reverse path mapping to get the path from NZBGet's perspective
|
||||
const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig);
|
||||
|
||||
logger.debug('Category path calculation', {
|
||||
rmabDownloadDir: this.defaultDownloadDir,
|
||||
desiredPathForNZBGet: desiredPath,
|
||||
nzbgetDestDir,
|
||||
pathMappingEnabled: this.pathMappingConfig.enabled,
|
||||
});
|
||||
|
||||
// Find existing categories and our category slot
|
||||
const { existingSlot, nextSlot } = this.findCategorySlot(config, this.defaultCategory);
|
||||
|
||||
if (existingSlot !== null) {
|
||||
// Category exists - check if DestDir needs updating
|
||||
const currentDestDir = config.find(c => c.Name === `Category${existingSlot}.DestDir`)?.Value || '';
|
||||
|
||||
if (this.normalizePath(currentDestDir) !== this.normalizePath(desiredPath)) {
|
||||
logger.info(`Updating category "${this.defaultCategory}" DestDir from "${currentDestDir}" to "${desiredPath}"`);
|
||||
const updatedConfig = this.mergeConfigEntries(config, [
|
||||
{ Name: `Category${existingSlot}.DestDir`, Value: desiredPath },
|
||||
]);
|
||||
await this.rpc('saveconfig', [updatedConfig]);
|
||||
await this.reloadAndWait();
|
||||
} else {
|
||||
logger.debug(`Category "${this.defaultCategory}" already configured correctly`);
|
||||
}
|
||||
} else {
|
||||
// Create new category — merge into full config so we don't wipe existing settings
|
||||
logger.info(`Creating category "${this.defaultCategory}" in slot ${nextSlot} with DestDir: "${desiredPath}"`);
|
||||
const updatedConfig = this.mergeConfigEntries(config, [
|
||||
{ Name: `Category${nextSlot}.Name`, Value: this.defaultCategory },
|
||||
{ Name: `Category${nextSlot}.DestDir`, Value: desiredPath },
|
||||
{ Name: `Category${nextSlot}.Unpack`, Value: 'yes' },
|
||||
]);
|
||||
await this.rpc('saveconfig', [updatedConfig]);
|
||||
await this.reloadAndWait();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure category', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Don't throw - category issues shouldn't block downloads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only entries returned by NZBGet's config() RPC that must NOT be
|
||||
* written back via saveconfig(). These are runtime/system properties.
|
||||
*/
|
||||
private static readonly READ_ONLY_CONFIG_KEYS = new Set([
|
||||
'ConfigFile',
|
||||
'AppBin',
|
||||
'AppDir',
|
||||
'Version',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Merge new/updated config entries into the full NZBGet config.
|
||||
* Returns a complete config array safe to pass to saveconfig().
|
||||
*
|
||||
* Filters out read-only system entries (ConfigFile, AppBin, AppDir, Version)
|
||||
* that config() returns but saveconfig() rejects.
|
||||
*
|
||||
* For entries that already exist (by Name), replaces the value.
|
||||
* For new entries, appends them to the array.
|
||||
*/
|
||||
private mergeConfigEntries(
|
||||
fullConfig: NZBGetConfigItem[],
|
||||
changes: NZBGetConfigItem[]
|
||||
): NZBGetConfigItem[] {
|
||||
const merged: NZBGetConfigItem[] = [];
|
||||
|
||||
for (const entry of fullConfig) {
|
||||
// Skip read-only system entries that saveconfig() rejects
|
||||
if (NZBGetService.READ_ONLY_CONFIG_KEYS.has(entry.Name)) {
|
||||
continue;
|
||||
}
|
||||
const override = changes.find(c => c.Name === entry.Name);
|
||||
merged.push(override ? { Name: entry.Name, Value: override.Value } : { Name: entry.Name, Value: entry.Value });
|
||||
}
|
||||
|
||||
// Append any entries that don't exist in the current config
|
||||
for (const change of changes) {
|
||||
if (!fullConfig.some(entry => entry.Name === change.Name)) {
|
||||
merged.push({ Name: change.Name, Value: change.Value });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the category slot number for an existing category or determine the next available slot.
|
||||
*/
|
||||
private findCategorySlot(
|
||||
config: NZBGetConfigItem[],
|
||||
categoryName: string
|
||||
): { existingSlot: number | null; nextSlot: number } {
|
||||
let maxSlot = 0;
|
||||
let existingSlot: number | null = null;
|
||||
|
||||
for (const entry of config) {
|
||||
const match = entry.Name.match(/^Category(\d+)\.Name$/);
|
||||
if (match) {
|
||||
const slot = parseInt(match[1], 10);
|
||||
if (slot > maxSlot) {
|
||||
maxSlot = slot;
|
||||
}
|
||||
if (entry.Value === categoryName) {
|
||||
existingSlot = slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { existingSlot, nextSlot: maxSlot + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload NZBGet so config changes (new categories, DestDir updates) take effect.
|
||||
* Polls version() to confirm NZBGet is back online before continuing.
|
||||
*/
|
||||
private async reloadAndWait(): Promise<void> {
|
||||
try {
|
||||
logger.info('Reloading NZBGet to apply configuration changes...');
|
||||
await this.rpc('reload');
|
||||
|
||||
const maxWait = 10000;
|
||||
const pollInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWait) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
try {
|
||||
await this.rpc<string>('version');
|
||||
logger.info('NZBGet reloaded successfully');
|
||||
return;
|
||||
} catch {
|
||||
// Still restarting, keep polling
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('NZBGet did not respond after reload within 10s, continuing anyway');
|
||||
} catch (error) {
|
||||
logger.warn('NZBGet reload request failed, config changes may require manual restart', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Status Mapping
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Map NZBGet queue group item to unified DownloadInfo
|
||||
*/
|
||||
private async mapGroupToDownloadInfo(group: NZBGetGroupItem): Promise<DownloadInfo> {
|
||||
const totalBytes = group.FileSizeMB * 1024 * 1024;
|
||||
const downloadedBytes = group.DownloadedSizeMB * 1024 * 1024;
|
||||
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1.0) : 0;
|
||||
|
||||
// Get global download speed for active items
|
||||
let downloadSpeed = 0;
|
||||
let eta = 0;
|
||||
const status = this.mapGroupStatus(group.Status);
|
||||
|
||||
if (status === 'downloading') {
|
||||
try {
|
||||
const serverStatus = await this.rpc<NZBGetStatus>('status');
|
||||
downloadSpeed = serverStatus?.DownloadRate || 0;
|
||||
const remainingBytes = group.RemainingSizeMB * 1024 * 1024;
|
||||
eta = downloadSpeed > 0 ? Math.round(remainingBytes / downloadSpeed) : 0;
|
||||
} catch {
|
||||
// Non-critical: speed/eta will be 0
|
||||
}
|
||||
}
|
||||
|
||||
// Return raw download path (path mapping is applied downstream by the consumer)
|
||||
const downloadPath = group.FinalDir || group.DestDir || undefined;
|
||||
|
||||
return {
|
||||
id: String(group.NZBID),
|
||||
name: group.NZBName,
|
||||
size: totalBytes,
|
||||
bytesDownloaded: downloadedBytes,
|
||||
progress,
|
||||
status,
|
||||
downloadSpeed,
|
||||
eta,
|
||||
category: group.Category || '',
|
||||
downloadPath,
|
||||
completedAt: undefined,
|
||||
errorMessage: undefined,
|
||||
seedingTime: undefined,
|
||||
ratio: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NZBGet history item to unified DownloadInfo
|
||||
*/
|
||||
private mapHistoryToDownloadInfo(history: NZBGetHistoryItem): DownloadInfo {
|
||||
const totalBytes = history.FileSizeMB * 1024 * 1024;
|
||||
const downloadedBytes = history.DownloadedSizeMB * 1024 * 1024;
|
||||
const status = this.mapHistoryStatus(history.Status);
|
||||
|
||||
// Return raw download path (path mapping is applied downstream by the consumer)
|
||||
const downloadPath = history.FinalDir || history.DestDir || undefined;
|
||||
|
||||
return {
|
||||
id: String(history.NZBID),
|
||||
name: history.Name,
|
||||
size: totalBytes,
|
||||
bytesDownloaded: status === 'completed' ? totalBytes : downloadedBytes,
|
||||
progress: status === 'completed' ? 1.0 : (totalBytes > 0 ? downloadedBytes / totalBytes : 0),
|
||||
status,
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: history.Category || '',
|
||||
downloadPath,
|
||||
completedAt: history.HistoryTime ? new Date(history.HistoryTime * 1000) : undefined,
|
||||
errorMessage: status === 'failed' ? this.buildHistoryErrorMessage(history) : undefined,
|
||||
seedingTime: undefined,
|
||||
ratio: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NZBGet queue status string to unified DownloadStatus
|
||||
*/
|
||||
private mapGroupStatus(status: string): DownloadStatus {
|
||||
switch (status) {
|
||||
case 'QUEUED':
|
||||
return 'queued';
|
||||
case 'PAUSED':
|
||||
return 'paused';
|
||||
case 'DOWNLOADING':
|
||||
case 'FETCHING':
|
||||
return 'downloading';
|
||||
case 'PP_QUEUED':
|
||||
case 'LOADING_PARS':
|
||||
case 'VERIFYING_SOURCES':
|
||||
case 'REPAIRING':
|
||||
case 'VERIFYING_REPAIRED':
|
||||
case 'RENAMING':
|
||||
case 'UNPACKING':
|
||||
case 'MOVING':
|
||||
case 'POST_UNPACK_RENAMING':
|
||||
case 'EXECUTING_SCRIPT':
|
||||
case 'PP_FINISHED':
|
||||
return 'processing';
|
||||
default:
|
||||
logger.warn(`Unknown NZBGet queue status: ${status}, defaulting to downloading`);
|
||||
return 'downloading';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NZBGet history status string to unified DownloadStatus.
|
||||
* History statuses have format: "PREFIX/DETAIL" (e.g., "SUCCESS/ALL", "FAILURE/PAR")
|
||||
*/
|
||||
private mapHistoryStatus(status: string): DownloadStatus {
|
||||
const prefix = status.split('/')[0];
|
||||
|
||||
switch (prefix) {
|
||||
case 'SUCCESS':
|
||||
return 'completed';
|
||||
case 'WARNING':
|
||||
// WARNING means the download succeeded but post-processing had issues
|
||||
// From RMAB's perspective, the download is still completed
|
||||
return 'completed';
|
||||
case 'FAILURE':
|
||||
return 'failed';
|
||||
case 'DELETED':
|
||||
return 'failed';
|
||||
default:
|
||||
logger.warn(`Unknown NZBGet history status: ${status}, defaulting to failed`);
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a descriptive error message from NZBGet history item
|
||||
*/
|
||||
private buildHistoryErrorMessage(history: NZBGetHistoryItem): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Include the raw status for context
|
||||
parts.push(history.Status);
|
||||
|
||||
if (history.ParStatus && history.ParStatus !== 'NONE' && history.ParStatus !== 'SUCCESS') {
|
||||
parts.push(`Par: ${history.ParStatus}`);
|
||||
}
|
||||
if (history.UnpackStatus && history.UnpackStatus !== 'NONE' && history.UnpackStatus !== 'SUCCESS') {
|
||||
parts.push(`Unpack: ${history.UnpackStatus}`);
|
||||
}
|
||||
if (history.DeleteStatus && history.DeleteStatus !== 'NONE') {
|
||||
parts.push(`Delete: ${history.DeleteStatus}`);
|
||||
}
|
||||
|
||||
// Article failure info
|
||||
if (history.FailedArticles > 0) {
|
||||
const failPercent = history.TotalArticles > 0
|
||||
? Math.round((history.FailedArticles / history.TotalArticles) * 100)
|
||||
: 0;
|
||||
parts.push(`${history.FailedArticles} failed articles (${failPercent}%)`);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Extract a usable filename for the NZB upload.
|
||||
* Tries Content-Disposition header first, then URL path, then falls back to a default.
|
||||
*/
|
||||
private extractNZBFilename(url: string, contentDisposition?: string): string {
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
|
||||
if (match?.[1]) {
|
||||
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
|
||||
if (decoded) {
|
||||
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const urlPath = new URL(url).pathname;
|
||||
const basename = urlPath.split('/').pop();
|
||||
if (basename && basename.length > 0 && basename !== 'download') {
|
||||
const decoded = decodeURIComponent(basename);
|
||||
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
|
||||
}
|
||||
} catch {
|
||||
// URL parsing failed
|
||||
}
|
||||
|
||||
return 'download.nzb';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map priority string to NZBGet priority integer.
|
||||
* NZBGet priorities: -100 (very low), -50 (low), 0 (normal), 50 (high), 100 (very high), 900 (force)
|
||||
*/
|
||||
private mapPriority(priority?: string): number {
|
||||
switch (priority) {
|
||||
case 'force':
|
||||
return 900;
|
||||
case 'high':
|
||||
return 50;
|
||||
case 'low':
|
||||
return -50;
|
||||
case 'normal':
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format connection error into a user-friendly message
|
||||
*/
|
||||
private formatConnectionError(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
return 'Authentication failed. Check your NZBGet username and password (Settings → Security).';
|
||||
}
|
||||
if (status === 403) {
|
||||
return 'Access denied. Check your NZBGet credentials and access permissions.';
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return `Connection refused. Is NZBGet running and accessible at this URL?`;
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||||
return 'Connection timed out. Check the URL and network connectivity.';
|
||||
}
|
||||
if (error.message?.includes('certificate') || error.message?.includes('SSL') || error.message?.includes('TLS')) {
|
||||
return 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.';
|
||||
}
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path for comparison (forward slashes, no trailing slash, lowercase)
|
||||
*/
|
||||
private normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Singleton Factory
|
||||
// =========================================================================
|
||||
|
||||
let nzbgetServiceInstance: NZBGetService | null = null;
|
||||
let configLoaded = false;
|
||||
|
||||
export async function getNZBGetService(): Promise<NZBGetService> {
|
||||
if (nzbgetServiceInstance && configLoaded) {
|
||||
return nzbgetServiceInstance;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
||||
const configService = await getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
|
||||
logger.info('Loading configuration from download client manager...');
|
||||
const clientConfig = await manager.getClientForProtocol('usenet');
|
||||
|
||||
if (!clientConfig) {
|
||||
throw new Error('NZBGet is not configured. Please configure an NZBGet client in the admin settings.');
|
||||
}
|
||||
|
||||
if (clientConfig.type !== 'nzbget') {
|
||||
throw new Error(`Expected NZBGet client but found ${clientConfig.type}`);
|
||||
}
|
||||
|
||||
const baseDir = await configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = clientConfig.customPath
|
||||
? require('path').join(baseDir, clientConfig.customPath)
|
||||
: baseDir;
|
||||
|
||||
const pathMappingConfig: PathMappingConfig = {
|
||||
enabled: clientConfig.remotePathMappingEnabled || false,
|
||||
remotePath: clientConfig.remotePath || '',
|
||||
localPath: clientConfig.localPath || '',
|
||||
};
|
||||
|
||||
logger.info('Config loaded:', {
|
||||
name: clientConfig.name,
|
||||
hasUrl: !!clientConfig.url,
|
||||
hasPassword: !!clientConfig.password,
|
||||
disableSSLVerify: clientConfig.disableSSLVerify,
|
||||
downloadDir,
|
||||
pathMappingEnabled: pathMappingConfig.enabled,
|
||||
});
|
||||
|
||||
if (!clientConfig.url || !clientConfig.password) {
|
||||
throw new Error('NZBGet is not fully configured. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
nzbgetServiceInstance = new NZBGetService(
|
||||
clientConfig.url,
|
||||
clientConfig.username || '',
|
||||
clientConfig.password,
|
||||
clientConfig.category || 'readmeabook',
|
||||
downloadDir,
|
||||
clientConfig.disableSSLVerify,
|
||||
pathMappingConfig
|
||||
);
|
||||
|
||||
await nzbgetServiceInstance.ensureCategory();
|
||||
|
||||
configLoaded = true;
|
||||
return nzbgetServiceInstance;
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize service', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
nzbgetServiceInstance = null;
|
||||
configLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateNZBGetService(): void {
|
||||
nzbgetServiceInstance = null;
|
||||
configLoaded = false;
|
||||
logger.info('Service singleton invalidated');
|
||||
}
|
||||
@@ -121,11 +121,6 @@ export class ProwlarrService {
|
||||
filters?: SearchFilters
|
||||
): Promise<TorrentResult[]> {
|
||||
try {
|
||||
// Get configured download client type to determine if we should filter by category
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const configService = getConfigService();
|
||||
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
|
||||
|
||||
// Determine which categories to search
|
||||
// Priority: filters.categories > filters.category > defaultCategory
|
||||
let categoriesToSearch: number[];
|
||||
@@ -560,20 +555,22 @@ export class ProwlarrService {
|
||||
* Extract audiobook metadata from torrent title
|
||||
*/
|
||||
private extractMetadata(title: string): {
|
||||
format?: 'M4B' | 'M4A' | 'MP3';
|
||||
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC';
|
||||
bitrate?: string;
|
||||
hasChapters?: boolean;
|
||||
} {
|
||||
const upperTitle = title.toUpperCase();
|
||||
|
||||
// Detect format
|
||||
let format: 'M4B' | 'M4A' | 'MP3' | undefined;
|
||||
let format: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | undefined;
|
||||
if (upperTitle.includes('M4B')) {
|
||||
format = 'M4B';
|
||||
} else if (upperTitle.includes('M4A')) {
|
||||
format = 'M4A';
|
||||
} else if (upperTitle.includes('MP3')) {
|
||||
format = 'MP3';
|
||||
} else if (upperTitle.includes('FLAC')) {
|
||||
format = 'FLAC';
|
||||
}
|
||||
|
||||
// Detect bitrate (e.g., "64kbps", "128 KBPS")
|
||||
|
||||
@@ -5,10 +5,20 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import FormData from 'form-data';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import {
|
||||
IDownloadClient,
|
||||
DownloadClientType,
|
||||
ProtocolType,
|
||||
DownloadInfo,
|
||||
DownloadStatus,
|
||||
AddDownloadOptions,
|
||||
ConnectionTestResult,
|
||||
} from '../interfaces/download-client.interface';
|
||||
|
||||
// Handle both ESM and CommonJS imports
|
||||
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
||||
@@ -59,7 +69,19 @@ export type TorrentState =
|
||||
| 'checkingUP'
|
||||
| 'error'
|
||||
| 'missingFiles'
|
||||
| 'allocating';
|
||||
| 'allocating'
|
||||
// Forced states (user clicked "Force Resume" in qBittorrent UI)
|
||||
| 'forcedDL'
|
||||
| 'forcedUP'
|
||||
// Metadata fetching states
|
||||
| 'metaDL'
|
||||
| 'forcedMetaDL'
|
||||
// qBittorrent v5.0+ renamed paused → stopped
|
||||
| 'stoppedDL'
|
||||
| 'stoppedUP'
|
||||
// Other states
|
||||
| 'checkingResumeData'
|
||||
| 'moving';
|
||||
|
||||
export interface TorrentFile {
|
||||
name: string;
|
||||
@@ -78,7 +100,10 @@ export interface DownloadProgress {
|
||||
state: string;
|
||||
}
|
||||
|
||||
export class QBittorrentService {
|
||||
export class QBittorrentService implements IDownloadClient {
|
||||
readonly clientType: DownloadClientType = 'qbittorrent';
|
||||
readonly protocol: ProtocolType = 'torrent';
|
||||
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private username: string;
|
||||
@@ -209,7 +234,7 @@ export class QBittorrentService {
|
||||
/**
|
||||
* Add torrent (magnet link or file URL) - Enterprise Implementation
|
||||
*/
|
||||
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
|
||||
async addTorrent(url: string, options?: AddTorrentOptions, retried = false): Promise<string> {
|
||||
// Validate URL parameter
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
logger.error('Invalid download URL', { url });
|
||||
@@ -236,11 +261,11 @@ export class QBittorrentService {
|
||||
return await this.addTorrentFile(url, category, options);
|
||||
}
|
||||
} catch (error) {
|
||||
// Try re-authenticating if we get a 403
|
||||
if (axios.isAxiosError(error) && error.response?.status === 403) {
|
||||
// Try re-authenticating once if we get a 403
|
||||
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
|
||||
logger.info('[QBittorrent] Session expired, re-authenticating...');
|
||||
await this.login();
|
||||
return this.addTorrent(url, options); // Retry once
|
||||
return this.addTorrent(url, options, true);
|
||||
}
|
||||
|
||||
logger.error('Failed to add torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -279,12 +304,17 @@ export class QBittorrentService {
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
// Upload via 'urls' parameter
|
||||
// Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's
|
||||
// global seeding rules don't remove the torrent prematurely.
|
||||
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
|
||||
const form = new URLSearchParams({
|
||||
urls: magnetUrl,
|
||||
savepath: remoteSavePath,
|
||||
category,
|
||||
paused: options?.paused ? 'true' : 'false',
|
||||
sequentialDownload: (options?.sequentialDownload !== false).toString(),
|
||||
ratioLimit: '-1',
|
||||
seedingTimeLimit: '-1',
|
||||
});
|
||||
|
||||
if (options?.tags) {
|
||||
@@ -432,6 +462,9 @@ export class QBittorrentService {
|
||||
formData.append('category', category);
|
||||
formData.append('paused', options?.paused ? 'true' : 'false');
|
||||
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
|
||||
// Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle
|
||||
formData.append('ratioLimit', '-1');
|
||||
formData.append('seedingTimeLimit', '-1');
|
||||
|
||||
if (options?.tags) {
|
||||
formData.append('tags', options.tags.join(','));
|
||||
@@ -729,13 +762,28 @@ export class QBittorrentService {
|
||||
/**
|
||||
* Test connection to qBittorrent
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
await this.login();
|
||||
return true;
|
||||
|
||||
// Fetch version after successful login
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const versionResponse = await this.client.get('/app/version', {
|
||||
headers: { Cookie: this.cookie },
|
||||
});
|
||||
const raw = versionResponse.data || '';
|
||||
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
||||
} catch {
|
||||
// Version fetch is non-critical - connection is still valid
|
||||
logger.debug('Could not fetch qBittorrent version');
|
||||
}
|
||||
|
||||
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
||||
} catch (error) {
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||
logger.error('Connection test failed', { error: message });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,7 +883,8 @@ export class QBittorrentService {
|
||||
version: versionResponse.data,
|
||||
});
|
||||
|
||||
return versionResponse.data || 'Connected';
|
||||
const rawVersion = versionResponse.data || '';
|
||||
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('[QBittorrent] Test connection failed with axios error', {
|
||||
@@ -931,6 +980,144 @@ export class QBittorrentService {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IDownloadClient Implementation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Add a download via the unified interface.
|
||||
* Delegates to addTorrent with sensible defaults for audiobook downloads.
|
||||
*/
|
||||
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
||||
return this.addTorrent(url, {
|
||||
category: options?.category,
|
||||
paused: options?.paused,
|
||||
tags: ['audiobook'],
|
||||
sequentialDownload: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download status via the unified interface.
|
||||
* Includes retry logic to handle the race condition where a torrent
|
||||
* isn't immediately available after being added.
|
||||
*/
|
||||
async getDownload(id: string): Promise<DownloadInfo | null> {
|
||||
const maxRetries = 3;
|
||||
const initialDelayMs = 500;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const torrent = await this.getTorrent(id);
|
||||
return this.mapTorrentToDownloadInfo(torrent);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
const isNotFound = message.includes('not found');
|
||||
|
||||
// If not a "not found" error, don't retry
|
||||
if (!isNotFound) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// If this is the last attempt, return null
|
||||
if (attempt === maxRetries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exponential backoff: 500ms, 1000ms, 2000ms
|
||||
const delayMs = initialDelayMs * Math.pow(2, attempt);
|
||||
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pause a download via the unified interface */
|
||||
async pauseDownload(id: string): Promise<void> {
|
||||
return this.pauseTorrent(id);
|
||||
}
|
||||
|
||||
/** Resume a download via the unified interface */
|
||||
async resumeDownload(id: string): Promise<void> {
|
||||
return this.resumeTorrent(id);
|
||||
}
|
||||
|
||||
/** Delete a download via the unified interface */
|
||||
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
||||
return this.deleteTorrent(id, deleteFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-download cleanup via the unified interface.
|
||||
* No-op for qBittorrent — torrents continue seeding until the
|
||||
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
|
||||
*/
|
||||
async postProcess(_id: string): Promise<void> {
|
||||
// No-op: torrents are managed by the seeding cleanup scheduler
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a TorrentInfo object to the unified DownloadInfo format.
|
||||
*/
|
||||
private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
|
||||
return {
|
||||
id: torrent.hash,
|
||||
name: torrent.name,
|
||||
size: torrent.size,
|
||||
bytesDownloaded: torrent.downloaded,
|
||||
progress: torrent.progress,
|
||||
status: this.mapStateToDownloadStatus(torrent.state),
|
||||
downloadSpeed: torrent.dlspeed,
|
||||
eta: torrent.eta,
|
||||
category: torrent.category,
|
||||
downloadPath: torrent.content_path || path.join(torrent.save_path, torrent.name),
|
||||
completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined,
|
||||
seedingTime: torrent.seeding_time,
|
||||
ratio: torrent.ratio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map qBittorrent torrent state to unified DownloadStatus.
|
||||
*/
|
||||
private mapStateToDownloadStatus(state: TorrentState): DownloadStatus {
|
||||
const stateMap: Record<TorrentState, DownloadStatus> = {
|
||||
downloading: 'downloading',
|
||||
uploading: 'seeding',
|
||||
stalledDL: 'downloading',
|
||||
stalledUP: 'seeding',
|
||||
pausedDL: 'paused',
|
||||
pausedUP: 'paused',
|
||||
queuedDL: 'queued',
|
||||
queuedUP: 'seeding',
|
||||
checkingDL: 'checking',
|
||||
checkingUP: 'checking',
|
||||
error: 'failed',
|
||||
missingFiles: 'failed',
|
||||
allocating: 'downloading',
|
||||
// Forced states (user clicked "Force Resume" in qBittorrent UI)
|
||||
forcedDL: 'downloading',
|
||||
forcedUP: 'seeding',
|
||||
// Metadata fetching states
|
||||
metaDL: 'downloading',
|
||||
forcedMetaDL: 'downloading',
|
||||
// qBittorrent v5.0+ renamed paused → stopped
|
||||
stoppedDL: 'paused',
|
||||
stoppedUP: 'paused',
|
||||
// Other states
|
||||
checkingResumeData: 'checking',
|
||||
moving: 'downloading',
|
||||
};
|
||||
|
||||
return stateMap[state] || 'downloading';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Legacy Methods (used internally and by direct callers)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get download progress details
|
||||
*/
|
||||
@@ -963,6 +1150,18 @@ export class QBittorrentService {
|
||||
error: 'failed',
|
||||
missingFiles: 'failed',
|
||||
allocating: 'downloading',
|
||||
// Forced states (user clicked "Force Resume" in qBittorrent UI)
|
||||
forcedDL: 'downloading',
|
||||
forcedUP: 'completed',
|
||||
// Metadata fetching states
|
||||
metaDL: 'downloading',
|
||||
forcedMetaDL: 'downloading',
|
||||
// qBittorrent v5.0+ renamed paused → stopped
|
||||
stoppedDL: 'paused',
|
||||
stoppedUP: 'paused',
|
||||
// Other states
|
||||
checkingResumeData: 'checking',
|
||||
moving: 'downloading',
|
||||
};
|
||||
|
||||
return stateMap[state] || 'unknown';
|
||||
@@ -1032,8 +1231,11 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
// Get download_dir from main config (not part of client config)
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
// Get download_dir from main config, applying customPath if configured
|
||||
const baseDir = await configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = clientConfig.customPath
|
||||
? require('path').join(baseDir, clientConfig.customPath)
|
||||
: baseDir;
|
||||
|
||||
// Path mapping configuration
|
||||
const pathMappingConfig: PathMappingConfig = {
|
||||
@@ -1055,10 +1257,10 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
|
||||
// Test connection
|
||||
logger.info('[QBittorrent] Testing connection...');
|
||||
const isConnected = await qbittorrentService.testConnection();
|
||||
if (!isConnected) {
|
||||
logger.warn('[QBittorrent] Connection test failed');
|
||||
throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.');
|
||||
const connectionResult = await qbittorrentService.testConnection();
|
||||
if (!connectionResult.success) {
|
||||
logger.warn('[QBittorrent] Connection test failed', { message: connectionResult.message });
|
||||
throw new Error(connectionResult.message || 'qBittorrent connection test failed. Please check your configuration in admin settings.');
|
||||
} else {
|
||||
logger.info('[QBittorrent] Connection test successful');
|
||||
configLoaded = true; // Mark as successfully loaded
|
||||
|
||||
@@ -5,8 +5,18 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import FormData from 'form-data';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||
import {
|
||||
IDownloadClient,
|
||||
DownloadClientType,
|
||||
ProtocolType,
|
||||
DownloadInfo,
|
||||
DownloadStatus,
|
||||
AddDownloadOptions,
|
||||
ConnectionTestResult,
|
||||
} from '../interfaces/download-client.interface';
|
||||
|
||||
const logger = RMABLogger.create('SABnzbd');
|
||||
|
||||
@@ -81,7 +91,10 @@ export interface DownloadProgress {
|
||||
state: string;
|
||||
}
|
||||
|
||||
export class SABnzbdService {
|
||||
export class SABnzbdService implements IDownloadClient {
|
||||
readonly clientType: DownloadClientType = 'sabnzbd';
|
||||
readonly protocol: ProtocolType = 'usenet';
|
||||
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
@@ -123,13 +136,13 @@ export class SABnzbdService {
|
||||
/**
|
||||
* Test connection to SABnzbd
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> {
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
// Validate API key is not empty
|
||||
if (!this.apiKey || this.apiKey.trim() === '') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API key is required for SABnzbd',
|
||||
message: 'API key is required for SABnzbd',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,7 +164,7 @@ export class SABnzbdService {
|
||||
const errorMsg = response.data?.error || 'Authentication failed';
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg.includes('API Key')
|
||||
message: errorMsg.includes('API Key')
|
||||
? 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).'
|
||||
: errorMsg,
|
||||
};
|
||||
@@ -160,7 +173,7 @@ export class SABnzbdService {
|
||||
// Queue endpoint requires auth - if we got here, API key is valid
|
||||
// Now get the version
|
||||
const version = await this.getVersion();
|
||||
return { success: true, version };
|
||||
return { success: true, version, message: `Connected to SABnzbd v${version}` };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -168,28 +181,28 @@ export class SABnzbdService {
|
||||
if (errorMessage.includes('ECONNREFUSED')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection refused. Is SABnzbd running and accessible at this URL?',
|
||||
message: 'Connection refused. Is SABnzbd running and accessible at this URL?',
|
||||
};
|
||||
} else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Connection timed out. Check the URL and network connectivity.',
|
||||
message: 'Connection timed out. Check the URL and network connectivity.',
|
||||
};
|
||||
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
|
||||
message: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
|
||||
};
|
||||
} else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
|
||||
message: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -447,8 +460,16 @@ export class SABnzbdService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add NZB by URL
|
||||
* Returns the NZB ID
|
||||
* Add NZB to SABnzbd
|
||||
*
|
||||
* Downloads the NZB file content from the source URL (typically a Prowlarr proxy URL)
|
||||
* and uploads it directly to SABnzbd via mode=addfile. This ensures SABnzbd does not
|
||||
* need network access to Prowlarr — RMAB acts as the intermediary, matching the pattern
|
||||
* used by qBittorrent for .torrent files.
|
||||
*
|
||||
* @param url - NZB download URL (usually a Prowlarr proxy URL)
|
||||
* @param options - Category, priority, and pause options
|
||||
* @returns SABnzbd NZB ID (nzo_id)
|
||||
*/
|
||||
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
|
||||
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
|
||||
@@ -459,20 +480,70 @@ export class SABnzbdService {
|
||||
// This syncs the category path with SABnzbd's complete_dir and handles path mapping
|
||||
await this.ensureCategory();
|
||||
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'addurl',
|
||||
name: url,
|
||||
cat: category,
|
||||
priority: this.mapPriority(options?.priority),
|
||||
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
// Download the NZB file content from the source URL
|
||||
// This decouples SABnzbd from needing direct network access to Prowlarr
|
||||
let nzbBuffer: Buffer;
|
||||
let filename: string;
|
||||
|
||||
try {
|
||||
logger.info('Downloading NZB file from source URL...');
|
||||
|
||||
const nzbResponse = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
// Use the same SSL settings as the SABnzbd client if the NZB URL
|
||||
// happens to be served over HTTPS with a self-signed cert
|
||||
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
||||
});
|
||||
|
||||
nzbBuffer = Buffer.from(nzbResponse.data);
|
||||
|
||||
if (nzbBuffer.length === 0) {
|
||||
throw new Error('NZB file is empty (0 bytes)');
|
||||
}
|
||||
|
||||
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
|
||||
|
||||
// Extract filename from Content-Disposition header, URL path, or use fallback
|
||||
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
if (status) {
|
||||
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
||||
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Upload NZB file content to SABnzbd via mode=addfile (multipart POST)
|
||||
const formData = new FormData();
|
||||
formData.append('nzbfile', nzbBuffer, {
|
||||
filename,
|
||||
contentType: 'application/x-nzb',
|
||||
});
|
||||
formData.append('mode', 'addfile');
|
||||
formData.append('cat', category);
|
||||
formData.append('priority', this.mapPriority(options?.priority));
|
||||
formData.append('pp', '3'); // Post-processing: +Repair, +Unpack, +Delete
|
||||
formData.append('output', 'json');
|
||||
formData.append('apikey', this.apiKey);
|
||||
|
||||
const response = await this.client.post('/api', formData, {
|
||||
headers: formData.getHeaders(),
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
|
||||
if (response.data?.status === false) {
|
||||
throw new Error(response.data.error || 'Failed to add NZB');
|
||||
throw new Error(response.data.error || 'Failed to add NZB to SABnzbd');
|
||||
}
|
||||
|
||||
const nzbIds = response.data?.nzo_ids;
|
||||
@@ -486,6 +557,39 @@ export class SABnzbdService {
|
||||
return nzbId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable filename for the NZB upload.
|
||||
* Tries Content-Disposition header first, then URL path, then falls back to a default.
|
||||
*/
|
||||
private extractNZBFilename(url: string, contentDisposition?: string): string {
|
||||
// Try Content-Disposition header (e.g., 'attachment; filename="My.Audiobook.nzb"')
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
|
||||
if (match?.[1]) {
|
||||
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
|
||||
if (decoded) {
|
||||
logger.debug(`Filename from Content-Disposition: ${decoded}`);
|
||||
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try extracting from URL path (before query params)
|
||||
try {
|
||||
const urlPath = new URL(url).pathname;
|
||||
const basename = urlPath.split('/').pop();
|
||||
if (basename && basename.length > 0 && basename !== 'download') {
|
||||
const decoded = decodeURIComponent(basename);
|
||||
logger.debug(`Filename from URL path: ${decoded}`);
|
||||
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
|
||||
}
|
||||
} catch {
|
||||
// URL parsing failed, fall through to default
|
||||
}
|
||||
|
||||
return 'download.nzb';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NZB info by ID
|
||||
* Checks queue first, then history
|
||||
@@ -663,6 +767,108 @@ export class SABnzbdService {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IDownloadClient Implementation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Add a download via the unified interface.
|
||||
* Delegates to addNZB with mapped options.
|
||||
*/
|
||||
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
||||
const priorityMap: Record<string, 'low' | 'normal' | 'high' | 'force'> = {
|
||||
low: 'low',
|
||||
normal: 'normal',
|
||||
high: 'high',
|
||||
force: 'force',
|
||||
};
|
||||
|
||||
return this.addNZB(url, {
|
||||
category: options?.category,
|
||||
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
|
||||
paused: options?.paused,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download status via the unified interface.
|
||||
* Checks both queue and history to find the NZB.
|
||||
*/
|
||||
async getDownload(id: string): Promise<DownloadInfo | null> {
|
||||
const nzbInfo = await this.getNZB(id);
|
||||
if (!nzbInfo) {
|
||||
return null;
|
||||
}
|
||||
return this.mapNZBInfoToDownloadInfo(nzbInfo);
|
||||
}
|
||||
|
||||
/** Pause a download via the unified interface */
|
||||
async pauseDownload(id: string): Promise<void> {
|
||||
return this.pauseNZB(id);
|
||||
}
|
||||
|
||||
/** Resume a download via the unified interface */
|
||||
async resumeDownload(id: string): Promise<void> {
|
||||
return this.resumeNZB(id);
|
||||
}
|
||||
|
||||
/** Delete a download via the unified interface */
|
||||
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
||||
return this.deleteNZB(id, deleteFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-download cleanup via the unified interface.
|
||||
* Archives the completed NZB from SABnzbd history.
|
||||
*/
|
||||
async postProcess(id: string): Promise<void> {
|
||||
await this.archiveCompletedNZB(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NZBInfo to the unified DownloadInfo format.
|
||||
*/
|
||||
private mapNZBInfoToDownloadInfo(nzb: NZBInfo): DownloadInfo {
|
||||
return {
|
||||
id: nzb.nzbId,
|
||||
name: nzb.name,
|
||||
size: nzb.size,
|
||||
bytesDownloaded: Math.round(nzb.size * nzb.progress),
|
||||
progress: nzb.progress,
|
||||
status: this.mapNZBStatusToDownloadStatus(nzb.status),
|
||||
downloadSpeed: nzb.downloadSpeed,
|
||||
eta: nzb.timeLeft,
|
||||
category: nzb.category,
|
||||
downloadPath: nzb.downloadPath,
|
||||
completedAt: nzb.completedAt,
|
||||
errorMessage: nzb.errorMessage,
|
||||
// Usenet has no seeding concept
|
||||
seedingTime: undefined,
|
||||
ratio: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map SABnzbd NZB status to unified DownloadStatus.
|
||||
*/
|
||||
private mapNZBStatusToDownloadStatus(status: NZBStatus): DownloadStatus {
|
||||
const statusMap: Record<NZBStatus, DownloadStatus> = {
|
||||
downloading: 'downloading',
|
||||
queued: 'queued',
|
||||
paused: 'paused',
|
||||
extracting: 'processing',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
repairing: 'processing',
|
||||
};
|
||||
|
||||
return statusMap[status] || 'downloading';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Legacy Methods (used internally and by direct callers)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get download progress from queue item
|
||||
*/
|
||||
@@ -796,8 +1002,11 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
|
||||
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
|
||||
}
|
||||
|
||||
// Get download_dir from main config
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
// Get download_dir from main config, applying customPath if configured
|
||||
const baseDir = await configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = clientConfig.customPath
|
||||
? require('path').join(baseDir, clientConfig.customPath)
|
||||
: baseDir;
|
||||
|
||||
logger.debug('RMAB download_dir from config', { downloadDir });
|
||||
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* Component: Transmission Integration Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import {
|
||||
IDownloadClient,
|
||||
DownloadClientType,
|
||||
ProtocolType,
|
||||
DownloadInfo,
|
||||
DownloadStatus,
|
||||
AddDownloadOptions,
|
||||
ConnectionTestResult,
|
||||
} from '../interfaces/download-client.interface';
|
||||
|
||||
// Handle both ESM and CommonJS imports
|
||||
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
||||
|
||||
const logger = RMABLogger.create('Transmission');
|
||||
|
||||
/** Transmission RPC numeric status codes */
|
||||
type TransmissionStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/** Transmission torrent fields we request */
|
||||
interface TransmissionTorrent {
|
||||
hashString: string;
|
||||
name: string;
|
||||
totalSize: number;
|
||||
downloadedEver: number;
|
||||
percentDone: number;
|
||||
status: TransmissionStatus;
|
||||
rateDownload: number;
|
||||
eta: number;
|
||||
labels: string[];
|
||||
downloadDir: string;
|
||||
doneDate: number;
|
||||
errorString: string;
|
||||
error: number;
|
||||
secondsSeeding: number;
|
||||
uploadRatio: number;
|
||||
uploadedEver: number;
|
||||
}
|
||||
|
||||
/** Fields we request from the Transmission RPC API */
|
||||
const TORRENT_FIELDS = [
|
||||
'hashString',
|
||||
'name',
|
||||
'totalSize',
|
||||
'downloadedEver',
|
||||
'percentDone',
|
||||
'status',
|
||||
'rateDownload',
|
||||
'eta',
|
||||
'labels',
|
||||
'downloadDir',
|
||||
'doneDate',
|
||||
'errorString',
|
||||
'error',
|
||||
'secondsSeeding',
|
||||
'uploadRatio',
|
||||
'uploadedEver',
|
||||
];
|
||||
|
||||
export class TransmissionService implements IDownloadClient {
|
||||
readonly clientType: DownloadClientType = 'transmission';
|
||||
readonly protocol: ProtocolType = 'torrent';
|
||||
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private username: string;
|
||||
private password: string;
|
||||
private defaultSavePath: string;
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
private httpsAgent?: https.Agent;
|
||||
private pathMappingConfig: PathMappingConfig;
|
||||
private sessionId: string = '';
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
defaultSavePath: string = '/downloads',
|
||||
defaultCategory: string = 'readmeabook',
|
||||
disableSSLVerify: boolean = false,
|
||||
pathMappingConfig?: PathMappingConfig
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
this.httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
logger.info('[Transmission] SSL certificate verification disabled');
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 30000,
|
||||
httpsAgent: this.httpsAgent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an RPC request to Transmission.
|
||||
* Handles CSRF token (409 → capture X-Transmission-Session-Id → retry).
|
||||
*/
|
||||
private async rpc(method: string, args?: Record<string, any>): Promise<any> {
|
||||
const body = { method, arguments: args };
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.sessionId) {
|
||||
headers['X-Transmission-Session-Id'] = this.sessionId;
|
||||
}
|
||||
|
||||
// Add Basic Auth if credentials provided
|
||||
const auth = this.username
|
||||
? { username: this.username, password: this.password }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
// Capture CSRF token and retry
|
||||
const newSessionId = error.response.headers['x-transmission-session-id'];
|
||||
if (newSessionId) {
|
||||
this.sessionId = newSessionId;
|
||||
headers['X-Transmission-Session-Id'] = this.sessionId;
|
||||
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IDownloadClient Implementation
|
||||
// =========================================================================
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const data = await this.rpc('session-get', { fields: ['version'] });
|
||||
|
||||
if (data.result !== 'success') {
|
||||
return { success: false, message: `Transmission RPC error: ${data.result}` };
|
||||
}
|
||||
|
||||
const version = data.arguments?.version;
|
||||
return {
|
||||
success: true,
|
||||
version,
|
||||
message: `Connected to Transmission${version ? ` ${version}` : ''}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const code = error.code;
|
||||
const status = error.response?.status;
|
||||
|
||||
if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
|
||||
code === 'CERT_HAS_EXPIRED' || code?.includes('CERT') || code?.includes('SSL')) {
|
||||
return { success: false, message: `SSL certificate verification failed (${code}). Enable "Disable SSL Verification" if you trust this server.` };
|
||||
}
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return { success: false, message: `Connection refused. Check if Transmission is running at: ${this.baseUrl}` };
|
||||
}
|
||||
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
|
||||
return { success: false, message: `Connection timeout. Verify the URL is correct: ${this.baseUrl}` };
|
||||
}
|
||||
if (code === 'ENOTFOUND') {
|
||||
return { success: false, message: `Host not found. Verify the address: ${this.baseUrl}` };
|
||||
}
|
||||
if (status === 401) {
|
||||
return { success: false, message: 'Authentication failed. Check your username and password.' };
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('Connection test failed', { error: message });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
const category = options?.category || this.defaultCategory;
|
||||
|
||||
if (url.startsWith('magnet:')) {
|
||||
return this.addMagnetLink(url, category, options);
|
||||
} else {
|
||||
return this.addTorrentFile(url, category, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async addMagnetLink(
|
||||
magnetUrl: string,
|
||||
category: string,
|
||||
options?: AddDownloadOptions
|
||||
): Promise<string> {
|
||||
const infoHash = this.extractHashFromMagnet(magnetUrl);
|
||||
if (!infoHash) {
|
||||
throw new Error('Invalid magnet link - could not extract info_hash');
|
||||
}
|
||||
|
||||
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
await this.getTorrentByHash(infoHash);
|
||||
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
return infoHash;
|
||||
} catch {
|
||||
// Torrent doesn't exist, continue
|
||||
}
|
||||
|
||||
const localSavePath = this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
const args: Record<string, any> = {
|
||||
filename: magnetUrl,
|
||||
'download-dir': remoteSavePath,
|
||||
paused: options?.paused || false,
|
||||
labels: [category],
|
||||
};
|
||||
|
||||
logger.info('[Transmission] Adding magnet link...');
|
||||
const data = await this.rpc('torrent-add', args);
|
||||
|
||||
if (data.result !== 'success') {
|
||||
throw new Error(`Transmission rejected magnet link: ${data.result}`);
|
||||
}
|
||||
|
||||
// torrent-add returns torrent-added or torrent-duplicate
|
||||
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
|
||||
if (!added) {
|
||||
throw new Error('Transmission did not return torrent info after adding');
|
||||
}
|
||||
|
||||
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
|
||||
await this.disableSeedLimits(added.hashString || infoHash);
|
||||
|
||||
logger.info(`Successfully added magnet link: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
private async addTorrentFile(
|
||||
torrentUrl: string,
|
||||
category: string,
|
||||
options?: AddDownloadOptions
|
||||
): Promise<string> {
|
||||
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
|
||||
|
||||
let torrentResponse;
|
||||
try {
|
||||
torrentResponse = await axios.get(torrentUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 300,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Check if response body is a magnet link
|
||||
if (torrentResponse.data.length > 0) {
|
||||
const responseText = torrentResponse.data.toString();
|
||||
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
|
||||
if (magnetMatch) {
|
||||
logger.info('Response body is a magnet link');
|
||||
return this.addMagnetLink(magnetMatch[0], category, options);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error) || !error.response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
if (status >= 300 && status < 400) {
|
||||
const location = error.response.headers['location'];
|
||||
if (location && location.startsWith('magnet:')) {
|
||||
return this.addMagnetLink(location, category, options);
|
||||
}
|
||||
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
|
||||
try {
|
||||
torrentResponse = await axios.get(location, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Failed to download torrent file after redirect');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid redirect location: ${location}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to download torrent: HTTP ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const torrentBuffer = Buffer.from(torrentResponse.data);
|
||||
|
||||
let parsedTorrentData: any;
|
||||
try {
|
||||
parsedTorrentData = await parseTorrent(torrentBuffer);
|
||||
} catch {
|
||||
throw new Error('Invalid .torrent file - failed to parse');
|
||||
}
|
||||
|
||||
const infoHash = parsedTorrentData.infoHash;
|
||||
if (!infoHash) {
|
||||
throw new Error('Failed to extract info_hash from .torrent file');
|
||||
}
|
||||
|
||||
logger.info(`Extracted info_hash: ${infoHash}`);
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
await this.getTorrentByHash(infoHash);
|
||||
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
return infoHash;
|
||||
} catch {
|
||||
// Torrent doesn't exist, continue
|
||||
}
|
||||
|
||||
const localSavePath = this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
// Transmission accepts base64-encoded .torrent content via 'metainfo' field
|
||||
const metainfo = torrentBuffer.toString('base64');
|
||||
|
||||
const args: Record<string, any> = {
|
||||
metainfo,
|
||||
'download-dir': remoteSavePath,
|
||||
paused: options?.paused || false,
|
||||
labels: [category],
|
||||
};
|
||||
|
||||
logger.info('[Transmission] Adding .torrent file...');
|
||||
const data = await this.rpc('torrent-add', args);
|
||||
|
||||
if (data.result !== 'success') {
|
||||
throw new Error(`Transmission rejected .torrent file: ${data.result}`);
|
||||
}
|
||||
|
||||
// torrent-add returns torrent-added or torrent-duplicate
|
||||
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
|
||||
|
||||
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
|
||||
await this.disableSeedLimits(added?.hashString || infoHash);
|
||||
|
||||
logger.info(`Successfully added torrent: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
async getDownload(id: string): Promise<DownloadInfo | null> {
|
||||
const maxRetries = 3;
|
||||
const initialDelayMs = 500;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
return this.mapToDownloadInfo(torrent);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (!message.includes('not found')) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt === maxRetries) {
|
||||
return null;
|
||||
}
|
||||
const delayMs = initialDelayMs * Math.pow(2, attempt);
|
||||
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async pauseDownload(id: string): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-stop', { ids: [torrent.hashString] });
|
||||
logger.info(`Paused torrent: ${id}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to pause torrent');
|
||||
}
|
||||
}
|
||||
|
||||
async resumeDownload(id: string): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-start', { ids: [torrent.hashString] });
|
||||
logger.info(`Resumed torrent: ${id}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to resume torrent');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-remove', {
|
||||
ids: [torrent.hashString],
|
||||
'delete-local-data': deleteFiles,
|
||||
});
|
||||
logger.info(`Deleted torrent: ${id}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to delete torrent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-download cleanup.
|
||||
* No-op for Transmission — torrents continue seeding until the
|
||||
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
|
||||
*/
|
||||
async postProcess(_id: string): Promise<void> {
|
||||
// No-op: torrents are managed by the seeding cleanup scheduler
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Disable Transmission's global seed ratio and idle time limits for a torrent.
|
||||
* Mode 2 = unlimited (ignore global settings). RMAB manages torrent lifecycle
|
||||
* via the cleanup-seeded-torrents processor using per-indexer seeding times.
|
||||
*/
|
||||
private async disableSeedLimits(hashOrId: string): Promise<void> {
|
||||
try {
|
||||
await this.rpc('torrent-set', {
|
||||
ids: [hashOrId],
|
||||
seedRatioMode: 2,
|
||||
seedIdleMode: 2,
|
||||
});
|
||||
logger.info(`Disabled seed limits for torrent: ${hashOrId}`);
|
||||
} catch (error) {
|
||||
// Non-fatal — torrent was still added, just might get cleaned up by Transmission's rules
|
||||
logger.warn(`Failed to disable seed limits for torrent ${hashOrId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a torrent by its info hash.
|
||||
*/
|
||||
private async getTorrentByHash(hash: string): Promise<TransmissionTorrent> {
|
||||
const data = await this.rpc('torrent-get', { ids: [hash], fields: TORRENT_FIELDS });
|
||||
|
||||
if (data.result !== 'success') {
|
||||
throw new Error(`Transmission RPC error: ${data.result}`);
|
||||
}
|
||||
|
||||
const torrents: TransmissionTorrent[] = data.arguments?.torrents || [];
|
||||
if (torrents.length === 0) {
|
||||
throw new Error(`Torrent ${hash} not found`);
|
||||
}
|
||||
|
||||
return torrents[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Transmission torrent to unified DownloadInfo.
|
||||
*/
|
||||
private mapToDownloadInfo(torrent: TransmissionTorrent): DownloadInfo {
|
||||
// Return raw download path (path mapping is applied downstream by the consumer)
|
||||
const downloadPath = path.join(torrent.downloadDir, torrent.name);
|
||||
|
||||
return {
|
||||
id: torrent.hashString,
|
||||
name: torrent.name,
|
||||
size: torrent.totalSize,
|
||||
bytesDownloaded: torrent.downloadedEver,
|
||||
progress: torrent.percentDone,
|
||||
status: this.mapStatus(torrent.status, torrent.error),
|
||||
downloadSpeed: torrent.rateDownload,
|
||||
eta: torrent.eta < 0 ? 0 : torrent.eta,
|
||||
category: torrent.labels?.[0] || '',
|
||||
downloadPath,
|
||||
completedAt: torrent.doneDate > 0 ? new Date(torrent.doneDate * 1000) : undefined,
|
||||
errorMessage: torrent.error > 0 ? torrent.errorString : undefined,
|
||||
seedingTime: torrent.secondsSeeding,
|
||||
ratio: torrent.uploadRatio >= 0 ? torrent.uploadRatio : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Transmission numeric status to unified DownloadStatus.
|
||||
* 0=stopped, 1=check-pending, 2=checking, 3=download-pending,
|
||||
* 4=downloading, 5=seed-pending, 6=seeding
|
||||
*/
|
||||
private mapStatus(status: TransmissionStatus, errorCode: number): DownloadStatus {
|
||||
if (errorCode > 0) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const statusMap: Record<TransmissionStatus, DownloadStatus> = {
|
||||
0: 'paused',
|
||||
1: 'checking',
|
||||
2: 'checking',
|
||||
3: 'queued',
|
||||
4: 'downloading',
|
||||
5: 'seeding',
|
||||
6: 'seeding',
|
||||
};
|
||||
|
||||
return statusMap[status] || 'downloading';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract info_hash from magnet link.
|
||||
*/
|
||||
private extractHashFromMagnet(magnetUrl: string): string | null {
|
||||
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
|
||||
if (match) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton factory (matches qBittorrent, SABnzbd, NZBGet pattern)
|
||||
let transmissionServiceInstance: TransmissionService | null = null;
|
||||
let configLoaded = false;
|
||||
|
||||
export async function getTransmissionService(): Promise<TransmissionService> {
|
||||
if (transmissionServiceInstance && configLoaded) {
|
||||
return transmissionServiceInstance;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
||||
const configService = await getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
|
||||
logger.info('[Transmission] Loading configuration from download client manager...');
|
||||
const clientConfig = await manager.getClientForProtocol('torrent');
|
||||
|
||||
if (!clientConfig) {
|
||||
throw new Error('Transmission is not configured. Please configure a Transmission client in the admin settings.');
|
||||
}
|
||||
|
||||
if (clientConfig.type !== 'transmission') {
|
||||
throw new Error(`Expected Transmission client but found ${clientConfig.type}`);
|
||||
}
|
||||
|
||||
const baseDir = await configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = clientConfig.customPath
|
||||
? require('path').join(baseDir, clientConfig.customPath)
|
||||
: baseDir;
|
||||
|
||||
const pathMappingConfig: PathMappingConfig = {
|
||||
enabled: clientConfig.remotePathMappingEnabled || false,
|
||||
remotePath: clientConfig.remotePath || '',
|
||||
localPath: clientConfig.localPath || '',
|
||||
};
|
||||
|
||||
logger.info('[Transmission] Config loaded:', {
|
||||
name: clientConfig.name,
|
||||
hasUrl: !!clientConfig.url,
|
||||
hasUsername: !!clientConfig.username,
|
||||
hasPassword: !!clientConfig.password,
|
||||
disableSSLVerify: clientConfig.disableSSLVerify,
|
||||
downloadDir,
|
||||
pathMappingEnabled: pathMappingConfig.enabled,
|
||||
});
|
||||
|
||||
if (!clientConfig.url) {
|
||||
throw new Error('Transmission is not fully configured. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
transmissionServiceInstance = new TransmissionService(
|
||||
clientConfig.url,
|
||||
clientConfig.username || '',
|
||||
clientConfig.password || '',
|
||||
downloadDir,
|
||||
clientConfig.category || 'readmeabook',
|
||||
clientConfig.disableSSLVerify,
|
||||
pathMappingConfig
|
||||
);
|
||||
|
||||
const connectionResult = await transmissionServiceInstance.testConnection();
|
||||
if (!connectionResult.success) {
|
||||
throw new Error(connectionResult.message || 'Transmission connection test failed. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
logger.info('[Transmission] Connection test successful');
|
||||
configLoaded = true;
|
||||
return transmissionServiceInstance;
|
||||
} catch (error) {
|
||||
logger.error('[Transmission] Failed to initialize service', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
transmissionServiceInstance = null;
|
||||
configLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateTransmissionService(): void {
|
||||
transmissionServiceInstance = null;
|
||||
configLoaded = false;
|
||||
logger.info('[Transmission] Service singleton invalidated');
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Component: Download Client Interface
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Defines the contract all download clients must implement.
|
||||
* Enables protocol-agnostic download management across torrent and usenet clients.
|
||||
*/
|
||||
|
||||
// =========================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// =========================================================================
|
||||
|
||||
/** Supported download client types — single source of truth */
|
||||
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const;
|
||||
|
||||
/** Identifies the specific download client software */
|
||||
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
|
||||
|
||||
/** Human-readable display names for each client type */
|
||||
export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
|
||||
qbittorrent: 'qBittorrent',
|
||||
sabnzbd: 'SABnzbd',
|
||||
nzbget: 'NZBGet',
|
||||
transmission: 'Transmission',
|
||||
};
|
||||
|
||||
/** Get display name for a client type, falling back to the raw type */
|
||||
export function getClientDisplayName(type: string): string {
|
||||
return CLIENT_DISPLAY_NAMES[type as DownloadClientType] || type;
|
||||
}
|
||||
|
||||
/** The download protocol a client operates on */
|
||||
export type ProtocolType = 'torrent' | 'usenet';
|
||||
|
||||
/** Maps each client type to its download protocol */
|
||||
export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
|
||||
qbittorrent: 'torrent',
|
||||
sabnzbd: 'usenet',
|
||||
nzbget: 'usenet',
|
||||
transmission: 'torrent',
|
||||
};
|
||||
|
||||
/** Unified download status across all clients */
|
||||
export type DownloadStatus =
|
||||
| 'downloading'
|
||||
| 'completed'
|
||||
| 'seeding'
|
||||
| 'paused'
|
||||
| 'queued'
|
||||
| 'failed'
|
||||
| 'processing'
|
||||
| 'checking';
|
||||
|
||||
// =========================================================================
|
||||
// DATA INTERFACES
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Unified download information returned by all clients.
|
||||
* Normalizes torrent and NZB data into a single shape.
|
||||
*/
|
||||
export interface DownloadInfo {
|
||||
/** Client-assigned identifier (torrent hash or NZB ID) */
|
||||
id: string;
|
||||
/** Display name of the download */
|
||||
name: string;
|
||||
/** Total size in bytes */
|
||||
size: number;
|
||||
/** Bytes downloaded so far */
|
||||
bytesDownloaded: number;
|
||||
/** Download progress from 0.0 to 1.0 */
|
||||
progress: number;
|
||||
/** Normalized download status */
|
||||
status: DownloadStatus;
|
||||
/** Current download speed in bytes/sec */
|
||||
downloadSpeed: number;
|
||||
/** Estimated time remaining in seconds */
|
||||
eta: number;
|
||||
/** Category/label assigned to this download */
|
||||
category: string;
|
||||
/** Filesystem path where download is stored (available after completion) */
|
||||
downloadPath?: string;
|
||||
/** When the download completed */
|
||||
completedAt?: Date;
|
||||
/** Error message if download failed */
|
||||
errorMessage?: string;
|
||||
/** Time spent seeding in seconds (torrent clients only) */
|
||||
seedingTime?: number;
|
||||
/** Upload/download ratio (torrent clients only) */
|
||||
ratio?: number;
|
||||
}
|
||||
|
||||
/** Options for adding a new download */
|
||||
export interface AddDownloadOptions {
|
||||
/** Category/label to assign */
|
||||
category?: string;
|
||||
/** Priority level (interpretation varies by client) */
|
||||
priority?: string;
|
||||
/** Whether to add in paused state */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a connection test */
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DOWNLOAD CLIENT INTERFACE
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* IDownloadClient — the contract every download client must implement.
|
||||
*
|
||||
* Provides a unified API for managing downloads across different protocols
|
||||
* and client software. Processors interact with this interface exclusively,
|
||||
* enabling new download clients to be added without modifying consumer code.
|
||||
*
|
||||
* To add a new client (e.g., Transmission):
|
||||
* 1. Create a service class implementing IDownloadClient
|
||||
* 2. Add the type to DownloadClientType
|
||||
* 3. Add factory case in DownloadClientManager
|
||||
*/
|
||||
export interface IDownloadClient {
|
||||
/** Identifies the client software (e.g., 'qbittorrent', 'sabnzbd') */
|
||||
readonly clientType: DownloadClientType;
|
||||
/** The protocol this client operates on */
|
||||
readonly protocol: ProtocolType;
|
||||
|
||||
/**
|
||||
* Test the connection to the download client.
|
||||
* @returns Connection test result with success/failure and optional version
|
||||
*/
|
||||
testConnection(): Promise<ConnectionTestResult>;
|
||||
|
||||
/**
|
||||
* Add a new download.
|
||||
* @param url - Download URL (magnet link, .torrent URL, or .nzb URL)
|
||||
* @param options - Optional download settings
|
||||
* @returns Client-assigned download ID (torrent hash or NZB ID)
|
||||
*/
|
||||
addDownload(url: string, options?: AddDownloadOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* Get current status of a download.
|
||||
* Includes retry logic for race conditions (e.g., torrent not immediately available after adding).
|
||||
* @param id - Download ID returned by addDownload
|
||||
* @returns Download info, or null if not found
|
||||
*/
|
||||
getDownload(id: string): Promise<DownloadInfo | null>;
|
||||
|
||||
/**
|
||||
* Pause a download.
|
||||
* @param id - Download ID
|
||||
*/
|
||||
pauseDownload(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resume a paused download.
|
||||
* @param id - Download ID
|
||||
*/
|
||||
resumeDownload(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a download from the client.
|
||||
* @param id - Download ID
|
||||
* @param deleteFiles - Whether to also delete downloaded files (default: false)
|
||||
*/
|
||||
deleteDownload(id: string, deleteFiles?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Perform post-download cleanup specific to the client.
|
||||
* - qBittorrent: No-op (torrents continue seeding, handled by cleanup job)
|
||||
* - SABnzbd: Archives the completed NZB from history
|
||||
* @param id - Download ID
|
||||
*/
|
||||
postProcess(id: string): Promise<void>;
|
||||
}
|
||||
@@ -220,3 +220,36 @@ export async function isLocalAdmin(userId: string): Promise<boolean> {
|
||||
|
||||
return user.isSetupAdmin && user.plexId.startsWith('local-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require setup to be incomplete
|
||||
* Blocks access to setup-only endpoints after initial setup is finished.
|
||||
* Returns 403 if setup is already complete, otherwise invokes the handler.
|
||||
*/
|
||||
export async function requireSetupIncomplete(
|
||||
request: NextRequest,
|
||||
handler: (request: NextRequest) => Promise<NextResponse>
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: 'setup_completed' },
|
||||
});
|
||||
|
||||
if (config?.value === 'true') {
|
||||
logger.warn('Setup endpoint called after setup is complete', {
|
||||
path: request.nextUrl.pathname,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden',
|
||||
message: 'Setup has already been completed',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If database is not ready, setup is definitely not complete — allow through
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* Component: Cleanup Seeded Torrents Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Cleans up torrents that have met their seeding requirements
|
||||
* Cleans up downloads that have met their seeding requirements.
|
||||
* Uses the IDownloadClient interface for client-agnostic operation.
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
|
||||
export interface CleanupSeededTorrentsPayload {
|
||||
jobId?: string;
|
||||
@@ -22,7 +24,9 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
try {
|
||||
// Get indexer configuration with per-indexer seeding times
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
|
||||
if (!indexersConfigStr) {
|
||||
@@ -44,22 +48,28 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
|
||||
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
|
||||
// Find all completed requests + soft-deleted requests (orphaned downloads)
|
||||
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
|
||||
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
|
||||
// Before deleting torrent, we check if other active requests are using it
|
||||
// NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
|
||||
// NOTE: Ebooks downloaded via indexer search use torrent clients and need seeding cleanup too.
|
||||
// Direct HTTP ebook downloads are naturally skipped (no torrent hash / unknown client type).
|
||||
const completedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
|
||||
OR: [
|
||||
// Active requests that are fully available (scanned by Plex/ABS)
|
||||
// Audiobook requests that are fully available (matched in Plex/ABS)
|
||||
{
|
||||
type: 'audiobook',
|
||||
status: 'available',
|
||||
deletedAt: null,
|
||||
},
|
||||
// Soft-deleted requests (orphaned downloads)
|
||||
// We'll check if torrent is shared with active requests before deletion
|
||||
// Ebook requests that are fully downloaded (terminal state for ebooks)
|
||||
{
|
||||
type: 'ebook',
|
||||
status: 'downloaded',
|
||||
deletedAt: null,
|
||||
},
|
||||
// Soft-deleted requests of any type (orphaned downloads)
|
||||
{
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
@@ -78,11 +88,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
take: 100, // Limit to 100 requests per run
|
||||
});
|
||||
|
||||
logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
|
||||
logger.info(`Found ${completedRequests.length} requests to check (audiobook: available, ebook: downloaded, or soft-deleted)`);
|
||||
|
||||
let cleaned = 0;
|
||||
let skipped = 0;
|
||||
let noConfig = 0;
|
||||
const deletedHashes = new Set<string>(); // Track torrents already deleted this run
|
||||
|
||||
for (const request of completedRequests) {
|
||||
try {
|
||||
@@ -92,18 +103,27 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SABnzbd downloads - Usenet doesn't have seeding concept
|
||||
// Skip Usenet downloads - no seeding concept
|
||||
if (downloadHistory.nzbId && !downloadHistory.torrentHash) {
|
||||
// For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed)
|
||||
// For soft-deleted Usenet requests, hard delete immediately (no seeding needed)
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned SABnzbd request ${request.id}`);
|
||||
logger.info(`Hard-deleted orphaned Usenet request ${request.id}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process torrent downloads
|
||||
if (!downloadHistory.torrentHash) {
|
||||
// Only process downloads that have a client ID
|
||||
if (!downloadHistory.downloadClientId && !downloadHistory.torrentHash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the download client ID and protocol
|
||||
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash!;
|
||||
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
|
||||
if (!protocol) {
|
||||
logger.warn(`Unknown download client type: ${clientType}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -126,20 +146,40 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
||||
|
||||
// Get torrent info from qBittorrent to check seeding time
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
// Skip if this torrent was already deleted earlier in this run
|
||||
if (deletedHashes.has(clientId.toLowerCase())) {
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (torrent already cleaned this run)`);
|
||||
}
|
||||
cleaned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let torrent;
|
||||
// Get download info from the appropriate client via the interface
|
||||
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||
|
||||
if (!client) {
|
||||
logger.warn(`No ${clientType} client configured, skipping request ${request.id}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let downloadInfo;
|
||||
try {
|
||||
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
downloadInfo = await client.getDownload(clientId);
|
||||
} catch (error) {
|
||||
// Torrent might already be deleted, skip
|
||||
// Download not found in client (already removed), skip
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!downloadInfo) {
|
||||
// Download not found in client (already removed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if seeding time requirement is met
|
||||
const actualSeedingTime = torrent.seeding_time || 0;
|
||||
const actualSeedingTime = downloadInfo.seedingTime || 0;
|
||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
||||
|
||||
if (!hasMetRequirement) {
|
||||
@@ -148,47 +188,49 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
|
||||
// CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash
|
||||
// This prevents deleting shared torrents when user re-requests the same audiobook
|
||||
const otherActiveRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
id: { not: request.id }, // Exclude current request
|
||||
type: 'audiobook', // Only check audiobook requests
|
||||
deletedAt: null, // Only check active requests
|
||||
downloadHistory: {
|
||||
some: {
|
||||
torrentHash: downloadHistory.torrentHash,
|
||||
selected: true,
|
||||
// CRITICAL: Check if any other active (non-deleted) request is using this same download
|
||||
const hashToCheck = downloadHistory.torrentHash;
|
||||
if (hashToCheck) {
|
||||
const otherActiveRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
id: { not: request.id }, // Exclude current request
|
||||
deletedAt: null, // Only check active requests
|
||||
downloadHistory: {
|
||||
some: {
|
||||
torrentHash: hashToCheck,
|
||||
selected: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (otherActiveRequests.length > 0) {
|
||||
logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
|
||||
if (otherActiveRequests.length > 0) {
|
||||
logger.info(`Skipping download deletion - ${otherActiveRequests.length} other active request(s) still using this download (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
|
||||
|
||||
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
|
||||
// If this is a soft-deleted request, hard delete it but DON'T delete the download
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared download for active requests)`);
|
||||
}
|
||||
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safe to delete - no other active requests using this torrent
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
|
||||
// Safe to delete - no other active requests using this download
|
||||
await client.deleteDownload(clientId, true); // true = delete files
|
||||
deletedHashes.add(clientId.toLowerCase());
|
||||
|
||||
// If this is a soft-deleted request (orphaned download), hard delete it now
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`);
|
||||
} else {
|
||||
logger.info(`Deleted torrent and files for active request ${request.id}`);
|
||||
logger.info(`Deleted download and files for active request ${request.id}`);
|
||||
}
|
||||
|
||||
cleaned++;
|
||||
@@ -197,7 +239,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay
|
||||
|
||||
// Get download configuration
|
||||
const configService = getConfigService();
|
||||
const downloadsDir = await configService.get('downloads_dir') || '/downloads';
|
||||
const downloadsDir = await configService.get('download_dir') || '/downloads';
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
|
||||
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { getSABnzbdService } from '../integrations/sabnzbd.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { ProwlarrService } from '../integrations/prowlarr.service';
|
||||
@@ -14,7 +12,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Process download job
|
||||
* Routes to appropriate download client based on configuration
|
||||
* Routes to appropriate download client based on protocol detection
|
||||
* Adds selected result to download client and starts monitoring
|
||||
*/
|
||||
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
|
||||
@@ -41,151 +39,85 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
},
|
||||
});
|
||||
|
||||
// Detect protocol from result and route to appropriate client
|
||||
// Detect protocol from result and get appropriate client
|
||||
const isUsenet = ProwlarrService.isNZBResult(torrent);
|
||||
const protocol = isUsenet ? 'usenet' : 'torrent';
|
||||
const config = await getConfigService();
|
||||
const manager = getDownloadClientManager(config);
|
||||
|
||||
const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent');
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (!clientConfig) {
|
||||
const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)';
|
||||
throw new Error(`No ${protocol} client configured`);
|
||||
if (!client) {
|
||||
throw new Error(`No ${protocol} download client configured. Please add a ${protocol} client in Settings > Download Clients.`);
|
||||
}
|
||||
|
||||
let downloadClientId: string;
|
||||
let downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||
// Get client config for category
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
const category = clientConfig?.category || 'readmeabook';
|
||||
|
||||
if (isUsenet) {
|
||||
// Route to SABnzbd
|
||||
logger.info(`Routing to SABnzbd`);
|
||||
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
|
||||
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
|
||||
category: clientConfig.category || 'readmeabook',
|
||||
priority: 'normal',
|
||||
});
|
||||
downloadClient = 'sabnzbd';
|
||||
// Add download via unified interface
|
||||
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
|
||||
category,
|
||||
priority: 'normal',
|
||||
});
|
||||
|
||||
logger.info(`NZB added with ID: ${downloadClientId}`);
|
||||
logger.info(`Download added with ID: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
|
||||
downloadClient: 'sabnzbd',
|
||||
downloadClientId,
|
||||
torrentName: torrent.title,
|
||||
nzbId: downloadClientId, // Store NZB ID
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
|
||||
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
|
||||
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
|
||||
leechers: 0,
|
||||
downloadStatus: 'downloading',
|
||||
selected: true,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorJob(
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId,
|
||||
downloadClient: client.clientType,
|
||||
downloadClientId,
|
||||
'sabnzbd',
|
||||
3 // Wait 3 seconds before first check
|
||||
);
|
||||
torrentName: torrent.title,
|
||||
// Set protocol-specific ID fields for backward compatibility
|
||||
torrentHash: client.protocol === 'torrent' ? (torrent.infoHash || downloadClientId) : undefined,
|
||||
nzbId: client.protocol === 'usenet' ? downloadClientId : undefined,
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: indexerPageUrl,
|
||||
magnetLink: torrent.downloadUrl,
|
||||
seeders: torrent.seeders || 0,
|
||||
leechers: torrent.leechers || 0,
|
||||
downloadStatus: 'downloading',
|
||||
selected: true,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'NZB added to SABnzbd and monitoring started',
|
||||
requestId,
|
||||
downloadHistoryId: downloadHistory.id,
|
||||
nzbId: downloadClientId,
|
||||
torrent: {
|
||||
title: torrent.title,
|
||||
size: torrent.size,
|
||||
format: torrent.format,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Route to qBittorrent (default)
|
||||
logger.info(`Routing to qBittorrent`);
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
downloadClientId,
|
||||
client.clientType,
|
||||
3 // Wait 3 seconds before first check
|
||||
);
|
||||
|
||||
const qbt = await getQBittorrentService();
|
||||
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
|
||||
category: clientConfig.category || 'readmeabook',
|
||||
tags: ['audiobook'],
|
||||
sequentialDownload: true,
|
||||
paused: false,
|
||||
});
|
||||
downloadClient = 'qbittorrent';
|
||||
logger.info(`Started monitoring job for request ${requestId} (${client.clientType}, 3s initial delay)`);
|
||||
|
||||
logger.info(`Torrent added with hash: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
|
||||
downloadClient: 'qbittorrent',
|
||||
downloadClientId,
|
||||
torrentName: torrent.title,
|
||||
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
|
||||
magnetLink: torrent.downloadUrl,
|
||||
seeders: torrent.seeders || 0,
|
||||
leechers: torrent.leechers || 0,
|
||||
downloadStatus: 'downloading',
|
||||
selected: true,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
downloadClientId,
|
||||
'qbittorrent',
|
||||
3 // Wait 3 seconds before first check to avoid race condition
|
||||
);
|
||||
|
||||
logger.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Torrent added to qBittorrent and monitoring started',
|
||||
requestId,
|
||||
downloadHistoryId: downloadHistory.id,
|
||||
torrentHash: downloadClientId,
|
||||
torrent: {
|
||||
title: torrent.title,
|
||||
size: torrent.size,
|
||||
seeders: torrent.seeders || 0,
|
||||
format: torrent.format,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Download added to ${client.clientType} and monitoring started`,
|
||||
requestId,
|
||||
downloadHistoryId: downloadHistory.id,
|
||||
downloadClientId,
|
||||
torrent: {
|
||||
title: torrent.title,
|
||||
size: torrent.size,
|
||||
seeders: torrent.seeders || 0,
|
||||
format: torrent.format,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
|
||||
@@ -3,50 +3,13 @@
|
||||
* Documentation: documentation/phase3/README.md
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
|
||||
/**
|
||||
* Helper function to retry getTorrent with exponential backoff
|
||||
* Handles race condition where torrent isn't immediately available after adding
|
||||
*/
|
||||
async function getTorrentWithRetry(
|
||||
qbt: any,
|
||||
hash: string,
|
||||
logger: RMABLogger,
|
||||
maxRetries: number = 3,
|
||||
initialDelayMs: number = 500
|
||||
): Promise<any> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await qbt.getTorrent(hash);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// If this is the last attempt, throw the error
|
||||
if (attempt === maxRetries - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff: 500ms, 1000ms, 2000ms
|
||||
const delayMs = initialDelayMs * Math.pow(2, attempt);
|
||||
logger.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
throw lastError || new Error('Failed to get torrent after retries');
|
||||
}
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
|
||||
/**
|
||||
* Process monitor download job
|
||||
@@ -59,57 +22,42 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
|
||||
|
||||
try {
|
||||
let progress: any;
|
||||
let downloadPath: string | undefined;
|
||||
// Get the download client service via the manager
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType];
|
||||
if (!protocol) {
|
||||
throw new Error(`Unknown download client type: ${downloadClient}`);
|
||||
}
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (downloadClient === 'qbittorrent') {
|
||||
// qBittorrent flow
|
||||
const qbt = await getQBittorrentService();
|
||||
if (!client) {
|
||||
throw new Error(`No ${downloadClient} client configured`);
|
||||
}
|
||||
|
||||
// Get torrent status with retry logic (handles race condition)
|
||||
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
|
||||
progress = qbt.getDownloadProgress(torrent);
|
||||
// Get download status via unified interface
|
||||
const info = await client.getDownload(downloadClientId);
|
||||
|
||||
// Store download path for later use
|
||||
downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
|
||||
} else if (downloadClient === 'sabnzbd') {
|
||||
// SABnzbd flow
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
if (!info) {
|
||||
throw new Error(`Download ${downloadClientId} not found in ${downloadClient}`);
|
||||
}
|
||||
|
||||
// Get NZB status
|
||||
const nzbInfo = await sabnzbd.getNZB(downloadClientId);
|
||||
// Build progress object for request updates
|
||||
const progressPercent = Math.round(info.progress * 100);
|
||||
const progressState = info.status;
|
||||
|
||||
if (!nzbInfo) {
|
||||
throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`);
|
||||
}
|
||||
|
||||
// Convert NZBInfo to progress format
|
||||
progress = {
|
||||
percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format)
|
||||
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
|
||||
bytesTotal: nzbInfo.size,
|
||||
speed: nzbInfo.downloadSpeed,
|
||||
eta: nzbInfo.timeLeft,
|
||||
state: nzbInfo.status,
|
||||
};
|
||||
|
||||
// Store download path if available (only set after completion)
|
||||
downloadPath = nzbInfo.downloadPath;
|
||||
|
||||
logger.info(`SABnzbd status: ${nzbInfo.status}`, {
|
||||
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
|
||||
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
|
||||
if (client.protocol === 'usenet') {
|
||||
logger.info(`${client.clientType} status: ${info.status}`, {
|
||||
progress: `${(info.progress * 100).toFixed(1)}%`,
|
||||
speed: `${(info.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Download client ${downloadClient} not supported`);
|
||||
}
|
||||
|
||||
// Update request progress
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
progress: progress.percent,
|
||||
progress: progressPercent,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -118,23 +66,21 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: progress.state,
|
||||
downloadStatus: progressState,
|
||||
},
|
||||
});
|
||||
|
||||
// Check download state
|
||||
if (progress.state === 'completed') {
|
||||
if (progressState === 'completed' || progressState === 'seeding') {
|
||||
logger.info(`Download completed for request ${requestId}`);
|
||||
|
||||
// Ensure we have a download path
|
||||
const downloadPath = info.downloadPath;
|
||||
if (!downloadPath) {
|
||||
throw new Error('Download path not available from download client');
|
||||
}
|
||||
|
||||
// Get path mapping configuration from the specific download client
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent';
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
|
||||
// Build path mapping config from client settings
|
||||
@@ -150,17 +96,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
const organizePath = PathMapper.transform(downloadPath, pathMappingConfig);
|
||||
|
||||
logger.info(`Download completed`, {
|
||||
downloadClient,
|
||||
downloadClient: client.clientType,
|
||||
downloadPath,
|
||||
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
|
||||
});
|
||||
|
||||
// Update download history to completed
|
||||
// Update download history to completed (store mapped path for retry reliability)
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'completed',
|
||||
completedAt: new Date(),
|
||||
downloadPath: organizePath,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,10 +144,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
progress: 100,
|
||||
downloadPath: organizePath,
|
||||
};
|
||||
} else if (progress.state === 'failed') {
|
||||
} else if (progressState === 'failed') {
|
||||
logger.error(`Download failed for request ${requestId}`);
|
||||
|
||||
const errorMessage = 'Download failed in qBittorrent';
|
||||
const errorMessage = `Download failed in ${client.clientType}`;
|
||||
|
||||
// Update request to failed
|
||||
await prisma.request.update({
|
||||
@@ -249,7 +196,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
completed: true,
|
||||
message: 'Download failed',
|
||||
requestId,
|
||||
progress: progress.percent,
|
||||
progress: progressPercent,
|
||||
};
|
||||
} else {
|
||||
// Still downloading - schedule another check in 10 seconds
|
||||
@@ -263,11 +210,11 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
);
|
||||
|
||||
// Only log every 5% progress to reduce log spam
|
||||
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
|
||||
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5;
|
||||
if (shouldLog) {
|
||||
logger.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
|
||||
speed: progress.speed,
|
||||
eta: progress.eta,
|
||||
logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, {
|
||||
speed: info.downloadSpeed,
|
||||
eta: info.eta,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -276,20 +223,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
completed: false,
|
||||
message: 'Download in progress, monitoring continues',
|
||||
requestId,
|
||||
progress: progress.percent,
|
||||
speed: progress.speed,
|
||||
eta: progress.eta,
|
||||
state: progress.state,
|
||||
progress: progressPercent,
|
||||
speed: info.downloadSpeed,
|
||||
eta: info.eta,
|
||||
state: progressState,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Check if this is a transient "torrent not found" error
|
||||
// Check if this is a transient "not found" error
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found');
|
||||
const isNotFound = errorMessage.includes('not found');
|
||||
|
||||
if (isTorrentNotFound) {
|
||||
if (isNotFound) {
|
||||
// Transient error - don't mark request as failed, let Bull retry
|
||||
// The request stays in 'downloading' status until Bull exhausts all retries
|
||||
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getFileOrganizer } from '../utils/file-organizer';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
import { generateFilesHash } from '../utils/files-hash';
|
||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
@@ -242,106 +244,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup Usenet downloads if configured
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for this download');
|
||||
|
||||
// Get download history to find NZB ID and indexer
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasNzbId: !!downloadHistory?.nzbId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
nzbId: downloadHistory?.nzbId || 'none',
|
||||
indexerId: downloadHistory?.indexerId || 'none',
|
||||
});
|
||||
|
||||
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
|
||||
|
||||
if (indexersConfig) {
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a Usenet indexer with cleanup enabled
|
||||
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
|
||||
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
// Check if it's a file or directory
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Remove directory and all contents
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
// Remove single file
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
|
||||
// Note: We only archive from history, not queue. If the NZB is still in the queue
|
||||
// when we're organizing files, something went wrong with the download monitoring.
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
|
||||
|
||||
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -592,12 +496,20 @@ async function processEbookOrganization(
|
||||
const isIndexerDownload = downloadHistory?.downloadClient !== 'direct';
|
||||
logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`);
|
||||
|
||||
// Get file organizer and template
|
||||
// Get file organizer and ebook-specific template (falls back to audiobook template)
|
||||
const organizer = await getFileOrganizer();
|
||||
const templateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'audiobook_path_template' },
|
||||
const ebookTemplateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'ebook_path_template' },
|
||||
});
|
||||
const template = templateConfig?.value || '{author}/{title} {asin}';
|
||||
let template: string;
|
||||
if (ebookTemplateConfig?.value) {
|
||||
template = ebookTemplateConfig.value;
|
||||
} else {
|
||||
const audiobookTemplateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'audiobook_path_template' },
|
||||
});
|
||||
template = audiobookTemplateConfig?.value || '{author}/{title} {asin}';
|
||||
}
|
||||
|
||||
// Check if Kindle EPUB fix is needed
|
||||
let effectiveDownloadPath = downloadPath;
|
||||
@@ -739,99 +651,8 @@ async function processEbookOrganization(
|
||||
logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`);
|
||||
}
|
||||
|
||||
// Cleanup Usenet downloads if configured (same logic as audiobooks)
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for ebook download');
|
||||
|
||||
// downloadHistory was already fetched earlier in this function
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasNzbId: !!downloadHistory?.nzbId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
nzbId: downloadHistory?.nzbId || 'none',
|
||||
indexerId: downloadHistory?.indexerId || 'none',
|
||||
});
|
||||
|
||||
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
|
||||
|
||||
if (indexersConfig) {
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a Usenet indexer with cleanup enabled
|
||||
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
|
||||
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
// Check if it's a file or directory
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Remove directory and all contents
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
// Remove single file
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
|
||||
|
||||
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
|
||||
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -932,6 +753,129 @@ async function createEbookRequestIfEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DOWNLOAD CLEANUP
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Cleanup download files and archive from download client after successful organization.
|
||||
* Uses the IDownloadClient.postProcess() method for client-specific cleanup (e.g., SABnzbd archive).
|
||||
* Shared between audiobook and ebook organization flows.
|
||||
*/
|
||||
async function cleanupDownloadAfterOrganize(
|
||||
requestId: string,
|
||||
downloadPath: string,
|
||||
configService: any,
|
||||
jobId: string | undefined,
|
||||
logger: RMABLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for this download');
|
||||
|
||||
// Get download history to find client ID and indexer
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasDownloadClientId: !!downloadHistory?.downloadClientId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
downloadClient: downloadHistory?.downloadClient || 'none',
|
||||
});
|
||||
|
||||
if (!downloadHistory?.indexerId || !downloadHistory?.downloadClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
if (!indexersConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a non-torrent indexer with cleanup enabled
|
||||
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Cleaning up download ${downloadHistory.downloadClientId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then use the download client interface for client-specific post-processing
|
||||
// (e.g., usenet clients archive from history, torrent clients are a no-op)
|
||||
const clientType = downloadHistory.downloadClient;
|
||||
if (clientType && clientType !== 'direct') {
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
|
||||
if (!protocol) {
|
||||
logger.warn(`Unknown download client type: ${clientType}, skipping post-processing`);
|
||||
return;
|
||||
}
|
||||
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||
|
||||
if (client) {
|
||||
await client.postProcess(downloadHistory.downloadClientId);
|
||||
logger.info(`Successfully post-processed download ${downloadHistory.downloadClientId} via ${client.clientType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =========================================================================
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
* Component: Retry Failed Imports Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Retries file organization for requests that are awaiting import
|
||||
* Retries file organization for requests that are awaiting import.
|
||||
* Uses the IDownloadClient interface for client-agnostic path resolution.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface';
|
||||
|
||||
export interface RetryFailedImportsPayload {
|
||||
jobId?: string;
|
||||
@@ -30,7 +33,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
|
||||
// Helper function to get path mapping config for a specific download client type
|
||||
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
|
||||
const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent';
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
|
||||
if (clientConfig && clientConfig.remotePathMappingEnabled) {
|
||||
@@ -43,11 +46,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
return { enabled: false, remotePath: '', localPath: '' };
|
||||
};
|
||||
|
||||
// Find all active audiobook requests in awaiting_import status
|
||||
// Note: Ebook requests use the same organize_files processor but with type branching
|
||||
// Find all requests in awaiting_import status (both audiobook and ebook)
|
||||
// The organize_files processor handles both types with type-based branching
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
|
||||
status: 'awaiting_import',
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -90,111 +92,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
|
||||
let downloadPath: string;
|
||||
|
||||
// Try to get download path from the appropriate download client
|
||||
// Get path mapping for this specific download client
|
||||
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||
const mappingConfig = await getPathMappingForClient(clientType);
|
||||
|
||||
if (downloadHistory.torrentHash) {
|
||||
// qBittorrent download
|
||||
try {
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
const qbPath = `${torrent.save_path}/${torrent.name}`;
|
||||
downloadPath = PathMapper.transform(qbPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
|
||||
(downloadPath !== qbPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} catch (qbtError) {
|
||||
// Torrent not found in qBittorrent - try to construct path from config
|
||||
logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} else if (downloadHistory.nzbId) {
|
||||
// SABnzbd download
|
||||
try {
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
|
||||
if (nzbInfo && nzbInfo.downloadPath) {
|
||||
downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` +
|
||||
(downloadPath !== nzbInfo.downloadPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} else {
|
||||
logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} catch (sabnzbdError) {
|
||||
logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Direct downloads (e.g. Anna's Archive ebooks) have no external download client
|
||||
// Use stored path or construct from download_dir directly
|
||||
if (clientType === 'direct') {
|
||||
const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' };
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger);
|
||||
} else {
|
||||
// No download client ID - use fallback path
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No download client ID or name for request ${request.id}, skipping`);
|
||||
// Real download client — resolve path via client API with path mapping
|
||||
const mappingConfig = await getPathMappingForClient(clientType);
|
||||
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
|
||||
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined;
|
||||
if (!protocol) {
|
||||
logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
if (clientId) {
|
||||
// Try to get path from download client via unified interface
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
if (client) {
|
||||
try {
|
||||
const info = await client.getDownload(clientId);
|
||||
if (info?.downloadPath) {
|
||||
downloadPath = PathMapper.transform(info.downloadPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` +
|
||||
(downloadPath !== info.downloadPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} else {
|
||||
// Download found but no path — try stored path, then fallback
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
} catch (clientError) {
|
||||
// Client error — try stored path, then fallback
|
||||
logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`);
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
} else {
|
||||
// No client configured — try stored path, then fallback
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
} else {
|
||||
// No client ID — try stored path, then fallback
|
||||
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
||||
}
|
||||
}
|
||||
|
||||
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using configured download path for request ${request.id}: ${configuredPath}` +
|
||||
(downloadPath !== configuredPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
// Check if we got a valid path (getFallbackPath returns empty string on failure)
|
||||
if (!downloadPath) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await jobQueue.addOrganizeJob(
|
||||
@@ -203,7 +156,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
downloadPath
|
||||
);
|
||||
triggered++;
|
||||
logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
|
||||
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
@@ -224,3 +177,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stored download path from the database (saved at download completion time).
|
||||
* Returns empty string if not available (old records won't have this field).
|
||||
*/
|
||||
function getStoredPath(
|
||||
downloadHistory: { downloadPath?: string | null },
|
||||
requestId: string,
|
||||
logger: RMABLogger
|
||||
): string {
|
||||
if (downloadHistory.downloadPath) {
|
||||
logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`);
|
||||
return downloadHistory.downloadPath;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a fallback download path from config when the download client can't provide one.
|
||||
* Returns empty string if path cannot be determined (caller should skip the request).
|
||||
*/
|
||||
async function getFallbackPath(
|
||||
downloadHistory: { torrentName: string | null },
|
||||
configService: any,
|
||||
mappingConfig: PathMappingConfig,
|
||||
requestId: string,
|
||||
logger: RMABLogger,
|
||||
manager?: DownloadClientManager,
|
||||
protocol?: ProtocolType
|
||||
): Promise<string> {
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const baseDir = await configService.get('download_dir');
|
||||
|
||||
if (!baseDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Resolve customPath from the client config if available
|
||||
let downloadDir = baseDir;
|
||||
if (manager && protocol) {
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
if (clientConfig?.customPath) {
|
||||
downloadDir = path.join(baseDir, clientConfig.customPath);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
const mappedPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${requestId}: ${fallbackPath}` +
|
||||
(mappedPath !== fallbackPath ? ` → ${mappedPath} (mapped)` : '')
|
||||
);
|
||||
return mappedPath;
|
||||
}
|
||||
|
||||
@@ -243,9 +243,14 @@ async function searchIndexers(
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Group indexers by their EBOOK category configuration
|
||||
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
if (skippedIndexers.length > 0) {
|
||||
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
|
||||
@@ -58,9 +58,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
// Group indexers by their category configuration
|
||||
// This minimizes API calls while ensuring each indexer only searches its configured categories
|
||||
const groups = groupIndexersByCategories(indexersConfig);
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
if (skippedIndexers.length > 0) {
|
||||
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
|
||||
@@ -2,37 +2,41 @@
|
||||
* Component: Download Client Manager Service
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*
|
||||
* Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
|
||||
* Manages multiple download clients (qBittorrent, Transmission, SABnzbd, NZBGet) with protocol-based routing.
|
||||
* Supports migration from legacy single-client config to multi-client JSON array format.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
import { ConfigurationService } from './config.service';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { isEncryptedFormat } from './credential-migration.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
import { NZBGetService } from '@/lib/integrations/nzbget.service';
|
||||
import { TransmissionService } from '@/lib/integrations/transmission.service';
|
||||
import { PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
const logger = RMABLogger.create('DownloadClientManager');
|
||||
|
||||
export interface DownloadClientConfig {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
username?: string; // qBittorrent only
|
||||
password: string; // Password (qBittorrent) or API key (SABnzbd)
|
||||
username?: string; // qBittorrent/Transmission/NZBGet only
|
||||
password: string; // Password (qBittorrent/Transmission/NZBGet) or API key (SABnzbd)
|
||||
disableSSLVerify: boolean;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string; // Default: 'readmeabook'
|
||||
customPath?: string; // Relative sub-path appended to download_dir
|
||||
}
|
||||
|
||||
type ProtocolType = 'torrent' | 'usenet';
|
||||
|
||||
/**
|
||||
* Download Client Manager
|
||||
@@ -47,6 +51,7 @@ export class DownloadClientManager {
|
||||
private static instance: DownloadClientManager | null = null;
|
||||
private configService: ConfigurationService;
|
||||
private clientsCache: DownloadClientConfig[] | null = null;
|
||||
private serviceCache: Map<string, IDownloadClient> = new Map();
|
||||
private migrationPerformed = false;
|
||||
|
||||
private constructor(configService: ConfigurationService) {
|
||||
@@ -69,6 +74,7 @@ export class DownloadClientManager {
|
||||
static invalidate(): void {
|
||||
if (DownloadClientManager.instance) {
|
||||
DownloadClientManager.instance.clientsCache = null;
|
||||
DownloadClientManager.instance.serviceCache.clear();
|
||||
DownloadClientManager.instance.migrationPerformed = false;
|
||||
logger.debug('Download client cache invalidated');
|
||||
}
|
||||
@@ -127,16 +133,17 @@ export class DownloadClientManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client for specific protocol
|
||||
* Get client for specific protocol.
|
||||
* Uses CLIENT_PROTOCOL_MAP so any client type matching the protocol is found
|
||||
* (e.g. both qBittorrent and Transmission can serve the 'torrent' protocol).
|
||||
*/
|
||||
async getClientForProtocol(protocol: ProtocolType): Promise<DownloadClientConfig | null> {
|
||||
const clients = await this.getAllClients();
|
||||
const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
|
||||
|
||||
const client = clients.find(c => c.enabled && c.type === targetType);
|
||||
const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol);
|
||||
|
||||
if (!client) {
|
||||
logger.warn(`No enabled ${targetType} client configured`);
|
||||
logger.warn(`No enabled ${protocol} client configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -152,36 +159,83 @@ export class DownloadClientManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instantiated client service for protocol
|
||||
* Get instantiated client service for protocol.
|
||||
* Returns the unified IDownloadClient interface for protocol-agnostic usage.
|
||||
*/
|
||||
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
|
||||
async getClientServiceForProtocol(protocol: ProtocolType): Promise<IDownloadClient | null> {
|
||||
const client = await this.getClientForProtocol(protocol);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client.type === 'qbittorrent') {
|
||||
return this.createQBittorrentService(client);
|
||||
} else {
|
||||
return this.createSABnzbdService(client);
|
||||
return this.getOrCreateService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: create a new IDownloadClient from config.
|
||||
* This is the single place where client type maps to a concrete class.
|
||||
* Add new client types (e.g. Transmission, NZBGet) here.
|
||||
*/
|
||||
private async createService(config: DownloadClientConfig): Promise<IDownloadClient> {
|
||||
const baseDir = await this.configService.get('download_dir') || '/downloads';
|
||||
const downloadDir = config.customPath
|
||||
? path.join(baseDir, config.customPath)
|
||||
: baseDir;
|
||||
|
||||
switch (config.type) {
|
||||
case 'qbittorrent':
|
||||
return this.createQBittorrentService(config, downloadDir);
|
||||
case 'sabnzbd':
|
||||
return this.createSABnzbdService(config, downloadDir);
|
||||
case 'nzbget':
|
||||
return this.createNZBGetService(config, downloadDir);
|
||||
case 'transmission':
|
||||
return this.createTransmissionService(config, downloadDir);
|
||||
default:
|
||||
throw new Error(`Unsupported download client type: ${config.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for a specific client config
|
||||
* Get a cached service instance or create a new one.
|
||||
* Caches by client config ID to preserve session state (e.g. qBittorrent SID cookie).
|
||||
*/
|
||||
private async getOrCreateService(config: DownloadClientConfig): Promise<IDownloadClient> {
|
||||
const cached = this.serviceCache.get(config.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const service = await this.createService(config);
|
||||
this.serviceCache.set(config.id, service);
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IDownloadClient instance from a config object.
|
||||
* Uses cached instances when available to preserve session state.
|
||||
*/
|
||||
async createClientFromConfig(config: DownloadClientConfig): Promise<IDownloadClient> {
|
||||
return this.getOrCreateService(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for a specific client config.
|
||||
* Uses the unified IDownloadClient.testConnection() method.
|
||||
*/
|
||||
async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (config.type === 'qbittorrent') {
|
||||
const service = this.createQBittorrentService(config);
|
||||
await service.testConnection();
|
||||
return { success: true, message: 'Successfully connected to qBittorrent' };
|
||||
} else {
|
||||
const service = this.createSABnzbdService(config);
|
||||
const version = await service.getVersion();
|
||||
return { success: true, message: `Successfully connected to SABnzbd (v${version})` };
|
||||
// Always create a fresh instance for connection testing (don't use cache)
|
||||
const service = await this.createService(config);
|
||||
const result = await service.testConnection();
|
||||
|
||||
if (result.success) {
|
||||
const versionSuffix = result.version ? ` (v${result.version})` : '';
|
||||
return { success: true, message: `Successfully connected to ${config.name}${versionSuffix}` };
|
||||
}
|
||||
|
||||
return { success: false, message: result.message || 'Connection failed' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Connection test failed', { type: config.type, error: message });
|
||||
@@ -192,7 +246,7 @@ export class DownloadClientManager {
|
||||
/**
|
||||
* Create qBittorrent service instance
|
||||
*/
|
||||
private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
|
||||
private createQBittorrentService(config: DownloadClientConfig, downloadDir: string): QBittorrentService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -205,8 +259,8 @@ export class DownloadClientManager {
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password || '', // Optional for IP whitelist auth
|
||||
'/downloads', // defaultSavePath
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
downloadDir,
|
||||
config.category || 'readmeabook',
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
@@ -215,7 +269,7 @@ export class DownloadClientManager {
|
||||
/**
|
||||
* Create SABnzbd service instance
|
||||
*/
|
||||
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
|
||||
private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -227,8 +281,54 @@ export class DownloadClientManager {
|
||||
return new SABnzbdService(
|
||||
config.url,
|
||||
config.password, // API key stored in password field
|
||||
config.category || 'readmeabook', // defaultCategory
|
||||
'/downloads', // defaultDownloadDir (will be overridden by singleton with actual config)
|
||||
config.category || 'readmeabook',
|
||||
downloadDir,
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create NZBGet service instance
|
||||
*/
|
||||
private createNZBGetService(config: DownloadClientConfig, downloadDir: string): NZBGetService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
remotePath: config.remotePath,
|
||||
localPath: config.localPath,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new NZBGetService(
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password,
|
||||
config.category || 'readmeabook',
|
||||
downloadDir,
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Transmission service instance
|
||||
*/
|
||||
private createTransmissionService(config: DownloadClientConfig, downloadDir: string): TransmissionService {
|
||||
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
|
||||
? {
|
||||
enabled: true,
|
||||
remotePath: config.remotePath,
|
||||
localPath: config.localPath,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new TransmissionService(
|
||||
config.url,
|
||||
config.username || '',
|
||||
config.password || '',
|
||||
downloadDir,
|
||||
config.category || 'readmeabook',
|
||||
config.disableSSLVerify,
|
||||
pathMapping
|
||||
);
|
||||
@@ -272,8 +372,8 @@ export class DownloadClientManager {
|
||||
|
||||
const newClient: DownloadClientConfig = {
|
||||
id: randomUUID(),
|
||||
type: clientType as 'qbittorrent' | 'sabnzbd',
|
||||
name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
|
||||
type: clientType as DownloadClientType,
|
||||
name: getClientDisplayName(clientType),
|
||||
enabled: true,
|
||||
url: clientUrl,
|
||||
username: clientUsername || undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@ import Queue, { Job as BullJob, JobOptions } from 'bull';
|
||||
import Redis from 'ioredis';
|
||||
import { prisma } from '../db';
|
||||
import { TorrentResult } from '../utils/ranking-algorithm';
|
||||
import { DownloadClientType } from '../interfaces/download-client.interface';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('JobQueue');
|
||||
@@ -59,7 +60,7 @@ export interface MonitorDownloadPayload extends JobPayload {
|
||||
requestId: string;
|
||||
downloadHistoryId: string;
|
||||
downloadClientId: string;
|
||||
downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||
downloadClient: DownloadClientType;
|
||||
}
|
||||
|
||||
export interface OrganizeFilesPayload extends JobPayload {
|
||||
@@ -545,7 +546,7 @@ export class JobQueueService {
|
||||
requestId: string,
|
||||
downloadHistoryId: string,
|
||||
downloadClientId: string,
|
||||
downloadClient: 'qbittorrent' | 'sabnzbd',
|
||||
downloadClient: DownloadClientType,
|
||||
delaySeconds: number = 0
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { buildAudiobookPath } from '../utils/file-organizer';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
|
||||
const logger = RMABLogger.create('RequestDelete');
|
||||
|
||||
@@ -119,77 +120,73 @@ export async function deleteRequest(
|
||||
);
|
||||
}
|
||||
|
||||
// Handle based on download client type (check which ID is present)
|
||||
if (downloadHistory.torrentHash) {
|
||||
// qBittorrent download
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
// Handle download cleanup via unified interface
|
||||
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
|
||||
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||
|
||||
let torrent;
|
||||
try {
|
||||
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
} catch (error) {
|
||||
// Torrent not found in qBittorrent (already removed)
|
||||
logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`);
|
||||
}
|
||||
if (clientId && clientType !== 'direct') {
|
||||
const { getDownloadClientManager } = await import('./download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
|
||||
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
|
||||
|
||||
if (torrent) {
|
||||
// Torrent exists in qBittorrent
|
||||
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
|
||||
const isCompleted = downloadHistory.downloadStatus === 'completed';
|
||||
if (client) {
|
||||
// Get download info to check seeding status
|
||||
let downloadInfo;
|
||||
try {
|
||||
downloadInfo = await client.getDownload(clientId);
|
||||
} catch (error) {
|
||||
logger.info(`Download ${clientId} not found in ${clientType}, skipping`);
|
||||
}
|
||||
|
||||
if (isUnlimitedSeeding) {
|
||||
// Unlimited seeding - keep in qBittorrent, stop monitoring
|
||||
logger.info(
|
||||
`Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
|
||||
);
|
||||
torrentsKeptUnlimited++;
|
||||
} else if (!isCompleted) {
|
||||
// Download not completed - delete immediately
|
||||
logger.info(
|
||||
`Deleting incomplete download: ${torrent.name}`
|
||||
);
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
|
||||
torrentsRemoved++;
|
||||
} else {
|
||||
// Check if seeding requirement is met
|
||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
||||
const actualSeedingTime = torrent.seeding_time || 0;
|
||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
||||
if (downloadInfo) {
|
||||
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
|
||||
const isCompleted = downloadHistory.downloadStatus === 'completed';
|
||||
|
||||
if (hasMetRequirement) {
|
||||
// Seeding requirement met - delete now
|
||||
if (client.protocol === 'usenet') {
|
||||
// Usenet - no seeding concept, delete immediately
|
||||
try {
|
||||
await client.deleteDownload(clientId, true);
|
||||
logger.info(`Deleted download ${clientId} from ${client.clientType}`);
|
||||
torrentsRemoved++;
|
||||
} catch (error) {
|
||||
logger.info(`Download ${clientId} not found in ${client.clientType}, skipping`);
|
||||
}
|
||||
} else if (isUnlimitedSeeding) {
|
||||
// Unlimited seeding - keep in client, stop monitoring
|
||||
logger.info(
|
||||
`Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
|
||||
actualSeedingTime / 60
|
||||
)}/${seedingConfig.seedingTimeMinutes} minutes)`
|
||||
`Keeping download ${downloadInfo.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
|
||||
);
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
|
||||
torrentsKeptUnlimited++;
|
||||
} else if (!isCompleted) {
|
||||
// Download not completed - delete immediately
|
||||
logger.info(`Deleting incomplete download: ${downloadInfo.name}`);
|
||||
await client.deleteDownload(clientId, true);
|
||||
torrentsRemoved++;
|
||||
} else {
|
||||
// Still needs seeding - keep for cleanup job
|
||||
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
|
||||
logger.info(
|
||||
`Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
|
||||
);
|
||||
torrentsKeptSeeding++;
|
||||
// Check if seeding requirement is met
|
||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
||||
const actualSeedingTime = downloadInfo.seedingTime || 0;
|
||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
||||
|
||||
if (hasMetRequirement) {
|
||||
logger.info(
|
||||
`Deleting download ${downloadInfo.name} (seeding complete: ${Math.floor(
|
||||
actualSeedingTime / 60
|
||||
)}/${seedingConfig.seedingTimeMinutes} minutes)`
|
||||
);
|
||||
await client.deleteDownload(clientId, true);
|
||||
torrentsRemoved++;
|
||||
} else {
|
||||
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
|
||||
logger.info(
|
||||
`Keeping download ${downloadInfo.name} for ${remainingMinutes} more minutes of seeding`
|
||||
);
|
||||
torrentsKeptSeeding++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (downloadHistory.nzbId) {
|
||||
// SABnzbd download - no seeding concept for Usenet
|
||||
try {
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
// Try to delete the NZB from SABnzbd (might already be completed/removed)
|
||||
await sabnzbd.deleteNZB(downloadHistory.nzbId, true);
|
||||
logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`);
|
||||
torrentsRemoved++;
|
||||
} catch (error) {
|
||||
// NZB not found or already removed
|
||||
logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -208,7 +205,11 @@ export async function deleteRequest(
|
||||
const { getConfigService } = await import('./config.service');
|
||||
const configService = getConfigService();
|
||||
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
|
||||
const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
|
||||
// Use ebook-specific template for ebook requests, with fallback to audiobook template
|
||||
const audiobookTemplate = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
|
||||
const template = isEbook
|
||||
? (await configService.get('ebook_path_template')) || audiobookTemplate
|
||||
: audiobookTemplate;
|
||||
|
||||
// Fetch year from audible cache if ASIN is available
|
||||
let year: number | undefined;
|
||||
|
||||
@@ -11,11 +11,12 @@ import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { RMABLogger } from './logger';
|
||||
import { CHAPTER_MERGE_FORMATS } from '../constants/audio-formats';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
// Supported audio formats for chapter merging
|
||||
const SUPPORTED_FORMATS = ['.mp3', '.m4a', '.m4b', '.mp4', '.aac'];
|
||||
// Supported audio formats for chapter merging (from shared constants)
|
||||
const SUPPORTED_FORMATS: readonly string[] = CHAPTER_MERGE_FORMATS;
|
||||
|
||||
// Patterns that indicate chapter-based files
|
||||
const CHAPTER_PATTERNS = [
|
||||
@@ -629,9 +630,9 @@ export async function mergeChapters(
|
||||
await fs.writeFile(metadataFile, chapterMetadata);
|
||||
await logger?.info(`Generated chapter metadata with ${chapters.length} chapter markers`);
|
||||
|
||||
// Determine if we need to re-encode (MP3 input requires conversion to AAC)
|
||||
// Determine if we need to re-encode (non-AAC input requires conversion to AAC for M4B)
|
||||
const inputFormat = path.extname(chapters[0].path).toLowerCase();
|
||||
const needsReencode = inputFormat === '.mp3';
|
||||
const needsReencode = inputFormat === '.mp3' || inputFormat === '.flac' || inputFormat === '.aac';
|
||||
|
||||
// Build ffmpeg command
|
||||
const args: string[] = [
|
||||
@@ -646,26 +647,28 @@ export async function mergeChapters(
|
||||
];
|
||||
|
||||
if (needsReencode) {
|
||||
// MP3 -> M4B requires re-encoding to AAC
|
||||
// Non-AAC -> M4B requires re-encoding to AAC
|
||||
const bitrate = determineOutputBitrate(chapters);
|
||||
|
||||
// Check for libfdk_aac (higher quality) or fall back to native aac
|
||||
const hasFdkAac = await checkLibFdkAac();
|
||||
|
||||
const formatLabel = inputFormat.slice(1).toUpperCase(); // '.mp3' -> 'MP3', '.flac' -> 'FLAC'
|
||||
|
||||
if (hasFdkAac) {
|
||||
args.push('-c:a', 'libfdk_aac');
|
||||
args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality)
|
||||
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
|
||||
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
|
||||
} else {
|
||||
// Use VBR for better quality at same average bitrate
|
||||
const vbrQuality = bitrateToVbrQuality(bitrate);
|
||||
args.push('-c:a', 'aac');
|
||||
args.push('-q:a', vbrQuality.toString());
|
||||
args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility
|
||||
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
|
||||
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
|
||||
}
|
||||
} else {
|
||||
// M4A/M4B -> M4B can use codec copy (fast, lossless)
|
||||
// M4A/M4B/MP4 -> M4B can use codec copy (fast, lossless)
|
||||
args.push('-c', 'copy');
|
||||
await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './chapter-merger';
|
||||
import { prisma } from '../db';
|
||||
import { substituteTemplate, type TemplateVariables } from './path-template.util';
|
||||
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
|
||||
|
||||
export interface AudiobookMetadata {
|
||||
title: string;
|
||||
@@ -362,6 +363,34 @@ export class FileOrganizer {
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
await logger?.error(`Failed to copy ${filename}: ${errorMsg}`);
|
||||
|
||||
// If the tagged temp file failed to copy, clean it up and try the original untagged file
|
||||
if (taggedFilePath) {
|
||||
// Clean up the tagged temp file that failed to copy
|
||||
try {
|
||||
await fs.unlink(taggedFilePath);
|
||||
await logger?.info(`Cleaned up temp file after copy failure: ${path.basename(taggedFilePath)}`);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Fallback: attempt to copy the original untagged file instead
|
||||
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
|
||||
try {
|
||||
await fs.access(originalSourcePath, fs.constants.R_OK);
|
||||
await fs.copyFile(originalSourcePath, targetFilePath);
|
||||
await fs.chmod(targetFilePath, 0o644);
|
||||
result.audioFiles.push(targetFilePath);
|
||||
result.filesMovedCount++;
|
||||
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
|
||||
result.errors.push(`Tagged copy failed for ${filename}, copied original without metadata tags`);
|
||||
continue;
|
||||
} catch (fallbackError) {
|
||||
const fallbackMsg = fallbackError instanceof Error ? fallbackError.message : 'Unknown error';
|
||||
await logger?.error(`Fallback copy of original file also failed: ${fallbackMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`);
|
||||
// Continue with other files instead of throwing
|
||||
}
|
||||
@@ -411,7 +440,15 @@ export class FileOrganizer {
|
||||
// This replaces the old inline ebook sidecar download that happened here.
|
||||
|
||||
result.targetPath = targetPath;
|
||||
result.success = true;
|
||||
|
||||
// Only mark as success if at least one audio file was placed in the target directory
|
||||
// (either freshly copied or already existed from a previous attempt)
|
||||
if (result.audioFiles.length > 0) {
|
||||
result.success = true;
|
||||
} else {
|
||||
result.errors.push('No audio files were successfully copied to the target directory');
|
||||
await logger?.error(`Organization failed: no audio files copied despite ${audioFiles.length} file(s) found`);
|
||||
}
|
||||
|
||||
// DO NOT clean up download directory - files needed for seeding
|
||||
// Cleanup will be handled by the seeding cleanup job after seeding requirements are met
|
||||
@@ -431,7 +468,7 @@ export class FileOrganizer {
|
||||
private async findAudiobookFiles(
|
||||
downloadPath: string
|
||||
): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> {
|
||||
const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
|
||||
const audioExtensions: readonly string[] = AUDIO_EXTENSIONS;
|
||||
const coverPatterns = [
|
||||
/cover\.(jpg|jpeg|png)$/i,
|
||||
/folder\.(jpg|jpeg|png)$/i,
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Supported audio file extensions for hash generation
|
||||
*/
|
||||
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
|
||||
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
|
||||
|
||||
/**
|
||||
* Generates a SHA256 hash of audio filenames for library matching.
|
||||
@@ -51,7 +47,7 @@ export function generateFilesHash(filePaths: string[]): string {
|
||||
})
|
||||
.filter((basename) => {
|
||||
const ext = path.extname(basename).toLowerCase();
|
||||
return AUDIO_EXTENSIONS.includes(ext);
|
||||
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
|
||||
})
|
||||
.map((basename) => basename.toLowerCase()) // Normalize case
|
||||
.sort(); // Sort alphabetically for deterministic hash
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Groups indexers by their category configuration to minimize API calls.
|
||||
* Indexers with identical categories are grouped together for a single search.
|
||||
* Supports separate audiobook and ebook category configurations per indexer.
|
||||
* Indexers with no categories for a given type are skipped (effectively disabled).
|
||||
*/
|
||||
|
||||
export type CategoryType = 'audiobook' | 'ebook';
|
||||
@@ -25,22 +26,33 @@ export interface IndexerGroup {
|
||||
indexers: IndexerConfig[];
|
||||
}
|
||||
|
||||
export interface GroupingResult {
|
||||
groups: IndexerGroup[];
|
||||
skippedIndexers: IndexerConfig[]; // Indexers skipped due to no categories for the type
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate categories from an indexer based on the category type.
|
||||
*
|
||||
* Returns empty array when the field is explicitly set to [] (user disabled this type).
|
||||
* Falls back to defaults only when the field is undefined/missing (legacy configs).
|
||||
*
|
||||
* @param indexer - The indexer configuration
|
||||
* @param type - The category type ('audiobook' or 'ebook')
|
||||
* @returns Array of category IDs
|
||||
* @returns Array of category IDs (empty = disabled for this type)
|
||||
*/
|
||||
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
|
||||
if (type === 'ebook') {
|
||||
return indexer.ebookCategories && indexer.ebookCategories.length > 0
|
||||
? indexer.ebookCategories
|
||||
: [7020]; // Default ebook category
|
||||
// Field exists (even if empty) — respect it
|
||||
if (Array.isArray(indexer.ebookCategories)) {
|
||||
return indexer.ebookCategories;
|
||||
}
|
||||
// Field missing — legacy config, use default
|
||||
return [7020];
|
||||
}
|
||||
|
||||
// Audiobook - check new field first, then legacy field
|
||||
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
|
||||
// Audiobook — check new field first, then legacy field
|
||||
if (Array.isArray(indexer.audiobookCategories)) {
|
||||
return indexer.audiobookCategories;
|
||||
}
|
||||
if (indexer.categories && indexer.categories.length > 0) {
|
||||
@@ -52,57 +64,49 @@ export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType)
|
||||
/**
|
||||
* Groups indexers by their category configuration.
|
||||
* Indexers with identical category arrays are grouped together.
|
||||
* Indexers with no categories for the specified type are skipped.
|
||||
*
|
||||
* @param indexers - Array of indexer configurations
|
||||
* @param type - The category type to group by ('audiobook' or 'ebook')
|
||||
* @returns Array of groups, each containing indexers with matching categories
|
||||
* @returns GroupingResult with groups and skipped indexers
|
||||
*
|
||||
* @example
|
||||
* const indexers = [
|
||||
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
|
||||
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
|
||||
* { id: 2, audiobookCategories: [3030], ebookCategories: [] },
|
||||
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
|
||||
* ];
|
||||
*
|
||||
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
|
||||
* // Result:
|
||||
* // [
|
||||
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
|
||||
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
|
||||
* // ]
|
||||
*
|
||||
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
|
||||
* // Result:
|
||||
* // [
|
||||
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
|
||||
* // ]
|
||||
* const result = groupIndexersByCategories(indexers, 'ebook');
|
||||
* // result.groups: [{ categories: [7020], indexerIds: [1, 3], indexers: [...] }]
|
||||
* // result.skippedIndexers: [{ id: 2, ... }] (no ebook categories)
|
||||
*/
|
||||
export function groupIndexersByCategories(
|
||||
indexers: IndexerConfig[],
|
||||
type: CategoryType = 'audiobook'
|
||||
): IndexerGroup[] {
|
||||
// Map to track unique category combinations
|
||||
// Key: sorted category IDs as string (e.g., "3030,3010")
|
||||
// Value: array of indexers with those categories
|
||||
): GroupingResult {
|
||||
const groupMap = new Map<string, IndexerConfig[]>();
|
||||
const skippedIndexers: IndexerConfig[] = [];
|
||||
|
||||
for (const indexer of indexers) {
|
||||
// Get categories for the specified type
|
||||
const categories = getCategoriesForType(indexer, type);
|
||||
|
||||
// Skip indexers with no categories for this type (effectively disabled)
|
||||
if (categories.length === 0) {
|
||||
skippedIndexers.push(indexer);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort categories to ensure consistent grouping
|
||||
// [3030, 3010] and [3010, 3030] should be the same group
|
||||
const sortedCategories = [...categories].sort((a, b) => a - b);
|
||||
const key = sortedCategories.join(',');
|
||||
|
||||
// Add indexer to group
|
||||
if (!groupMap.has(key)) {
|
||||
groupMap.set(key, []);
|
||||
}
|
||||
groupMap.get(key)!.push(indexer);
|
||||
}
|
||||
|
||||
// Convert map to array of groups
|
||||
const groups: IndexerGroup[] = [];
|
||||
for (const [key, indexersInGroup] of groupMap.entries()) {
|
||||
const categories = key.split(',').map(Number);
|
||||
@@ -115,7 +119,7 @@ export function groupIndexersByCategories(
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
return { groups, skippedIndexers };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { METADATA_TAG_FORMATS, MP4_CONTAINER_FORMATS } from '../constants/audio-formats';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
@@ -41,7 +42,7 @@ export async function tagAudioFileMetadata(
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Only process supported formats
|
||||
if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) {
|
||||
if (!(METADATA_TAG_FORMATS as readonly string[]).includes(ext)) {
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
@@ -61,7 +62,7 @@ export async function tagAudioFileMetadata(
|
||||
];
|
||||
|
||||
// For m4b/m4a/mp4 files, use standard metadata tags
|
||||
if (['.m4b', '.m4a', '.mp4'].includes(ext)) {
|
||||
if ((MP4_CONTAINER_FORMATS as readonly string[]).includes(ext)) {
|
||||
args.push(
|
||||
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
|
||||
'-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this)
|
||||
@@ -85,6 +86,31 @@ export async function tagAudioFileMetadata(
|
||||
// Explicitly specify output format (fixes .tmp extension issue)
|
||||
args.push('-f', 'mp4');
|
||||
}
|
||||
// For FLAC files, use Vorbis comment tags (native FLAC metadata)
|
||||
else if (ext === '.flac') {
|
||||
args.push(
|
||||
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
|
||||
'-metadata', `album="${escapeMetadata(metadata.title)}"`,
|
||||
'-metadata', `albumartist="${escapeMetadata(metadata.author)}"`,
|
||||
'-metadata', `artist="${escapeMetadata(metadata.author)}"`
|
||||
);
|
||||
|
||||
if (metadata.narrator) {
|
||||
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`);
|
||||
}
|
||||
|
||||
if (metadata.year) {
|
||||
args.push('-metadata', `date="${metadata.year}"`);
|
||||
}
|
||||
|
||||
if (metadata.asin) {
|
||||
// FLAC supports arbitrary Vorbis comment tags
|
||||
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
|
||||
}
|
||||
|
||||
// Explicitly specify output format
|
||||
args.push('-f', 'flac');
|
||||
}
|
||||
// For mp3 files, use ID3v2 tags
|
||||
else if (ext === '.mp3') {
|
||||
args.push(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Utility: Permission Resolution
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Resolves effective user permissions from the tri-state pattern:
|
||||
* admin → always granted
|
||||
* per-user setting (true/false) → explicit override
|
||||
* null → falls back to global setting
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* Resolve a tri-state permission (admin → per-user → global fallback).
|
||||
* @param userRole - 'admin' or 'user'
|
||||
* @param userValue - per-user setting (true, false, or null)
|
||||
* @param globalValue - global setting from Configuration table
|
||||
* @returns effective boolean permission
|
||||
*/
|
||||
export function resolvePermission(
|
||||
userRole: string,
|
||||
userValue: boolean | null,
|
||||
globalValue: boolean
|
||||
): boolean {
|
||||
if (userRole === 'admin') return true;
|
||||
if (userValue === true) return true;
|
||||
if (userValue === false) return false;
|
||||
return globalValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a global boolean setting from the Configuration table.
|
||||
* @param key - Configuration key
|
||||
* @param defaultValue - Value to use if the key doesn't exist
|
||||
*/
|
||||
export async function getGlobalBooleanSetting(
|
||||
key: string,
|
||||
defaultValue: boolean = true
|
||||
): Promise<boolean> {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
return config == null ? defaultValue : config.value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user's effective interactive search access permission.
|
||||
*/
|
||||
export async function resolveInteractiveSearchAccess(
|
||||
userRole: string,
|
||||
userInteractiveSearchAccess: boolean | null
|
||||
): Promise<boolean> {
|
||||
if (userRole === 'admin') return true;
|
||||
if (userInteractiveSearchAccess === true) return true;
|
||||
if (userInteractiveSearchAccess === false) return false;
|
||||
return getGlobalBooleanSetting('interactive_search_access', true);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export interface TorrentResult {
|
||||
infoUrl?: string; // Link to indexer's info page (for user reference)
|
||||
infoHash?: string;
|
||||
guid: string;
|
||||
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
|
||||
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER';
|
||||
bitrate?: string;
|
||||
hasChapters?: boolean;
|
||||
flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc.
|
||||
@@ -254,6 +254,7 @@ export class RankingAlgorithm {
|
||||
* Reduced from 25 to make room for data-driven size scoring
|
||||
* M4B with chapters: 10 pts
|
||||
* M4B without chapters: 9 pts
|
||||
* FLAC: 7 pts (lossless audio, excellent quality)
|
||||
* M4A: 6 pts
|
||||
* MP3: 4 pts
|
||||
* Other: 1 pt
|
||||
@@ -264,6 +265,8 @@ export class RankingAlgorithm {
|
||||
switch (format) {
|
||||
case 'M4B':
|
||||
return torrent.hasChapters !== false ? 10 : 9;
|
||||
case 'FLAC':
|
||||
return 7;
|
||||
case 'M4A':
|
||||
return 6;
|
||||
case 'MP3':
|
||||
@@ -395,11 +398,13 @@ export class RankingAlgorithm {
|
||||
.filter(word => word.length > 0 && !stopList.includes(word));
|
||||
};
|
||||
|
||||
// Separate required words (outside parentheses/brackets) from optional words (inside)
|
||||
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
|
||||
// Note: Run on ORIGINAL title to preserve brackets, then normalize the result
|
||||
// Separate required words (outside parentheses/brackets/colon subtitles) from optional words
|
||||
// This handles common patterns like:
|
||||
// "Title (Subtitle)" where subtitle may be omitted
|
||||
// "Title: Series Name" where Audible appends series names after a colon
|
||||
// Note: Run on ORIGINAL title to preserve brackets/colons, then normalize the result
|
||||
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
|
||||
// Work with original title format for bracket detection
|
||||
// Work with original title format for bracket/colon detection
|
||||
const originalTitle = audiobook.title.toLowerCase();
|
||||
|
||||
// Extract content in parentheses/brackets as optional
|
||||
@@ -411,8 +416,20 @@ export class RankingAlgorithm {
|
||||
optionalMatches.push(match[1]);
|
||||
}
|
||||
|
||||
// Remove parenthetical/bracketed content to get required portion
|
||||
const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
|
||||
// Remove parenthetical/bracketed content to get the non-bracketed portion
|
||||
let requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
|
||||
|
||||
// Treat content after a colon as optional (Audible commonly appends series names)
|
||||
// e.g., "The Finest Edge of Twilight: Dungeons & Dragons" → required: title, optional: series
|
||||
const colonIndex = requiredRaw.indexOf(':');
|
||||
if (colonIndex > 0 && colonIndex < requiredRaw.length - 1) {
|
||||
const afterColon = requiredRaw.substring(colonIndex + 1).trim();
|
||||
if (afterColon.length > 0) {
|
||||
optionalMatches.push(afterColon);
|
||||
}
|
||||
requiredRaw = requiredRaw.substring(0, colonIndex).trim();
|
||||
}
|
||||
|
||||
// Normalize the required portion (handles CamelCase, punctuation)
|
||||
const required = this.normalizeForMatching(requiredRaw);
|
||||
const optional = optionalMatches.join(' ');
|
||||
@@ -652,7 +669,7 @@ export class RankingAlgorithm {
|
||||
/**
|
||||
* Detect format from torrent title
|
||||
*/
|
||||
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'OTHER' {
|
||||
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER' {
|
||||
// Use explicit format if provided
|
||||
if (torrent.format) {
|
||||
return torrent.format;
|
||||
@@ -664,6 +681,7 @@ export class RankingAlgorithm {
|
||||
if (title.includes('M4B')) return 'M4B';
|
||||
if (title.includes('M4A')) return 'M4A';
|
||||
if (title.includes('MP3')) return 'MP3';
|
||||
if (title.includes('FLAC')) return 'FLAC';
|
||||
|
||||
// Default to OTHER if no format detected
|
||||
return 'OTHER';
|
||||
@@ -686,6 +704,8 @@ export class RankingAlgorithm {
|
||||
if (torrent.hasChapters !== false) {
|
||||
notes.push('Has chapter markers');
|
||||
}
|
||||
} else if (format === 'FLAC') {
|
||||
notes.push('Lossless format (FLAC)');
|
||||
} else if (format === 'M4A') {
|
||||
notes.push('Good format (M4A)');
|
||||
} else if (format === 'MP3') {
|
||||
|
||||
@@ -80,3 +80,26 @@ export function isParentCategory(categoryId: number): boolean {
|
||||
const category = TORRENT_CATEGORIES.find((cat) => cat.id === categoryId);
|
||||
return !!category?.children && category.children.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all standard category IDs (parents and children) from the predefined tree
|
||||
*/
|
||||
export function getAllStandardCategoryIds(): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
for (const parent of TORRENT_CATEGORIES) {
|
||||
ids.add(parent.id);
|
||||
if (parent.children) {
|
||||
for (const child of parent.children) {
|
||||
ids.add(child.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category ID exists in the predefined category tree
|
||||
*/
|
||||
export function isStandardCategory(categoryId: number): boolean {
|
||||
return getAllStandardCategoryIds().has(categoryId);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -468,6 +468,7 @@ describe('Request Approval Workflow', () => {
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ const settingsFixture = {
|
||||
downloadDir: '',
|
||||
mediaDir: '',
|
||||
audiobookPathTemplate: '',
|
||||
ebookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
|
||||
@@ -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<string, any> = {}) => ({
|
||||
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<typeof vi.fn>;
|
||||
mutatePending?: ReturnType<typeof vi.fn>;
|
||||
mutateAutoApprove?: ReturnType<typeof vi.fn>;
|
||||
mutateInteractiveSearch?: ReturnType<typeof vi.fn>;
|
||||
} = {}) {
|
||||
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(<AdminUsersPage />);
|
||||
|
||||
// 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(<AdminUsersPage />);
|
||||
|
||||
// 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(<AdminUsersPage />);
|
||||
|
||||
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(<AdminUsersPage />);
|
||||
|
||||
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(<AdminUsersPage />);
|
||||
|
||||
// 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(<AdminUsersPage />);
|
||||
|
||||
// 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(<AdminUsersPage />);
|
||||
|
||||
// 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(<AdminUsersPage />);
|
||||
|
||||
// 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 });
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const baseSettings = {
|
||||
downloadDir: '',
|
||||
mediaDir: '',
|
||||
audiobookPathTemplate: '',
|
||||
ebookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ const baseSettings = {
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
audiobookPathTemplate: '',
|
||||
ebookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "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(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -34,6 +34,22 @@ vi.mock('next/image', () => ({
|
||||
default: (props: any) => <img {...props} />,
|
||||
}));
|
||||
|
||||
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',
|
||||
|
||||
@@ -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(<VersionBadge />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -18,4 +18,5 @@ export const createJobQueueMock = () => ({
|
||||
addRetryMissingTorrentsJob: vi.fn(),
|
||||
addRetryFailedImportsJob: vi.fn(),
|
||||
addCleanupSeededTorrentsJob: vi.fn(),
|
||||
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user