mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add SABnzbd Usenet/NZB integration and documentation
Introduces SABnzbd as a supported download client for Usenet/NZB alongside qBittorrent, including service implementation, setup wizard and admin settings UI updates, and protocol-specific job processor logic. Updates documentation, PRD, and database schema to support NZB downloads, adds comprehensive technical details and testing strategies, and fixes Audible integration issues related to search and ASIN extraction.
This commit is contained in:
@@ -38,9 +38,10 @@
|
|||||||
|
|
||||||
## Automation Pipeline
|
## Automation Pipeline
|
||||||
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
||||||
- **Torrent search via Prowlarr** → [phase3/prowlarr.md](phase3/prowlarr.md)
|
- **Search via Prowlarr (torrents + NZBs)** → [phase3/prowlarr.md](phase3/prowlarr.md)
|
||||||
- **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md)
|
- **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md)
|
||||||
- **qBittorrent integration** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
|
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
|
||||||
|
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
|
||||||
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md)
|
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md)
|
||||||
- **Chapter merging (PRD, not implemented)** → [features/chapter-merging.md](features/chapter-merging.md)
|
- **Chapter merging (PRD, not implemented)** → [features/chapter-merging.md](features/chapter-merging.md)
|
||||||
|
|
||||||
@@ -72,7 +73,8 @@
|
|||||||
|
|
||||||
## Feature-Specific Lookups
|
## Feature-Specific Lookups
|
||||||
**"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation)
|
**"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation)
|
||||||
**"How do downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md)
|
**"How do torrent downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md)
|
||||||
|
**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [backend/services/jobs.md](backend/services/jobs.md)
|
||||||
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
|
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
|
||||||
**"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
**"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||||
**"How do I configure external services?"** → [setup-wizard.md](setup-wizard.md), [settings-pages.md](settings-pages.md)
|
**"How do I configure external services?"** → [setup-wizard.md](setup-wizard.md), [settings-pages.md](settings-pages.md)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -144,3 +144,25 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
|||||||
- Redis (caching, optional)
|
- Redis (caching, optional)
|
||||||
- Database (PostgreSQL)
|
- Database (PostgreSQL)
|
||||||
- string-similarity (matching)
|
- string-similarity (matching)
|
||||||
|
|
||||||
|
## Fixed Issues
|
||||||
|
|
||||||
|
**Search returning empty results (2026-01-07)**
|
||||||
|
- **Problem:** Audible changed HTML structure for search results from `.productListItem` to `.s-result-item`
|
||||||
|
- **Impact:** All search queries returned 0 results
|
||||||
|
- **Fix:** Updated `search()` method to support both `.s-result-item` (current) and `.productListItem` (legacy)
|
||||||
|
- **Selectors updated:**
|
||||||
|
- Main: `.s-result-item, .productListItem`
|
||||||
|
- Title: `h2` (new) or `h3 a` (legacy)
|
||||||
|
- Author: `a[href*="/author/"]` (new) or `.authorLabel` (legacy)
|
||||||
|
- Narrator: `a[href*="searchNarrator="]` (new) or `.narratorLabel` (legacy)
|
||||||
|
- Runtime: `span:contains("Length:")` (new) or `.runtimeLabel` (legacy)
|
||||||
|
- Rating: `.a-icon-star span` (new) or `.ratingsLabel` (legacy)
|
||||||
|
- **Location:** `src/lib/integrations/audible.service.ts:235`
|
||||||
|
|
||||||
|
**Some audiobooks missing from search results (2026-01-07)**
|
||||||
|
- **Problem:** ASIN extraction only matched `/pd/` URLs but some audiobooks use `/ac/` URLs
|
||||||
|
- **Impact:** Books like "Beatitude" by DJ Krimmer (ASIN: B0DVH7XL36) were skipped
|
||||||
|
- **Fix:** Updated ASIN regex to match both `/pd/` and `/ac/` URL patterns: `/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/`
|
||||||
|
- **Location:** `src/lib/integrations/audible.service.ts:75, 161, 240`
|
||||||
|
- **Affects:** `getPopularAudiobooks()`, `getNewReleases()`, `search()` methods
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ Evaluates and scores torrents to automatically select best audiobook download.
|
|||||||
|
|
||||||
**Stage 2: Title Matching (0-35 pts)**
|
**Stage 2: Title Matching (0-35 pts)**
|
||||||
- Only scored if Stage 1 passes
|
- Only scored if Stage 1 passes
|
||||||
|
- **Tries full title first, then required title (without parentheses)** if no match
|
||||||
|
- Example: "We Are Legion (We Are Bob)" tries both full title and "We Are Legion"
|
||||||
|
- Handles torrents that include subtitle AND those that omit it
|
||||||
- Complete title match requirements (both must be true):
|
- Complete title match requirements (both must be true):
|
||||||
- No significant words BEFORE matched title (prevents "This Inevitable Ruin Dungeon Crawler Carl, Book 7")
|
- No significant words BEFORE matched title (prevents "This Inevitable Ruin Dungeon Crawler Carl, Book 7")
|
||||||
- Followed by metadata markers: " by", " [", " -", " (", " {", " :", ","
|
- Followed by metadata markers: " by", " [", " -", " (", " {", " :", ","
|
||||||
- Complete match → 35 pts
|
- Complete match → 35 pts
|
||||||
- Title has prefix/suffix words OR continues with more words → fuzzy similarity (partial credit)
|
- Title has prefix/suffix words OR continues with more words → fuzzy similarity (partial credit)
|
||||||
- Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret", "Dungeon Crawler Carl" vs "Book 7"
|
- Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret", "Dungeon Crawler Carl" vs "Book 7"
|
||||||
- No substring match → fuzzy similarity (partial credit)
|
- No substring match → fuzzy similarity (best score from full or required title)
|
||||||
|
|
||||||
**Stage 3: Author Matching (0-15 pts)**
|
**Stage 3: Author Matching (0-15 pts)**
|
||||||
- Exact substring match → proportional credit
|
- Exact substring match → proportional credit
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# SABnzbd Integration
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented
|
||||||
|
|
||||||
|
Free, open-source Usenet/NZB download client with comprehensive Web API. Industry standard for automation workflows.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Protocol:** Usenet/NZB downloads (not torrents)
|
||||||
|
- **Post-Processing:** Automatic par2 repair, rar/zip extraction, cleanup
|
||||||
|
- **Category Support:** Per-category download paths
|
||||||
|
- **API:** RESTful JSON API with API key authentication
|
||||||
|
- **Status:** No hash extraction needed (NZB ID returned immediately)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
**Base:** `http://sabnzbd:8080/api`
|
||||||
|
**Auth:** API key parameter (`apikey={key}`)
|
||||||
|
**Format:** All requests use `output=json` for JSON responses
|
||||||
|
|
||||||
|
**GET /api?mode=version&output=json&apikey={key}** - Get SABnzbd version
|
||||||
|
**GET /api?mode=addurl&name={url}&cat={category}&output=json&apikey={key}** - Add NZB by URL
|
||||||
|
**GET /api?mode=queue&output=json&apikey={key}** - Get active downloads
|
||||||
|
**GET /api?mode=history&limit=100&output=json&apikey={key}** - Get completed/failed downloads
|
||||||
|
**GET /api?mode=pause&value={nzbId}&output=json&apikey={key}** - Pause download
|
||||||
|
**GET /api?mode=resume&value={nzbId}&output=json&apikey={key}** - Resume download
|
||||||
|
**GET /api?mode=queue&name=delete&value={nzbId}&del_files={0|1}&output=json&apikey={key}** - Delete download
|
||||||
|
**GET /api?mode=get_config&output=json&apikey={key}** - Get configuration (categories)
|
||||||
|
**GET /api?mode=set_config§ion=categories&keyword={cat}&value={path}&output=json&apikey={key}** - Create/update category
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
**Required (database only, no env fallbacks):**
|
||||||
|
- `download_client_type` - Must be 'sabnzbd'
|
||||||
|
- `download_client_url` - SABnzbd Web UI URL (supports HTTP and HTTPS)
|
||||||
|
- `download_client_password` - API key (reuses password field)
|
||||||
|
- `download_dir` - Download save path (passed to SABnzbd category)
|
||||||
|
|
||||||
|
**Optional (SSL/TLS):**
|
||||||
|
- `download_client_disable_ssl_verify` - Disable SSL certificate verification (boolean as string "true"/"false", default: "false")
|
||||||
|
- Use when connecting to SABnzbd with self-signed certificates
|
||||||
|
- ⚠️ Security warning: Only use on trusted private networks
|
||||||
|
|
||||||
|
**Optional (Remote Path Mapping):**
|
||||||
|
- `download_client_remote_path_mapping_enabled` - Enable path mapping (boolean)
|
||||||
|
- `download_client_remote_path` - Remote path prefix from SABnzbd
|
||||||
|
- `download_client_local_path` - Local path prefix for ReadMeABook
|
||||||
|
|
||||||
|
**Optional (SABnzbd-specific):**
|
||||||
|
- `sabnzbd_category` - Category name (default: 'readmeabook')
|
||||||
|
|
||||||
|
Validation: All required fields checked before service initialization. Path mapping fields validated when enabled.
|
||||||
|
|
||||||
|
**Singleton Invalidation:**
|
||||||
|
Service uses singleton pattern. When settings change, singleton invalidated to force reload:
|
||||||
|
- `invalidateSABnzbdService()` called after updating settings
|
||||||
|
- Forces service to re-read database config
|
||||||
|
- Ensures category and credentials are always current
|
||||||
|
|
||||||
|
## Category Management
|
||||||
|
|
||||||
|
**Category:** `readmeabook` (auto-created for all downloads)
|
||||||
|
|
||||||
|
**Save Path Synchronization:**
|
||||||
|
- Category created on first download if not exists
|
||||||
|
- Category path set to `download_dir` config value
|
||||||
|
- Unlike qBittorrent, SABnzbd categories are less frequently updated (set once at creation)
|
||||||
|
|
||||||
|
## Post-Processing
|
||||||
|
|
||||||
|
**Automatic (Built-in SABnzbd Features):**
|
||||||
|
- **Par2 Repair:** Verifies and repairs damaged downloads
|
||||||
|
- **Archive Extraction:** Extracts .rar, .zip, .7z archives automatically
|
||||||
|
- **Cleanup:** Deletes .par2, .nfo, .nzb files after extraction
|
||||||
|
- **Result:** `downloadPath` points to extracted directory (ready for file organizer)
|
||||||
|
|
||||||
|
**Configuration:** Post-processing level set to `pp=3` (Repair + Unpack + Delete) on all downloads
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NZBInfo {
|
||||||
|
nzbId: string; // SABnzbd NZB ID
|
||||||
|
name: string;
|
||||||
|
size: number; // bytes
|
||||||
|
progress: number; // 0.0-1.0
|
||||||
|
status: NZBStatus;
|
||||||
|
downloadSpeed: number; // bytes/s
|
||||||
|
timeLeft: number; // seconds
|
||||||
|
category: string;
|
||||||
|
downloadPath?: string; // Available after completion
|
||||||
|
completedAt?: Date;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NZBStatus = 'downloading' | 'queued' | 'paused' | 'extracting' | 'completed' | 'failed' | 'repairing';
|
||||||
|
|
||||||
|
interface QueueItem {
|
||||||
|
nzbId: string;
|
||||||
|
name: string;
|
||||||
|
size: number; // MB
|
||||||
|
sizeLeft: number; // MB
|
||||||
|
percentage: number; // 0-100
|
||||||
|
status: string; // "Downloading", "Paused", "Queued"
|
||||||
|
timeLeft: string; // "0:15:30" format
|
||||||
|
category: string;
|
||||||
|
priority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryItem {
|
||||||
|
nzbId: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
status: string; // "Completed", "Failed"
|
||||||
|
bytes: string; // Size in bytes (as string)
|
||||||
|
failMessage: string;
|
||||||
|
storage: string; // Download path
|
||||||
|
completedTimestamp: string; // Unix timestamp
|
||||||
|
downloadTime: string; // Seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## NZB ID vs Torrent Hash
|
||||||
|
|
||||||
|
**Key Difference:** SABnzbd returns NZB ID immediately (no extraction needed)
|
||||||
|
|
||||||
|
**qBittorrent:**
|
||||||
|
- Returns "Ok." without hash
|
||||||
|
- Requires parsing magnet link or .torrent file
|
||||||
|
- Hash extraction needed for tracking
|
||||||
|
|
||||||
|
**SABnzbd:**
|
||||||
|
- Returns `nzo_ids` array immediately
|
||||||
|
- NZB ID format: `SABnzbd_nzo_abc123xyz`
|
||||||
|
- No extraction needed, can track immediately
|
||||||
|
|
||||||
|
**Database Storage:**
|
||||||
|
- qBittorrent: `torrent_hash` field
|
||||||
|
- SABnzbd: `nzb_id` field (new)
|
||||||
|
- Both nullable, exactly one must be set
|
||||||
|
|
||||||
|
## Status Tracking
|
||||||
|
|
||||||
|
**Queue vs History:**
|
||||||
|
- **Queue:** Active downloads (downloading, queued, paused)
|
||||||
|
- **History:** Completed or failed downloads
|
||||||
|
|
||||||
|
**Monitoring Flow:**
|
||||||
|
1. Check queue for NZB ID
|
||||||
|
2. If found: Parse progress, status, speed
|
||||||
|
3. If not in queue: Check history
|
||||||
|
4. If in history: Parse completion status or error
|
||||||
|
|
||||||
|
**States:**
|
||||||
|
- `Downloading` → Active download
|
||||||
|
- `Queued` → Waiting in queue
|
||||||
|
- `Paused` → Manually paused
|
||||||
|
- `Extracting/Unpacking` → Post-processing extraction
|
||||||
|
- `Repairing/Verifying` → Post-processing par2 repair
|
||||||
|
- `Completed` → Successfully downloaded and extracted
|
||||||
|
- `Failed` → Download or post-processing failed
|
||||||
|
|
||||||
|
## Remote Path Mapping
|
||||||
|
|
||||||
|
**Use Case:** SABnzbd runs on different machine/container with different filesystem perspective.
|
||||||
|
|
||||||
|
**Example Scenario:**
|
||||||
|
- SABnzbd reports: `/remote/usenet/complete/Audiobook.Name`
|
||||||
|
- ReadMeABook needs: `/downloads/Audiobook.Name`
|
||||||
|
- Mapping: Remote `/remote/usenet/complete` → Local `/downloads`
|
||||||
|
|
||||||
|
**Implementation:** Same as qBittorrent (uses `PathMapper` utility)
|
||||||
|
|
||||||
|
## Fixed Issues ✅
|
||||||
|
|
||||||
|
**1. API Key Authentication** - Uses `apikey` parameter (not username/password)
|
||||||
|
**2. Immediate NZB ID** - No hash extraction needed (returned by API)
|
||||||
|
**3. Post-Processing Tracking** - Monitors extracting/repairing states
|
||||||
|
**4. Queue vs History Logic** - Checks queue first, falls back to history
|
||||||
|
**5. SSL Certificate Errors** - Optional SSL verification disable for self-signed certs
|
||||||
|
|
||||||
|
## Comparison: SABnzbd vs qBittorrent
|
||||||
|
|
||||||
|
| Feature | SABnzbd | qBittorrent |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| Protocol | Usenet/NZB | BitTorrent |
|
||||||
|
| Auth | API key only | Username + Password |
|
||||||
|
| ID Format | NZB ID (immediate) | Torrent hash (extracted) |
|
||||||
|
| Post-Processing | Automatic (par2, extraction) | None (manual) |
|
||||||
|
| Seeding | N/A (Usenet is not P2P) | Required (tracker) |
|
||||||
|
| Categories | Path-based | Path + tag-based |
|
||||||
|
| File Handling | Auto-extracts archives | Downloads as-is |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- axios (HTTP client)
|
||||||
|
- Node.js https (SSL/TLS agent)
|
||||||
|
- JSON API responses
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- See [File Organization](./file-organization.md) for post-download processing
|
||||||
|
- See [qBittorrent Integration](./qbittorrent.md) for torrent alternative
|
||||||
@@ -238,6 +238,7 @@ model DownloadHistory {
|
|||||||
indexerName String @map("indexer_name")
|
indexerName String @map("indexer_name")
|
||||||
torrentName String? @map("torrent_name")
|
torrentName String? @map("torrent_name")
|
||||||
torrentHash String? @map("torrent_hash")
|
torrentHash String? @map("torrent_hash")
|
||||||
|
nzbId String? @map("nzb_id") // SABnzbd NZB ID (mutually exclusive with torrentHash)
|
||||||
torrentSizeBytes BigInt? @map("torrent_size_bytes")
|
torrentSizeBytes BigInt? @map("torrent_size_bytes")
|
||||||
magnetLink String? @map("magnet_link") @db.Text
|
magnetLink String? @map("magnet_link") @db.Text
|
||||||
torrentUrl String? @map("torrent_url") @db.Text
|
torrentUrl String? @map("torrent_url") @db.Text
|
||||||
@@ -245,7 +246,7 @@ model DownloadHistory {
|
|||||||
leechers Int?
|
leechers Int?
|
||||||
qualityScore Int? @map("quality_score")
|
qualityScore Int? @map("quality_score")
|
||||||
selected Boolean @default(false)
|
selected Boolean @default(false)
|
||||||
downloadClient String? @map("download_client") // qbittorrent, transmission
|
downloadClient String? @map("download_client") // qbittorrent, sabnzbd
|
||||||
downloadClientId String? @map("download_client_id")
|
downloadClientId String? @map("download_client_id")
|
||||||
downloadStatus String? @map("download_status")
|
downloadStatus String? @map("download_status")
|
||||||
// Status values: queued, downloading, completed, failed, stalled
|
// Status values: queued, downloading, completed, failed, stalled
|
||||||
@@ -259,6 +260,8 @@ model DownloadHistory {
|
|||||||
|
|
||||||
@@index([requestId])
|
@@index([requestId])
|
||||||
@@index([selected])
|
@@index([selected])
|
||||||
|
@@index([torrentHash])
|
||||||
|
@@index([nzbId])
|
||||||
@@index([createdAt(sort: Desc)])
|
@@index([createdAt(sort: Desc)])
|
||||||
@@map("download_history")
|
@@map("download_history")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1521,7 +1521,7 @@ export default function AdminSettings() {
|
|||||||
Download Client
|
Download Client
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
Configure your torrent download client (qBittorrent/Transmission).
|
Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1541,7 +1541,7 @@ export default function AdminSettings() {
|
|||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="qbittorrent">qBittorrent</option>
|
<option value="qbittorrent">qBittorrent</option>
|
||||||
<option value="transmission">Transmission</option>
|
<option value="sabnzbd">SABnzbd</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1563,47 +1563,79 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* qBittorrent: Username + Password */}
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
{settings.downloadClient.type === 'qbittorrent' && (
|
||||||
Username
|
<>
|
||||||
</label>
|
<div>
|
||||||
<Input
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
type="text"
|
Username
|
||||||
value={settings.downloadClient.username}
|
</label>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
setSettings({
|
type="text"
|
||||||
...settings,
|
value={settings.downloadClient.username}
|
||||||
downloadClient: {
|
onChange={(e) => {
|
||||||
...settings.downloadClient,
|
setSettings({
|
||||||
username: e.target.value,
|
...settings,
|
||||||
},
|
downloadClient: {
|
||||||
});
|
...settings.downloadClient,
|
||||||
setValidated({ ...validated, download: false });
|
username: e.target.value,
|
||||||
}}
|
},
|
||||||
placeholder="admin"
|
});
|
||||||
/>
|
setValidated({ ...validated, download: false });
|
||||||
</div>
|
}}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={settings.downloadClient.password}
|
value={settings.downloadClient.password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSettings({
|
setSettings({
|
||||||
...settings,
|
...settings,
|
||||||
downloadClient: {
|
downloadClient: {
|
||||||
...settings.downloadClient,
|
...settings.downloadClient,
|
||||||
password: e.target.value,
|
password: e.target.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setValidated({ ...validated, download: false });
|
setValidated({ ...validated, download: false });
|
||||||
}}
|
}}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SABnzbd: API Key only */}
|
||||||
|
{settings.downloadClient.type === 'sabnzbd' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={settings.downloadClient.password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
downloadClient: {
|
||||||
|
...settings.downloadClient,
|
||||||
|
password: e.target.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setValidated({ ...validated, download: false });
|
||||||
|
}}
|
||||||
|
placeholder="Enter SABnzbd API key"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Find this in SABnzbd under Config → General → API Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* SSL Verification Toggle */}
|
{/* SSL Verification Toggle */}
|
||||||
{settings.downloadClient.url.startsWith('https') && (
|
{settings.downloadClient.url.startsWith('https') && (
|
||||||
|
|||||||
@@ -23,19 +23,29 @@ export async function PUT(request: NextRequest) {
|
|||||||
localPath,
|
localPath,
|
||||||
} = await request.json();
|
} = await request.json();
|
||||||
|
|
||||||
if (!type || !url || !username || !password) {
|
// Validate type
|
||||||
|
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Type, URL, username, and password are required' },
|
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate type
|
// Validate required fields (SABnzbd only needs URL and API key)
|
||||||
if (type !== 'qbittorrent' && type !== 'transmission') {
|
if (type === 'sabnzbd') {
|
||||||
return NextResponse.json(
|
if (!url || !password) {
|
||||||
{ error: 'Invalid client type. Must be qbittorrent or transmission' },
|
return NextResponse.json(
|
||||||
{ status: 400 }
|
{ error: 'URL and API key (password) are required for SABnzbd' },
|
||||||
);
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (type === 'qbittorrent') {
|
||||||
|
if (!url || !username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'URL, username, and password are required for qBittorrent' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate path mapping if enabled
|
// Validate path mapping if enabled
|
||||||
@@ -127,9 +137,14 @@ export async function PUT(request: NextRequest) {
|
|||||||
|
|
||||||
console.log('[Admin] Download client settings updated');
|
console.log('[Admin] Download client settings updated');
|
||||||
|
|
||||||
// Invalidate qBittorrent service singleton to force reload of credentials and URL
|
// Invalidate download client service singleton to force reload of credentials and URL
|
||||||
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
if (type === 'qbittorrent') {
|
||||||
invalidateQBittorrentService();
|
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
||||||
|
invalidateQBittorrentService();
|
||||||
|
} else if (type === 'sabnzbd') {
|
||||||
|
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
|
||||||
|
invalidateSABnzbdService();
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||||
|
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
@@ -23,30 +24,30 @@ export async function POST(request: NextRequest) {
|
|||||||
localPath,
|
localPath,
|
||||||
} = await request.json();
|
} = await request.json();
|
||||||
|
|
||||||
if (!type || !url || !username || !password) {
|
if (!type || !url) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'All fields are required' },
|
{ success: false, error: 'Type and URL are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== 'qbittorrent') {
|
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Only qBittorrent is currently supported' },
|
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If password is masked, fetch the actual value from database
|
// If password is masked, fetch the actual value from database
|
||||||
let actualPassword = password;
|
let actualPassword = password;
|
||||||
if (password.startsWith('••••')) {
|
if (password && password.startsWith('••••')) {
|
||||||
const storedPassword = await prisma.configuration.findUnique({
|
const storedPassword = await prisma.configuration.findUnique({
|
||||||
where: { key: 'download_client_password' },
|
where: { key: 'download_client_password' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!storedPassword?.value) {
|
if (!storedPassword?.value) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'No stored password found. Please re-enter your download client password.' },
|
{ success: false, error: 'No stored password/API key found. Please re-enter it.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,13 +55,48 @@ export async function POST(request: NextRequest) {
|
|||||||
actualPassword = storedPassword.value;
|
actualPassword = storedPassword.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test connection with credentials
|
// Validate required fields per client type and test connection
|
||||||
const version = await QBittorrentService.testConnectionWithCredentials(
|
let version: string | undefined;
|
||||||
url,
|
|
||||||
username,
|
if (type === 'qbittorrent') {
|
||||||
actualPassword,
|
if (!username || !actualPassword) {
|
||||||
disableSSLVerify || false
|
return NextResponse.json(
|
||||||
);
|
{ success: false, error: 'Username and password are required for qBittorrent' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test qBittorrent connection
|
||||||
|
version = await QBittorrentService.testConnectionWithCredentials(
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
actualPassword,
|
||||||
|
disableSSLVerify || false
|
||||||
|
);
|
||||||
|
} else if (type === 'sabnzbd') {
|
||||||
|
if (!actualPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'API key (password) is required for SABnzbd' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SABnzbd connection
|
||||||
|
const sabnzbd = new SABnzbdService(url, actualPassword, 'readmeabook', disableSSLVerify || false);
|
||||||
|
const result = await sabnzbd.testConnection();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Failed to connect to SABnzbd',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
version = result.version;
|
||||||
|
}
|
||||||
|
|
||||||
// If path mapping enabled, validate local path exists
|
// If path mapping enabled, validate local path exists
|
||||||
if (remotePathMappingEnabled) {
|
if (remotePathMappingEnabled) {
|
||||||
|
|||||||
@@ -5,37 +5,80 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||||
|
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { type, url, username, password, disableSSLVerify } = await request.json();
|
const { type, url, username, password, disableSSLVerify } = await request.json();
|
||||||
|
|
||||||
if (!type || !url || !username || !password) {
|
if (!type || !url) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'All fields are required' },
|
{ success: false, error: 'Type and URL are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== 'qbittorrent') {
|
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Only qBittorrent is currently supported' },
|
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test connection with custom credentials
|
// Validate required fields per client type
|
||||||
const version = await QBittorrentService.testConnectionWithCredentials(
|
if (type === 'qbittorrent') {
|
||||||
url,
|
if (!username || !password) {
|
||||||
username,
|
return NextResponse.json(
|
||||||
password,
|
{ success: false, error: 'Username and password are required for qBittorrent' },
|
||||||
disableSSLVerify || false
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test qBittorrent connection
|
||||||
|
const version = await QBittorrentService.testConnectionWithCredentials(
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
disableSSLVerify || false
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
} else if (type === 'sabnzbd') {
|
||||||
|
if (!password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'API key (password) is required for SABnzbd' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SABnzbd connection
|
||||||
|
const sabnzbd = new SABnzbdService(url, password, 'readmeabook', disableSSLVerify || false);
|
||||||
|
const result = await sabnzbd.testConnection();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Failed to connect to SABnzbd',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
version: result.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should never reach here
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Invalid client type' },
|
||||||
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
version,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Setup] Download client test failed:', error);
|
console.error('[Setup] Download client test failed:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ interface SetupState {
|
|||||||
prowlarrUrl: string;
|
prowlarrUrl: string;
|
||||||
prowlarrApiKey: string;
|
prowlarrApiKey: string;
|
||||||
prowlarrIndexers: SelectedIndexer[];
|
prowlarrIndexers: SelectedIndexer[];
|
||||||
downloadClient: 'qbittorrent' | 'transmission';
|
downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||||
downloadClientUrl: string;
|
downloadClientUrl: string;
|
||||||
downloadClientUsername: string;
|
downloadClientUsername: string;
|
||||||
downloadClientPassword: string;
|
downloadClientPassword: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
interface DownloadClientStepProps {
|
interface DownloadClientStepProps {
|
||||||
downloadClient: 'qbittorrent' | 'transmission';
|
downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||||
downloadClientUrl: string;
|
downloadClientUrl: string;
|
||||||
downloadClientUsername: string;
|
downloadClientUsername: string;
|
||||||
downloadClientPassword: string;
|
downloadClientPassword: string;
|
||||||
@@ -99,6 +99,11 @@ export function DownloadClientStep({
|
|||||||
onNext();
|
onNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SABnzbd only requires URL and API key (no username)
|
||||||
|
const isFormValid = downloadClient === 'sabnzbd'
|
||||||
|
? downloadClientUrl && downloadClientPassword // Password field stores API key for SABnzbd
|
||||||
|
: downloadClientUrl && downloadClientUsername && downloadClientPassword;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -106,7 +111,7 @@ export function DownloadClientStep({
|
|||||||
Configure Download Client
|
Configure Download Client
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
Choose and configure your torrent download client.
|
Choose your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,21 +132,21 @@ export function DownloadClientStep({
|
|||||||
>
|
>
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">qBittorrent</div>
|
<div className="font-semibold text-gray-900 dark:text-gray-100">qBittorrent</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Recommended - Full feature support
|
Torrent downloads
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onUpdate('downloadClient', 'transmission')}
|
onClick={() => onUpdate('downloadClient', 'sabnzbd')}
|
||||||
className={`p-4 border-2 rounded-lg text-left transition-colors ${
|
className={`p-4 border-2 rounded-lg text-left transition-colors ${
|
||||||
downloadClient === 'transmission'
|
downloadClient === 'sabnzbd'
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">Transmission</div>
|
<div className="font-semibold text-gray-900 dark:text-gray-100">SABnzbd</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Coming soon
|
Usenet/NZB downloads
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,11 +154,11 @@ export function DownloadClientStep({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{downloadClient === 'qbittorrent' ? 'qBittorrent' : 'Transmission'} URL
|
{downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} URL
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
placeholder={downloadClient === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:9091'}
|
placeholder={downloadClient === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8080/sabnzbd'}
|
||||||
value={downloadClientUrl}
|
value={downloadClientUrl}
|
||||||
onChange={(e) => onUpdate('downloadClientUrl', e.target.value)}
|
onChange={(e) => onUpdate('downloadClientUrl', e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -162,31 +167,53 @@ export function DownloadClientStep({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{downloadClient === 'qbittorrent' && (
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<>
|
||||||
Username
|
<div>
|
||||||
</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<Input
|
Username
|
||||||
type="text"
|
</label>
|
||||||
placeholder="admin"
|
<Input
|
||||||
value={downloadClientUsername}
|
type="text"
|
||||||
onChange={(e) => onUpdate('downloadClientUsername', e.target.value)}
|
placeholder="admin"
|
||||||
autoComplete="username"
|
value={downloadClientUsername}
|
||||||
/>
|
onChange={(e) => onUpdate('downloadClientUsername', e.target.value)}
|
||||||
</div>
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
value={downloadClientPassword}
|
value={downloadClientPassword}
|
||||||
onChange={(e) => onUpdate('downloadClientPassword', e.target.value)}
|
onChange={(e) => onUpdate('downloadClientPassword', e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{downloadClient === 'sabnzbd' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter SABnzbd API key"
|
||||||
|
value={downloadClientPassword}
|
||||||
|
onChange={(e) => onUpdate('downloadClientPassword', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Find this in SABnzbd under Config → General → API Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* SSL Verification Toggle */}
|
{/* SSL Verification Toggle */}
|
||||||
{downloadClientUrl.startsWith('https') && (
|
{downloadClientUrl.startsWith('https') && (
|
||||||
@@ -215,7 +242,7 @@ export function DownloadClientStep({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Remote Path Mapping */}
|
{/* Remote Path Mapping (only for clients that download to filesystem) */}
|
||||||
<div className="mt-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
<div className="mt-4 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">
|
<div className="flex items-start gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -233,7 +260,7 @@ export function DownloadClientStep({
|
|||||||
Enable Remote Path Mapping
|
Enable Remote Path Mapping
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
Use this when {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
||||||
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
||||||
@@ -244,7 +271,7 @@ export function DownloadClientStep({
|
|||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Remote Path (from qBittorrent)
|
Remote Path (from {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'})
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -253,7 +280,7 @@ export function DownloadClientStep({
|
|||||||
onChange={(e) => onUpdate('remotePath', e.target.value)}
|
onChange={(e) => onUpdate('remotePath', e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
The path prefix as reported by qBittorrent
|
The path prefix as reported by {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,7 +307,7 @@ export function DownloadClientStep({
|
|||||||
<Button
|
<Button
|
||||||
onClick={testConnection}
|
onClick={testConnection}
|
||||||
loading={testing}
|
loading={testing}
|
||||||
disabled={!downloadClientUrl || !downloadClientUsername || !downloadClientPassword}
|
disabled={!isFormValid}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
@@ -359,12 +386,12 @@ export function DownloadClientStep({
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||||
{downloadClient === 'qbittorrent' ? 'qBittorrent Setup' : 'Transmission Setup'}
|
{downloadClient === 'qbittorrent' ? 'qBittorrent Setup' : 'SABnzbd Setup'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||||
{downloadClient === 'qbittorrent'
|
{downloadClient === 'qbittorrent'
|
||||||
? 'Make sure Web UI is enabled in qBittorrent settings (Tools → Options → Web UI)'
|
? 'Make sure Web UI is enabled in qBittorrent settings (Tools → Options → Web UI)'
|
||||||
: 'Transmission support is coming soon. Please use qBittorrent for now.'}
|
: 'Make sure SABnzbd is running and the API key is configured (Config → General → API Key)'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface ReviewStepProps {
|
|||||||
|
|
||||||
// Common config
|
// Common config
|
||||||
prowlarrUrl: string;
|
prowlarrUrl: string;
|
||||||
downloadClient: 'qbittorrent' | 'transmission';
|
downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||||
downloadClientUrl: string;
|
downloadClientUrl: string;
|
||||||
downloadDir: string;
|
downloadDir: string;
|
||||||
mediaDir: string;
|
mediaDir: string;
|
||||||
|
|||||||
@@ -98,10 +98,10 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong className="text-gray-900 dark:text-gray-100">
|
<strong className="text-gray-900 dark:text-gray-100">
|
||||||
qBittorrent or Transmission
|
qBittorrent or SABnzbd
|
||||||
</strong>
|
</strong>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Download client for managing torrent downloads (URL and credentials)
|
Download client for torrents (qBittorrent) or Usenet/NZB (SABnzbd)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export class AudibleService {
|
|||||||
|
|
||||||
const $el = $(element);
|
const $el = $(element);
|
||||||
|
|
||||||
// Extract ASIN from data attribute or link
|
// Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs
|
||||||
const asin = $el.find('li').attr('data-asin') ||
|
const asin = $el.find('li').attr('data-asin') ||
|
||||||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||||
|
|
||||||
if (!asin) return;
|
if (!asin) return;
|
||||||
|
|
||||||
@@ -156,8 +156,9 @@ export class AudibleService {
|
|||||||
|
|
||||||
const $el = $(element);
|
const $el = $(element);
|
||||||
|
|
||||||
|
// Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs
|
||||||
const asin = $el.find('li').attr('data-asin') ||
|
const asin = $el.find('li').attr('data-asin') ||
|
||||||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||||
|
|
||||||
if (!asin) return;
|
if (!asin) return;
|
||||||
|
|
||||||
@@ -231,29 +232,42 @@ export class AudibleService {
|
|||||||
|
|
||||||
const audiobooks: AudibleAudiobook[] = [];
|
const audiobooks: AudibleAudiobook[] = [];
|
||||||
|
|
||||||
// Parse search results
|
// Parse search results - Audible uses s-result-item for search pages
|
||||||
$('.productListItem').each((index, element) => {
|
$('.s-result-item, .productListItem').each((index, element) => {
|
||||||
const $el = $(element);
|
const $el = $(element);
|
||||||
|
|
||||||
|
// Extract ASIN from product detail link - handle both /pd/ and /ac/ URLs
|
||||||
const asin = $el.find('li').attr('data-asin') ||
|
const asin = $el.find('li').attr('data-asin') ||
|
||||||
$el.find('a').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||||
|
|
||||||
if (!asin) return;
|
if (!asin) return;
|
||||||
|
|
||||||
const title = $el.find('h3 a').text().trim() ||
|
// Extract title from h2 tag (search results) or h3 (legacy)
|
||||||
|
const title = $el.find('h2').first().text().trim() ||
|
||||||
|
$el.find('h3 a').text().trim() ||
|
||||||
$el.find('.bc-heading a').text().trim();
|
$el.find('.bc-heading a').text().trim();
|
||||||
|
|
||||||
const authorText = $el.find('.authorLabel').text().trim() ||
|
// Extract author from author link
|
||||||
|
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||||
|
$el.find('.authorLabel').text().trim() ||
|
||||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||||
|
|
||||||
const narratorText = $el.find('.narratorLabel').text().trim();
|
// Extract narrator from narrator search link
|
||||||
|
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||||
|
$el.find('.narratorLabel').text().trim();
|
||||||
|
|
||||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||||
|
|
||||||
const runtimeText = $el.find('.runtimeLabel').text().trim();
|
// Extract runtime/duration
|
||||||
|
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||||
|
$el.find('span:contains("Length:")').text().trim();
|
||||||
const durationMinutes = this.parseRuntime(runtimeText);
|
const durationMinutes = this.parseRuntime(runtimeText);
|
||||||
|
|
||||||
const ratingText = $el.find('.ratingsLabel').text().trim();
|
// Extract rating
|
||||||
|
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||||
|
$el.find('.a-icon-star span').first().text().trim();
|
||||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||||
|
|
||||||
audiobooks.push({
|
audiobooks.push({
|
||||||
|
|||||||
@@ -114,8 +114,10 @@ export class ProwlarrService {
|
|||||||
.map((result: ProwlarrSearchResult) => this.transformResult(result))
|
.map((result: ProwlarrSearchResult) => this.transformResult(result))
|
||||||
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
|
.filter((result: TorrentResult | null) => result !== null) as TorrentResult[];
|
||||||
|
|
||||||
// Apply filters
|
// Filter by protocol based on configured download client
|
||||||
let filtered = results;
|
let filtered = await this.filterByProtocol(results);
|
||||||
|
|
||||||
|
// Apply additional filters
|
||||||
|
|
||||||
if (filters?.minSeeders) {
|
if (filters?.minSeeders) {
|
||||||
filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
|
filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
|
||||||
@@ -293,6 +295,58 @@ export class ProwlarrService {
|
|||||||
return allResults;
|
return allResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter results based on configured download client protocol
|
||||||
|
* If qBittorrent is configured: only return torrent results
|
||||||
|
* If SABnzbd is configured: only return NZB results
|
||||||
|
*/
|
||||||
|
private async filterByProtocol(results: TorrentResult[]): Promise<TorrentResult[]> {
|
||||||
|
try {
|
||||||
|
// Get configured download client type
|
||||||
|
const { getConfigService } = await import('../services/config.service');
|
||||||
|
const config = await getConfigService();
|
||||||
|
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
|
||||||
|
|
||||||
|
if (clientType === 'sabnzbd') {
|
||||||
|
// Filter for NZB results only
|
||||||
|
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
|
||||||
|
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
|
||||||
|
return filtered;
|
||||||
|
} else {
|
||||||
|
// Filter for torrent results only (default)
|
||||||
|
const filtered = results.filter(result => !ProwlarrService.isNZBResult(result));
|
||||||
|
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} torrent results for qBittorrent`);
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Prowlarr] Failed to filter by protocol, returning all results:', error);
|
||||||
|
return results; // Fallback: return unfiltered if config fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if a result is an NZB download (Usenet) or torrent (BitTorrent)
|
||||||
|
* Static method for protocol detection
|
||||||
|
*/
|
||||||
|
static isNZBResult(result: TorrentResult): boolean {
|
||||||
|
const url = result.downloadUrl.toLowerCase();
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
if (url.endsWith('.nzb')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check URL path
|
||||||
|
if (url.includes('/nzb/') || url.includes('&t=get')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check categories (3030 is audiobooks, but some indexers use Usenet-specific codes)
|
||||||
|
// Note: This is less reliable, so we prioritize URL patterns
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform Prowlarr result to our TorrentResult format
|
* Transform Prowlarr result to our TorrentResult format
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
/**
|
||||||
|
* Component: SABnzbd Integration Service
|
||||||
|
* Documentation: documentation/phase3/sabnzbd.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
export interface AddNZBOptions {
|
||||||
|
category?: string;
|
||||||
|
priority?: 'low' | 'normal' | 'high' | 'force';
|
||||||
|
paused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NZBInfo {
|
||||||
|
nzbId: string;
|
||||||
|
name: string;
|
||||||
|
size: number; // Bytes
|
||||||
|
progress: number; // 0.0 to 1.0
|
||||||
|
status: NZBStatus;
|
||||||
|
downloadSpeed: number; // Bytes/sec
|
||||||
|
timeLeft: number; // Seconds
|
||||||
|
category: string;
|
||||||
|
downloadPath?: string;
|
||||||
|
completedAt?: Date;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NZBStatus =
|
||||||
|
| 'downloading'
|
||||||
|
| 'queued'
|
||||||
|
| 'paused'
|
||||||
|
| 'extracting'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'repairing';
|
||||||
|
|
||||||
|
export interface QueueItem {
|
||||||
|
nzbId: string;
|
||||||
|
name: string;
|
||||||
|
size: number; // MB (converted to bytes in getNZB)
|
||||||
|
sizeLeft: number; // MB
|
||||||
|
percentage: number; // 0-100
|
||||||
|
status: string; // "Downloading", "Paused", "Queued"
|
||||||
|
timeLeft: string; // "0:15:30" format
|
||||||
|
category: string;
|
||||||
|
priority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
nzbId: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
status: string; // "Completed", "Failed"
|
||||||
|
bytes: string; // Size in bytes (as string)
|
||||||
|
failMessage: string;
|
||||||
|
storage: string; // Download path
|
||||||
|
completedTimestamp: string; // Unix timestamp
|
||||||
|
downloadTime: string; // Seconds (as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SABnzbdConfig {
|
||||||
|
version: string;
|
||||||
|
categories: Array<{
|
||||||
|
name: string;
|
||||||
|
dir: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadProgress {
|
||||||
|
percent: number;
|
||||||
|
bytesDownloaded: number;
|
||||||
|
bytesTotal: number;
|
||||||
|
speed: number;
|
||||||
|
eta: number;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SABnzbdService {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private baseUrl: string;
|
||||||
|
private apiKey: string;
|
||||||
|
private defaultCategory: string;
|
||||||
|
private disableSSLVerify: boolean;
|
||||||
|
private httpsAgent?: https.Agent;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
defaultCategory: string = 'readmeabook',
|
||||||
|
disableSSLVerify: boolean = false
|
||||||
|
) {
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.defaultCategory = defaultCategory;
|
||||||
|
this.disableSSLVerify = disableSSLVerify;
|
||||||
|
|
||||||
|
// Configure HTTPS agent if SSL verification is disabled
|
||||||
|
if (this.disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||||
|
this.httpsAgent = new https.Agent({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: this.baseUrl,
|
||||||
|
timeout: 30000,
|
||||||
|
httpsAgent: this.httpsAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to SABnzbd
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const version = await this.getVersion();
|
||||||
|
return { success: true, version };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
// Enhanced error messages for common issues
|
||||||
|
if (errorMessage.includes('ECONNREFUSED')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Connection refused. Is SABnzbd running and accessible at this URL?',
|
||||||
|
};
|
||||||
|
} else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Connection timed out. Check the URL and network connectivity.',
|
||||||
|
};
|
||||||
|
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
|
||||||
|
};
|
||||||
|
} else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SABnzbd version
|
||||||
|
*/
|
||||||
|
async getVersion(): Promise<string> {
|
||||||
|
const response = await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'version',
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.version) {
|
||||||
|
return response.data.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to get SABnzbd version');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SABnzbd configuration
|
||||||
|
*/
|
||||||
|
async getConfig(): Promise<SABnzbdConfig> {
|
||||||
|
const response = await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'get_config',
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = response.data?.config;
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Failed to get SABnzbd configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: config.version || '',
|
||||||
|
categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({
|
||||||
|
name,
|
||||||
|
dir: details.dir || '',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the default category exists
|
||||||
|
* Creates category if it doesn't exist
|
||||||
|
*/
|
||||||
|
async ensureCategory(downloadPath?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
const categoryExists = config.categories.some(cat => cat.name === this.defaultCategory);
|
||||||
|
|
||||||
|
if (!categoryExists) {
|
||||||
|
console.log(`[SABnzbd] Creating category: ${this.defaultCategory}`);
|
||||||
|
|
||||||
|
// Create category
|
||||||
|
await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'set_config',
|
||||||
|
section: 'categories',
|
||||||
|
keyword: this.defaultCategory,
|
||||||
|
value: downloadPath || '',
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SABnzbd] Category created successfully: ${this.defaultCategory}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[SABnzbd] Category already exists: ${this.defaultCategory}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SABnzbd] Failed to ensure category:', error);
|
||||||
|
// Don't throw - category creation failure shouldn't block downloads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add NZB by URL
|
||||||
|
* Returns the NZB ID
|
||||||
|
*/
|
||||||
|
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
|
||||||
|
const response = await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'addurl',
|
||||||
|
name: url,
|
||||||
|
cat: options?.category || this.defaultCategory,
|
||||||
|
priority: this.mapPriority(options?.priority),
|
||||||
|
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.status === false) {
|
||||||
|
throw new Error(response.data.error || 'Failed to add NZB');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nzbIds = response.data?.nzo_ids;
|
||||||
|
if (!nzbIds || nzbIds.length === 0) {
|
||||||
|
throw new Error('SABnzbd did not return an NZB ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nzbId = nzbIds[0];
|
||||||
|
console.log(`[SABnzbd] Added NZB: ${nzbId}`);
|
||||||
|
|
||||||
|
return nzbId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get NZB info by ID
|
||||||
|
* Checks queue first, then history
|
||||||
|
*/
|
||||||
|
async getNZB(nzbId: string): Promise<NZBInfo | null> {
|
||||||
|
// Check queue first
|
||||||
|
const queue = await this.getQueue();
|
||||||
|
const queueItem = queue.find(item => item.nzbId === nzbId);
|
||||||
|
|
||||||
|
if (queueItem) {
|
||||||
|
return this.mapQueueItemToNZBInfo(queueItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in queue, check history
|
||||||
|
const history = await this.getHistory(100);
|
||||||
|
const historyItem = history.find(item => item.nzbId === nzbId);
|
||||||
|
|
||||||
|
if (historyItem) {
|
||||||
|
return this.mapHistoryItemToNZBInfo(historyItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current download queue
|
||||||
|
*/
|
||||||
|
async getQueue(): Promise<QueueItem[]> {
|
||||||
|
const response = await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'queue',
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = response.data?.queue?.slots || [];
|
||||||
|
return slots.map((slot: any) => ({
|
||||||
|
nzbId: slot.nzo_id,
|
||||||
|
name: slot.filename,
|
||||||
|
size: parseFloat(slot.mb || '0'),
|
||||||
|
sizeLeft: parseFloat(slot.mbleft || '0'),
|
||||||
|
percentage: parseInt(slot.percentage || '0', 10),
|
||||||
|
status: slot.status,
|
||||||
|
timeLeft: slot.timeleft || '0:00:00',
|
||||||
|
category: slot.cat || '',
|
||||||
|
priority: slot.priority || 'Normal',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download history
|
||||||
|
*/
|
||||||
|
async getHistory(limit: number = 100): Promise<HistoryItem[]> {
|
||||||
|
const response = await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'history',
|
||||||
|
limit,
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = response.data?.history?.slots || [];
|
||||||
|
return slots.map((slot: any) => ({
|
||||||
|
nzbId: slot.nzo_id,
|
||||||
|
name: slot.name,
|
||||||
|
category: slot.category || '',
|
||||||
|
status: slot.status,
|
||||||
|
bytes: slot.bytes || '0',
|
||||||
|
failMessage: slot.fail_message || '',
|
||||||
|
storage: slot.storage || '',
|
||||||
|
completedTimestamp: slot.completed || '0',
|
||||||
|
downloadTime: slot.download_time || '0',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause NZB download
|
||||||
|
*/
|
||||||
|
async pauseNZB(nzbId: string): Promise<void> {
|
||||||
|
await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'pause',
|
||||||
|
value: nzbId,
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume NZB download
|
||||||
|
*/
|
||||||
|
async resumeNZB(nzbId: string): Promise<void> {
|
||||||
|
await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'resume',
|
||||||
|
value: nzbId,
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete NZB download
|
||||||
|
*/
|
||||||
|
async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise<void> {
|
||||||
|
await this.client.get('/api', {
|
||||||
|
params: {
|
||||||
|
mode: 'queue',
|
||||||
|
name: 'delete',
|
||||||
|
value: nzbId,
|
||||||
|
del_files: deleteFiles ? '1' : '0',
|
||||||
|
output: 'json',
|
||||||
|
apikey: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download progress from queue item
|
||||||
|
*/
|
||||||
|
getDownloadProgress(queueItem: QueueItem): DownloadProgress {
|
||||||
|
const bytesTotal = queueItem.size * 1024 * 1024; // Convert MB to bytes
|
||||||
|
const bytesLeft = queueItem.sizeLeft * 1024 * 1024;
|
||||||
|
const bytesDownloaded = bytesTotal - bytesLeft;
|
||||||
|
const percent = queueItem.percentage / 100; // Convert 0-100 to 0.0-1.0
|
||||||
|
|
||||||
|
// Parse time left (format: "0:15:30")
|
||||||
|
let etaSeconds = 0;
|
||||||
|
if (queueItem.timeLeft && queueItem.timeLeft !== '0:00:00') {
|
||||||
|
const parts = queueItem.timeLeft.split(':');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
etaSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate speed (bytes/sec)
|
||||||
|
const speed = etaSeconds > 0 ? bytesLeft / etaSeconds : 0;
|
||||||
|
|
||||||
|
// Map SABnzbd status to our state format
|
||||||
|
let state = 'downloading';
|
||||||
|
const statusLower = queueItem.status.toLowerCase();
|
||||||
|
if (statusLower.includes('paused')) {
|
||||||
|
state = 'paused';
|
||||||
|
} else if (statusLower.includes('queued')) {
|
||||||
|
state = 'queued';
|
||||||
|
} else if (statusLower.includes('extracting') || statusLower.includes('unpacking')) {
|
||||||
|
state = 'extracting';
|
||||||
|
} else if (statusLower.includes('repairing') || statusLower.includes('verifying')) {
|
||||||
|
state = 'repairing';
|
||||||
|
} else if (percent >= 1.0) {
|
||||||
|
state = 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
percent: Math.min(percent, 1.0),
|
||||||
|
bytesDownloaded,
|
||||||
|
bytesTotal,
|
||||||
|
speed,
|
||||||
|
eta: etaSeconds,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map queue item to NZBInfo
|
||||||
|
*/
|
||||||
|
private mapQueueItemToNZBInfo(queueItem: QueueItem): NZBInfo {
|
||||||
|
const progress = this.getDownloadProgress(queueItem);
|
||||||
|
return {
|
||||||
|
nzbId: queueItem.nzbId,
|
||||||
|
name: queueItem.name,
|
||||||
|
size: queueItem.size * 1024 * 1024, // MB to bytes
|
||||||
|
progress: progress.percent,
|
||||||
|
status: progress.state as NZBStatus,
|
||||||
|
downloadSpeed: progress.speed,
|
||||||
|
timeLeft: progress.eta,
|
||||||
|
category: queueItem.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map history item to NZBInfo
|
||||||
|
*/
|
||||||
|
private mapHistoryItemToNZBInfo(historyItem: HistoryItem): NZBInfo {
|
||||||
|
const isCompleted = historyItem.status.toLowerCase().includes('completed');
|
||||||
|
const isFailed = historyItem.status.toLowerCase().includes('failed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
nzbId: historyItem.nzbId,
|
||||||
|
name: historyItem.name,
|
||||||
|
size: parseInt(historyItem.bytes || '0', 10),
|
||||||
|
progress: isCompleted ? 1.0 : 0.0,
|
||||||
|
status: isFailed ? 'failed' : isCompleted ? 'completed' : 'downloading',
|
||||||
|
downloadSpeed: 0,
|
||||||
|
timeLeft: 0,
|
||||||
|
category: historyItem.category,
|
||||||
|
downloadPath: historyItem.storage,
|
||||||
|
completedAt: historyItem.completedTimestamp ? new Date(parseInt(historyItem.completedTimestamp) * 1000) : undefined,
|
||||||
|
errorMessage: historyItem.failMessage || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map priority option to SABnzbd priority value
|
||||||
|
*/
|
||||||
|
private mapPriority(priority?: 'low' | 'normal' | 'high' | 'force'): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 'force':
|
||||||
|
return '2'; // Force (highest)
|
||||||
|
case 'high':
|
||||||
|
return '1'; // High
|
||||||
|
case 'low':
|
||||||
|
return '-1'; // Low
|
||||||
|
case 'normal':
|
||||||
|
default:
|
||||||
|
return '0'; // Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance and factory
|
||||||
|
*/
|
||||||
|
let sabnzbdServiceInstance: SABnzbdService | null = null;
|
||||||
|
|
||||||
|
export async function getSABnzbdService(): Promise<SABnzbdService> {
|
||||||
|
if (sabnzbdServiceInstance) {
|
||||||
|
return sabnzbdServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration from database
|
||||||
|
const { getConfigService } = await import('../services/config.service');
|
||||||
|
const config = await getConfigService();
|
||||||
|
|
||||||
|
const url = await config.get('download_client_url');
|
||||||
|
const apiKey = await config.get('download_client_password'); // Reuse password field for API key
|
||||||
|
const category = (await config.get('sabnzbd_category')) || 'readmeabook';
|
||||||
|
const disableSSL = ((await config.get('download_client_disable_ssl_verify')) || 'false') === 'true';
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('SABnzbd URL not configured. Please configure download client settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('SABnzbd API key not configured. Please configure download client settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
sabnzbdServiceInstance = new SABnzbdService(url, apiKey, category, disableSSL);
|
||||||
|
|
||||||
|
// Ensure category exists
|
||||||
|
const downloadDir = await config.get('download_dir');
|
||||||
|
await sabnzbdServiceInstance.ensureCategory(downloadDir || undefined);
|
||||||
|
|
||||||
|
return sabnzbdServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateSABnzbdService(): void {
|
||||||
|
sabnzbdServiceInstance = null;
|
||||||
|
console.log('[SABnzbd] Service singleton invalidated');
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Download Torrent Job Processor
|
* Component: Download Job Processor
|
||||||
* Documentation: documentation/phase3/README.md
|
* Documentation: documentation/phase3/README.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
|
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
|
||||||
import { prisma } from '../db';
|
import { prisma } from '../db';
|
||||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||||
|
import { getSABnzbdService } from '../integrations/sabnzbd.service';
|
||||||
|
import { getConfigService } from '../services/config.service';
|
||||||
import { createJobLogger } from '../utils/job-logger';
|
import { createJobLogger } from '../utils/job-logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process download torrent job
|
* Process download job
|
||||||
* Adds selected torrent to download client and starts monitoring
|
* Routes to appropriate download client based on configuration
|
||||||
|
* Adds selected result to download client and starts monitoring
|
||||||
*/
|
*/
|
||||||
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
|
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
|
||||||
const { requestId, audiobook, torrent, jobId } = payload;
|
const { requestId, audiobook, torrent, jobId } = payload;
|
||||||
@@ -18,7 +21,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
|||||||
const logger = jobId ? createJobLogger(jobId, 'DownloadTorrent') : null;
|
const logger = jobId ? createJobLogger(jobId, 'DownloadTorrent') : null;
|
||||||
|
|
||||||
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
|
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
|
||||||
await logger?.info(`Selected torrent: ${torrent.title}`, {
|
await logger?.info(`Selected result: ${torrent.title}`, {
|
||||||
size: torrent.size,
|
size: torrent.size,
|
||||||
seeders: torrent.seeders,
|
seeders: torrent.seeders,
|
||||||
format: torrent.format,
|
format: torrent.format,
|
||||||
@@ -36,69 +39,135 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get qBittorrent service
|
// Get configured download client type
|
||||||
const qbt = await getQBittorrentService();
|
const config = await getConfigService();
|
||||||
|
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
|
||||||
|
|
||||||
// Add torrent to qBittorrent
|
let downloadClientId: string;
|
||||||
await logger?.info(`Adding torrent to qBittorrent`);
|
let downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||||
|
|
||||||
const torrentHash = await qbt.addTorrent(torrent.downloadUrl, {
|
if (clientType === 'sabnzbd') {
|
||||||
category: 'readmeabook',
|
// Route to SABnzbd
|
||||||
tags: ['audiobook'], // Generic tag for all audiobooks
|
await logger?.info(`Routing to SABnzbd`);
|
||||||
sequentialDownload: true, // Download in order for potential streaming
|
|
||||||
paused: false, // Start immediately
|
|
||||||
});
|
|
||||||
|
|
||||||
await logger?.info(`Torrent added with hash: ${torrentHash}`);
|
const sabnzbd = await getSABnzbdService();
|
||||||
|
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
|
||||||
|
category: 'readmeabook',
|
||||||
|
priority: 'normal',
|
||||||
|
});
|
||||||
|
downloadClient = 'sabnzbd';
|
||||||
|
|
||||||
// Create DownloadHistory record
|
await logger?.info(`NZB added with ID: ${downloadClientId}`);
|
||||||
const downloadHistory = await prisma.downloadHistory.create({
|
|
||||||
data: {
|
// Create DownloadHistory record
|
||||||
|
const downloadHistory = await prisma.downloadHistory.create({
|
||||||
|
data: {
|
||||||
|
requestId,
|
||||||
|
indexerName: torrent.indexer,
|
||||||
|
downloadClient: 'sabnzbd',
|
||||||
|
downloadClientId,
|
||||||
|
torrentName: torrent.title,
|
||||||
|
nzbId: downloadClientId, // Store NZB ID
|
||||||
|
torrentSizeBytes: torrent.size,
|
||||||
|
torrentUrl: torrent.guid, // Source URL
|
||||||
|
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
|
||||||
|
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
|
||||||
|
leechers: 0,
|
||||||
|
downloadStatus: 'downloading',
|
||||||
|
selected: true,
|
||||||
|
startedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logger?.info(`Created download history record: ${downloadHistory.id}`);
|
||||||
|
|
||||||
|
// Trigger monitor download job with initial delay
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
await jobQueue.addMonitorJob(
|
||||||
requestId,
|
requestId,
|
||||||
indexerName: torrent.indexer,
|
downloadHistory.id,
|
||||||
downloadClient: 'qbittorrent',
|
downloadClientId,
|
||||||
downloadClientId: torrentHash,
|
'sabnzbd',
|
||||||
torrentName: torrent.title,
|
3 // Wait 3 seconds before first check
|
||||||
torrentHash: torrent.infoHash || torrentHash,
|
);
|
||||||
torrentSizeBytes: torrent.size,
|
|
||||||
torrentUrl: torrent.guid, // Source URL for the torrent page
|
|
||||||
magnetLink: torrent.downloadUrl, // Download URL (magnet or .torrent)
|
|
||||||
seeders: torrent.seeders,
|
|
||||||
leechers: torrent.leechers || 0,
|
|
||||||
downloadStatus: 'downloading',
|
|
||||||
selected: true,
|
|
||||||
startedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await logger?.info(`Created download history record: ${downloadHistory.id}`);
|
await logger?.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
|
||||||
|
|
||||||
// Trigger monitor download job with initial delay
|
return {
|
||||||
// qBittorrent needs a few seconds to process the torrent before it's available via API
|
success: true,
|
||||||
const jobQueue = getJobQueueService();
|
message: 'NZB added to SABnzbd and monitoring started',
|
||||||
await jobQueue.addMonitorJob(
|
requestId,
|
||||||
requestId,
|
downloadHistoryId: downloadHistory.id,
|
||||||
downloadHistory.id,
|
nzbId: downloadClientId,
|
||||||
torrentHash,
|
torrent: {
|
||||||
'qbittorrent',
|
title: torrent.title,
|
||||||
3 // Wait 3 seconds before first check to avoid race condition
|
size: torrent.size,
|
||||||
);
|
format: torrent.format,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Route to qBittorrent (default)
|
||||||
|
await logger?.info(`Routing to qBittorrent`);
|
||||||
|
|
||||||
await logger?.info(`Started monitoring job for request ${requestId} (3s initial delay)`);
|
const qbt = await getQBittorrentService();
|
||||||
|
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
|
||||||
|
category: 'readmeabook',
|
||||||
|
tags: ['audiobook'],
|
||||||
|
sequentialDownload: true,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
downloadClient = 'qbittorrent';
|
||||||
|
|
||||||
return {
|
await logger?.info(`Torrent added with hash: ${downloadClientId}`);
|
||||||
success: true,
|
|
||||||
message: 'Torrent added to download client and monitoring started',
|
// Create DownloadHistory record
|
||||||
requestId,
|
const downloadHistory = await prisma.downloadHistory.create({
|
||||||
downloadHistoryId: downloadHistory.id,
|
data: {
|
||||||
torrentHash,
|
requestId,
|
||||||
torrent: {
|
indexerName: torrent.indexer,
|
||||||
title: torrent.title,
|
downloadClient: 'qbittorrent',
|
||||||
size: torrent.size,
|
downloadClientId,
|
||||||
seeders: torrent.seeders,
|
torrentName: torrent.title,
|
||||||
format: torrent.format,
|
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
|
||||||
},
|
torrentSizeBytes: torrent.size,
|
||||||
};
|
torrentUrl: torrent.guid,
|
||||||
|
magnetLink: torrent.downloadUrl,
|
||||||
|
seeders: torrent.seeders,
|
||||||
|
leechers: torrent.leechers || 0,
|
||||||
|
downloadStatus: 'downloading',
|
||||||
|
selected: true,
|
||||||
|
startedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logger?.info(`Created download history record: ${downloadHistory.id}`);
|
||||||
|
|
||||||
|
// Trigger monitor download job with initial delay
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
await jobQueue.addMonitorJob(
|
||||||
|
requestId,
|
||||||
|
downloadHistory.id,
|
||||||
|
downloadClientId,
|
||||||
|
'qbittorrent',
|
||||||
|
3 // Wait 3 seconds before first check to avoid race condition
|
||||||
|
);
|
||||||
|
|
||||||
|
await logger?.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Torrent added to qBittorrent and monitoring started',
|
||||||
|
requestId,
|
||||||
|
downloadHistoryId: downloadHistory.id,
|
||||||
|
torrentHash: downloadClientId,
|
||||||
|
torrent: {
|
||||||
|
title: torrent.title,
|
||||||
|
size: torrent.size,
|
||||||
|
seeders: torrent.seeders,
|
||||||
|
format: torrent.format,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
|
||||||
@@ -107,7 +176,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
|||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
data: {
|
data: {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
errorMessage: error instanceof Error ? error.message : 'Failed to add torrent to download client',
|
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,17 +58,52 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
|||||||
const logger = jobId ? createJobLogger(jobId, 'MonitorDownload') : null;
|
const logger = jobId ? createJobLogger(jobId, 'MonitorDownload') : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get download client service (currently only qBittorrent supported)
|
let progress: any;
|
||||||
if (downloadClient !== 'qbittorrent') {
|
let downloadPath: string | undefined;
|
||||||
throw new Error(`Download client ${downloadClient} not yet supported`);
|
|
||||||
|
if (downloadClient === 'qbittorrent') {
|
||||||
|
// qBittorrent flow
|
||||||
|
const qbt = await getQBittorrentService();
|
||||||
|
|
||||||
|
// Get torrent status with retry logic (handles race condition)
|
||||||
|
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
|
||||||
|
progress = qbt.getDownloadProgress(torrent);
|
||||||
|
|
||||||
|
// Store download path for later use
|
||||||
|
downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
|
||||||
|
} else if (downloadClient === 'sabnzbd') {
|
||||||
|
// SABnzbd flow
|
||||||
|
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||||
|
const sabnzbd = await getSABnzbdService();
|
||||||
|
|
||||||
|
// Get NZB status
|
||||||
|
const nzbInfo = await sabnzbd.getNZB(downloadClientId);
|
||||||
|
|
||||||
|
if (!nzbInfo) {
|
||||||
|
throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert NZBInfo to progress format
|
||||||
|
progress = {
|
||||||
|
percent: nzbInfo.progress,
|
||||||
|
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
|
||||||
|
bytesTotal: nzbInfo.size,
|
||||||
|
speed: nzbInfo.downloadSpeed,
|
||||||
|
eta: nzbInfo.timeLeft,
|
||||||
|
state: nzbInfo.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store download path if available (only set after completion)
|
||||||
|
downloadPath = nzbInfo.downloadPath;
|
||||||
|
|
||||||
|
await logger?.info(`SABnzbd status: ${nzbInfo.status}`, {
|
||||||
|
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
|
||||||
|
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(`Download client ${downloadClient} not supported`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qbt = await getQBittorrentService();
|
|
||||||
|
|
||||||
// Get torrent status with retry logic (handles race condition)
|
|
||||||
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
|
|
||||||
const progress = qbt.getDownloadProgress(torrent);
|
|
||||||
|
|
||||||
// Update request progress
|
// Update request progress
|
||||||
await prisma.request.update({
|
await prisma.request.update({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
@@ -90,15 +125,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
|||||||
if (progress.state === 'completed') {
|
if (progress.state === 'completed') {
|
||||||
await logger?.info(`Download completed for request ${requestId}`);
|
await logger?.info(`Download completed for request ${requestId}`);
|
||||||
|
|
||||||
// Get torrent files to find download path
|
// Ensure we have a download path
|
||||||
const files = await qbt.getFiles(downloadClientId);
|
if (!downloadPath) {
|
||||||
|
throw new Error('Download path not available from download client');
|
||||||
// Determine actual content path for file organization
|
}
|
||||||
// Priority 1: Use content_path if provided by qBittorrent (most reliable)
|
|
||||||
// Priority 2: Construct path using path.join() for proper normalization
|
|
||||||
const qbPath = torrent.content_path
|
|
||||||
? torrent.content_path
|
|
||||||
: path.join(torrent.save_path, torrent.name);
|
|
||||||
|
|
||||||
// Load path mapping configuration
|
// Load path mapping configuration
|
||||||
const configService = getConfigService();
|
const configService = getConfigService();
|
||||||
@@ -109,19 +139,16 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply remote-to-local path transformation if enabled
|
// Apply remote-to-local path transformation if enabled
|
||||||
const organizePath = PathMapper.transform(qbPath, {
|
const organizePath = PathMapper.transform(downloadPath, {
|
||||||
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
|
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
|
||||||
remotePath: pathMappingConfig.download_client_remote_path || '',
|
remotePath: pathMappingConfig.download_client_remote_path || '',
|
||||||
localPath: pathMappingConfig.download_client_local_path || '',
|
localPath: pathMappingConfig.download_client_local_path || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
await logger?.info(`Download completed`, {
|
await logger?.info(`Download completed`, {
|
||||||
filesCount: files.length,
|
downloadClient,
|
||||||
torrentName: torrent.name,
|
downloadPath,
|
||||||
savePath: torrent.save_path,
|
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
|
||||||
contentPath: torrent.content_path || '(not provided)',
|
|
||||||
qbittorrentPath: qbPath,
|
|
||||||
organizePath: organizePath !== qbPath ? `${organizePath} (mapped)` : organizePath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update download history to completed
|
// Update download history to completed
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export interface MonitorDownloadPayload extends JobPayload {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
downloadHistoryId: string;
|
downloadHistoryId: string;
|
||||||
downloadClientId: string;
|
downloadClientId: string;
|
||||||
downloadClient: 'qbittorrent' | 'transmission';
|
downloadClient: 'qbittorrent' | 'sabnzbd';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizeFilesPayload extends JobPayload {
|
export interface OrganizeFilesPayload extends JobPayload {
|
||||||
@@ -479,7 +479,7 @@ export class JobQueueService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
downloadHistoryId: string,
|
downloadHistoryId: string,
|
||||||
downloadClientId: string,
|
downloadClientId: string,
|
||||||
downloadClient: 'qbittorrent' | 'transmission',
|
downloadClient: 'qbittorrent' | 'sabnzbd',
|
||||||
delaySeconds: number = 0
|
delaySeconds: number = 0
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
|
|||||||
@@ -331,36 +331,48 @@ export class RankingAlgorithm {
|
|||||||
|
|
||||||
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
|
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
|
||||||
let titleScore = 0;
|
let titleScore = 0;
|
||||||
if (torrentTitle.includes(requestTitle)) {
|
|
||||||
// Found the title, but is it the complete title or part of a longer one?
|
|
||||||
const titleIndex = torrentTitle.indexOf(requestTitle);
|
|
||||||
const beforeTitle = torrentTitle.substring(0, titleIndex);
|
|
||||||
const afterTitle = torrentTitle.substring(titleIndex + requestTitle.length);
|
|
||||||
|
|
||||||
// Extract significant words BEFORE the matched title
|
// Try matching with full title first, then fall back to required title (without parentheses)
|
||||||
const beforeWords = extractWords(beforeTitle, stopWords);
|
const titlesToTry = [requestTitle];
|
||||||
|
if (requiredTitle !== requestTitle) {
|
||||||
|
titlesToTry.push(requiredTitle); // Add required-only version if different
|
||||||
|
}
|
||||||
|
|
||||||
// Title is complete if:
|
let bestMatch = false;
|
||||||
// 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl")
|
for (const titleToMatch of titlesToTry) {
|
||||||
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
|
if (torrentTitle.includes(titleToMatch)) {
|
||||||
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
|
// Found the title, but is it the complete title or part of a longer one?
|
||||||
const hasNoWordsPrefix = beforeWords.length === 0;
|
const titleIndex = torrentTitle.indexOf(titleToMatch);
|
||||||
const hasMetadataSuffix = afterTitle === '' ||
|
const beforeTitle = torrentTitle.substring(0, titleIndex);
|
||||||
metadataMarkers.some(marker => afterTitle.startsWith(marker));
|
const afterTitle = torrentTitle.substring(titleIndex + titleToMatch.length);
|
||||||
|
|
||||||
const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix;
|
// Extract significant words BEFORE the matched title
|
||||||
|
const beforeWords = extractWords(beforeTitle, stopWords);
|
||||||
|
|
||||||
if (isCompleteTitle) {
|
// Title is complete if:
|
||||||
// Complete title match → full points
|
// 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl")
|
||||||
titleScore = 35;
|
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
|
||||||
} else {
|
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
|
||||||
// Title has prefix words OR continues with more words
|
const hasNoWordsPrefix = beforeWords.length === 0;
|
||||||
// This is likely a different book in a series → use fuzzy similarity
|
const hasMetadataSuffix = afterTitle === '' ||
|
||||||
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
|
metadataMarkers.some(marker => afterTitle.startsWith(marker));
|
||||||
|
|
||||||
|
const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix;
|
||||||
|
|
||||||
|
if (isCompleteTitle) {
|
||||||
|
// Complete title match → full points
|
||||||
|
titleScore = 35;
|
||||||
|
bestMatch = true;
|
||||||
|
break; // Found a good match, stop trying
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// No substring match at all → use fuzzy similarity
|
|
||||||
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
|
if (!bestMatch) {
|
||||||
|
// No complete match found, use fuzzy similarity as fallback
|
||||||
|
// Try against full title first, then required title
|
||||||
|
const fuzzyScores = titlesToTry.map(title => compareTwoStrings(title, torrentTitle));
|
||||||
|
titleScore = Math.max(...fuzzyScores) * 35;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
|
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
|
||||||
|
|||||||
Reference in New Issue
Block a user