mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 `<DownloadClientManagement mode="settings" />`
|
||||
|
||||
### Wizard Step
|
||||
**File:** `src/app/setup/steps/DownloadClientStep.tsx`
|
||||
|
||||
Replaced single-client form with `<DownloadClientManagement mode="wizard" />`
|
||||
|
||||
**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
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -6,153 +6,717 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep';
|
||||
|
||||
interface DownloadClient {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
username?: string;
|
||||
password: string;
|
||||
disableSSLVerify: boolean;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const DownloadClientHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialState,
|
||||
initialClients = [],
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof DownloadClientStep>>;
|
||||
initialClients?: DownloadClient[];
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
downloadClient: 'qbittorrent' as const,
|
||||
downloadClientUrl: 'https://qbittorrent.local',
|
||||
downloadClientUsername: 'admin',
|
||||
downloadClientPassword: 'secret',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
...initialState,
|
||||
});
|
||||
const [downloadClients, setDownloadClients] = useState<DownloadClient[]>(initialClients);
|
||||
|
||||
return (
|
||||
<DownloadClientStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
downloadClients={downloadClients}
|
||||
onUpdate={(field, value) => {
|
||||
if (field === 'downloadClients') {
|
||||
setDownloadClients(value);
|
||||
}
|
||||
}}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create a mock client
|
||||
const createMockClient = (overrides: Partial<DownloadClient> = {}): DownloadClient => ({
|
||||
id: 'test-client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'secret',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DownloadClientStep', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('tests connection and enables navigation after success', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, version: '1.2.3' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
describe('Initial State', () => {
|
||||
it('shows empty state when no clients configured', () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
expect(screen.getByText('No download clients configured yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add at least one client to start downloading audiobooks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Connected successfully!/)).toBeInTheDocument();
|
||||
it('shows Add qBittorrent and Add SABnzbd buttons', () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows remote path fields and toggles SSL verify', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
const sslToggle = screen.getByLabelText('Disable SSL Certificate Verification');
|
||||
fireEvent.click(sslToggle);
|
||||
expect(sslToggle).toBeChecked();
|
||||
|
||||
const remoteToggle = screen.getByLabelText('Enable Remote Path Mapping');
|
||||
fireEvent.click(remoteToggle);
|
||||
|
||||
expect(screen.getByPlaceholderText('/remote/mnt/d/done')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('/downloads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to SABnzbd and shows API key field', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ downloadClient: 'qbittorrent' }}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /SABnzbd/ }));
|
||||
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Username')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks next when connection has not been tested', async () => {
|
||||
const onNext = vi.fn();
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText('Please test the connection before proceeding')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error when the connection test fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Bad credentials' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Bad credentials/)).toBeInTheDocument();
|
||||
it('displays configured clients when provided', () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
expect(screen.getByText('My qBittorrent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No download clients configured yet')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables test connection when SABnzbd fields are incomplete', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{
|
||||
downloadClient: 'sabnzbd',
|
||||
downloadClientUrl: '',
|
||||
downloadClientPassword: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
describe('Adding a qBittorrent Client', () => {
|
||||
it('opens modal when clicking Add qBittorrent', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
const testButton = screen.getByRole('button', { name: 'Test Connection' });
|
||||
expect(testButton).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct form fields for qBittorrent', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// qBittorrent should show Name, URL, Username, Password
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('URL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Username')).toBeInTheDocument();
|
||||
expect(screen.getByText('Password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required fields before testing connection', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Test Connection without filling required fields
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
// Should show validation errors
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/URL is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// fetch should not have been called
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tests connection and shows success message', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected to qBittorrent v4.5.0' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in required fields
|
||||
const urlInput = screen.getByPlaceholderText('http://localhost:8080');
|
||||
const usernameInput = screen.getByPlaceholderText('admin');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'http://localhost:8080' } });
|
||||
fireEvent.change(usernameInput, { target: { value: 'admin' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'secret' } });
|
||||
|
||||
// Test connection
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected to qBittorrent v4.5.0/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message when connection test fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Invalid credentials' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in required fields
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'wrong' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables save button only after successful connection test', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected successfully!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add Client button should be disabled initially
|
||||
const addButton = screen.getByRole('button', { name: /Add Client/i });
|
||||
expect(addButton).toBeDisabled();
|
||||
|
||||
// Fill and test
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected successfully!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now Add Client should be enabled
|
||||
expect(addButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds client to list after saving', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected successfully!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill and test
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected successfully!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save the client
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
|
||||
|
||||
// Modal should close and client should appear in list
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Client should be in the configured clients list
|
||||
expect(screen.getByText('Configured Clients')).toBeInTheDocument();
|
||||
// The client name should be visible in the configured clients section
|
||||
const configuredSection = screen.getByText('Configured Clients').parentElement;
|
||||
expect(configuredSection).toBeInTheDocument();
|
||||
// There should be edit/delete buttons for the configured client
|
||||
expect(screen.getByTitle('Edit client')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Delete client')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides SSL toggle when using http URLs', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ downloadClientUrl: 'http://qbittorrent.local' }}
|
||||
/>
|
||||
);
|
||||
describe('Adding a SABnzbd Client', () => {
|
||||
it('opens modal when clicking Add SABnzbd', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByLabelText('Disable SSL Certificate Verification')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API Key field instead of Username for SABnzbd', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// SABnzbd should show API Key, not Username
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Username')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates API key is required for SABnzbd', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill URL but not API key
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8081'), {
|
||||
target: { value: 'http://localhost:8081' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/API key is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSL Verification Toggle', () => {
|
||||
it('shows SSL toggle only for HTTPS URLs', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// SSL toggle should not be visible for HTTP
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Disable SSL certificate verification/i)).not.toBeInTheDocument();
|
||||
|
||||
// Change to HTTPS - SSL toggle should appear
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'https://localhost:8080' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Disable SSL certificate verification/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote Path Mapping', () => {
|
||||
it('shows remote path fields when enabled', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Remote path fields should not be visible initially
|
||||
expect(screen.queryByText(/Remote Path \(qBittorrent\)/i)).not.toBeInTheDocument();
|
||||
|
||||
// Enable remote path mapping
|
||||
const toggle = screen.getByLabelText(/Enable Remote Path Mapping/i);
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Now remote path fields should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Remote Path \(qBittorrent\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Local Path \(ReadMeABook\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates remote path fields when enabled', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill required fields
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
|
||||
// Enable remote path mapping but don't fill paths
|
||||
fireEvent.click(screen.getByLabelText(/Enable Remote Path Mapping/i));
|
||||
|
||||
// Try to test connection
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Remote path is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('blocks Next when no enabled client is configured', () => {
|
||||
const onNext = vi.fn();
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText(/Please add at least one download client before proceeding/i)).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows Next when at least one enabled client exists', () => {
|
||||
const onNext = vi.fn();
|
||||
const mockClient = createMockClient();
|
||||
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks Next when client exists but is disabled', () => {
|
||||
const onNext = vi.fn();
|
||||
const mockClient = createMockClient({ enabled: false });
|
||||
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText(/Please add at least one download client before proceeding/i)).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onBack when Back button is clicked', () => {
|
||||
const onBack = vi.fn();
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={onBack} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Type Restrictions', () => {
|
||||
it('shows "Already configured" when qBittorrent is already added', () => {
|
||||
const mockClient = createMockClient({ type: 'qbittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "Already configured" text should appear for qBittorrent
|
||||
expect(screen.getByText('Already configured')).toBeInTheDocument();
|
||||
|
||||
// Add qBittorrent button should not exist
|
||||
expect(screen.queryByRole('button', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
|
||||
// SABnzbd should still have Add button
|
||||
expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Already configured" when SABnzbd is already added', () => {
|
||||
const mockClient = createMockClient({ type: 'sabnzbd', name: 'My SABnzbd' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "Already configured" text should appear for SABnzbd
|
||||
expect(screen.getByText('Already configured')).toBeInTheDocument();
|
||||
|
||||
// Add SABnzbd button should not exist
|
||||
expect(screen.queryByRole('button', { name: /Add SABnzbd/i })).not.toBeInTheDocument();
|
||||
|
||||
// qBittorrent should still have Add button
|
||||
expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Card Actions', () => {
|
||||
it('opens edit modal when edit button is clicked', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Find and click edit button
|
||||
const editButton = screen.getByTitle('Edit client');
|
||||
fireEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Edit qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows delete confirmation when delete button is clicked', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Find and click delete button
|
||||
const deleteButton = screen.getByTitle('Delete client');
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('removes client when delete is confirmed', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTitle('Delete client'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Confirm deletion
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
// Client should be removed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('My qBittorrent')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No download clients configured yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels delete when cancel is clicked', async () => {
|
||||
const mockClient = createMockClient({ name: 'My qBittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTitle('Delete client'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Download Client/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Cancel deletion
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
// Dialog should close, client should still be there
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete Download Client/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('My qBittorrent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Clients', () => {
|
||||
it('allows configuring both qBittorrent and SABnzbd', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'Connected!' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
// Add qBittorrent
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://localhost:8080' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connected!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now add SABnzbd
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add SABnzbd/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8081'), {
|
||||
target: { value: 'http://localhost:8081' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/API Key from SABnzbd/i), {
|
||||
target: { value: 'my-api-key' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the success message in the modal
|
||||
const successMessages = screen.getAllByText(/Connected!/i);
|
||||
expect(successMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Client/i }));
|
||||
|
||||
// Both clients should be in the list - check for edit buttons (2 of them)
|
||||
await waitFor(() => {
|
||||
const editButtons = screen.getAllByTitle('Edit client');
|
||||
expect(editButtons).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Both "Already configured" messages should appear
|
||||
const alreadyConfiguredMessages = screen.getAllByText('Already configured');
|
||||
expect(alreadyConfiguredMessages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Behavior', () => {
|
||||
it('closes modal when Cancel is clicked', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal when clicking the X button', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the close button (X icon in modal header)
|
||||
const modal = screen.getByRole('heading', { name: /Add qBittorrent/i }).closest('[class*="relative"]');
|
||||
const closeButton = within(modal!).getAllByRole('button')[0]; // First button in modal header area
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets form state when reopening modal', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Connection failed' }),
|
||||
});
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
// Open, fill, and trigger error
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('http://localhost:8080'), {
|
||||
target: { value: 'http://bad-url' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('admin'), { target: { value: 'user' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'pass' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connection failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close modal
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reopen - error should be cleared
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add qBittorrent/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Error message should not be present
|
||||
expect(screen.queryByText(/Connection failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,6 @@ describe('Modal', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
expect(document.body.style.overflow).toBe('unset');
|
||||
expect(document.body.style.overflow).toBe(''); // Cleared to default
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,13 @@ const configMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock for DownloadClientManager
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
getAllClients: vi.fn(),
|
||||
hasClientForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
@@ -34,16 +41,27 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
invalidateDownloadClientManager: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ProwlarrService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
configMock.get.mockReset();
|
||||
downloadClientManagerMock.getClientForProtocol.mockReset();
|
||||
downloadClientManagerMock.getAllClients.mockReset();
|
||||
downloadClientManagerMock.hasClientForProtocol.mockReset();
|
||||
});
|
||||
|
||||
it('filters results for SABnzbd (usenet)', async () => {
|
||||
configMock.get.mockResolvedValue('sabnzbd');
|
||||
// Mock: Only SABnzbd is configured (usenet only)
|
||||
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
||||
return protocol === 'usenet';
|
||||
});
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -76,7 +94,10 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('throws when search fails', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
// Mock: qBittorrent is configured (torrent only)
|
||||
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
||||
return protocol === 'torrent';
|
||||
});
|
||||
clientMock.get.mockRejectedValue(new Error('bad search'));
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
@@ -85,7 +106,10 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('filters results for qBittorrent (torrent)', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
// Mock: Only qBittorrent is configured (torrent only)
|
||||
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
||||
return protocol === 'torrent';
|
||||
});
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -178,7 +202,10 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('applies category, indexer, and seeder filters', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
// Mock: Only qBittorrent is configured (torrent only)
|
||||
downloadClientManagerMock.hasClientForProtocol.mockImplementation(async (protocol: string) => {
|
||||
return protocol === 'torrent';
|
||||
});
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -223,9 +250,8 @@ describe('ProwlarrService', () => {
|
||||
});
|
||||
|
||||
it('returns unfiltered results when protocol filtering fails', async () => {
|
||||
configMock.get
|
||||
.mockResolvedValueOnce('qbittorrent')
|
||||
.mockRejectedValueOnce(new Error('config fail'));
|
||||
// Mock: hasClientForProtocol throws an error
|
||||
downloadClientManagerMock.hasClientForProtocol.mockRejectedValue(new Error('config fail'));
|
||||
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
|
||||
@@ -21,6 +21,14 @@ const axiosMock = vi.hoisted(() => ({
|
||||
const parseTorrentMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock for DownloadClientManager
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
getAllClients: vi.fn(),
|
||||
hasClientForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
@@ -33,7 +41,12 @@ vi.mock('parse-torrent', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
getConfigService: vi.fn(async () => configServiceMock),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
invalidateDownloadClientManager: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('QBittorrentService', () => {
|
||||
@@ -45,6 +58,10 @@ describe('QBittorrentService', () => {
|
||||
axiosMock.post.mockReset();
|
||||
parseTorrentMock.mockReset();
|
||||
configServiceMock.getMany.mockReset();
|
||||
configServiceMock.get.mockReset();
|
||||
downloadClientManagerMock.getClientForProtocol.mockReset();
|
||||
downloadClientManagerMock.getAllClients.mockReset();
|
||||
downloadClientManagerMock.hasClientForProtocol.mockReset();
|
||||
invalidateQBittorrentService();
|
||||
});
|
||||
|
||||
@@ -586,25 +603,26 @@ describe('QBittorrentService', () => {
|
||||
});
|
||||
|
||||
it('throws when qBittorrent configuration is incomplete', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: null,
|
||||
download_client_username: null,
|
||||
download_client_password: null,
|
||||
download_dir: null,
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
// Mock: no qBittorrent client configured
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
|
||||
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not fully configured');
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not configured');
|
||||
});
|
||||
|
||||
it('returns a cached instance after successful initialization', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
// Mock: qBittorrent client configured
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://qb',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
|
||||
|
||||
@@ -612,19 +630,26 @@ describe('QBittorrentService', () => {
|
||||
const second = await getQBittorrentService();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(configServiceMock.getMany).toHaveBeenCalledTimes(1);
|
||||
// Should only call getClientForProtocol once (cached after first call)
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledTimes(1);
|
||||
|
||||
testConnectionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws when connection test fails during service creation', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
// Mock: qBittorrent client configured
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://qb',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
|
||||
|
||||
|
||||
@@ -18,13 +18,25 @@ const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock for DownloadClientManager
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
getAllClients: vi.fn(),
|
||||
hasClientForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
getConfigService: vi.fn(async () => configServiceMock),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
invalidateDownloadClientManager: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('SABnzbdService', () => {
|
||||
@@ -32,6 +44,9 @@ describe('SABnzbdService', () => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
configServiceMock.get.mockReset();
|
||||
downloadClientManagerMock.getClientForProtocol.mockReset();
|
||||
downloadClientManagerMock.getAllClients.mockReset();
|
||||
downloadClientManagerMock.hasClientForProtocol.mockReset();
|
||||
invalidateSABnzbdService();
|
||||
});
|
||||
|
||||
@@ -456,22 +471,19 @@ describe('SABnzbdService', () => {
|
||||
});
|
||||
|
||||
it('creates a singleton service from config', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
switch (key) {
|
||||
case 'download_client_url':
|
||||
return 'http://sab';
|
||||
case 'download_client_password':
|
||||
return 'api-key';
|
||||
case 'sabnzbd_category':
|
||||
return 'books';
|
||||
case 'download_client_disable_ssl_verify':
|
||||
return 'false';
|
||||
case 'download_dir':
|
||||
return '/downloads';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// Mock: SABnzbd client configured via DownloadClientManager
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
url: 'http://sab',
|
||||
password: 'api-key', // API key stored in password field
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'books',
|
||||
});
|
||||
configServiceMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const jobQueueMock = createJobQueueMock();
|
||||
const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() }));
|
||||
const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
|
||||
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
@@ -21,6 +25,10 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
@@ -33,16 +41,26 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: () => sabMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
ProwlarrService: {
|
||||
isNZBResult: vi.fn((result: any) => {
|
||||
// Detect NZB by URL pattern or protocol field
|
||||
return result.downloadUrl?.endsWith('.nzb') || result.protocol === 'usenet';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('processDownloadTorrent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const payload = {
|
||||
const torrentPayload = {
|
||||
requestId: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Book', author: 'Author' },
|
||||
torrent: {
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Book - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
@@ -50,20 +68,44 @@ describe('processDownloadTorrent', () => {
|
||||
downloadUrl: 'magnet:?xt=urn:btih:abc',
|
||||
guid: 'guid-1',
|
||||
format: 'M4B',
|
||||
protocol: 'torrent',
|
||||
},
|
||||
jobId: 'job-1',
|
||||
};
|
||||
|
||||
it('routes downloads to qBittorrent by default', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
const nzbPayload = {
|
||||
requestId: 'req-2',
|
||||
audiobook: { id: 'a2', title: 'Book2', author: 'Author2' },
|
||||
torrent: {
|
||||
indexer: 'UsenetIndexer',
|
||||
indexerId: 2,
|
||||
title: 'Book2 - Author2',
|
||||
size: 100 * 1024 * 1024,
|
||||
seeders: 0,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'http://indexer.com/download/file.nzb',
|
||||
guid: 'guid-2',
|
||||
format: 'M4B',
|
||||
protocol: 'usenet',
|
||||
},
|
||||
jobId: 'job-2',
|
||||
};
|
||||
|
||||
it('routes torrent downloads to qBittorrent', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
enabled: true,
|
||||
});
|
||||
qbtMock.addTorrent.mockResolvedValue('hash-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
const result = await processDownloadTorrent(payload);
|
||||
const result = await processDownloadTorrent(torrentPayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
expect(qbtMock.addTorrent).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
@@ -74,25 +116,70 @@ describe('processDownloadTorrent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes downloads to SABnzbd when configured', async () => {
|
||||
configMock.get.mockResolvedValue('sabnzbd');
|
||||
it('routes NZB downloads to SABnzbd', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
enabled: true,
|
||||
});
|
||||
sabMock.addNZB.mockResolvedValue('nzb-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
const result = await processDownloadTorrent(payload);
|
||||
const result = await processDownloadTorrent(nzbPayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
|
||||
expect(sabMock.addNZB).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'req-2',
|
||||
'dh-2',
|
||||
'nzb-1',
|
||||
'sabnzbd',
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when no client configured for protocol', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
|
||||
await expect(processDownloadTorrent(torrentPayload)).rejects.toThrow(
|
||||
'No Torrent (qBittorrent) client configured'
|
||||
);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
});
|
||||
|
||||
it('detects protocol from result and routes appropriately', async () => {
|
||||
// Torrent result
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
enabled: true,
|
||||
});
|
||||
qbtMock.addTorrent.mockResolvedValue('hash-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
await processDownloadTorrent(torrentPayload);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
|
||||
// NZB result
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
enabled: true,
|
||||
});
|
||||
sabMock.addNZB.mockResolvedValue('nzb-1');
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
|
||||
await processDownloadTorrent(nzbPayload);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Component: Download Client Manager Service Tests
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
setMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
// Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time
|
||||
const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({
|
||||
qbtServiceMock: {
|
||||
testConnection: vi.fn(),
|
||||
},
|
||||
sabServiceMock: {
|
||||
getVersion: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Use class syntax for proper constructor mocking
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
QBittorrentService: class MockQBittorrentService {
|
||||
testConnection = qbtServiceMock.testConnection;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
SABnzbdService: class MockSABnzbdService {
|
||||
getVersion = sabServiceMock.getVersion;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DownloadClientManager', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset singleton using dynamic import
|
||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
invalidateDownloadClientManager();
|
||||
});
|
||||
|
||||
describe('getAllClients', () => {
|
||||
it('returns parsed clients from config', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getAllClients();
|
||||
|
||||
expect(result).toEqual(clients);
|
||||
expect(configMock.get).toHaveBeenCalledWith('download_clients');
|
||||
});
|
||||
|
||||
it('returns empty array when no clients configured', async () => {
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getAllClients();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('caches clients for subsequent calls', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
await manager.getAllClients();
|
||||
await manager.getAllClients();
|
||||
|
||||
expect(configMock.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientForProtocol', () => {
|
||||
it('returns qBittorrent client for torrent protocol', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getClientForProtocol('torrent');
|
||||
|
||||
expect(result).toEqual(clients[0]);
|
||||
});
|
||||
|
||||
it('returns SABnzbd client for usenet protocol', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8081',
|
||||
password: 'apikey',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getClientForProtocol('usenet');
|
||||
|
||||
expect(result).toEqual(clients[0]);
|
||||
});
|
||||
|
||||
it('returns null when no client configured for protocol', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getClientForProtocol('usenet');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('skips disabled clients', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: false, // Disabled
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getClientForProtocol('torrent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasClientForProtocol', () => {
|
||||
it('returns true when client is configured', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.hasClientForProtocol('torrent');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when client is not configured', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.hasClientForProtocol('usenet');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('successfully tests qBittorrent connection', async () => {
|
||||
qbtServiceMock.testConnection.mockResolvedValue(undefined);
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const config = {
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent' as const,
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
};
|
||||
|
||||
const result = await manager.testConnection(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Successfully connected to qBittorrent');
|
||||
});
|
||||
|
||||
it('successfully tests SABnzbd connection', async () => {
|
||||
sabServiceMock.getVersion.mockResolvedValue('3.5.0');
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const config = {
|
||||
id: 'client-1',
|
||||
type: 'sabnzbd' as const,
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8081',
|
||||
password: 'apikey',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
};
|
||||
|
||||
const result = await manager.testConnection(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Successfully connected to SABnzbd (v3.5.0)');
|
||||
});
|
||||
|
||||
it('returns error on connection failure', async () => {
|
||||
qbtServiceMock.testConnection.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const config = {
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent' as const,
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
};
|
||||
|
||||
const result = await manager.testConnection(config);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Connection refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('migrates legacy single-client config to array format', async () => {
|
||||
// First call returns null for download_clients (new format doesn't exist)
|
||||
// Then return legacy values for migration
|
||||
configMock.get
|
||||
.mockResolvedValueOnce(null) // download_clients
|
||||
.mockResolvedValueOnce('qbittorrent') // download_client_type
|
||||
.mockResolvedValueOnce('http://localhost:8080') // download_client_url
|
||||
.mockResolvedValueOnce('admin') // download_client_username
|
||||
.mockResolvedValueOnce('password') // download_client_password
|
||||
.mockResolvedValueOnce('false') // download_client_disable_ssl_verify
|
||||
.mockResolvedValueOnce('false') // download_client_remote_path_mapping_enabled
|
||||
.mockResolvedValueOnce(null) // download_client_remote_path
|
||||
.mockResolvedValueOnce(null) // download_client_local_path
|
||||
.mockResolvedValueOnce(null); // sabnzbd_category
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getAllClients();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('qbittorrent');
|
||||
expect(result[0].name).toBe('qBittorrent');
|
||||
expect(result[0].enabled).toBe(true);
|
||||
expect(result[0].url).toBe('http://localhost:8080');
|
||||
expect(result[0].username).toBe('admin');
|
||||
expect(result[0].password).toBe('password');
|
||||
|
||||
// Should have saved the migrated config
|
||||
expect(configMock.setMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'download_clients',
|
||||
value: expect.stringContaining('qbittorrent'),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('does not migrate when legacy config is incomplete', async () => {
|
||||
configMock.get
|
||||
.mockResolvedValueOnce(null) // download_clients
|
||||
.mockResolvedValueOnce(null) // download_client_type (missing)
|
||||
.mockResolvedValueOnce(null) // download_client_url (missing)
|
||||
.mockResolvedValueOnce(null); // download_client_password (missing)
|
||||
|
||||
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
const result = await manager.getAllClients();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(configMock.setMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
it('clears cache on invalidation', async () => {
|
||||
const clients = [
|
||||
{
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
category: 'readmeabook',
|
||||
},
|
||||
];
|
||||
|
||||
configMock.get.mockResolvedValue(JSON.stringify(clients));
|
||||
|
||||
const { getDownloadClientManager, invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
const manager = getDownloadClientManager(configMock as any);
|
||||
|
||||
await manager.getAllClients(); // First call - caches
|
||||
|
||||
invalidateDownloadClientManager(); // Invalidate cache
|
||||
|
||||
await manager.getAllClients(); // Second call - should fetch again
|
||||
|
||||
expect(configMock.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user