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:
kikootwo
2026-01-07 02:40:11 -05:00
parent 23881eb670
commit e008744df1
21 changed files with 2378 additions and 254 deletions
+5 -3
View File
@@ -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
+22
View File
@@ -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
+4 -1
View File
@@ -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
+203
View File
@@ -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&section=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
+4 -1
View File
@@ -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")
}
+74 -42
View File
@@ -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) {
+58 -15
View File
@@ -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(
+1 -1
View File
@@ -73,7 +73,7 @@ interface SetupState {
prowlarrUrl: string;
prowlarrApiKey: string;
prowlarrIndexers: SelectedIndexer[];
downloadClient: 'qbittorrent' | 'transmission';
downloadClient: 'qbittorrent' | 'sabnzbd';
downloadClientUrl: string;
downloadClientUsername: string;
downloadClientPassword: string;
+67 -40
View File
@@ -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>
+1 -1
View File
@@ -26,7 +26,7 @@ interface ReviewStepProps {
// Common config
prowlarrUrl: string;
downloadClient: 'qbittorrent' | 'transmission';
downloadClient: 'qbittorrent' | 'sabnzbd';
downloadClientUrl: string;
downloadDir: string;
mediaDir: string;
+2 -2
View File
@@ -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>
+25 -11
View File
@@ -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({
+56 -2
View File
@@ -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
*/
+527
View File
@@ -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');
}
+128 -59
View File
@@ -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
+2 -2
View File
@@ -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(
+37 -25
View File
@@ -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) ==========