diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md
index b325ed2..725d42d 100644
--- a/documentation/TABLEOFCONTENTS.md
+++ b/documentation/TABLEOFCONTENTS.md
@@ -47,6 +47,7 @@
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
- **Search via Prowlarr (torrents + NZBs)** → [phase3/prowlarr.md](phase3/prowlarr.md)
- **Torrent ranking/selection** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md)
+- **Multi-download-client support (qBittorrent + SABnzbd)** → [phase3/download-clients.md](phase3/download-clients.md)
- **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)
@@ -102,8 +103,10 @@
## Feature-Specific Lookups
**"How do I add a new audiobook?"** → [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation)
+**"How do I configure multiple download clients?"** → [phase3/download-clients.md](phase3/download-clients.md)
**"How do torrent downloads work?"** → [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md)
**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [backend/services/jobs.md](backend/services/jobs.md)
+**"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
**"How does e-book sidecar work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md)
diff --git a/documentation/phase3/download-clients.md b/documentation/phase3/download-clients.md
new file mode 100644
index 0000000..6bea13c
--- /dev/null
+++ b/documentation/phase3/download-clients.md
@@ -0,0 +1,153 @@
+# Multi-Download-Client Support
+
+**Status:** ✅ Implemented | Simultaneous qBittorrent + SABnzbd support
+
+## Overview
+Users can configure both qBittorrent (torrents) and SABnzbd (Usenet) simultaneously. System selects best release across all indexer types regardless of protocol.
+
+**Constraint:** 1 client per type (torrent/usenet) for now; architecture supports future expansion.
+
+## Key Details
+
+### Configuration Structure
+**Key:** `download_clients` (JSON array, replaces legacy flat keys)
+
+```typescript
+interface DownloadClientConfig {
+ id: string; // UUID
+ type: 'qbittorrent' | 'sabnzbd';
+ name: string; // User-friendly name
+ enabled: boolean;
+ url: string;
+ username?: string; // qBittorrent only
+ password: string; // Password or API key
+ disableSSLVerify: boolean;
+ remotePathMappingEnabled: boolean;
+ remotePath?: string;
+ localPath?: string;
+ category?: string; // Default: 'readmeabook'
+}
+```
+
+### Download Client Manager Service
+**File:** `src/lib/services/download-client-manager.service.ts`
+
+**Methods:**
+- `getClientForProtocol(protocol: 'torrent' | 'usenet')` - Get client by protocol
+- `hasClientForProtocol(protocol)` - Check if protocol configured
+- `getAllClients()` - List all configs
+- `testConnection(config)` - Test specific config
+- `invalidate()` - Clear cache on config change
+- `getClientServiceForProtocol(protocol)` - Get instantiated service
+
+**Singleton Pattern:** Uses caching with invalidation on config changes.
+
+### Protocol Filtering
+**File:** `src/lib/integrations/prowlarr.service.ts:379`
+
+**Logic:**
+- Both clients configured: Return all results (mixed torrent + NZB)
+- Only torrent client: Filter for torrent results only
+- Only usenet client: Filter for NZB results only
+- No clients: Return empty
+
+### Download Routing
+**File:** `src/lib/processors/download-torrent.processor.ts:44`
+
+**Logic:**
+1. Detect protocol from result (`ProwlarrService.isNZBResult()`)
+2. Get appropriate client via manager (`getClientForProtocol()`)
+3. Route to qBittorrent or SABnzbd service
+4. Create download history record
+
+### Migration
+**Auto-migration** from legacy single-client config to new JSON array format on first access:
+- Reads legacy keys: `download_client_type`, `download_client_url`, etc.
+- Converts to single-client array
+- Saves as `download_clients` JSON
+- Legacy keys remain for backward compatibility (cleaned up on migration)
+
+## API Routes
+
+**GET /api/admin/settings/download-clients** - List all configured clients
+**POST /api/admin/settings/download-clients** - Add new client
+**PUT /api/admin/settings/download-clients/[id]** - Update client by ID
+**DELETE /api/admin/settings/download-clients/[id]** - Delete client by ID
+**POST /api/admin/settings/download-clients/test** - Test connection
+
+**Validation:**
+- Only 1 client per type allowed (enforced on add)
+- Test connection required before save
+- Password masking in responses (`********`)
+
+## UI Components
+
+**Directory:** `src/components/admin/download-clients/`
+
+| Component | Purpose |
+|-----------|---------|
+| `DownloadClientManagement.tsx` | Container with add buttons + configured cards |
+| `DownloadClientCard.tsx` | Card with name, type badge, edit/delete |
+| `DownloadClientModal.tsx` | Add/edit modal with type-specific fields |
+
+**UI Flow:**
+1. **Add Client Section:** Two cards (qBittorrent, SABnzbd) with "Add" button or "Already configured" badge
+2. **Configured Clients:** Grid of cards showing name, type, URL, status
+3. **Modal:** Type-specific fields, SSL toggle, path mapping, test connection
+
+## Integration Points
+
+### Settings Tab
+**File:** `src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx`
+
+Replaced legacy form with ` `
+
+### Wizard Step
+**File:** `src/app/setup/steps/DownloadClientStep.tsx`
+
+Replaced single-client form with ` `
+
+**Validation:** At least 1 enabled client required to proceed
+
+### Setup Complete API
+**File:** `src/app/api/setup/complete/route.ts`
+
+Accepts both legacy single client and new array format:
+- Legacy: Converts to array on save
+- New: Saves directly as `download_clients` JSON
+
+## Edge Cases
+
+**Single client:** Works exactly as before (protocol filtering active)
+**No clients:** Wizard requires one; settings shows warning
+**Client disabled:** Results for that protocol filtered out
+**Connection failure:** Per-download error handling (existing)
+**Mixed results:** Best release selected regardless of protocol when both clients configured
+
+## Verification Steps
+
+1. **Migration:** Existing single-client users see config as card after update
+2. **Single client:** Configure only qBittorrent → only torrent results shown
+3. **Both clients:** Configure both → mixed results, best selected across protocols
+4. **Download routing:** Torrent result → qBittorrent; NZB result → SABnzbd
+5. **Wizard:** Must add at least one client to proceed
+6. **Settings:** Can add/edit/delete/test clients; changes persist
+
+## Critical Files
+
+| File | Changes |
+|------|---------|
+| `src/lib/services/download-client-manager.service.ts` | **NEW** - Core multi-client service |
+| `src/lib/integrations/prowlarr.service.ts:379` | Protocol filtering logic (both clients = all results) |
+| `src/lib/processors/download-torrent.processor.ts:44` | Download routing (detect protocol → route) |
+| `src/app/api/admin/settings/download-clients/*` | **NEW** - CRUD API routes |
+| `src/components/admin/download-clients/*` | **NEW** - UI components (card-based) |
+| `src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx` | Replaced with management component |
+| `src/app/setup/steps/DownloadClientStep.tsx` | Replaced with management component |
+| `src/app/api/setup/complete/route.ts` | Save as JSON array, support legacy |
+
+## Related
+
+- [qBittorrent Integration](./qbittorrent.md) - Torrent client details
+- [SABnzbd Integration](./sabnzbd.md) - Usenet client details
+- [Prowlarr Integration](./prowlarr.md) - Indexer search
diff --git a/src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx b/src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx
index 265b6e3..658e371 100644
--- a/src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx
+++ b/src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx
@@ -6,9 +6,7 @@
'use client';
import React from 'react';
-import { Input } from '@/components/ui/Input';
-import { Button } from '@/components/ui/Button';
-import { useDownloadSettings } from './useDownloadSettings';
+import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
import type { DownloadClientSettings } from '../../lib/types';
interface DownloadTabProps {
@@ -18,218 +16,29 @@ interface DownloadTabProps {
}
export function DownloadTab({ downloadClient, onChange, onValidationChange }: DownloadTabProps) {
- const { testing, testResult, updateField, handleTypeChange, testConnection } = useDownloadSettings({
- downloadClient,
- onChange,
- onValidationChange,
- });
+ // Store callback in ref to avoid re-running effect when callback reference changes
+ const onValidationChangeRef = React.useRef(onValidationChange);
+ onValidationChangeRef.current = onValidationChange;
+
+ // Validation is handled by the DownloadClientManagement component
+ // At least one enabled client is required
+ React.useEffect(() => {
+ // Always valid in settings mode - validation handled by individual save operations
+ onValidationChangeRef.current(true);
+ }, []); // Empty deps - only run once on mount
return (
-
+
- Download Client
+ Download Clients
- Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.
+ Configure one or both download clients to enable automatic downloads. qBittorrent handles torrents, while SABnzbd handles Usenet/NZB downloads.
-
-
- Client Type
-
- handleTypeChange(e.target.value)}
- 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"
- >
- qBittorrent
- SABnzbd
-
-
-
-
-
- Server URL
-
- updateField('url', e.target.value)}
- placeholder="http://localhost:8080"
- />
-
-
- {/* qBittorrent: Username + Password */}
- {downloadClient.type === 'qbittorrent' && (
- <>
-
-
- Username
-
- updateField('username', e.target.value)}
- placeholder="admin"
- />
-
-
-
-
- Password
-
- updateField('password', e.target.value)}
- placeholder="Enter password"
- />
-
- >
- )}
-
- {/* SABnzbd: API Key only */}
- {downloadClient.type === 'sabnzbd' && (
-
-
- API Key
-
-
updateField('password', e.target.value)}
- placeholder="Enter SABnzbd API key"
- />
-
- Find this in SABnzbd under Config → General → API Key
-
-
- )}
-
- {/* SSL Verification Toggle */}
- {downloadClient.url.startsWith('https') && (
-
-
-
updateField('disableSSLVerify', e.target.checked)}
- className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
- />
-
-
- Disable SSL Certificate Verification
-
-
- Enable this if you're using a self-signed certificate or getting SSL errors.
- ⚠️ Only use on trusted private networks.
-
-
-
-
- )}
-
- {/* Remote Path Mapping */}
-
-
-
updateField('remotePathMappingEnabled', e.target.checked)}
- className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
- />
-
-
- Enable Remote Path Mapping
-
-
- Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
-
-
- Example: Remote /remote/mnt/d/done → Local /downloads
-
-
- {/* Warning for existing downloads */}
- {downloadClient.remotePathMappingEnabled && (
-
-
- ⚠️ Note: Path mapping only affects new downloads. In-progress downloads will continue using their original paths.
-
-
- )}
-
- {/* Conditional Fields */}
- {downloadClient.remotePathMappingEnabled && (
-
-
-
- Remote Path (from qBittorrent)
-
-
updateField('remotePath', e.target.value)}
- />
-
- The path prefix as reported by qBittorrent
-
-
-
-
-
- Local Path (for ReadMeABook)
-
-
updateField('localPath', e.target.value)}
- />
-
- The actual path where files are accessible
-
-
-
- )}
-
-
-
-
-
-
- Test Connection
-
- {testResult && (
-
- {testResult.message}
-
- )}
-
+
);
}
diff --git a/src/app/api/admin/settings/download-clients/[id]/route.ts b/src/app/api/admin/settings/download-clients/[id]/route.ts
new file mode 100644
index 0000000..8b90d03
--- /dev/null
+++ b/src/app/api/admin/settings/download-clients/[id]/route.ts
@@ -0,0 +1,175 @@
+/**
+ * Component: Admin Download Client by ID API
+ * Documentation: documentation/phase3/download-clients.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { getConfigService } from '@/lib/services/config.service';
+import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
+import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.ID');
+
+/**
+ * PUT - Update download client by ID
+ */
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ // Await params in Next.js 15+
+ const { id } = await params;
+ const body = await request.json();
+ const {
+ name,
+ enabled,
+ url,
+ username,
+ password,
+ disableSSLVerify,
+ remotePathMappingEnabled,
+ remotePath,
+ localPath,
+ category,
+ } = body;
+
+ const config = await getConfigService();
+ const manager = getDownloadClientManager(config);
+ const clients = await manager.getAllClients();
+
+ const clientIndex = clients.findIndex(c => c.id === id);
+ if (clientIndex === -1) {
+ return NextResponse.json(
+ { error: 'Download client not found' },
+ { status: 404 }
+ );
+ }
+
+ const existingClient = clients[clientIndex];
+
+ // Build updated client (preserve fields not in request)
+ const updatedClient: DownloadClientConfig = {
+ ...existingClient,
+ name: name !== undefined ? name : existingClient.name,
+ enabled: enabled !== undefined ? enabled : existingClient.enabled,
+ url: url !== undefined ? url : existingClient.url,
+ username: username !== undefined ? username : existingClient.username,
+ password: password === '********' ? existingClient.password : (password || existingClient.password),
+ disableSSLVerify: disableSSLVerify !== undefined ? disableSSLVerify : existingClient.disableSSLVerify,
+ remotePathMappingEnabled: remotePathMappingEnabled !== undefined ? remotePathMappingEnabled : existingClient.remotePathMappingEnabled,
+ remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath,
+ localPath: localPath !== undefined ? localPath : existingClient.localPath,
+ category: category !== undefined ? category : existingClient.category,
+ };
+
+ // Validate path mapping if enabled
+ if (updatedClient.remotePathMappingEnabled) {
+ if (!updatedClient.remotePath || !updatedClient.localPath) {
+ return NextResponse.json(
+ { error: 'Remote path and local path are required when path mapping is enabled' },
+ { status: 400 }
+ );
+ }
+ }
+
+ // Test connection if credentials/URL changed
+ if (
+ url !== undefined ||
+ username !== undefined ||
+ (password && password !== '********') ||
+ disableSSLVerify !== undefined
+ ) {
+ const testResult = await manager.testConnection(updatedClient);
+ if (!testResult.success) {
+ return NextResponse.json(
+ { error: `Connection test failed: ${testResult.message}` },
+ { status: 400 }
+ );
+ }
+ }
+
+ // Update clients array
+ clients[clientIndex] = updatedClient;
+ await config.setMany([
+ { key: 'download_clients', value: JSON.stringify(clients) },
+ ]);
+
+ // Invalidate cache
+ invalidateDownloadClientManager();
+
+ logger.info('Download client updated', { id, type: updatedClient.type, name: updatedClient.name });
+
+ return NextResponse.json({
+ message: 'Download client updated successfully',
+ client: {
+ ...updatedClient,
+ password: '********',
+ },
+ });
+ } catch (error) {
+ logger.error('Failed to update download client', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json(
+ { error: 'Failed to update download client' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
+
+/**
+ * DELETE - Remove download client by ID
+ */
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ // Await params in Next.js 15+
+ const { id } = await params;
+
+ const config = await getConfigService();
+ const manager = getDownloadClientManager(config);
+ const clients = await manager.getAllClients();
+
+ const clientIndex = clients.findIndex(c => c.id === id);
+ if (clientIndex === -1) {
+ return NextResponse.json(
+ { error: 'Download client not found' },
+ { status: 404 }
+ );
+ }
+
+ const deletedClient = clients[clientIndex];
+
+ // Remove client from array
+ const updatedClients = clients.filter(c => c.id !== id);
+ await config.setMany([
+ { key: 'download_clients', value: JSON.stringify(updatedClients) },
+ ]);
+
+ // Invalidate cache
+ invalidateDownloadClientManager();
+
+ logger.info('Download client deleted', { id, type: deletedClient.type, name: deletedClient.name });
+
+ return NextResponse.json({
+ message: 'Download client deleted successfully',
+ });
+ } catch (error) {
+ logger.error('Failed to delete download client', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json(
+ { error: 'Failed to delete download client' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/settings/download-clients/route.ts b/src/app/api/admin/settings/download-clients/route.ts
new file mode 100644
index 0000000..cebc747
--- /dev/null
+++ b/src/app/api/admin/settings/download-clients/route.ts
@@ -0,0 +1,165 @@
+/**
+ * Component: Admin Download Clients Management API
+ * Documentation: documentation/phase3/download-clients.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { getConfigService } from '@/lib/services/config.service';
+import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
+import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
+import { RMABLogger } from '@/lib/utils/logger';
+import { randomUUID } from 'crypto';
+
+const logger = RMABLogger.create('API.Admin.Settings.DownloadClients');
+
+/**
+ * GET - List all configured download clients
+ */
+export async function GET(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ const config = await getConfigService();
+ const manager = getDownloadClientManager(config);
+ const clients = await manager.getAllClients();
+
+ // Mask passwords in response
+ const maskedClients = clients.map(c => ({
+ ...c,
+ password: c.password ? '********' : '',
+ }));
+
+ return NextResponse.json({ clients: maskedClients });
+ } catch (error) {
+ logger.error('Failed to get download clients', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json(
+ { error: 'Failed to retrieve download clients' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
+
+/**
+ * POST - Add new download client
+ */
+export async function POST(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ const body = await request.json();
+ const {
+ type,
+ name,
+ url,
+ username,
+ password,
+ disableSSLVerify,
+ remotePathMappingEnabled,
+ remotePath,
+ localPath,
+ category,
+ } = body;
+
+ // Validate type
+ if (type !== 'qbittorrent' && type !== 'sabnzbd') {
+ return NextResponse.json(
+ { error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
+ { status: 400 }
+ );
+ }
+
+ // Validate required fields
+ if (!name || !url || !password) {
+ return NextResponse.json(
+ { error: 'Name, URL, and password/API key are required' },
+ { status: 400 }
+ );
+ }
+
+ // qBittorrent requires username
+ if (type === 'qbittorrent' && !username) {
+ return NextResponse.json(
+ { error: 'Username is required for qBittorrent' },
+ { status: 400 }
+ );
+ }
+
+ // Validate path mapping if enabled
+ if (remotePathMappingEnabled) {
+ if (!remotePath || !localPath) {
+ return NextResponse.json(
+ { error: 'Remote path and local path are required when path mapping is enabled' },
+ { status: 400 }
+ );
+ }
+ }
+
+ // Check for duplicate type (only one client per type for now)
+ const config = await getConfigService();
+ const manager = getDownloadClientManager(config);
+ const existingClients = await manager.getAllClients();
+
+ const duplicateType = existingClients.find(c => c.type === type && c.enabled);
+ if (duplicateType) {
+ return NextResponse.json(
+ { error: `A ${type} client is already configured. Please disable or remove it first.` },
+ { status: 400 }
+ );
+ }
+
+ // Create new client config
+ const newClient: DownloadClientConfig = {
+ id: randomUUID(),
+ type,
+ name,
+ enabled: true,
+ url,
+ username: username || undefined,
+ password,
+ disableSSLVerify: disableSSLVerify || false,
+ remotePathMappingEnabled: remotePathMappingEnabled || false,
+ remotePath: remotePath || undefined,
+ localPath: localPath || undefined,
+ category: category || 'readmeabook',
+ };
+
+ // Test connection before saving
+ const testResult = await manager.testConnection(newClient);
+ if (!testResult.success) {
+ return NextResponse.json(
+ { error: `Connection test failed: ${testResult.message}` },
+ { status: 400 }
+ );
+ }
+
+ // Save updated clients array
+ const updatedClients = [...existingClients, newClient];
+ await config.setMany([
+ { key: 'download_clients', value: JSON.stringify(updatedClients) },
+ ]);
+
+ // Invalidate cache
+ invalidateDownloadClientManager();
+
+ logger.info('Download client added', { id: newClient.id, type, name });
+
+ return NextResponse.json({
+ message: 'Download client added successfully',
+ client: {
+ ...newClient,
+ password: '********',
+ },
+ });
+ } catch (error) {
+ logger.error('Failed to add download client', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json(
+ { error: 'Failed to add download client' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/settings/download-clients/test/route.ts b/src/app/api/admin/settings/download-clients/test/route.ts
new file mode 100644
index 0000000..fa0a701
--- /dev/null
+++ b/src/app/api/admin/settings/download-clients/test/route.ts
@@ -0,0 +1,128 @@
+/**
+ * Component: Test Download Client Connection API
+ * Documentation: documentation/phase3/download-clients.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { getConfigService } from '@/lib/services/config.service';
+import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
+import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Test');
+
+/**
+ * POST - Test download client connection
+ */
+export async function POST(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ const body = await request.json();
+ const {
+ clientId, // Optional: existing client ID to use stored password
+ type,
+ url,
+ username,
+ password,
+ disableSSLVerify,
+ remotePathMappingEnabled,
+ remotePath,
+ localPath,
+ } = body;
+
+ // Validate type
+ if (type !== 'qbittorrent' && type !== 'sabnzbd') {
+ return NextResponse.json(
+ { error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
+ { status: 400 }
+ );
+ }
+
+ const config = await getConfigService();
+ const manager = getDownloadClientManager(config);
+
+ // If editing an existing client and password not provided, use stored password
+ let effectivePassword = password;
+ let effectiveUsername = username;
+
+ if (clientId && !password) {
+ const existingClients = await manager.getAllClients();
+ const existingClient = existingClients.find(c => c.id === clientId);
+
+ if (!existingClient) {
+ return NextResponse.json(
+ { error: 'Client not found' },
+ { status: 404 }
+ );
+ }
+
+ effectivePassword = existingClient.password;
+ // Also use stored username if not provided (for qBittorrent)
+ if (!username && existingClient.username) {
+ effectiveUsername = existingClient.username;
+ }
+ }
+
+ // Validate required fields
+ if (!url || !effectivePassword) {
+ return NextResponse.json(
+ { error: 'URL and password/API key are required' },
+ { status: 400 }
+ );
+ }
+
+ if (type === 'qbittorrent' && !effectiveUsername) {
+ return NextResponse.json(
+ { error: 'Username is required for qBittorrent' },
+ { status: 400 }
+ );
+ }
+
+ // Create temporary client config for testing
+ const testConfig: DownloadClientConfig = {
+ id: 'test',
+ type,
+ name: 'Test Client',
+ enabled: true,
+ url,
+ username: effectiveUsername || undefined,
+ password: effectivePassword,
+ disableSSLVerify: disableSSLVerify || false,
+ remotePathMappingEnabled: remotePathMappingEnabled || false,
+ remotePath: remotePath || undefined,
+ localPath: localPath || undefined,
+ category: 'readmeabook',
+ };
+
+ const result = await manager.testConnection(testConfig);
+
+ if (result.success) {
+ return NextResponse.json({
+ success: true,
+ message: result.message,
+ });
+ } else {
+ return NextResponse.json(
+ {
+ success: false,
+ error: result.message,
+ },
+ { status: 400 }
+ );
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger.error('Connection test failed', { error: message });
+ return NextResponse.json(
+ {
+ success: false,
+ error: message,
+ },
+ { status: 400 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts
index 624ab20..0e74f57 100644
--- a/src/app/api/setup/complete/route.ts
+++ b/src/app/api/setup/complete/route.ts
@@ -80,10 +80,6 @@ export async function POST(request: NextRequest) {
!prowlarr?.indexers ||
!Array.isArray(prowlarr.indexers) ||
prowlarr.indexers.length === 0 ||
- !downloadClient?.type ||
- !downloadClient?.url ||
- !downloadClient?.username ||
- !downloadClient?.password ||
!paths?.download_dir ||
!paths?.media_dir
) {
@@ -93,6 +89,39 @@ export async function POST(request: NextRequest) {
);
}
+ // Validate download client(s)
+ if (!downloadClient) {
+ return NextResponse.json(
+ { success: false, error: 'Download client configuration is required' },
+ { status: 400 }
+ );
+ }
+
+ // Support both legacy single client and new multi-client array
+ const clients = Array.isArray(downloadClient) ? downloadClient : [downloadClient];
+ if (clients.length === 0) {
+ return NextResponse.json(
+ { success: false, error: 'At least one download client must be configured' },
+ { status: 400 }
+ );
+ }
+
+ // Validate each client has required fields
+ for (const client of clients) {
+ if (!client.url || !client.password) {
+ return NextResponse.json(
+ { success: false, error: 'Download client URL and password/API key are required' },
+ { status: 400 }
+ );
+ }
+ if (client.type === 'qbittorrent' && !client.username) {
+ return NextResponse.json(
+ { success: false, error: 'qBittorrent username is required' },
+ { status: 400 }
+ );
+ }
+ }
+
// Create admin user (for Plex mode or ABS + Manual auth)
let adminUser: any = null;
let accessToken: string | null = null;
@@ -356,50 +385,41 @@ export async function POST(request: NextRequest) {
create: { key: 'prowlarr_indexers', value: JSON.stringify(prowlarr.indexers) },
});
- // Download client configuration
- await prisma.configuration.upsert({
- where: { key: 'download_client_type' },
- update: { value: downloadClient.type },
- create: { key: 'download_client_type', value: downloadClient.type },
- });
+ // Download clients configuration (multi-client support)
+ // Accept either legacy single client or new clients array
+ let downloadClientsArray: any[];
+
+ if (Array.isArray(downloadClient)) {
+ // New format: array of clients
+ downloadClientsArray = downloadClient;
+ } else if (downloadClient && typeof downloadClient === 'object') {
+ // Legacy format: convert single client to array
+ downloadClientsArray = [{
+ id: `temp-${Date.now()}`,
+ type: downloadClient.type,
+ name: downloadClient.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
+ enabled: true,
+ url: downloadClient.url,
+ username: downloadClient.username,
+ password: downloadClient.password,
+ disableSSLVerify: downloadClient.disableSSLVerify || false,
+ remotePathMappingEnabled: downloadClient.remotePathMappingEnabled || false,
+ remotePath: downloadClient.remotePath,
+ localPath: downloadClient.localPath,
+ category: 'readmeabook',
+ }];
+ } else {
+ throw new Error('Invalid download client configuration');
+ }
await prisma.configuration.upsert({
- where: { key: 'download_client_url' },
- update: { value: downloadClient.url },
- create: { key: 'download_client_url', value: downloadClient.url },
- });
-
- await prisma.configuration.upsert({
- where: { key: 'download_client_username' },
- update: { value: downloadClient.username },
- create: { key: 'download_client_username', value: downloadClient.username },
- });
-
- await prisma.configuration.upsert({
- where: { key: 'download_client_password' },
- update: { value: downloadClient.password },
- create: { key: 'download_client_password', value: downloadClient.password },
- });
-
- await prisma.configuration.upsert({
- where: { key: 'download_client_disable_ssl_verify' },
- update: { value: downloadClient.disableSSLVerify ? 'true' : 'false' },
- create: {
- key: 'download_client_disable_ssl_verify',
- value: downloadClient.disableSSLVerify ? 'true' : 'false',
- },
- });
-
- // Remote path mapping configuration
- await prisma.configuration.upsert({
- where: { key: 'download_client_remote_path_mapping_enabled' },
- update: { value: downloadClient.remotePathMappingEnabled ? 'true' : 'false' },
- create: {
- key: 'download_client_remote_path_mapping_enabled',
- value: downloadClient.remotePathMappingEnabled ? 'true' : 'false',
- },
+ where: { key: 'download_clients' },
+ update: { value: JSON.stringify(downloadClientsArray) },
+ create: { key: 'download_clients', value: JSON.stringify(downloadClientsArray) },
});
+ // Legacy: Keep old keys for backward compatibility with migration
+ // (Will be cleaned up by migration on first access)
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path' },
update: { value: downloadClient.remotePath || '' },
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx
index 01cb039..6c3155a 100644
--- a/src/app/setup/page.tsx
+++ b/src/app/setup/page.tsx
@@ -77,14 +77,7 @@ interface SetupState {
prowlarrUrl: string;
prowlarrApiKey: string;
prowlarrIndexers: SelectedIndexer[];
- downloadClient: 'qbittorrent' | 'sabnzbd';
- downloadClientUrl: string;
- downloadClientUsername: string;
- downloadClientPassword: string;
- disableSSLVerify: boolean;
- remotePathMappingEnabled: boolean;
- remotePath: string;
- localPath: string;
+ downloadClients: any[]; // Array of download client configs
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
@@ -150,14 +143,7 @@ export default function SetupWizard() {
prowlarrUrl: '',
prowlarrApiKey: '',
prowlarrIndexers: [],
- downloadClient: 'qbittorrent',
- downloadClientUrl: '',
- downloadClientUsername: 'admin',
- downloadClientPassword: '',
- disableSSLVerify: false,
- remotePathMappingEnabled: false,
- remotePath: '',
- localPath: '',
+ downloadClients: [], // Empty array - user will add clients in wizard
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
@@ -224,16 +210,7 @@ export default function SetupWizard() {
api_key: state.prowlarrApiKey,
indexers: state.prowlarrIndexers,
},
- downloadClient: {
- type: state.downloadClient,
- url: state.downloadClientUrl,
- username: state.downloadClientUsername,
- password: state.downloadClientPassword,
- disableSSLVerify: state.disableSSLVerify,
- remotePathMappingEnabled: state.remotePathMappingEnabled,
- remotePath: state.remotePath,
- localPath: state.localPath,
- },
+ downloadClient: state.downloadClients, // Send array of clients
paths: {
download_dir: state.downloadDir,
media_dir: state.mediaDir,
@@ -517,14 +494,7 @@ export default function SetupWizard() {
if (state.currentStep === currentStepNumber) {
return (
goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
diff --git a/src/app/setup/steps/DownloadClientStep.tsx b/src/app/setup/steps/DownloadClientStep.tsx
index 3a87b3c..a1b92f0 100644
--- a/src/app/setup/steps/DownloadClientStep.tsx
+++ b/src/app/setup/steps/DownloadClientStep.tsx
@@ -5,403 +5,90 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
-import { Input } from '@/components/ui/Input';
+import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
-interface DownloadClientStepProps {
- downloadClient: 'qbittorrent' | 'sabnzbd';
- downloadClientUrl: string;
- downloadClientUsername: string;
- downloadClientPassword: string;
+interface DownloadClient {
+ id: string;
+ type: 'qbittorrent' | 'sabnzbd';
+ name: string;
+ enabled: boolean;
+ url: string;
+ username?: string;
+ password: string;
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean;
- remotePath: string;
- localPath: string;
+ remotePath?: string;
+ localPath?: string;
+ category?: string;
+}
+
+interface DownloadClientStepProps {
+ downloadClients: DownloadClient[];
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
}
export function DownloadClientStep({
- downloadClient,
- downloadClientUrl,
- downloadClientUsername,
- downloadClientPassword,
- disableSSLVerify,
- remotePathMappingEnabled,
- remotePath,
- localPath,
+ downloadClients,
onUpdate,
onNext,
onBack,
}: DownloadClientStepProps) {
- const [testing, setTesting] = useState(false);
- const [testResult, setTestResult] = useState<{
- success: boolean;
- message: string;
- version?: string;
- } | null>(null);
+ const [clients, setClients] = useState(downloadClients || []);
+ const [error, setError] = useState(null);
- const testConnection = async () => {
- setTesting(true);
- setTestResult(null);
-
- try {
- const response = await fetch('/api/setup/test-download-client', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- type: downloadClient,
- url: downloadClientUrl,
- username: downloadClientUsername,
- password: downloadClientPassword,
- disableSSLVerify,
- remotePathMappingEnabled,
- remotePath,
- localPath,
- }),
- });
-
- const data = await response.json();
-
- if (response.ok && data.success) {
- setTestResult({
- success: true,
- message: `Connected successfully! ${data.version ? `Version: ${data.version}` : ''}`,
- version: data.version,
- });
- } else {
- setTestResult({
- success: false,
- message: data.error || 'Connection failed',
- });
- }
- } catch (error) {
- setTestResult({
- success: false,
- message: error instanceof Error ? error.message : 'Connection test failed',
- });
- } finally {
- setTesting(false);
- }
+ // Update parent when clients change
+ const handleClientsChange = (updatedClients: DownloadClient[]) => {
+ setClients(updatedClients);
+ onUpdate('downloadClients', updatedClients);
};
const handleNext = () => {
- if (!testResult?.success) {
- setTestResult({
- success: false,
- message: 'Please test the connection before proceeding',
- });
+ // Validate: At least one enabled client required
+ const hasEnabledClient = clients.some(c => c.enabled);
+
+ if (!hasEnabledClient) {
+ setError('Please add at least one download client before proceeding');
return;
}
+ setError(null);
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 (
-
- Configure Download Client
+
+ Configure Download Clients
-
- Choose your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.
+
+ Add at least one download client. You can configure both qBittorrent (torrents) and SABnzbd (Usenet) to search across all indexer types.
-
-
-
- Download Client
-
-
-
onUpdate('downloadClient', 'qbittorrent')}
- className={`p-4 border-2 rounded-lg text-left transition-colors ${
- downloadClient === 'qbittorrent'
- ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
- : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
- }`}
- >
- qBittorrent
-
- Torrent downloads
-
-
-
onUpdate('downloadClient', 'sabnzbd')}
- className={`p-4 border-2 rounded-lg text-left transition-colors ${
- downloadClient === 'sabnzbd'
- ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
- : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
- }`}
- >
- SABnzbd
-
- Usenet/NZB downloads
-
-
-
+ {error && (
+
+ )}
-
-
- {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} URL
-
-
onUpdate('downloadClientUrl', e.target.value)}
- />
-
- The URL where your download client is running (include port)
-
-
+
- {downloadClient === 'qbittorrent' && (
- <>
-
-
- Username
-
- onUpdate('downloadClientUsername', e.target.value)}
- autoComplete="username"
- />
-
-
-
-
- Password
-
- onUpdate('downloadClientPassword', e.target.value)}
- autoComplete="current-password"
- />
-
- >
- )}
-
- {downloadClient === 'sabnzbd' && (
-
-
- API Key
-
-
onUpdate('downloadClientPassword', e.target.value)}
- autoComplete="off"
- />
-
- Find this in SABnzbd under Config → General → API Key
-
-
- )}
-
- {/* SSL Verification Toggle */}
- {downloadClientUrl.startsWith('https') && (
-
-
-
onUpdate('disableSSLVerify', e.target.checked)}
- className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
- />
-
-
- Disable SSL Certificate Verification
-
-
- Enable this if you're using a self-signed certificate or getting SSL errors.
- ⚠️ Only use on trusted private networks.
-
-
-
-
- )}
-
- {/* Remote Path Mapping (only for clients that download to filesystem) */}
-
-
-
onUpdate('remotePathMappingEnabled', e.target.checked)}
- className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
- />
-
-
- Enable Remote Path Mapping
-
-
- Use this when {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
-
-
- Example: Remote /remote/mnt/d/done → Local /downloads
-
-
- {/* Conditional Fields */}
- {remotePathMappingEnabled && (
-
-
-
- Remote Path (from {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'})
-
-
onUpdate('remotePath', e.target.value)}
- />
-
- The path prefix as reported by {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'}
-
-
-
-
-
- Local Path (for ReadMeABook)
-
-
onUpdate('localPath', e.target.value)}
- />
-
- The actual path where files are accessible
-
-
-
- )}
-
-
-
-
-
- Test Connection
-
-
- {testResult && (
-
-
-
- {testResult.success ? (
-
- ) : (
-
- )}
-
-
-
- {testResult.success ? 'Success' : 'Error'}
-
-
- {testResult.message}
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
- {downloadClient === 'qbittorrent' ? 'qBittorrent Setup' : 'SABnzbd Setup'}
-
-
- {downloadClient === 'qbittorrent'
- ? 'Make sure Web UI is enabled in qBittorrent settings (Tools → Options → Web UI)'
- : 'Make sure SABnzbd is running and the API key is configured (Config → General → API Key)'}
-
-
-
-
-
-
-
+
+
Back
- Next
+
+ Next
+
);
diff --git a/src/app/setup/steps/ReviewStep.tsx b/src/app/setup/steps/ReviewStep.tsx
index e5846ed..b13ea58 100644
--- a/src/app/setup/steps/ReviewStep.tsx
+++ b/src/app/setup/steps/ReviewStep.tsx
@@ -26,8 +26,7 @@ interface ReviewStepProps {
// Common config
prowlarrUrl: string;
- downloadClient: 'qbittorrent' | 'sabnzbd';
- downloadClientUrl: string;
+ downloadClients: any[]; // Array of download client configs
downloadDir: string;
mediaDir: string;
@@ -172,21 +171,35 @@ export function ReviewStep({ config, loading, error, onComplete, onBack }: Revie
{/* Download Client Configuration */}
- Download Client
+ Download Clients
-
-
-
Type:
-
- {config.downloadClient}
-
-
-
-
Server URL:
-
- {config.downloadClientUrl}
-
-
+
+ {config.downloadClients && config.downloadClients.length > 0 ? (
+ config.downloadClients.map((client: any, index: number) => (
+ 0 ? 'pt-3 border-t border-gray-200 dark:border-gray-700' : ''}>
+
+
Name:
+
+ {client.name}
+
+
+
+
Type:
+
+ {client.type}
+
+
+
+
URL:
+
+ {client.url}
+
+
+
+ ))
+ ) : (
+ No download clients configured
+ )}
diff --git a/src/components/admin/download-clients/DownloadClientCard.tsx b/src/components/admin/download-clients/DownloadClientCard.tsx
new file mode 100644
index 0000000..b667638
--- /dev/null
+++ b/src/components/admin/download-clients/DownloadClientCard.tsx
@@ -0,0 +1,102 @@
+/**
+ * Component: Download Client Card
+ * Documentation: documentation/phase3/download-clients.md
+ */
+
+'use client';
+
+import React from 'react';
+
+interface DownloadClientCardProps {
+ client: {
+ id: string;
+ type: 'qbittorrent' | 'sabnzbd';
+ name: string;
+ url: string;
+ enabled: boolean;
+ };
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientCardProps) {
+ const typeName = client.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
+ const typeColor = client.type === 'qbittorrent' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300';
+
+ // Truncate URL for display
+ const displayUrl = client.url.length > 40 ? `${client.url.substring(0, 40)}...` : client.url;
+
+ return (
+
+
+ {/* Client Info */}
+
+
+
+ {client.name}
+
+ {!client.enabled && (
+
+ Disabled
+
+ )}
+
+
+
+
+ {typeName}
+
+
+ {displayUrl}
+
+
+
+
+ {/* Action Buttons */}
+
+ {/* Edit Button */}
+
+
+
+
+
+
+ {/* Delete Button */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/download-clients/DownloadClientManagement.tsx b/src/components/admin/download-clients/DownloadClientManagement.tsx
new file mode 100644
index 0000000..9e63e7a
--- /dev/null
+++ b/src/components/admin/download-clients/DownloadClientManagement.tsx
@@ -0,0 +1,374 @@
+/**
+ * Component: Download Client Management Container
+ * Documentation: documentation/phase3/download-clients.md
+ */
+
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/Button';
+import { DownloadClientCard } from './DownloadClientCard';
+import { DownloadClientModal } from './DownloadClientModal';
+import { fetchWithAuth } from '@/lib/utils/api';
+
+interface DownloadClient {
+ id: string;
+ type: 'qbittorrent' | 'sabnzbd';
+ name: string;
+ url: string;
+ username?: string;
+ password: string;
+ enabled: boolean;
+ disableSSLVerify: boolean;
+ remotePathMappingEnabled: boolean;
+ remotePath?: string;
+ localPath?: string;
+ category?: string;
+}
+
+interface DownloadClientManagementProps {
+ mode: 'wizard' | 'settings';
+ initialClients?: DownloadClient[];
+ onClientsChange?: (clients: DownloadClient[]) => void;
+}
+
+export function DownloadClientManagement({
+ mode,
+ initialClients = [],
+ onClientsChange,
+}: DownloadClientManagementProps) {
+ const [clients, setClients] = useState
(initialClients);
+ const [modalState, setModalState] = useState<{
+ isOpen: boolean;
+ mode: 'add' | 'edit';
+ clientType?: 'qbittorrent' | 'sabnzbd';
+ currentClient?: DownloadClient;
+ }>({ isOpen: false, mode: 'add' });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState<{
+ isOpen: boolean;
+ clientId?: string;
+ clientName?: string;
+ }>({ isOpen: false });
+
+ // Fetch clients when in settings mode
+ useEffect(() => {
+ if (mode === 'settings') {
+ fetchClients();
+ }
+ }, [mode]);
+
+ // Sync with parent when clients change
+ useEffect(() => {
+ if (onClientsChange) {
+ onClientsChange(clients);
+ }
+ }, [clients, onClientsChange]);
+
+ // Sync with initialClients prop changes (wizard mode)
+ useEffect(() => {
+ if (mode === 'wizard') {
+ setClients(initialClients);
+ }
+ }, [initialClients, mode]);
+
+ const fetchClients = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetchWithAuth('/api/admin/settings/download-clients');
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch download clients');
+ }
+
+ const data = await response.json();
+ setClients(data.clients || []);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to fetch download clients');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAddClient = (type: 'qbittorrent' | 'sabnzbd') => {
+ // Check if this type already exists
+ const existingClient = clients.find(c => c.type === type && c.enabled);
+ if (existingClient) {
+ setError(`A ${type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} client is already configured.`);
+ return;
+ }
+
+ setModalState({
+ isOpen: true,
+ mode: 'add',
+ clientType: type,
+ });
+ };
+
+ const handleEditClient = (client: DownloadClient) => {
+ setModalState({
+ isOpen: true,
+ mode: 'edit',
+ currentClient: client,
+ });
+ };
+
+ const handleDeleteClient = (client: DownloadClient) => {
+ setDeleteConfirm({
+ isOpen: true,
+ clientId: client.id,
+ clientName: client.name,
+ });
+ };
+
+ const confirmDelete = async () => {
+ if (!deleteConfirm.clientId) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ if (mode === 'settings') {
+ // API call for settings mode
+ const response = await fetchWithAuth(`/api/admin/settings/download-clients/${deleteConfirm.clientId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete download client');
+ }
+
+ await fetchClients(); // Refresh list
+ } else {
+ // Local removal for wizard mode
+ setClients(clients.filter(c => c.id !== deleteConfirm.clientId));
+ }
+
+ setDeleteConfirm({ isOpen: false });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to delete download client');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSaveClient = async (clientData: any) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ if (mode === 'settings') {
+ // API call for settings mode
+ if (modalState.mode === 'add') {
+ const response = await fetchWithAuth('/api/admin/settings/download-clients', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(clientData),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to add download client');
+ }
+
+ await fetchClients(); // Refresh list
+ } else {
+ const response = await fetchWithAuth(`/api/admin/settings/download-clients/${clientData.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(clientData),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to update download client');
+ }
+
+ await fetchClients(); // Refresh list
+ }
+ } else {
+ // Local update for wizard mode
+ if (modalState.mode === 'add') {
+ const newClient = {
+ ...clientData,
+ id: `temp-${Date.now()}`, // Temporary ID for wizard mode
+ };
+ setClients([...clients, newClient]);
+ } else {
+ setClients(clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c)));
+ }
+ }
+
+ setModalState({ isOpen: false, mode: 'add' });
+ } catch (err) {
+ throw err; // Re-throw to let modal handle the error
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const hasQBittorrent = clients.some(c => c.type === 'qbittorrent' && c.enabled);
+ const hasSABnzbd = clients.some(c => c.type === 'sabnzbd' && c.enabled);
+
+ return (
+
+ {/* Error Display */}
+ {error && (
+
+
{error}
+
setError(null)}
+ className="mt-2 text-xs underline hover:no-underline"
+ >
+ Dismiss
+
+
+ )}
+
+ {/* Add Client Section */}
+
+
+ Add Download Client
+
+
+ {/* qBittorrent Card */}
+
+
+
+
+ qBittorrent
+
+
+ Torrent downloads
+
+
+
+ Torrent
+
+
+ {hasQBittorrent ? (
+
+ Already configured
+
+ ) : (
+
handleAddClient('qbittorrent')}
+ variant="primary"
+ size="sm"
+ disabled={loading}
+ >
+ Add qBittorrent
+
+ )}
+
+
+ {/* SABnzbd Card */}
+
+
+
+
+ SABnzbd
+
+
+ Usenet/NZB downloads
+
+
+
+ Usenet
+
+
+ {hasSABnzbd ? (
+
+ Already configured
+
+ ) : (
+
handleAddClient('sabnzbd')}
+ variant="primary"
+ size="sm"
+ disabled={loading}
+ >
+ Add SABnzbd
+
+ )}
+
+
+
+
+ {/* Configured Clients Section */}
+ {clients.length > 0 && (
+
+
+ Configured Clients
+
+
+ {clients.map(client => (
+ handleEditClient(client)}
+ onDelete={() => handleDeleteClient(client)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Empty State */}
+ {clients.length === 0 && !loading && (
+
+
+ No download clients configured yet
+
+
+ Add at least one client to start downloading audiobooks
+
+
+ )}
+
+ {/* Client Modal */}
+
setModalState({ isOpen: false, mode: 'add' })}
+ mode={modalState.mode}
+ clientType={modalState.clientType}
+ initialClient={modalState.currentClient}
+ onSave={handleSaveClient}
+ apiMode={mode}
+ />
+
+ {/* Delete Confirmation Modal */}
+ {deleteConfirm.isOpen && (
+
+
+
+ Delete Download Client
+
+
+ Are you sure you want to delete {deleteConfirm.clientName} ? This action cannot be undone.
+
+
+ setDeleteConfirm({ isOpen: false })}
+ variant="secondary"
+ disabled={loading}
+ >
+ Cancel
+
+
+ {loading ? 'Deleting...' : 'Delete'}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/admin/download-clients/DownloadClientModal.tsx b/src/components/admin/download-clients/DownloadClientModal.tsx
new file mode 100644
index 0000000..88b6749
--- /dev/null
+++ b/src/components/admin/download-clients/DownloadClientModal.tsx
@@ -0,0 +1,451 @@
+/**
+ * Component: Download Client Configuration Modal
+ * Documentation: documentation/phase3/download-clients.md
+ */
+
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { fetchWithAuth } from '@/lib/utils/api';
+
+interface DownloadClientModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ mode: 'add' | 'edit';
+ clientType?: 'qbittorrent' | 'sabnzbd';
+ initialClient?: {
+ id: string;
+ type: 'qbittorrent' | 'sabnzbd';
+ name: string;
+ url: string;
+ username?: string;
+ password: string;
+ enabled: boolean;
+ disableSSLVerify: boolean;
+ remotePathMappingEnabled: boolean;
+ remotePath?: string;
+ localPath?: string;
+ category?: string;
+ };
+ onSave: (client: any) => Promise;
+ apiMode: 'wizard' | 'settings';
+}
+
+export function DownloadClientModal({
+ isOpen,
+ onClose,
+ mode,
+ clientType,
+ initialClient,
+ onSave,
+ apiMode,
+}: DownloadClientModalProps) {
+ const type = mode === 'edit' ? initialClient?.type : clientType;
+ const typeName = type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
+
+ // Form state
+ const [name, setName] = useState('');
+ const [url, setUrl] = useState('');
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [enabled, setEnabled] = useState(true);
+ const [disableSSLVerify, setDisableSSLVerify] = useState(false);
+ const [remotePathMappingEnabled, setRemotePathMappingEnabled] = useState(false);
+ const [remotePath, setRemotePath] = useState('');
+ const [localPath, setLocalPath] = useState('');
+ const [category, setCategory] = useState('readmeabook');
+
+ const [testing, setTesting] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+ const [errors, setErrors] = useState>({});
+
+ // Reset form when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ if (mode === 'edit' && initialClient) {
+ setName(initialClient.name);
+ setUrl(initialClient.url);
+ setUsername(initialClient.username || '');
+ // In wizard mode, use actual password from local state
+ // In settings mode, mask password (server doesn't send real passwords)
+ setPassword(apiMode === 'wizard' ? initialClient.password : '********');
+ setEnabled(initialClient.enabled);
+ setDisableSSLVerify(initialClient.disableSSLVerify);
+ setRemotePathMappingEnabled(initialClient.remotePathMappingEnabled);
+ setRemotePath(initialClient.remotePath || '');
+ setLocalPath(initialClient.localPath || '');
+ setCategory(initialClient.category || 'readmeabook');
+ } else {
+ // Add mode defaults
+ setName(typeName);
+ setUrl('');
+ setUsername('');
+ setPassword('');
+ setEnabled(true);
+ setDisableSSLVerify(false);
+ setRemotePathMappingEnabled(false);
+ setRemotePath('');
+ setLocalPath('');
+ setCategory('readmeabook');
+ }
+ setTestResult(null);
+ setErrors({});
+ }
+ }, [isOpen, mode, initialClient, type]);
+
+ const validate = () => {
+ const newErrors: Record = {};
+
+ if (!name.trim()) {
+ newErrors.name = 'Name is required';
+ }
+
+ if (!url.trim()) {
+ newErrors.url = 'URL is required';
+ }
+
+ if (type === 'qbittorrent' && !username.trim()) {
+ newErrors.username = 'Username is required for qBittorrent';
+ }
+
+ if (!password.trim() || (mode === 'add' && password === '********')) {
+ newErrors.password = type === 'qbittorrent' ? 'Password is required' : 'API key is required';
+ }
+
+ if (remotePathMappingEnabled) {
+ if (!remotePath.trim()) {
+ newErrors.remotePath = 'Remote path is required when path mapping is enabled';
+ }
+ if (!localPath.trim()) {
+ newErrors.localPath = 'Local path is required when path mapping is enabled';
+ }
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleTestConnection = async () => {
+ if (!validate()) {
+ return;
+ }
+
+ setTesting(true);
+ setTestResult(null);
+
+ try {
+ // If editing and password is masked, send clientId so server uses stored password
+ const isPasswordMasked = password === '********';
+
+ const testData = {
+ type,
+ url,
+ username: type === 'qbittorrent' ? username : undefined,
+ password: isPasswordMasked ? undefined : password,
+ // Include clientId when editing so server can use stored password
+ ...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
+ disableSSLVerify,
+ remotePathMappingEnabled,
+ remotePath: remotePathMappingEnabled ? remotePath : undefined,
+ localPath: remotePathMappingEnabled ? localPath : undefined,
+ };
+
+ const endpoint = apiMode === 'wizard'
+ ? '/api/setup/test-download-client'
+ : '/api/admin/settings/download-clients/test';
+
+ // Wizard mode: no auth required (public endpoint during setup)
+ // Settings mode: use fetchWithAuth to include JWT token
+ const response = apiMode === 'wizard'
+ ? await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(testData),
+ })
+ : await fetchWithAuth(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(testData),
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.success) {
+ // Handle both endpoint response formats (settings returns message, wizard returns version)
+ const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
+ setTestResult({ success: true, message });
+ } else {
+ setTestResult({ success: false, message: data.error || 'Connection test failed' });
+ }
+ } catch (error) {
+ setTestResult({
+ success: false,
+ message: error instanceof Error ? error.message : 'Connection test failed',
+ });
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!validate()) {
+ return;
+ }
+
+ if (!testResult?.success) {
+ setErrors({ ...errors, test: 'Please test the connection before saving' });
+ return;
+ }
+
+ setSaving(true);
+
+ try {
+ const clientData: any = {
+ type,
+ name,
+ url,
+ username: type === 'qbittorrent' ? username : undefined,
+ password: password === '********' ? undefined : password, // Don't send masked password on edit
+ enabled,
+ disableSSLVerify,
+ remotePathMappingEnabled,
+ remotePath: remotePathMappingEnabled ? remotePath : undefined,
+ localPath: remotePathMappingEnabled ? localPath : undefined,
+ category,
+ };
+
+ if (mode === 'edit' && initialClient) {
+ clientData.id = initialClient.id;
+ }
+
+ await onSave(clientData);
+ onClose();
+ } catch (error) {
+ setErrors({
+ ...errors,
+ save: error instanceof Error ? error.message : 'Failed to save client',
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+ {/* Name */}
+
+
+ Name
+
+
setName(e.target.value)}
+ placeholder={`My ${typeName}`}
+ error={errors.name}
+ />
+
+ Friendly name to identify this client
+
+
+
+ {/* URL */}
+
+
+ URL
+
+
setUrl(e.target.value)}
+ placeholder={type === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8081'}
+ error={errors.url}
+ />
+
+ Web UI URL (e.g., http://localhost:8080)
+
+
+
+ {/* Username (qBittorrent only) */}
+ {type === 'qbittorrent' && (
+
+
+ Username
+
+ setUsername(e.target.value)}
+ placeholder="admin"
+ error={errors.username}
+ />
+
+ )}
+
+ {/* Password / API Key */}
+
+
+ {type === 'qbittorrent' ? 'Password' : 'API Key'}
+
+
setPassword(e.target.value)}
+ placeholder={type === 'qbittorrent' ? 'Password' : 'API Key from SABnzbd Config > General'}
+ error={errors.password}
+ />
+ {type === 'sabnzbd' && (
+
+ Found in SABnzbd under Config → General → API Key
+
+ )}
+
+
+ {/* SSL Verification */}
+ {url.startsWith('https://') && (
+
+
setDisableSSLVerify(e.target.checked)}
+ className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ />
+
+ Disable SSL certificate verification
+
+ Use for self-signed certificates (not recommended for production)
+
+
+
+ )}
+
+ {/* Enabled Toggle */}
+
+
setEnabled(e.target.checked)}
+ className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ />
+
+ Enabled
+
+ Use this client for downloads
+
+
+
+
+ {/* Remote Path Mapping */}
+
+
+
setRemotePathMappingEnabled(e.target.checked)}
+ className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ />
+
+ Enable Remote Path Mapping
+
+ Use when download client sees a different filesystem than ReadMeABook
+
+
+
+
+ {remotePathMappingEnabled && (
+
+
+
+ Remote Path ({typeName})
+
+
setRemotePath(e.target.value)}
+ placeholder="F:\Docker\downloads\completed\books"
+ error={errors.remotePath}
+ />
+
+ Path as seen by {typeName}
+
+
+
+
+
+ Local Path (ReadMeABook)
+
+
setLocalPath(e.target.value)}
+ placeholder="/downloads"
+ error={errors.localPath}
+ />
+
+ Path as seen by ReadMeABook
+
+
+
+ )}
+
+
+ {/* Test Result */}
+ {testResult && (
+
+ )}
+
+ {/* Errors */}
+ {errors.test && (
+
+ )}
+
+ {errors.save && (
+
+ )}
+
+ {/* Action Buttons */}
+
+
+ {testing ? 'Testing...' : 'Test Connection'}
+
+
+
+
+ Cancel
+
+
+ {saving ? 'Saving...' : mode === 'add' ? 'Add Client' : 'Save Changes'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index 4c7360e..baecde5 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -5,7 +5,7 @@
'use client';
-import React, { useEffect } from 'react';
+import React, { useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils/cn';
interface ModalProps {
@@ -25,25 +25,33 @@ export function Modal({
size = 'md',
showCloseButton = true,
}: ModalProps) {
- // Close on ESC key
+ // Use ref to avoid re-running effect when onClose changes
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+
+ // Stable close handler
+ const handleClose = useCallback(() => {
+ onCloseRef.current();
+ }, []);
+
+ // Close on ESC key and manage body scroll
useEffect(() => {
+ if (!isOpen) return;
+
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
- onClose();
+ handleClose();
}
};
- if (isOpen) {
- document.addEventListener('keydown', handleEsc);
- // Prevent body scroll when modal is open
- document.body.style.overflow = 'hidden';
- }
+ document.addEventListener('keydown', handleEsc);
+ document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEsc);
- document.body.style.overflow = 'unset';
+ document.body.style.overflow = '';
};
- }, [isOpen, onClose]);
+ }, [isOpen, handleClose]);
if (!isOpen) return null;
@@ -60,7 +68,7 @@ export function Modal({
{/* Backdrop */}
{/* Modal container */}
@@ -80,7 +88,7 @@ export function Modal({
{showCloseButton && (
{
try {
- // Get configured download client type
+ // Get configured download clients
+ const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const { getConfigService } = await import('../services/config.service');
const config = await getConfigService();
- const clientType = (await config.get('download_client_type')) || 'qbittorrent';
+ const manager = getDownloadClientManager(config);
+
+ const hasTorrentClient = await manager.hasClientForProtocol('torrent');
+ const hasUsenetClient = await manager.hasClientForProtocol('usenet');
// Debug: Log protocol distribution
const protocolCounts = results.reduce((acc, r) => {
@@ -403,17 +407,29 @@ export class ProwlarrService {
});
}
- if (clientType === 'sabnzbd') {
- // Filter for NZB results only
- const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
- logger.info(` Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
- return filtered;
- } else {
- // Filter for torrent results only (default)
+ // If both clients configured, return all results (best result selected across all protocols)
+ if (hasTorrentClient && hasUsenetClient) {
+ logger.info(` Both torrent and usenet clients configured, returning all ${results.length} results`);
+ return results;
+ }
+
+ // If only torrent client configured, filter for torrent results
+ if (hasTorrentClient) {
const filtered = results.filter(result => !ProwlarrService.isNZBResult(result));
logger.info(` Filtered ${results.length} results to ${filtered.length} torrent results for qBittorrent`);
return filtered;
}
+
+ // If only usenet client configured, filter for NZB results
+ if (hasUsenetClient) {
+ const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
+ logger.info(` Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
+ return filtered;
+ }
+
+ // No clients configured - return empty
+ logger.warn('No download clients configured, returning empty results');
+ return [];
} catch (error) {
logger.error('Failed to filter by protocol, returning all results', { error: error instanceof Error ? error.message : String(error) });
return results; // Fallback: return unfiltered if config fails
diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts
index eaee5d0..308021d 100644
--- a/src/lib/integrations/qbittorrent.service.ts
+++ b/src/lib/integrations/qbittorrent.service.ts
@@ -997,75 +997,55 @@ export async function getQBittorrentService(): Promise {
// Always recreate if config hasn't been loaded successfully
if (!qbittorrentService || !configLoaded) {
try {
- // Get configuration from database ONLY (no env var fallback)
+ // Get configuration from download client manager (uses new multi-client config format)
const { getConfigService } = await import('@/lib/services/config.service');
- const configService = getConfigService();
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const configService = await getConfigService();
+ const manager = getDownloadClientManager(configService);
- logger.info('[QBittorrent] Loading configuration from database...');
- const config = await configService.getMany([
- 'download_client_url',
- 'download_client_username',
- 'download_client_password',
- 'download_dir',
- 'download_client_disable_ssl_verify',
- 'download_client_remote_path_mapping_enabled',
- 'download_client_remote_path',
- 'download_client_local_path',
- ]);
+ logger.info('[QBittorrent] Loading configuration from download client manager...');
+ const clientConfig = await manager.getClientForProtocol('torrent');
+
+ if (!clientConfig) {
+ throw new Error('qBittorrent is not configured. Please configure a qBittorrent client in the admin settings.');
+ }
+
+ if (clientConfig.type !== 'qbittorrent') {
+ throw new Error(`Expected qBittorrent client but found ${clientConfig.type}`);
+ }
logger.info('[QBittorrent] Config loaded:', {
- hasUrl: !!config.download_client_url,
- hasUsername: !!config.download_client_username,
- hasPassword: !!config.download_client_password,
- hasPath: !!config.download_dir,
- disableSSLVerify: config.download_client_disable_ssl_verify === 'true',
- pathMappingEnabled: config.download_client_remote_path_mapping_enabled === 'true',
+ name: clientConfig.name,
+ hasUrl: !!clientConfig.url,
+ hasUsername: !!clientConfig.username,
+ hasPassword: !!clientConfig.password,
+ disableSSLVerify: clientConfig.disableSSLVerify,
+ pathMappingEnabled: clientConfig.remotePathMappingEnabled,
});
- // Validate all required fields are present (no env var fallback)
- const missingFields: string[] = [];
-
- if (!config.download_client_url) {
- missingFields.push('qBittorrent URL');
- }
- if (!config.download_client_username) {
- missingFields.push('qBittorrent username');
- }
- if (!config.download_client_password) {
- missingFields.push('qBittorrent password');
- }
- if (!config.download_dir) {
- missingFields.push('Download path');
+ // Validate required fields
+ if (!clientConfig.url || !clientConfig.username || !clientConfig.password) {
+ throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
}
- if (missingFields.length > 0) {
- const errorMsg = `qBittorrent is not fully configured. Missing: ${missingFields.join(', ')}. Please configure qBittorrent in the admin settings.`;
- logger.error('Configuration incomplete', { missingFields });
- throw new Error(errorMsg);
- }
-
- // TypeScript type narrowing: at this point we know all values are non-null
- const url = config.download_client_url as string;
- const username = config.download_client_username as string;
- const password = config.download_client_password as string;
- const savePath = config.download_dir as string;
- const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
+ // Get download_dir from main config (not part of client config)
+ const downloadDir = await configService.get('download_dir') || '/downloads';
// Path mapping configuration
const pathMappingConfig: PathMappingConfig = {
- enabled: config.download_client_remote_path_mapping_enabled === 'true',
- remotePath: config.download_client_remote_path || '',
- localPath: config.download_client_local_path || '',
+ enabled: clientConfig.remotePathMappingEnabled,
+ remotePath: clientConfig.remotePath || '',
+ localPath: clientConfig.localPath || '',
};
logger.info('[QBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService(
- url,
- username,
- password,
- savePath,
- 'readmeabook',
- disableSSLVerify,
+ clientConfig.url,
+ clientConfig.username,
+ clientConfig.password,
+ downloadDir,
+ clientConfig.category || 'readmeabook',
+ clientConfig.disableSSLVerify,
pathMappingConfig
);
diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts
index 97fa668..98b4c9e 100644
--- a/src/lib/integrations/sabnzbd.service.ts
+++ b/src/lib/integrations/sabnzbd.service.ts
@@ -589,27 +589,43 @@ export async function getSABnzbdService(): Promise {
return sabnzbdServiceInstance;
}
- // Load configuration from database
+ // Load configuration from download client manager (uses new multi-client config format)
const { getConfigService } = await import('../services/config.service');
- const config = await getConfigService();
+ const { getDownloadClientManager } = await import('../services/download-client-manager.service');
+ const configService = await getConfigService();
+ const manager = getDownloadClientManager(configService);
- 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';
+ logger.info('Loading configuration from download client manager...');
+ const clientConfig = await manager.getClientForProtocol('usenet');
- if (!url) {
- throw new Error('SABnzbd URL not configured. Please configure download client settings.');
+ if (!clientConfig) {
+ throw new Error('SABnzbd is not configured. Please configure a SABnzbd client in the admin settings.');
}
- if (!apiKey) {
- throw new Error('SABnzbd API key not configured. Please configure download client settings.');
+ if (clientConfig.type !== 'sabnzbd') {
+ throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
}
- sabnzbdServiceInstance = new SABnzbdService(url, apiKey, category, disableSSL);
+ logger.info('Config loaded:', {
+ name: clientConfig.name,
+ hasUrl: !!clientConfig.url,
+ hasApiKey: !!clientConfig.password,
+ disableSSLVerify: clientConfig.disableSSLVerify,
+ });
+
+ if (!clientConfig.url || !clientConfig.password) {
+ throw new Error('SABnzbd is not fully configured. Please check your configuration in admin settings.');
+ }
+
+ sabnzbdServiceInstance = new SABnzbdService(
+ clientConfig.url,
+ clientConfig.password, // API key stored in password field
+ clientConfig.category || 'readmeabook',
+ clientConfig.disableSSLVerify
+ );
// Ensure category exists
- const downloadDir = await config.get('download_dir');
+ const downloadDir = await configService.get('download_dir');
await sabnzbdServiceInstance.ensureCategory(downloadDir || undefined);
return sabnzbdServiceInstance;
diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts
index d8c9fad..c7647c0 100644
--- a/src/lib/processors/download-torrent.processor.ts
+++ b/src/lib/processors/download-torrent.processor.ts
@@ -8,6 +8,8 @@ import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { getSABnzbdService } from '../integrations/sabnzbd.service';
import { getConfigService } from '../services/config.service';
+import { getDownloadClientManager } from '../services/download-client-manager.service';
+import { ProwlarrService } from '../integrations/prowlarr.service';
import { RMABLogger } from '../utils/logger';
/**
@@ -39,20 +41,28 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
},
});
- // Get configured download client type
+ // Detect protocol from result and route to appropriate client
+ const isUsenet = ProwlarrService.isNZBResult(torrent);
const config = await getConfigService();
- const clientType = (await config.get('download_client_type')) || 'qbittorrent';
+ const manager = getDownloadClientManager(config);
+
+ const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent');
+
+ if (!clientConfig) {
+ const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)';
+ throw new Error(`No ${protocol} client configured`);
+ }
let downloadClientId: string;
let downloadClient: 'qbittorrent' | 'sabnzbd';
- if (clientType === 'sabnzbd') {
+ if (isUsenet) {
// Route to SABnzbd
logger.info(`Routing to SABnzbd`);
const sabnzbd = await getSABnzbdService();
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
- category: 'readmeabook',
+ category: clientConfig.category || 'readmeabook',
priority: 'normal',
});
downloadClient = 'sabnzbd';
@@ -115,7 +125,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
const qbt = await getQBittorrentService();
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
- category: 'readmeabook',
+ category: clientConfig.category || 'readmeabook',
tags: ['audiobook'],
sequentialDownload: true,
paused: false,
diff --git a/src/lib/services/download-client-manager.service.ts b/src/lib/services/download-client-manager.service.ts
new file mode 100644
index 0000000..ab15e55
--- /dev/null
+++ b/src/lib/services/download-client-manager.service.ts
@@ -0,0 +1,289 @@
+/**
+ * Component: Download Client Manager Service
+ * Documentation: documentation/phase3/download-clients.md
+ *
+ * Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
+ * Supports migration from legacy single-client config to multi-client JSON array format.
+ */
+
+import { randomUUID } from 'crypto';
+import { ConfigurationService } from './config.service';
+import { RMABLogger } from '@/lib/utils/logger';
+import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
+import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
+import { PathMappingConfig } from '@/lib/utils/path-mapper';
+
+const logger = RMABLogger.create('DownloadClientManager');
+
+export interface DownloadClientConfig {
+ id: string;
+ type: 'qbittorrent' | 'sabnzbd';
+ name: string;
+ enabled: boolean;
+ url: string;
+ username?: string; // qBittorrent only
+ password: string; // Password (qBittorrent) or API key (SABnzbd)
+ disableSSLVerify: boolean;
+ remotePathMappingEnabled: boolean;
+ remotePath?: string;
+ localPath?: string;
+ category?: string; // Default: 'readmeabook'
+}
+
+type ProtocolType = 'torrent' | 'usenet';
+
+/**
+ * Download Client Manager
+ *
+ * Provides centralized management of multiple download clients with:
+ * - Protocol-based routing (torrent → qBittorrent, usenet → SABnzbd)
+ * - Auto-migration from legacy single-client config
+ * - Singleton caching with invalidation
+ * - Connection testing
+ */
+export class DownloadClientManager {
+ private static instance: DownloadClientManager | null = null;
+ private configService: ConfigurationService;
+ private clientsCache: DownloadClientConfig[] | null = null;
+ private migrationPerformed = false;
+
+ private constructor(configService: ConfigurationService) {
+ this.configService = configService;
+ }
+
+ static getInstance(configService?: ConfigurationService): DownloadClientManager {
+ if (!DownloadClientManager.instance) {
+ if (!configService) {
+ throw new Error('ConfigurationService required for first initialization');
+ }
+ DownloadClientManager.instance = new DownloadClientManager(configService);
+ }
+ return DownloadClientManager.instance;
+ }
+
+ /**
+ * Invalidate cached clients (call after config changes)
+ */
+ static invalidate(): void {
+ if (DownloadClientManager.instance) {
+ DownloadClientManager.instance.clientsCache = null;
+ DownloadClientManager.instance.migrationPerformed = false;
+ logger.debug('Download client cache invalidated');
+ }
+ }
+
+ /**
+ * Get all configured download clients
+ */
+ async getAllClients(): Promise {
+ if (this.clientsCache) {
+ return this.clientsCache;
+ }
+
+ // Read from database
+ const configValue = await this.configService.get('download_clients');
+
+ if (configValue) {
+ try {
+ const clients = JSON.parse(configValue) as DownloadClientConfig[];
+ this.clientsCache = clients;
+ return clients;
+ } catch (error) {
+ logger.error('Failed to parse download_clients config', { error });
+ return [];
+ }
+ }
+
+ // Check for legacy config and migrate
+ if (!this.migrationPerformed) {
+ const migrated = await this.migrateLegacyConfig();
+ this.migrationPerformed = true;
+ if (migrated) {
+ return this.getAllClients(); // Recursive call after migration
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Get client for specific protocol
+ */
+ async getClientForProtocol(protocol: ProtocolType): Promise {
+ const clients = await this.getAllClients();
+ const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
+
+ const client = clients.find(c => c.enabled && c.type === targetType);
+
+ if (!client) {
+ logger.warn(`No enabled ${targetType} client configured`);
+ return null;
+ }
+
+ return client;
+ }
+
+ /**
+ * Check if protocol is configured
+ */
+ async hasClientForProtocol(protocol: ProtocolType): Promise {
+ const client = await this.getClientForProtocol(protocol);
+ return client !== null;
+ }
+
+ /**
+ * Get instantiated client service for protocol
+ */
+ async getClientServiceForProtocol(protocol: ProtocolType): Promise {
+ const client = await this.getClientForProtocol(protocol);
+
+ if (!client) {
+ return null;
+ }
+
+ if (client.type === 'qbittorrent') {
+ return this.createQBittorrentService(client);
+ } else {
+ return this.createSABnzbdService(client);
+ }
+ }
+
+ /**
+ * Test connection for a specific client config
+ */
+ async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
+ try {
+ if (config.type === 'qbittorrent') {
+ const service = this.createQBittorrentService(config);
+ await service.testConnection();
+ return { success: true, message: 'Successfully connected to qBittorrent' };
+ } else {
+ const service = this.createSABnzbdService(config);
+ const version = await service.getVersion();
+ return { success: true, message: `Successfully connected to SABnzbd (v${version})` };
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger.error('Connection test failed', { type: config.type, error: message });
+ return { success: false, message };
+ }
+ }
+
+ /**
+ * Create qBittorrent service instance
+ */
+ private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
+ const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
+ ? {
+ enabled: true,
+ remotePath: config.remotePath,
+ localPath: config.localPath,
+ }
+ : undefined;
+
+ return new QBittorrentService(
+ config.url,
+ config.username || '',
+ config.password,
+ '/downloads', // defaultSavePath
+ config.category || 'readmeabook', // defaultCategory
+ config.disableSSLVerify,
+ pathMapping
+ );
+ }
+
+ /**
+ * Create SABnzbd service instance
+ */
+ private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
+ return new SABnzbdService(
+ config.url,
+ config.password, // API key stored in password field
+ config.category || 'readmeabook', // defaultCategory
+ config.disableSSLVerify
+ );
+ }
+
+ /**
+ * Migrate legacy single-client config to new multi-client format
+ */
+ private async migrateLegacyConfig(): Promise {
+ logger.info('Checking for legacy download client config...');
+
+ const [
+ clientType,
+ clientUrl,
+ clientUsername,
+ clientPassword,
+ disableSSLVerify,
+ remotePathMappingEnabled,
+ remotePath,
+ localPath,
+ category,
+ ] = await Promise.all([
+ this.configService.get('download_client_type'),
+ this.configService.get('download_client_url'),
+ this.configService.get('download_client_username'),
+ this.configService.get('download_client_password'),
+ this.configService.get('download_client_disable_ssl_verify'),
+ this.configService.get('download_client_remote_path_mapping_enabled'),
+ this.configService.get('download_client_remote_path'),
+ this.configService.get('download_client_local_path'),
+ this.configService.get('sabnzbd_category'),
+ ]);
+
+ // Check if legacy config exists
+ if (!clientType || !clientUrl || !clientPassword) {
+ logger.info('No legacy config found');
+ return false;
+ }
+
+ logger.info(`Migrating legacy ${clientType} config...`);
+
+ const newClient: DownloadClientConfig = {
+ id: randomUUID(),
+ type: clientType as 'qbittorrent' | 'sabnzbd',
+ name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
+ enabled: true,
+ url: clientUrl,
+ username: clientUsername || undefined,
+ password: clientPassword,
+ disableSSLVerify: disableSSLVerify === 'true',
+ remotePathMappingEnabled: remotePathMappingEnabled === 'true',
+ remotePath: remotePath || undefined,
+ localPath: localPath || undefined,
+ category: category || 'readmeabook',
+ };
+
+ // Save to new format
+ const newConfig = [newClient];
+ await this.configService.setMany([
+ { key: 'download_clients', value: JSON.stringify(newConfig) },
+ ]);
+
+ logger.info('Migration completed successfully', {
+ type: newClient.type,
+ name: newClient.name,
+ id: newClient.id
+ });
+
+ // Update cache
+ this.clientsCache = newConfig;
+
+ return true;
+ }
+}
+
+/**
+ * Get or create singleton instance
+ */
+export function getDownloadClientManager(configService?: ConfigurationService): DownloadClientManager {
+ return DownloadClientManager.getInstance(configService);
+}
+
+/**
+ * Invalidate singleton (call after config changes)
+ */
+export function invalidateDownloadClientManager(): void {
+ DownloadClientManager.invalidate();
+}
diff --git a/tests/app/setup/steps/DownloadClientStep.test.tsx b/tests/app/setup/steps/DownloadClientStep.test.tsx
index 55bf5b5..209372e 100644
--- a/tests/app/setup/steps/DownloadClientStep.test.tsx
+++ b/tests/app/setup/steps/DownloadClientStep.test.tsx
@@ -6,153 +6,717 @@
// @vitest-environment jsdom
import React, { useState } from 'react';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep';
+interface DownloadClient {
+ id: string;
+ type: 'qbittorrent' | 'sabnzbd';
+ name: string;
+ enabled: boolean;
+ url: string;
+ username?: string;
+ password: string;
+ disableSSLVerify: boolean;
+ remotePathMappingEnabled: boolean;
+ remotePath?: string;
+ localPath?: string;
+ category?: string;
+}
+
const DownloadClientHarness = ({
onNext,
onBack,
- initialState,
+ initialClients = [],
}: {
onNext: () => void;
onBack: () => void;
- initialState?: Partial>;
+ initialClients?: DownloadClient[];
}) => {
- const [state, setState] = useState({
- downloadClient: 'qbittorrent' as const,
- downloadClientUrl: 'https://qbittorrent.local',
- downloadClientUsername: 'admin',
- downloadClientPassword: 'secret',
- disableSSLVerify: false,
- remotePathMappingEnabled: false,
- remotePath: '',
- localPath: '',
- ...initialState,
- });
+ const [downloadClients, setDownloadClients] = useState(initialClients);
return (
setState((prev) => ({ ...prev, [field]: value }))}
+ downloadClients={downloadClients}
+ onUpdate={(field, value) => {
+ if (field === 'downloadClients') {
+ setDownloadClients(value);
+ }
+ }}
onNext={onNext}
onBack={onBack}
/>
);
};
+// Helper to create a mock client
+const createMockClient = (overrides: Partial = {}): DownloadClient => ({
+ id: 'test-client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'secret',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ ...overrides,
+});
+
describe('DownloadClientStep', () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
afterEach(() => {
vi.unstubAllGlobals();
+ vi.clearAllMocks();
});
- it('tests connection and enables navigation after success', async () => {
- const fetchMock = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({ success: true, version: '1.2.3' }),
- });
- vi.stubGlobal('fetch', fetchMock);
- const onNext = vi.fn();
+ describe('Initial State', () => {
+ it('shows empty state when no clients configured', () => {
+ render( );
- render( );
-
- fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
-
- await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
+ expect(screen.getByText('No download clients configured yet')).toBeInTheDocument();
+ expect(screen.getByText('Add at least one client to start downloading audiobooks')).toBeInTheDocument();
});
- expect(screen.getByText(/Connected successfully!/)).toBeInTheDocument();
+ it('shows Add qBittorrent and Add SABnzbd buttons', () => {
+ render( );
- fireEvent.click(screen.getByRole('button', { name: 'Next' }));
- expect(onNext).toHaveBeenCalled();
- });
-
- it('shows remote path fields and toggles SSL verify', async () => {
- render( );
-
- const sslToggle = screen.getByLabelText('Disable SSL Certificate Verification');
- fireEvent.click(sslToggle);
- expect(sslToggle).toBeChecked();
-
- const remoteToggle = screen.getByLabelText('Enable Remote Path Mapping');
- fireEvent.click(remoteToggle);
-
- expect(screen.getByPlaceholderText('/remote/mnt/d/done')).toBeInTheDocument();
- expect(screen.getByPlaceholderText('/downloads')).toBeInTheDocument();
- });
-
- it('switches to SABnzbd and shows API key field', async () => {
- render(
-
- );
-
- fireEvent.click(screen.getByRole('button', { name: /SABnzbd/ }));
-
- expect(screen.getByText('API Key')).toBeInTheDocument();
- expect(screen.queryByText('Username')).toBeNull();
- });
-
- it('blocks next when connection has not been tested', async () => {
- const onNext = vi.fn();
- render( );
-
- fireEvent.click(screen.getByRole('button', { name: 'Next' }));
-
- expect(screen.getByText('Please test the connection before proceeding')).toBeInTheDocument();
- expect(onNext).not.toHaveBeenCalled();
- });
-
- it('shows an error when the connection test fails', async () => {
- const fetchMock = vi.fn().mockResolvedValue({
- ok: false,
- json: async () => ({ error: 'Bad credentials' }),
- });
- vi.stubGlobal('fetch', fetchMock);
-
- render( );
-
- fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
-
- await waitFor(() => {
- expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
+ expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
});
- await waitFor(() => {
- expect(screen.getByText(/Bad credentials/)).toBeInTheDocument();
+ it('displays configured clients when provided', () => {
+ const mockClient = createMockClient({ name: 'My qBittorrent' });
+ render( );
+
+ expect(screen.getByText('My qBittorrent')).toBeInTheDocument();
+ expect(screen.queryByText('No download clients configured yet')).not.toBeInTheDocument();
});
});
- it('disables test connection when SABnzbd fields are incomplete', async () => {
- render(
-
- );
+ describe('Adding a qBittorrent Client', () => {
+ it('opens modal when clicking Add qBittorrent', async () => {
+ render( );
- const testButton = screen.getByRole('button', { name: 'Test Connection' });
- expect(testButton).toBeDisabled();
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+ });
+
+ it('shows correct form fields for qBittorrent', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // qBittorrent should show Name, URL, Username, Password
+ expect(screen.getByText('Name')).toBeInTheDocument();
+ expect(screen.getByText('URL')).toBeInTheDocument();
+ expect(screen.getByText('Username')).toBeInTheDocument();
+ expect(screen.getByText('Password')).toBeInTheDocument();
+ });
+
+ it('validates required fields before testing connection', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Click Test Connection without filling required fields
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ // Should show validation errors
+ await waitFor(() => {
+ expect(screen.getByText(/URL is required/i)).toBeInTheDocument();
+ });
+
+ // fetch should not have been called
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('tests connection and shows success message', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, message: 'Connected to qBittorrent v4.5.0' }),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Fill in required fields
+ const urlInput = screen.getByPlaceholderText('http://localhost:8080');
+ const usernameInput = screen.getByPlaceholderText('admin');
+ const passwordInput = screen.getByPlaceholderText('Password');
+
+ fireEvent.change(urlInput, { target: { value: 'http://localhost:8080' } });
+ fireEvent.change(usernameInput, { target: { value: 'admin' } });
+ fireEvent.change(passwordInput, { target: { value: 'secret' } });
+
+ // Test connection
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/Connected to qBittorrent v4.5.0/i)).toBeInTheDocument();
+ });
+ });
+
+ it('shows error message when connection test fails', async () => {
+ fetchMock.mockResolvedValue({
+ ok: false,
+ json: async () => ({ error: 'Invalid credentials' }),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Fill in required fields
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'http://localhost:8080' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
+ fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'wrong' } });
+
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Invalid credentials/i)).toBeInTheDocument();
+ });
+ });
+
+ it('enables save button only after successful connection test', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, message: 'Connected successfully!' }),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Add Client button should be disabled initially
+ const addButton = screen.getByRole('button', { name: /Add Client/i });
+ expect(addButton).toBeDisabled();
+
+ // Fill and test
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'http://localhost:8080' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
+ fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Connected successfully!/i)).toBeInTheDocument();
+ });
+
+ // Now Add Client should be enabled
+ expect(addButton).not.toBeDisabled();
+ });
+
+ it('adds client to list after saving', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, message: 'Connected successfully!' }),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Fill and test
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'http://localhost:8080' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
+ fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Connected successfully!/i)).toBeInTheDocument();
+ });
+
+ // Save the client
+ fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
+
+ // Modal should close and client should appear in list
+ await waitFor(() => {
+ expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
+ });
+
+ // Client should be in the configured clients list
+ expect(screen.getByText('Configured Clients')).toBeInTheDocument();
+ // The client name should be visible in the configured clients section
+ const configuredSection = screen.getByText('Configured Clients').parentElement;
+ expect(configuredSection).toBeInTheDocument();
+ // There should be edit/delete buttons for the configured client
+ expect(screen.getByTitle('Edit client')).toBeInTheDocument();
+ expect(screen.getByTitle('Delete client')).toBeInTheDocument();
+ });
});
- it('hides SSL toggle when using http URLs', async () => {
- render(
-
- );
+ describe('Adding a SABnzbd Client', () => {
+ it('opens modal when clicking Add SABnzbd', async () => {
+ render( );
- expect(screen.queryByLabelText('Disable SSL Certificate Verification')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
+ });
+ });
+
+ it('shows API Key field instead of Username for SABnzbd', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
+ });
+
+ // SABnzbd should show API Key, not Username
+ expect(screen.getByText('API Key')).toBeInTheDocument();
+ expect(screen.queryByText('Username')).not.toBeInTheDocument();
+ });
+
+ it('validates API key is required for SABnzbd', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
+ });
+
+ // Fill URL but not API key
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8081'), {
+ target: { value: 'http://localhost:8081' },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/API key is required/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('SSL Verification Toggle', () => {
+ it('shows SSL toggle only for HTTPS URLs', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // SSL toggle should not be visible for HTTP
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'http://localhost:8080' },
+ });
+
+ expect(screen.queryByText(/Disable SSL certificate verification/i)).not.toBeInTheDocument();
+
+ // Change to HTTPS - SSL toggle should appear
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'https://localhost:8080' },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/Disable SSL certificate verification/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Remote Path Mapping', () => {
+ it('shows remote path fields when enabled', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Remote path fields should not be visible initially
+ expect(screen.queryByText(/Remote Path \(qBittorrent\)/i)).not.toBeInTheDocument();
+
+ // Enable remote path mapping
+ const toggle = screen.getByLabelText(/Enable Remote Path Mapping/i);
+ fireEvent.click(toggle);
+
+ // Now remote path fields should be visible
+ await waitFor(() => {
+ expect(screen.getByText(/Remote Path \(qBittorrent\)/i)).toBeInTheDocument();
+ expect(screen.getByText(/Local Path \(ReadMeABook\)/i)).toBeInTheDocument();
+ });
+ });
+
+ it('validates remote path fields when enabled', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, message: 'Connected!' }),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Fill required fields
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'http://localhost:8080' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
+ fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
+
+ // Enable remote path mapping but don't fill paths
+ fireEvent.click(screen.getByLabelText(/Enable Remote Path Mapping/i));
+
+ // Try to test connection
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Remote path is required/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Navigation', () => {
+ it('blocks Next when no enabled client is configured', () => {
+ const onNext = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(screen.getByText(/Please add at least one download client before proceeding/i)).toBeInTheDocument();
+ expect(onNext).not.toHaveBeenCalled();
+ });
+
+ it('allows Next when at least one enabled client exists', () => {
+ const onNext = vi.fn();
+ const mockClient = createMockClient();
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(onNext).toHaveBeenCalled();
+ });
+
+ it('blocks Next when client exists but is disabled', () => {
+ const onNext = vi.fn();
+ const mockClient = createMockClient({ enabled: false });
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
+
+ expect(screen.getByText(/Please add at least one download client before proceeding/i)).toBeInTheDocument();
+ expect(onNext).not.toHaveBeenCalled();
+ });
+
+ it('calls onBack when Back button is clicked', () => {
+ const onBack = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Back' }));
+
+ expect(onBack).toHaveBeenCalled();
+ });
+ });
+
+ describe('Client Type Restrictions', () => {
+ it('shows "Already configured" when qBittorrent is already added', () => {
+ const mockClient = createMockClient({ type: 'qbittorrent' });
+
+ render( );
+
+ // "Already configured" text should appear for qBittorrent
+ expect(screen.getByText('Already configured')).toBeInTheDocument();
+
+ // Add qBittorrent button should not exist
+ expect(screen.queryByRole('button', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
+
+ // SABnzbd should still have Add button
+ expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
+ });
+
+ it('shows "Already configured" when SABnzbd is already added', () => {
+ const mockClient = createMockClient({ type: 'sabnzbd', name: 'My SABnzbd' });
+
+ render( );
+
+ // "Already configured" text should appear for SABnzbd
+ expect(screen.getByText('Already configured')).toBeInTheDocument();
+
+ // Add SABnzbd button should not exist
+ expect(screen.queryByRole('button', { name: /Add SABnzbd/i })).not.toBeInTheDocument();
+
+ // qBittorrent should still have Add button
+ expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('Client Card Actions', () => {
+ it('opens edit modal when edit button is clicked', async () => {
+ const mockClient = createMockClient({ name: 'My qBittorrent' });
+
+ render( );
+
+ // Find and click edit button
+ const editButton = screen.getByTitle('Edit client');
+ fireEvent.click(editButton);
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Edit qBittorrent/i })).toBeInTheDocument();
+ });
+ });
+
+ it('shows delete confirmation when delete button is clicked', async () => {
+ const mockClient = createMockClient({ name: 'My qBittorrent' });
+
+ render( );
+
+ // Find and click delete button
+ const deleteButton = screen.getByTitle('Delete client');
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
+ expect(screen.getByText(/Are you sure you want to delete/i)).toBeInTheDocument();
+ });
+ });
+
+ it('removes client when delete is confirmed', async () => {
+ const mockClient = createMockClient({ name: 'My qBittorrent' });
+
+ render( );
+
+ // Click delete button
+ fireEvent.click(screen.getByTitle('Delete client'));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
+ });
+
+ // Confirm deletion
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+
+ // Client should be removed
+ await waitFor(() => {
+ expect(screen.queryByText('My qBittorrent')).not.toBeInTheDocument();
+ expect(screen.getByText('No download clients configured yet')).toBeInTheDocument();
+ });
+ });
+
+ it('cancels delete when cancel is clicked', async () => {
+ const mockClient = createMockClient({ name: 'My qBittorrent' });
+
+ render( );
+
+ // Click delete button
+ fireEvent.click(screen.getByTitle('Delete client'));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
+ });
+
+ // Cancel deletion
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ // Dialog should close, client should still be there
+ await waitFor(() => {
+ expect(screen.queryByText(/Delete Download Client/i)).not.toBeInTheDocument();
+ expect(screen.getByText('My qBittorrent')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Multiple Clients', () => {
+ it('allows configuring both qBittorrent and SABnzbd', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, message: 'Connected!' }),
+ });
+
+ render( );
+
+ // Add qBittorrent
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'http://localhost:8080' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
+ fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Connected!/i)).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
+ });
+
+ // Now add SABnzbd
+ fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8081'), {
+ target: { value: 'http://localhost:8081' },
+ });
+ fireEvent.change(screen.getByPlaceholderText(/API Key from SABnzbd/i), {
+ target: { value: 'my-api-key' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ // Find the success message in the modal
+ const successMessages = screen.getAllByText(/Connected!/i);
+ expect(successMessages.length).toBeGreaterThan(0);
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
+
+ // Both clients should be in the list - check for edit buttons (2 of them)
+ await waitFor(() => {
+ const editButtons = screen.getAllByTitle('Edit client');
+ expect(editButtons).toHaveLength(2);
+ });
+
+ // Both "Already configured" messages should appear
+ const alreadyConfiguredMessages = screen.getAllByText('Already configured');
+ expect(alreadyConfiguredMessages).toHaveLength(2);
+ });
+ });
+
+ describe('Modal Behavior', () => {
+ it('closes modal when Cancel is clicked', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
+ });
+ });
+
+ it('closes modal when clicking the X button', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Find and click the close button (X icon in modal header)
+ const modal = screen.getByRole('heading', { name: /Add qBittorrent/i }).closest('[class*="relative"]');
+ const closeButton = within(modal!).getAllByRole('button')[0]; // First button in modal header area
+ fireEvent.click(closeButton);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
+ });
+ });
+
+ it('resets form state when reopening modal', async () => {
+ fetchMock.mockResolvedValue({
+ ok: false,
+ json: async () => ({ error: 'Connection failed' }),
+ });
+
+ render( );
+
+ // Open, fill, and trigger error
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
+ target: { value: 'http://bad-url' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'user' } });
+ fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'pass' } });
+ fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Connection failed/i)).toBeInTheDocument();
+ });
+
+ // Close modal
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
+ });
+
+ // Reopen - error should be cleared
+ fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
+ });
+
+ // Error message should not be present
+ expect(screen.queryByText(/Connection failed/i)).not.toBeInTheDocument();
+ });
});
});
diff --git a/tests/components/ui/Modal.test.tsx b/tests/components/ui/Modal.test.tsx
index 8f300c8..23707f2 100644
--- a/tests/components/ui/Modal.test.tsx
+++ b/tests/components/ui/Modal.test.tsx
@@ -26,6 +26,6 @@ describe('Modal', () => {
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
- expect(document.body.style.overflow).toBe('unset');
+ expect(document.body.style.overflow).toBe(''); // Cleared to default
});
});
diff --git a/tests/integrations/prowlarr.service.test.ts b/tests/integrations/prowlarr.service.test.ts
index 01fa805..aae4e50 100644
--- a/tests/integrations/prowlarr.service.test.ts
+++ b/tests/integrations/prowlarr.service.test.ts
@@ -25,6 +25,13 @@ const configMock = vi.hoisted(() => ({
getMany: vi.fn(),
}));
+// Mock for DownloadClientManager
+const downloadClientManagerMock = vi.hoisted(() => ({
+ getClientForProtocol: vi.fn(),
+ getAllClients: vi.fn(),
+ hasClientForProtocol: vi.fn(),
+}));
+
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
@@ -34,16 +41,27 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
+vi.mock('@/lib/services/download-client-manager.service', () => ({
+ getDownloadClientManager: () => downloadClientManagerMock,
+ invalidateDownloadClientManager: vi.fn(),
+}));
+
describe('ProwlarrService', () => {
beforeEach(() => {
vi.clearAllMocks();
clientMock.get.mockReset();
axiosMock.get.mockReset();
configMock.get.mockReset();
+ downloadClientManagerMock.getClientForProtocol.mockReset();
+ downloadClientManagerMock.getAllClients.mockReset();
+ downloadClientManagerMock.hasClientForProtocol.mockReset();
});
it('filters results for SABnzbd (usenet)', async () => {
- configMock.get.mockResolvedValue('sabnzbd');
+ // Mock: Only SABnzbd is configured (usenet only)
+ downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
+ return protocol === 'usenet';
+ });
clientMock.get.mockResolvedValue({
data: [
{
@@ -76,7 +94,10 @@ describe('ProwlarrService', () => {
});
it('throws when search fails', async () => {
- configMock.get.mockResolvedValue('qbittorrent');
+ // Mock: qBittorrent is configured (torrent only)
+ downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
+ return protocol === 'torrent';
+ });
clientMock.get.mockRejectedValue(new Error('bad search'));
const service = new ProwlarrService('http://prowlarr', 'key');
@@ -85,7 +106,10 @@ describe('ProwlarrService', () => {
});
it('filters results for qBittorrent (torrent)', async () => {
- configMock.get.mockResolvedValue('qbittorrent');
+ // Mock: Only qBittorrent is configured (torrent only)
+ downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
+ return protocol === 'torrent';
+ });
clientMock.get.mockResolvedValue({
data: [
{
@@ -178,7 +202,10 @@ describe('ProwlarrService', () => {
});
it('applies category, indexer, and seeder filters', async () => {
- configMock.get.mockResolvedValue('qbittorrent');
+ // Mock: Only qBittorrent is configured (torrent only)
+ downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
+ return protocol === 'torrent';
+ });
clientMock.get.mockResolvedValue({
data: [
{
@@ -223,9 +250,8 @@ describe('ProwlarrService', () => {
});
it('returns unfiltered results when protocol filtering fails', async () => {
- configMock.get
- .mockResolvedValueOnce('qbittorrent')
- .mockRejectedValueOnce(new Error('config fail'));
+ // Mock: hasClientForProtocol throws an error
+ downloadClientManagerMock.hasClientForProtocol.mockRejectedValue(new Error('config fail'));
clientMock.get.mockResolvedValue({
data: [
diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts
index 0208046..7316fc1 100644
--- a/tests/integrations/qbittorrent.service.test.ts
+++ b/tests/integrations/qbittorrent.service.test.ts
@@ -21,6 +21,14 @@ const axiosMock = vi.hoisted(() => ({
const parseTorrentMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({
getMany: vi.fn(),
+ get: vi.fn(),
+}));
+
+// Mock for DownloadClientManager
+const downloadClientManagerMock = vi.hoisted(() => ({
+ getClientForProtocol: vi.fn(),
+ getAllClients: vi.fn(),
+ hasClientForProtocol: vi.fn(),
}));
vi.mock('axios', () => ({
@@ -33,7 +41,12 @@ vi.mock('parse-torrent', () => ({
}));
vi.mock('@/lib/services/config.service', () => ({
- getConfigService: () => configServiceMock,
+ getConfigService: vi.fn(async () => configServiceMock),
+}));
+
+vi.mock('@/lib/services/download-client-manager.service', () => ({
+ getDownloadClientManager: () => downloadClientManagerMock,
+ invalidateDownloadClientManager: vi.fn(),
}));
describe('QBittorrentService', () => {
@@ -45,6 +58,10 @@ describe('QBittorrentService', () => {
axiosMock.post.mockReset();
parseTorrentMock.mockReset();
configServiceMock.getMany.mockReset();
+ configServiceMock.get.mockReset();
+ downloadClientManagerMock.getClientForProtocol.mockReset();
+ downloadClientManagerMock.getAllClients.mockReset();
+ downloadClientManagerMock.hasClientForProtocol.mockReset();
invalidateQBittorrentService();
});
@@ -586,25 +603,26 @@ describe('QBittorrentService', () => {
});
it('throws when qBittorrent configuration is incomplete', async () => {
- configServiceMock.getMany.mockResolvedValue({
- download_client_url: null,
- download_client_username: null,
- download_client_password: null,
- download_dir: null,
- download_client_disable_ssl_verify: 'false',
- });
+ // Mock: no qBittorrent client configured
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
- await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not fully configured');
+ await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not configured');
});
it('returns a cached instance after successful initialization', async () => {
- configServiceMock.getMany.mockResolvedValue({
- download_client_url: 'http://qb',
- download_client_username: 'user',
- download_client_password: 'pass',
- download_dir: '/downloads',
- download_client_disable_ssl_verify: 'false',
+ // Mock: qBittorrent client configured
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://qb',
+ username: 'user',
+ password: 'pass',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
});
+ configServiceMock.get.mockResolvedValue('/downloads');
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
@@ -612,19 +630,26 @@ describe('QBittorrentService', () => {
const second = await getQBittorrentService();
expect(first).toBe(second);
- expect(configServiceMock.getMany).toHaveBeenCalledTimes(1);
+ // Should only call getClientForProtocol once (cached after first call)
+ expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledTimes(1);
testConnectionSpy.mockRestore();
});
it('throws when connection test fails during service creation', async () => {
- configServiceMock.getMany.mockResolvedValue({
- download_client_url: 'http://qb',
- download_client_username: 'user',
- download_client_password: 'pass',
- download_dir: '/downloads',
- download_client_disable_ssl_verify: 'false',
+ // Mock: qBittorrent client configured
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://qb',
+ username: 'user',
+ password: 'pass',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
});
+ configServiceMock.get.mockResolvedValue('/downloads');
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
diff --git a/tests/integrations/sabnzbd.service.test.ts b/tests/integrations/sabnzbd.service.test.ts
index 59e7094..5204b75 100644
--- a/tests/integrations/sabnzbd.service.test.ts
+++ b/tests/integrations/sabnzbd.service.test.ts
@@ -18,13 +18,25 @@ const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
+// Mock for DownloadClientManager
+const downloadClientManagerMock = vi.hoisted(() => ({
+ getClientForProtocol: vi.fn(),
+ getAllClients: vi.fn(),
+ hasClientForProtocol: vi.fn(),
+}));
+
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
vi.mock('@/lib/services/config.service', () => ({
- getConfigService: () => configServiceMock,
+ getConfigService: vi.fn(async () => configServiceMock),
+}));
+
+vi.mock('@/lib/services/download-client-manager.service', () => ({
+ getDownloadClientManager: () => downloadClientManagerMock,
+ invalidateDownloadClientManager: vi.fn(),
}));
describe('SABnzbdService', () => {
@@ -32,6 +44,9 @@ describe('SABnzbdService', () => {
vi.clearAllMocks();
clientMock.get.mockReset();
configServiceMock.get.mockReset();
+ downloadClientManagerMock.getClientForProtocol.mockReset();
+ downloadClientManagerMock.getAllClients.mockReset();
+ downloadClientManagerMock.hasClientForProtocol.mockReset();
invalidateSABnzbdService();
});
@@ -456,22 +471,19 @@ describe('SABnzbdService', () => {
});
it('creates a singleton service from config', async () => {
- configServiceMock.get.mockImplementation(async (key: string) => {
- switch (key) {
- case 'download_client_url':
- return 'http://sab';
- case 'download_client_password':
- return 'api-key';
- case 'sabnzbd_category':
- return 'books';
- case 'download_client_disable_ssl_verify':
- return 'false';
- case 'download_dir':
- return '/downloads';
- default:
- return null;
- }
+ // Mock: SABnzbd client configured via DownloadClientManager
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
+ id: 'client-1',
+ type: 'sabnzbd',
+ name: 'SABnzbd',
+ enabled: true,
+ url: 'http://sab',
+ password: 'api-key', // API key stored in password field
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'books',
});
+ configServiceMock.get.mockResolvedValue('/downloads');
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
diff --git a/tests/processors/download-torrent.processor.test.ts b/tests/processors/download-torrent.processor.test.ts
index 3bf429d..b61c80b 100644
--- a/tests/processors/download-torrent.processor.test.ts
+++ b/tests/processors/download-torrent.processor.test.ts
@@ -13,6 +13,10 @@ const jobQueueMock = createJobQueueMock();
const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() }));
const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
+const downloadClientManagerMock = vi.hoisted(() => ({
+ getClientForProtocol: vi.fn(),
+}));
+
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
@@ -21,6 +25,10 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
+vi.mock('@/lib/services/download-client-manager.service', () => ({
+ getDownloadClientManager: () => downloadClientManagerMock,
+}));
+
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
@@ -33,16 +41,26 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
getSABnzbdService: () => sabMock,
}));
+vi.mock('@/lib/integrations/prowlarr.service', () => ({
+ ProwlarrService: {
+ isNZBResult: vi.fn((result: any) => {
+ // Detect NZB by URL pattern or protocol field
+ return result.downloadUrl?.endsWith('.nzb') || result.protocol === 'usenet';
+ }),
+ },
+}));
+
describe('processDownloadTorrent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
- const payload = {
+ const torrentPayload = {
requestId: 'req-1',
audiobook: { id: 'a1', title: 'Book', author: 'Author' },
torrent: {
indexer: 'Indexer',
+ indexerId: 1,
title: 'Book - Author',
size: 50 * 1024 * 1024,
seeders: 10,
@@ -50,20 +68,44 @@ describe('processDownloadTorrent', () => {
downloadUrl: 'magnet:?xt=urn:btih:abc',
guid: 'guid-1',
format: 'M4B',
+ protocol: 'torrent',
},
jobId: 'job-1',
};
- it('routes downloads to qBittorrent by default', async () => {
- configMock.get.mockResolvedValue('qbittorrent');
+ const nzbPayload = {
+ requestId: 'req-2',
+ audiobook: { id: 'a2', title: 'Book2', author: 'Author2' },
+ torrent: {
+ indexer: 'UsenetIndexer',
+ indexerId: 2,
+ title: 'Book2 - Author2',
+ size: 100 * 1024 * 1024,
+ seeders: 0,
+ publishDate: new Date(),
+ downloadUrl: 'http://indexer.com/download/file.nzb',
+ guid: 'guid-2',
+ format: 'M4B',
+ protocol: 'usenet',
+ },
+ jobId: 'job-2',
+ };
+
+ it('routes torrent downloads to qBittorrent', async () => {
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
+ id: 'client-1',
+ type: 'qbittorrent',
+ enabled: true,
+ });
qbtMock.addTorrent.mockResolvedValue('hash-1');
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
- const result = await processDownloadTorrent(payload);
+ const result = await processDownloadTorrent(torrentPayload);
expect(result.success).toBe(true);
+ expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
expect(qbtMock.addTorrent).toHaveBeenCalled();
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
'req-1',
@@ -74,25 +116,70 @@ describe('processDownloadTorrent', () => {
);
});
- it('routes downloads to SABnzbd when configured', async () => {
- configMock.get.mockResolvedValue('sabnzbd');
+ it('routes NZB downloads to SABnzbd', async () => {
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
+ id: 'client-2',
+ type: 'sabnzbd',
+ enabled: true,
+ });
sabMock.addNZB.mockResolvedValue('nzb-1');
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
- const result = await processDownloadTorrent(payload);
+ const result = await processDownloadTorrent(nzbPayload);
expect(result.success).toBe(true);
+ expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
expect(sabMock.addNZB).toHaveBeenCalled();
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
- 'req-1',
+ 'req-2',
'dh-2',
'nzb-1',
'sabnzbd',
3
);
});
+
+ it('throws error when no client configured for protocol', async () => {
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
+
+ const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
+
+ await expect(processDownloadTorrent(torrentPayload)).rejects.toThrow(
+ 'No Torrent (qBittorrent) client configured'
+ );
+
+ expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
+ });
+
+ it('detects protocol from result and routes appropriately', async () => {
+ // Torrent result
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
+ id: 'client-1',
+ type: 'qbittorrent',
+ enabled: true,
+ });
+ qbtMock.addTorrent.mockResolvedValue('hash-1');
+ prismaMock.request.update.mockResolvedValue({});
+ prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
+
+ const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
+ await processDownloadTorrent(torrentPayload);
+
+ expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
+
+ // NZB result
+ downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
+ id: 'client-2',
+ type: 'sabnzbd',
+ enabled: true,
+ });
+ sabMock.addNZB.mockResolvedValue('nzb-1');
+ prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
+
+ await processDownloadTorrent(nzbPayload);
+
+ expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
+ });
});
-
-
diff --git a/tests/services/download-client-manager.service.test.ts b/tests/services/download-client-manager.service.test.ts
new file mode 100644
index 0000000..85aaed0
--- /dev/null
+++ b/tests/services/download-client-manager.service.test.ts
@@ -0,0 +1,445 @@
+/**
+ * Component: Download Client Manager Service Tests
+ * Documentation: documentation/phase3/download-clients.md
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createPrismaMock } from '../helpers/prisma';
+
+const prismaMock = createPrismaMock();
+
+const configMock = vi.hoisted(() => ({
+ get: vi.fn(),
+ setMany: vi.fn(),
+}));
+
+vi.mock('@/lib/db', () => ({
+ prisma: prismaMock,
+}));
+
+vi.mock('@/lib/services/config.service', () => ({
+ getConfigService: () => configMock,
+}));
+
+// Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time
+const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({
+ qbtServiceMock: {
+ testConnection: vi.fn(),
+ },
+ sabServiceMock: {
+ getVersion: vi.fn(),
+ },
+}));
+
+// Use class syntax for proper constructor mocking
+vi.mock('@/lib/integrations/qbittorrent.service', () => ({
+ QBittorrentService: class MockQBittorrentService {
+ testConnection = qbtServiceMock.testConnection;
+ },
+}));
+
+vi.mock('@/lib/integrations/sabnzbd.service', () => ({
+ SABnzbdService: class MockSABnzbdService {
+ getVersion = sabServiceMock.getVersion;
+ },
+}));
+
+describe('DownloadClientManager', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ // Reset singleton using dynamic import
+ const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ invalidateDownloadClientManager();
+ });
+
+ describe('getAllClients', () => {
+ it('returns parsed clients from config', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getAllClients();
+
+ expect(result).toEqual(clients);
+ expect(configMock.get).toHaveBeenCalledWith('download_clients');
+ });
+
+ it('returns empty array when no clients configured', async () => {
+ configMock.get.mockResolvedValue(null);
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getAllClients();
+
+ expect(result).toEqual([]);
+ });
+
+ it('caches clients for subsequent calls', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ await manager.getAllClients();
+ await manager.getAllClients();
+
+ expect(configMock.get).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('getClientForProtocol', () => {
+ it('returns qBittorrent client for torrent protocol', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getClientForProtocol('torrent');
+
+ expect(result).toEqual(clients[0]);
+ });
+
+ it('returns SABnzbd client for usenet protocol', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'sabnzbd',
+ name: 'SABnzbd',
+ enabled: true,
+ url: 'http://localhost:8081',
+ password: 'apikey',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getClientForProtocol('usenet');
+
+ expect(result).toEqual(clients[0]);
+ });
+
+ it('returns null when no client configured for protocol', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getClientForProtocol('usenet');
+
+ expect(result).toBeNull();
+ });
+
+ it('skips disabled clients', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: false, // Disabled
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getClientForProtocol('torrent');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('hasClientForProtocol', () => {
+ it('returns true when client is configured', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.hasClientForProtocol('torrent');
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when client is not configured', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.hasClientForProtocol('usenet');
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('testConnection', () => {
+ it('successfully tests qBittorrent connection', async () => {
+ qbtServiceMock.testConnection.mockResolvedValue(undefined);
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const config = {
+ id: 'client-1',
+ type: 'qbittorrent' as const,
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ };
+
+ const result = await manager.testConnection(config);
+
+ expect(result.success).toBe(true);
+ expect(result.message).toBe('Successfully connected to qBittorrent');
+ });
+
+ it('successfully tests SABnzbd connection', async () => {
+ sabServiceMock.getVersion.mockResolvedValue('3.5.0');
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const config = {
+ id: 'client-1',
+ type: 'sabnzbd' as const,
+ name: 'SABnzbd',
+ enabled: true,
+ url: 'http://localhost:8081',
+ password: 'apikey',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ };
+
+ const result = await manager.testConnection(config);
+
+ expect(result.success).toBe(true);
+ expect(result.message).toBe('Successfully connected to SABnzbd (v3.5.0)');
+ });
+
+ it('returns error on connection failure', async () => {
+ qbtServiceMock.testConnection.mockRejectedValue(new Error('Connection refused'));
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const config = {
+ id: 'client-1',
+ type: 'qbittorrent' as const,
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ };
+
+ const result = await manager.testConnection(config);
+
+ expect(result.success).toBe(false);
+ expect(result.message).toBe('Connection refused');
+ });
+ });
+
+ describe('migration', () => {
+ it('migrates legacy single-client config to array format', async () => {
+ // First call returns null for download_clients (new format doesn't exist)
+ // Then return legacy values for migration
+ configMock.get
+ .mockResolvedValueOnce(null) // download_clients
+ .mockResolvedValueOnce('qbittorrent') // download_client_type
+ .mockResolvedValueOnce('http://localhost:8080') // download_client_url
+ .mockResolvedValueOnce('admin') // download_client_username
+ .mockResolvedValueOnce('password') // download_client_password
+ .mockResolvedValueOnce('false') // download_client_disable_ssl_verify
+ .mockResolvedValueOnce('false') // download_client_remote_path_mapping_enabled
+ .mockResolvedValueOnce(null) // download_client_remote_path
+ .mockResolvedValueOnce(null) // download_client_local_path
+ .mockResolvedValueOnce(null); // sabnzbd_category
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getAllClients();
+
+ expect(result).toHaveLength(1);
+ expect(result[0].type).toBe('qbittorrent');
+ expect(result[0].name).toBe('qBittorrent');
+ expect(result[0].enabled).toBe(true);
+ expect(result[0].url).toBe('http://localhost:8080');
+ expect(result[0].username).toBe('admin');
+ expect(result[0].password).toBe('password');
+
+ // Should have saved the migrated config
+ expect(configMock.setMany).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ key: 'download_clients',
+ value: expect.stringContaining('qbittorrent'),
+ }),
+ ])
+ );
+ });
+
+ it('does not migrate when legacy config is incomplete', async () => {
+ configMock.get
+ .mockResolvedValueOnce(null) // download_clients
+ .mockResolvedValueOnce(null) // download_client_type (missing)
+ .mockResolvedValueOnce(null) // download_client_url (missing)
+ .mockResolvedValueOnce(null); // download_client_password (missing)
+
+ const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ const result = await manager.getAllClients();
+
+ expect(result).toEqual([]);
+ expect(configMock.setMany).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('invalidate', () => {
+ it('clears cache on invalidation', async () => {
+ const clients = [
+ {
+ id: 'client-1',
+ type: 'qbittorrent',
+ name: 'qBittorrent',
+ enabled: true,
+ url: 'http://localhost:8080',
+ username: 'admin',
+ password: 'password',
+ disableSSLVerify: false,
+ remotePathMappingEnabled: false,
+ category: 'readmeabook',
+ },
+ ];
+
+ configMock.get.mockResolvedValue(JSON.stringify(clients));
+
+ const { getDownloadClientManager, invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
+ const manager = getDownloadClientManager(configMock as any);
+
+ await manager.getAllClients(); // First call - caches
+
+ invalidateDownloadClientManager(); // Invalidate cache
+
+ await manager.getAllClients(); // Second call - should fetch again
+
+ expect(configMock.get).toHaveBeenCalledTimes(2);
+ });
+ });
+});