Add Transmission/NZBGet and per-client paths and much more

Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
kikootwo
2026-02-09 19:45:43 -05:00
parent d7acd67aa4
commit 4b90b35748
117 changed files with 9346 additions and 1488 deletions
@@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { PathMapper } from '@/lib/utils/path-mapper';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -35,9 +36,9 @@ export async function PUT(request: NextRequest) {
logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.');
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -97,7 +98,7 @@ export async function PUT(request: NextRequest) {
const updatedClient: DownloadClientConfig = {
id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(),
type,
name: type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
name: getClientDisplayName(type),
enabled: true,
url,
username: username || undefined,
@@ -137,6 +138,12 @@ export async function PUT(request: NextRequest) {
} else if (type === 'sabnzbd') {
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
invalidateSABnzbdService();
} else if (type === 'nzbget') {
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
invalidateNZBGetService();
} else if (type === 'transmission') {
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
invalidateTransmissionService();
}
return NextResponse.json({
@@ -37,6 +37,7 @@ export async function PUT(
remotePath,
localPath,
category,
customPath,
} = body;
const config = await getConfigService();
@@ -53,6 +54,14 @@ export async function PUT(
const existingClient = clients[clientIndex];
// Validate customPath: reject paths containing ".."
if (customPath && customPath.includes('..')) {
return NextResponse.json(
{ error: 'Custom path cannot contain ".."' },
{ status: 400 }
);
}
// Build updated client (preserve fields not in request)
const updatedClient: DownloadClientConfig = {
...existingClient,
@@ -66,6 +75,7 @@ export async function PUT(
remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath,
localPath: localPath !== undefined ? localPath : existingClient.localPath,
category: category !== undefined ? category : existingClient.category,
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
};
// Validate path mapping if enabled
@@ -6,8 +6,8 @@
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 { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES, CLIENT_PROTOCOL_MAP, DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -62,12 +62,13 @@ export async function POST(request: NextRequest) {
remotePath,
localPath,
category,
customPath,
} = body;
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -99,21 +100,30 @@ export async function POST(request: NextRequest) {
}
}
// Check for duplicate type (only one client per type for now)
// Check for duplicate protocol (only one client per protocol)
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) {
const protocol = CLIENT_PROTOCOL_MAP[type as DownloadClientType];
const duplicateProtocol = existingClients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol);
if (duplicateProtocol) {
return NextResponse.json(
{ error: `A ${type} client is already configured. Please disable or remove it first.` },
{ error: `A ${protocol} client (${getClientDisplayName(duplicateProtocol.type)}) is already configured. Remove it first to add a different ${protocol} client.` },
{ status: 400 }
);
}
// Create new client config for testing (with plaintext password)
// qBittorrent credentials are optional (supports IP whitelist auth)
// Validate customPath: reject paths containing ".."
if (customPath && customPath.includes('..')) {
return NextResponse.json(
{ error: 'Custom path cannot contain ".."' },
{ status: 400 }
);
}
const newClient: DownloadClientConfig = {
id: randomUUID(),
type,
@@ -127,6 +137,7 @@ export async function POST(request: NextRequest) {
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: category || 'readmeabook',
customPath: customPath || undefined,
};
// Test connection before saving
@@ -6,8 +6,8 @@
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 { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Test');
@@ -23,6 +23,7 @@ export async function POST(request: NextRequest) {
const {
clientId, // Optional: existing client ID to use stored password
type,
name: clientName,
url,
username,
password,
@@ -33,9 +34,9 @@ export async function POST(request: NextRequest) {
} = body;
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -87,7 +88,7 @@ export async function POST(request: NextRequest) {
const testConfig: DownloadClientConfig = {
id: 'test',
type,
name: 'Test Client',
name: clientName || type,
enabled: true,
url,
username: effectiveUsername || '',
@@ -0,0 +1,91 @@
/**
* Component: Admin Interactive Search Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.InteractiveSearch');
const CONFIG_KEY = 'interactive_search_access';
/**
* GET /api/admin/settings/interactive-search
* Get current global interactive search access setting
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const config = await prisma.configuration.findUnique({
where: { key: CONFIG_KEY },
});
// Default to true if not configured (backward compatibility)
const interactiveSearchAccess = config === null ? true : config.value === 'true';
return NextResponse.json({ interactiveSearchAccess });
} catch (error) {
logger.error('Failed to fetch interactive search setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to fetch interactive search setting' },
{ status: 500 }
);
}
});
});
}
/**
* PATCH /api/admin/settings/interactive-search
* Update global interactive search access setting
*/
export async function PATCH(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { interactiveSearchAccess } = body;
// Validate input
if (typeof interactiveSearchAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid input. interactiveSearchAccess must be a boolean' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: CONFIG_KEY },
create: {
key: CONFIG_KEY,
value: interactiveSearchAccess.toString(),
},
update: {
value: interactiveSearchAccess.toString(),
},
});
logger.info(`Interactive search access setting updated to: ${interactiveSearchAccess}`, {
userId: req.user?.sub,
});
return NextResponse.json({ interactiveSearchAccess });
} catch (error) {
logger.error('Failed to update interactive search setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to update interactive search setting' },
{ status: 500 }
);
}
});
});
}
+25 -2
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -59,6 +59,20 @@ export async function PUT(request: NextRequest) {
});
}
// Update ebook path template
if (ebookPathTemplate !== undefined) {
await prisma.configuration.upsert({
where: { key: 'ebook_path_template' },
update: { value: ebookPathTemplate },
create: {
key: 'ebook_path_template',
value: ebookPathTemplate,
category: 'automation',
description: 'Template for organizing ebook files in media directory',
},
});
}
// Update metadata tagging setting
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
@@ -90,12 +104,21 @@ export async function PUT(request: NextRequest) {
configService.clearCache('download_dir');
configService.clearCache('media_dir');
configService.clearCache('audiobook_path_template');
configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled');
// Invalidate qBittorrent service singleton to force reload of download_dir
// Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
invalidateDownloadClientManager();
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
invalidateQBittorrentService();
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
invalidateSABnzbdService();
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
invalidateNZBGetService();
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
invalidateTransmissionService();
return NextResponse.json({
success: true,
+1
View File
@@ -125,6 +125,7 @@ export async function GET(request: NextRequest) {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
},
@@ -1,14 +1,16 @@
/**
* Component: Admin Settings Test Download Client API
* Component: Admin Settings Test Download Client API (DEPRECATED)
* Documentation: documentation/settings-pages.md
*
* DEPRECATED: Use /api/admin/settings/download-clients/test instead.
* Maintained for backward compatibility.
*/
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 { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.TestDownloadClient');
@@ -19,6 +21,7 @@ export async function POST(request: NextRequest) {
try {
const {
type,
name: clientName,
url,
username,
password,
@@ -37,9 +40,9 @@ export async function POST(request: NextRequest) {
);
}
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -64,53 +67,28 @@ export async function POST(request: NextRequest) {
actualPassword = matchingClient.password;
}
// Validate required fields per client type and test connection
let version: string | undefined;
// Build a temporary config for testing
const testConfig: DownloadClientConfig = {
id: 'legacy-test',
type,
name: clientName || type,
enabled: true,
url,
username: username || '',
password: actualPassword || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: 'readmeabook',
};
if (type === 'qbittorrent') {
logger.debug('Testing qBittorrent connection');
if (!username || !actualPassword) {
return NextResponse.json(
{ success: false, error: 'Username and password are required for qBittorrent' },
{ status: 400 }
);
}
// Test qBittorrent connection
version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
actualPassword,
disableSSLVerify || false
);
} else if (type === 'sabnzbd') {
logger.debug('Testing SABnzbd connection');
if (!actualPassword) {
return NextResponse.json(
{ success: false, error: 'API key (password) is required for SABnzbd' },
{ status: 400 }
);
}
// Test SABnzbd connection
const sabnzbd = new SABnzbdService(url, actualPassword, 'readmeabook', disableSSLVerify || false);
const result = await sabnzbd.testConnection();
if (!result.success) {
return NextResponse.json(
{
success: false,
error: result.error || 'Failed to connect to SABnzbd',
},
{ status: 500 }
);
}
version = result.version;
}
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const result = await manager.testConnection(testConfig);
// If path mapping enabled, validate local path exists
if (remotePathMappingEnabled) {
if (result.success && remotePathMappingEnabled) {
if (!remotePath || !localPath) {
return NextResponse.json(
{
@@ -136,10 +114,14 @@ export async function POST(request: NextRequest) {
}
}
return NextResponse.json({
success: true,
version,
});
if (result.success) {
return NextResponse.json({ success: true, message: result.message });
}
return NextResponse.json(
{ success: false, error: result.message },
{ status: 400 }
);
} catch (error) {
logger.error('Download client test failed', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(