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.

-
- - -
- -
- - updateField('url', e.target.value)} - placeholder="http://localhost:8080" - /> -
- - {/* qBittorrent: Username + Password */} - {downloadClient.type === 'qbittorrent' && ( - <> -
- - updateField('username', e.target.value)} - placeholder="admin" - /> -
- -
- - updateField('password', e.target.value)} - placeholder="Enter password" - /> -
- - )} - - {/* SABnzbd: API Key only */} - {downloadClient.type === 'sabnzbd' && ( -
- - 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" - /> -
- -

- 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" - /> -
- -

- 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 && ( -
-
- - updateField('remotePath', e.target.value)} - /> -

- The path prefix as reported by qBittorrent -

-
- -
- - updateField('localPath', e.target.value)} - /> -

- The actual path where files are accessible -

-
-
- )} -
-
-
- -
- - {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.

-
-
- -
- - -
+ {error && ( +
+

{error}

+ )} -
- - onUpdate('downloadClientUrl', e.target.value)} - /> -

- The URL where your download client is running (include port) -

-
+ - {downloadClient === 'qbittorrent' && ( - <> -
- - onUpdate('downloadClientUsername', e.target.value)} - autoComplete="username" - /> -
- -
- - onUpdate('downloadClientPassword', e.target.value)} - autoComplete="current-password" - /> -
- - )} - - {downloadClient === 'sabnzbd' && ( -
- - 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" - /> -
- -

- 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" - /> -
- -

- 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 && ( -
-
- - onUpdate('remotePath', e.target.value)} - /> -

- The path prefix as reported by {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} -

-
- -
- - onUpdate('localPath', e.target.value)} - /> -

- The actual path where files are accessible -

-
-
- )} -
-
-
- - - - {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)'} -

-
-
-
- -
- - +
); 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}

+ +
+ )} + + {/* Add Client Section */} +
+

+ Add Download Client +

+
+ {/* qBittorrent Card */} +
+
+
+

+ qBittorrent +

+

+ Torrent downloads +

+
+ + Torrent + +
+ {hasQBittorrent ? ( +
+ Already configured +
+ ) : ( + + )} +
+ + {/* SABnzbd Card */} +
+
+
+

+ SABnzbd +

+

+ Usenet/NZB downloads +

+
+ + Usenet + +
+ {hasSABnzbd ? ( +
+ Already configured +
+ ) : ( + + )} +
+
+
+ + {/* 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. +

+
+ + +
+
+
+ )} +
+ ); +} 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 */} +
+ + setName(e.target.value)} + placeholder={`My ${typeName}`} + error={errors.name} + /> +

+ Friendly name to identify this client +

+
+ + {/* 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' && ( +
+ + setUsername(e.target.value)} + placeholder="admin" + error={errors.username} + /> +
+ )} + + {/* 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" + /> + +
+ )} + + {/* Enabled Toggle */} +
+ setEnabled(e.target.checked)} + className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+ + {/* 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" + /> + +
+ + {remotePathMappingEnabled && ( +
+
+ + setRemotePath(e.target.value)} + placeholder="F:\Docker\downloads\completed\books" + error={errors.remotePath} + /> +

+ Path as seen by {typeName} +

+
+ +
+ + setLocalPath(e.target.value)} + placeholder="/downloads" + error={errors.localPath} + /> +

+ Path as seen by ReadMeABook +

+
+
+ )} +
+ + {/* Test Result */} + {testResult && ( +
+

{testResult.message}

+
+ )} + + {/* Errors */} + {errors.test && ( +
+

{errors.test}

+
+ )} + + {errors.save && ( +
+

{errors.save}

+
+ )} + + {/* Action Buttons */} +
+ + +
+ + +
+
+
+
+ ); +} 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 && (