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
+18 -22
View File
@@ -6,10 +6,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { getSABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('API.Admin.Downloads');
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest) {
torrentName: true,
torrentHash: true,
nzbId: true,
downloadClientId: true,
downloadClient: true, // qbittorrent, sabnzbd, or direct
torrentSizeBytes: true,
startedAt: true,
@@ -68,9 +69,9 @@ export async function GET(request: NextRequest) {
take: 20,
});
// Get configured download client type
// Get download client manager
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
const manager = getDownloadClientManager(configService);
// Format response with speed and ETA from download client
const formatted = await Promise.all(
@@ -98,24 +99,19 @@ export async function GET(request: NextRequest) {
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
}
}
} else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) {
// Get torrent hash from download history
const torrentHash = downloadHistory?.torrentHash;
if (torrentHash) {
const qbService = await getQBittorrentService();
const torrentInfo = await qbService.getTorrent(torrentHash);
speed = torrentInfo.dlspeed;
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
}
} else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) {
// Get NZB ID from download history
const nzbId = downloadHistory?.nzbId;
if (nzbId) {
const sabnzbdService = await getSABnzbdService();
const nzbInfo = await sabnzbdService.getNZB(nzbId);
if (nzbInfo) {
speed = nzbInfo.downloadSpeed;
eta = nzbInfo.timeLeft > 0 ? nzbInfo.timeLeft : null;
} else {
// Use unified interface for all download clients (qBittorrent, SABnzbd, etc.)
const clientId = downloadHistory?.downloadClientId || downloadHistory?.torrentHash || downloadHistory?.nzbId;
if (clientId && downloadClient) {
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (client) {
const info = await client.getDownload(clientId);
if (info) {
speed = info.downloadSpeed;
eta = info.eta > 0 ? info.eta : null;
}
}
}
}
@@ -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(
+22 -4
View File
@@ -19,7 +19,7 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
const { role, autoApproveRequests } = body;
const { role, autoApproveRequests, interactiveSearchAccess } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
@@ -37,6 +37,14 @@ export async function PUT(
);
}
// Validate interactiveSearchAccess (optional)
if (interactiveSearchAccess !== undefined && interactiveSearchAccess !== null && typeof interactiveSearchAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid interactiveSearchAccess. Must be a boolean or null' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
@@ -91,21 +99,30 @@ export async function PUT(
);
}
// Validate that admins cannot have autoApproveRequests set to false
// Validate that admins cannot have permissions set to false
if (role === 'admin' && autoApproveRequests === false) {
return NextResponse.json(
{ error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' },
{ status: 400 }
);
}
if (role === 'admin' && interactiveSearchAccess === false) {
return NextResponse.json(
{ error: 'Admins always have interactive search access. Cannot set interactiveSearchAccess to false for admin users.' },
{ status: 400 }
);
}
// Prepare update data
const updateData: { role: string; autoApproveRequests?: boolean | null } = { role };
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
if (autoApproveRequests !== undefined) {
updateData.autoApproveRequests = autoApproveRequests;
}
if (interactiveSearchAccess !== undefined) {
updateData.interactiveSearchAccess = interactiveSearchAccess;
}
// Update user role and autoApproveRequests
// Update user
const updatedUser = await prisma.user.update({
where: { id },
data: updateData,
@@ -114,6 +131,7 @@ export async function PUT(
plexUsername: true,
role: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
},
});
+1
View File
@@ -31,6 +31,7 @@ export async function GET(request: NextRequest) {
updatedAt: true,
lastLoginAt: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
_count: {
select: {
requests: true,
@@ -17,6 +17,7 @@ import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
import {
searchByAsin,
searchByTitle,
@@ -83,6 +84,21 @@ export async function POST(
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { asin } = await params;
// Check interactive search access permission
if (req.user) {
const callingUser = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, interactiveSearchAccess: true },
});
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
{ status: 403 }
);
}
}
const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined;
@@ -410,9 +426,14 @@ async function searchIndexersForInteractive(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
@@ -70,9 +70,14 @@ export async function POST(request: NextRequest) {
// Group indexers by their category configuration
// This minimizes API calls while ensuring each indexer only searches its configured categories
const groups = groupIndexersByCategories(indexersConfig);
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
// Log each group for transparency
groups.forEach((group, index) => {
+13
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { resolvePermission, getGlobalBooleanSetting } from '@/lib/utils/permissions';
/**
* GET /api/auth/me
@@ -37,6 +38,7 @@ export async function GET(request: NextRequest) {
authProvider: true,
createdAt: true,
lastLoginAt: true,
interactiveSearchAccess: true,
},
});
@@ -53,6 +55,14 @@ export async function GET(request: NextRequest) {
// Determine if user is local admin (setup admin with local authentication)
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
// Resolve effective permissions
const globalInteractiveSearch = await getGlobalBooleanSetting('interactive_search_access', true);
const effectiveInteractiveSearch = resolvePermission(
user.role,
user.interactiveSearchAccess,
globalInteractiveSearch
);
return NextResponse.json({
user: {
id: user.id,
@@ -65,6 +75,9 @@ export async function GET(request: NextRequest) {
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
permissions: {
interactiveSearch: effectiveInteractiveSearch,
},
},
});
});
@@ -321,9 +321,14 @@ async function searchIndexersForInteractive(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
@@ -9,6 +9,7 @@ import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
const logger = RMABLogger.create('API.InteractiveSearch');
@@ -71,6 +72,18 @@ export async function POST(
);
}
// Check interactive search access permission
const callingUser = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, interactiveSearchAccess: true },
});
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
{ status: 403 }
);
}
// Get enabled indexers from configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
+35 -22
View File
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('API.RequestById');
@@ -200,28 +201,11 @@ export async function PATCH(
// Get download path from the appropriate download client
let downloadPath: string;
if (downloadHistory.torrentHash) {
// qBittorrent - get path from torrent info
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
downloadPath = `${torrent.save_path}/${torrent.name}`;
} else if (downloadHistory.nzbId) {
// SABnzbd - get path from NZB info
const { getSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
if (!nzbInfo || !nzbInfo.downloadPath) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Download path not available from SABnzbd',
},
{ status: 400 }
);
}
downloadPath = nzbInfo.downloadPath;
} else {
// Get download path via unified interface
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
if (!clientId || clientType === 'direct') {
return NextResponse.json(
{
error: 'ValidationError',
@@ -231,6 +215,35 @@ export async function PATCH(
);
}
const { getConfigService } = await import('@/lib/services/config.service');
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (!client) {
return NextResponse.json(
{
error: 'ValidationError',
message: `No ${clientType} client configured`,
},
{ status: 400 }
);
}
const info = await client.getDownload(clientId);
if (!info?.downloadPath) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Download path not available from ${client.clientType}`,
},
{ status: 400 }
);
}
downloadPath = info.downloadPath;
await jobQueue.addOrganizeJob(
id,
requestWithData.audiobook.id,
+6 -2
View File
@@ -9,11 +9,14 @@ import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.Complete');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const {
backendMode,
@@ -28,7 +31,7 @@ export async function POST(request: NextRequest) {
downloadClient,
paths,
bookdate,
} = await request.json();
} = await req.json();
// Validate backend mode
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
@@ -401,7 +404,7 @@ export async function POST(request: NextRequest) {
downloadClientsArray = [{
id: `temp-${Date.now()}`,
type: downloadClient.type,
name: downloadClient.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
name: getClientDisplayName(downloadClient.type),
enabled: true,
url: downloadClient.url,
username: downloadClient.username,
@@ -562,4 +565,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -4,10 +4,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { serverUrl, apiToken } = await request.json();
const { serverUrl, apiToken } = await req.json();
if (!serverUrl) {
return NextResponse.json(
@@ -79,4 +81,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+28 -44
View File
@@ -4,15 +4,18 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestDownloadClient');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { type, url, username, password, disableSSLVerify } = await request.json();
const { type, name, url, username, password, disableSSLVerify } = await req.json();
if (!type || !url) {
return NextResponse.json(
@@ -21,59 +24,39 @@ 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 }
);
}
// Validate required fields per client type
// qBittorrent credentials are optional (supports IP whitelist auth)
if (type === 'qbittorrent') {
// Test qBittorrent connection (empty credentials work with IP whitelist)
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username || '',
password || '',
disableSSLVerify || false
);
// Build a temporary config for testing
const testConfig: DownloadClientConfig = {
id: 'setup-test',
type,
name: name || type,
enabled: true,
url,
username: username || '',
password: password || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: false,
};
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const result = await manager.testConnection(testConfig);
if (result.success) {
return NextResponse.json({
success: true,
version,
});
} else if (type === 'sabnzbd') {
if (!password) {
return NextResponse.json(
{ success: false, error: 'API key (password) is required for SABnzbd' },
{ status: 400 }
);
}
// Test SABnzbd connection
const sabnzbd = new SABnzbdService(url, password, '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 }
);
}
return NextResponse.json({
success: true,
version: result.version,
message: result.message,
});
}
// Should never reach here
return NextResponse.json(
{ success: false, error: 'Invalid client type' },
{ success: false, error: result.message },
{ status: 400 }
);
} catch (error) {
@@ -86,4 +69,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestOIDC');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const body = await request.json();
const body = await req.json();
const { issuerUrl, clientId, clientSecret } = body;
// Validate required fields
@@ -93,4 +95,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
@@ -45,8 +46,9 @@ async function testPath(dirPath: string): Promise<boolean> {
}
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -126,4 +128,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestPlex');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { url, token } = await request.json();
const { url, token } = await req.json();
if (!url || !token) {
return NextResponse.json(
@@ -61,4 +63,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestProwlarr');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { url, apiKey } = await request.json();
const { url, apiKey } = await req.json();
if (!url || !apiKey) {
return NextResponse.json(
@@ -50,4 +52,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}