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