Add multi-download-client support and UI management

Implements support for configuring both qBittorrent and SABnzbd simultaneously, including migration from legacy config, protocol-aware routing, and protocol filtering. Adds new CRUD API routes for download clients, new UI management components, and updates setup and settings flows to use the new multi-client architecture. Updates documentation to describe the new structure and usage.
This commit is contained in:
kikootwo
2026-01-29 09:21:33 -05:00
parent 3290ebbc9d
commit 2cda6decbe
26 changed files with 3452 additions and 924 deletions
@@ -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 (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Download Client
Download Clients
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
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.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Type
</label>
<select
value={downloadClient.type}
onChange={(e) => 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"
>
<option value="qbittorrent">qBittorrent</option>
<option value="sabnzbd">SABnzbd</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Server URL
</label>
<Input
type="url"
value={downloadClient.url}
onChange={(e) => updateField('url', e.target.value)}
placeholder="http://localhost:8080"
/>
</div>
{/* qBittorrent: Username + Password */}
{downloadClient.type === 'qbittorrent' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<Input
type="text"
value={downloadClient.username}
onChange={(e) => updateField('username', e.target.value)}
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<Input
type="password"
value={downloadClient.password}
onChange={(e) => updateField('password', e.target.value)}
placeholder="Enter password"
/>
</div>
</>
)}
{/* SABnzbd: API Key only */}
{downloadClient.type === 'sabnzbd' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Key
</label>
<Input
type="password"
value={downloadClient.password}
onChange={(e) => updateField('password', e.target.value)}
placeholder="Enter SABnzbd API key"
/>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Find this in SABnzbd under Config General API Key
</p>
</div>
)}
{/* SSL Verification Toggle */}
{downloadClient.url.startsWith('https') && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="disable-ssl-verify"
checked={downloadClient.disableSSLVerify}
onChange={(e) => 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"
/>
<div className="flex-1">
<label
htmlFor="disable-ssl-verify"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Disable SSL Certificate Verification
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Enable this if you're using a self-signed certificate or getting SSL errors.
<span className="text-yellow-700 dark:text-yellow-500 font-medium"> ⚠️ Only use on trusted private networks.</span>
</p>
</div>
</div>
</div>
)}
{/* Remote Path Mapping */}
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="remote-path-mapping"
checked={downloadClient.remotePathMappingEnabled}
onChange={(e) => 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"
/>
<div className="flex-1">
<label
htmlFor="remote-path-mapping"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Enable Remote Path Mapping
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
</p>
{/* Warning for existing downloads */}
{downloadClient.remotePathMappingEnabled && (
<div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
⚠️ <strong>Note:</strong> Path mapping only affects new downloads. In-progress downloads will continue using their original paths.
</p>
</div>
)}
{/* Conditional Fields */}
{downloadClient.remotePathMappingEnabled && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Remote Path (from qBittorrent)
</label>
<Input
type="text"
placeholder="/remote/mnt/d/done"
value={downloadClient.remotePath}
onChange={(e) => updateField('remotePath', e.target.value)}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
The path prefix as reported by qBittorrent
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Local Path (for ReadMeABook)
</label>
<Input
type="text"
placeholder="/downloads"
value={downloadClient.localPath}
onChange={(e) => updateField('localPath', e.target.value)}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
The actual path where files are accessible
</p>
</div>
</div>
)}
</div>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
onClick={testConnection}
loading={testing}
disabled={
!downloadClient.url ||
!downloadClient.password ||
(downloadClient.type === 'qbittorrent' && !downloadClient.username)
}
variant="outline"
className="w-full"
>
Test Connection
</Button>
{testResult && (
<div className={`mt-3 p-3 rounded-lg text-sm ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
}`}>
{testResult.message}
</div>
)}
</div>
<DownloadClientManagement mode="settings" />
</div>
);
}
@@ -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 }
);
}
});
});
}
@@ -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 }
);
}
});
});
}
@@ -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 }
);
}
});
});
}
+64 -44
View File
@@ -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 || '' },
+4 -34
View File
@@ -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 (
<DownloadClientStep
downloadClient={state.downloadClient}
downloadClientUrl={state.downloadClientUrl}
downloadClientUsername={state.downloadClientUsername}
downloadClientPassword={state.downloadClientPassword}
disableSSLVerify={state.disableSSLVerify}
remotePathMappingEnabled={state.remotePathMappingEnabled}
remotePath={state.remotePath}
localPath={state.localPath}
downloadClients={state.downloadClients}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
+48 -361
View File
@@ -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<DownloadClient[]>(downloadClients || []);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure Download Client
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Configure Download Clients
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Choose your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.
<p className="text-gray-600 dark:text-gray-400">
Add at least one download client. You can configure both qBittorrent (torrents) and SABnzbd (Usenet) to search across all indexer types.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Download Client
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => 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'
}`}
>
<div className="font-semibold text-gray-900 dark:text-gray-100">qBittorrent</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Torrent downloads
</div>
</button>
<button
type="button"
onClick={() => 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'
}`}
>
<div className="font-semibold text-gray-900 dark:text-gray-100">SABnzbd</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Usenet/NZB downloads
</div>
</button>
</div>
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300 rounded-lg">
<p className="text-sm">{error}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} URL
</label>
<Input
type="url"
placeholder={downloadClient === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8080/sabnzbd'}
value={downloadClientUrl}
onChange={(e) => onUpdate('downloadClientUrl', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The URL where your download client is running (include port)
</p>
</div>
<DownloadClientManagement
mode="wizard"
initialClients={clients}
onClientsChange={handleClientsChange}
/>
{downloadClient === 'qbittorrent' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<Input
type="text"
placeholder="admin"
value={downloadClientUsername}
onChange={(e) => onUpdate('downloadClientUsername', e.target.value)}
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<Input
type="password"
placeholder="Enter password"
value={downloadClientPassword}
onChange={(e) => onUpdate('downloadClientPassword', e.target.value)}
autoComplete="current-password"
/>
</div>
</>
)}
{downloadClient === 'sabnzbd' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Key
</label>
<Input
type="password"
placeholder="Enter SABnzbd API key"
value={downloadClientPassword}
onChange={(e) => onUpdate('downloadClientPassword', e.target.value)}
autoComplete="off"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Find this in SABnzbd under Config General API Key
</p>
</div>
)}
{/* SSL Verification Toggle */}
{downloadClientUrl.startsWith('https') && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="disable-ssl-verify-setup"
checked={disableSSLVerify}
onChange={(e) => 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"
/>
<div className="flex-1">
<label
htmlFor="disable-ssl-verify-setup"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Disable SSL Certificate Verification
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Enable this if you're using a self-signed certificate or getting SSL errors.
<span className="text-yellow-700 dark:text-yellow-500 font-medium"> ⚠️ Only use on trusted private networks.</span>
</p>
</div>
</div>
</div>
)}
{/* Remote Path Mapping (only for clients that download to filesystem) */}
<div className="mt-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="remote-path-mapping-setup"
checked={remotePathMappingEnabled}
onChange={(e) => 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"
/>
<div className="flex-1">
<label
htmlFor="remote-path-mapping-setup"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Enable Remote Path Mapping
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use this when {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
</p>
{/* Conditional Fields */}
{remotePathMappingEnabled && (
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Remote Path (from {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'})
</label>
<Input
type="text"
placeholder="/remote/mnt/d/done"
value={remotePath}
onChange={(e) => onUpdate('remotePath', e.target.value)}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
The path prefix as reported by {downloadClient === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Local Path (for ReadMeABook)
</label>
<Input
type="text"
placeholder="/downloads"
value={localPath}
onChange={(e) => onUpdate('localPath', e.target.value)}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
The actual path where files are accessible
</p>
</div>
</div>
)}
</div>
</div>
</div>
<Button
onClick={testConnection}
loading={testing}
disabled={!isFormValid}
variant="outline"
className="w-full"
>
Test Connection
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{downloadClient === 'qbittorrent' ? 'qBittorrent Setup' : 'SABnzbd Setup'}
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
{downloadClient === 'qbittorrent'
? 'Make sure Web UI is enabled in qBittorrent settings (Tools Options Web UI)'
: 'Make sure SABnzbd is running and the API key is configured (Config General API Key)'}
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
<div className="flex justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
<Button onClick={onBack} variant="secondary">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
<Button onClick={handleNext} variant="primary">
Next
</Button>
</div>
</div>
);
+29 -16
View File
@@ -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 */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Download Client
Download Clients
</h3>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Type:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 capitalize">
{config.downloadClient}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
{config.downloadClientUrl}
</dd>
</div>
<dl className="space-y-3">
{config.downloadClients && config.downloadClients.length > 0 ? (
config.downloadClients.map((client: any, index: number) => (
<div key={index} className={index > 0 ? 'pt-3 border-t border-gray-200 dark:border-gray-700' : ''}>
<div className="flex justify-between mb-1">
<dt className="text-sm text-gray-600 dark:text-gray-400">Name:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
{client.name}
</dd>
</div>
<div className="flex justify-between mb-1">
<dt className="text-sm text-gray-600 dark:text-gray-400">Type:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 capitalize">
{client.type}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">URL:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
{client.url}
</dd>
</div>
</div>
))
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">No download clients configured</div>
)}
</dl>
</div>
@@ -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 (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between gap-3">
{/* Client Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
{client.name}
</h3>
{!client.enabled && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
Disabled
</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={`inline-block text-xs px-2 py-1 rounded font-medium ${typeColor} w-fit`}>
{typeName}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={client.url}>
{displayUrl}
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Edit Button */}
<button
onClick={onEdit}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Edit client"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{/* Delete Button */}
<button
onClick={onDelete}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Delete client"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
);
}
@@ -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<DownloadClient[]>(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<string | null>(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 (
<div className="space-y-6">
{/* Error Display */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300 rounded-lg">
<p className="text-sm">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-xs underline hover:no-underline"
>
Dismiss
</button>
</div>
)}
{/* Add Client Section */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Add Download Client
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* qBittorrent Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
qBittorrent
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Torrent downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium">
Torrent
</span>
</div>
{hasQBittorrent ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Already configured
</div>
) : (
<Button
onClick={() => handleAddClient('qbittorrent')}
variant="primary"
size="sm"
disabled={loading}
>
Add qBittorrent
</Button>
)}
</div>
{/* SABnzbd Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
SABnzbd
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Usenet/NZB downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium">
Usenet
</span>
</div>
{hasSABnzbd ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Already configured
</div>
) : (
<Button
onClick={() => handleAddClient('sabnzbd')}
variant="primary"
size="sm"
disabled={loading}
>
Add SABnzbd
</Button>
)}
</div>
</div>
</div>
{/* Configured Clients Section */}
{clients.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Configured Clients
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{clients.map(client => (
<DownloadClientCard
key={client.id}
client={client}
onEdit={() => handleEditClient(client)}
onDelete={() => handleDeleteClient(client)}
/>
))}
</div>
</div>
)}
{/* Empty State */}
{clients.length === 0 && !loading && (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-700">
<p className="text-gray-600 dark:text-gray-400 mb-2">
No download clients configured yet
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Add at least one client to start downloading audiobooks
</p>
</div>
)}
{/* Client Modal */}
<DownloadClientModal
isOpen={modalState.isOpen}
onClose={() => setModalState({ isOpen: false, mode: 'add' })}
mode={modalState.mode}
clientType={modalState.clientType}
initialClient={modalState.currentClient}
onSave={handleSaveClient}
apiMode={mode}
/>
{/* Delete Confirmation Modal */}
{deleteConfirm.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Delete Download Client
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Are you sure you want to delete <strong>{deleteConfirm.clientName}</strong>? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<Button
onClick={() => setDeleteConfirm({ isOpen: false })}
variant="secondary"
disabled={loading}
>
Cancel
</Button>
<Button
onClick={confirmDelete}
variant="danger"
disabled={loading}
>
{loading ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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<void>;
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<Record<string, string>>({});
// 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<string, string> = {};
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`${mode === 'add' ? 'Add' : 'Edit'} ${typeName}`}
size="lg"
>
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`My ${typeName}`}
error={errors.name}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Friendly name to identify this client
</p>
</div>
{/* URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
URL
</label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={type === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8081'}
error={errors.url}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Web UI URL (e.g., http://localhost:8080)
</p>
</div>
{/* Username (qBittorrent only) */}
{type === 'qbittorrent' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
error={errors.username}
/>
</div>
)}
{/* Password / API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{type === 'qbittorrent' ? 'Password' : 'API Key'}
</label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={type === 'qbittorrent' ? 'Password' : 'API Key from SABnzbd Config > General'}
error={errors.password}
/>
{type === 'sabnzbd' && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Found in SABnzbd under Config General API Key
</p>
)}
</div>
{/* SSL Verification */}
{url.startsWith('https://') && (
<div className="flex items-start">
<input
type="checkbox"
id="disableSSLVerify"
checked={disableSSLVerify}
onChange={(e) => setDisableSSLVerify(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="disableSSLVerify" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Disable SSL certificate verification
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use for self-signed certificates (not recommended for production)
</p>
</label>
</div>
)}
{/* Enabled Toggle */}
<div className="flex items-start">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="enabled" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Enabled
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use this client for downloads
</p>
</label>
</div>
{/* Remote Path Mapping */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-start mb-3">
<input
type="checkbox"
id="remotePathMapping"
checked={remotePathMappingEnabled}
onChange={(e) => setRemotePathMappingEnabled(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="remotePathMapping" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Enable Remote Path Mapping
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use when download client sees a different filesystem than ReadMeABook
</p>
</label>
</div>
{remotePathMappingEnabled && (
<div className="space-y-3 ml-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Remote Path ({typeName})
</label>
<Input
value={remotePath}
onChange={(e) => setRemotePath(e.target.value)}
placeholder="F:\Docker\downloads\completed\books"
error={errors.remotePath}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Path as seen by {typeName}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Local Path (ReadMeABook)
</label>
<Input
value={localPath}
onChange={(e) => setLocalPath(e.target.value)}
placeholder="/downloads"
error={errors.localPath}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Path as seen by ReadMeABook
</p>
</div>
</div>
)}
</div>
{/* Test Result */}
{testResult && (
<div
className={`p-3 rounded-md ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
<p className="text-sm">{testResult.message}</p>
</div>
)}
{/* Errors */}
{errors.test && (
<div className="p-3 rounded-md bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300">
<p className="text-sm">{errors.test}</p>
</div>
)}
{errors.save && (
<div className="p-3 rounded-md bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300">
<p className="text-sm">{errors.save}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
onClick={handleTestConnection}
disabled={testing}
variant="secondary"
>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
<div className="flex gap-2">
<Button onClick={onClose} variant="secondary" disabled={saving}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || !testResult?.success}
>
{saving ? 'Saving...' : mode === 'add' ? 'Add Client' : 'Save Changes'}
</Button>
</div>
</div>
</div>
</Modal>
);
}
+20 -12
View File
@@ -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 */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
onClick={handleClose}
/>
{/* Modal container */}
@@ -80,7 +88,7 @@ export function Modal({
</h2>
{showCloseButton && (
<button
onClick={onClose}
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg
+28 -12
View File
@@ -372,16 +372,20 @@ export class ProwlarrService {
}
/**
* Filter results based on configured download client protocol
* If qBittorrent is configured: only return torrent results
* If SABnzbd is configured: only return NZB results
* Filter results based on configured download client protocols
* If both clients configured: return all results
* If only one client configured: return only matching protocol results
*/
private async filterByProtocol(results: TorrentResult[]): Promise<TorrentResult[]> {
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
+34 -54
View File
@@ -997,75 +997,55 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
// 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
);
+28 -12
View File
@@ -589,27 +589,43 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
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;
@@ -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,
@@ -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<DownloadClientConfig[]> {
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<DownloadClientConfig | null> {
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<boolean> {
const client = await this.getClientForProtocol(protocol);
return client !== null;
}
/**
* Get instantiated client service for protocol
*/
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
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<boolean> {
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();
}