Add multi-download-client support and UI management

Implements support for configuring both qBittorrent and SABnzbd simultaneously, including migration from legacy config, protocol-aware routing, and protocol filtering. Adds new CRUD API routes for download clients, new UI management components, and updates setup and settings flows to use the new multi-client architecture. Updates documentation to describe the new structure and usage.
This commit is contained in:
kikootwo
2026-01-29 09:21:33 -05:00
parent 3290ebbc9d
commit 2cda6decbe
26 changed files with 3452 additions and 924 deletions
@@ -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 }
);
}
});
});
}