Add remote path mapping for qBittorrent integration

Implements remote-to-local path mapping for qBittorrent downloads, allowing the app to handle differing filesystem paths between qBittorrent and the local environment (e.g., remote seedboxes, Docker). Adds UI controls in admin settings and setup wizard, validates mapping configuration, and applies path transformation in download and import processors. Updates documentation, API routes, and data models to support the new feature. Also improves library scan logic to remove stale records and reset orphaned audiobooks and requests. Increases minimum torrent score threshold from 30 to 50 in search and ranking logic, and exposes torrent source URLs in the admin UI.
This commit is contained in:
kikootwo
2026-01-04 06:28:17 -05:00
parent d617e26c92
commit ca7cac0c88
26 changed files with 1108 additions and 75 deletions
+4 -2
View File
@@ -48,6 +48,8 @@ export async function GET(request: NextRequest) {
downloadStatus: true,
torrentName: true,
torrentHash: true,
startedAt: true,
createdAt: true,
},
},
},
@@ -75,7 +77,7 @@ export async function GET(request: NextRequest) {
torrentName: download.downloadHistory[0]?.torrentName || null,
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
user: download.user.plexUsername,
startedAt: download.updatedAt,
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
}));
return NextResponse.json({ downloads: formatted });
}
@@ -112,7 +114,7 @@ export async function GET(request: NextRequest) {
torrentName: download.downloadHistory[0]?.torrentName || null,
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
user: download.user.plexUsername,
startedAt: download.updatedAt,
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
};
})
);
@@ -30,6 +30,15 @@ export async function GET(request: NextRequest) {
plexUsername: true,
},
},
downloadHistory: {
where: {
selected: true,
},
select: {
torrentUrl: true,
},
take: 1,
},
},
orderBy: {
createdAt: 'desc',
@@ -47,6 +56,7 @@ export async function GET(request: NextRequest) {
createdAt: request.createdAt,
completedAt: request.completedAt,
errorMessage: request.errorMessage,
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
}));
return NextResponse.json({ requests: formatted });
@@ -6,12 +6,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { PathMapper } from '@/lib/utils/path-mapper';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
const {
type,
url,
username,
password,
remotePathMappingEnabled,
remotePath,
localPath,
} = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
@@ -28,6 +37,33 @@ export async function PUT(request: NextRequest) {
);
}
// 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 }
);
}
try {
PathMapper.validate({
enabled: true,
remotePath,
localPath,
});
} catch (validationError) {
return NextResponse.json(
{
error: validationError instanceof Error
? validationError.message
: 'Invalid path mapping configuration',
},
{ status: 400 }
);
}
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_client_type' },
@@ -56,6 +92,28 @@ export async function PUT(request: NextRequest) {
});
}
// Save remote path mapping configuration
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path_mapping_enabled' },
update: { value: remotePathMappingEnabled ? 'true' : 'false' },
create: {
key: 'download_client_remote_path_mapping_enabled',
value: remotePathMappingEnabled ? 'true' : 'false',
},
});
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path' },
update: { value: remotePath || '' },
create: { key: 'download_client_remote_path', value: remotePath || '' },
});
await prisma.configuration.upsert({
where: { key: 'download_client_local_path' },
update: { value: localPath || '' },
create: { key: 'download_client_local_path', value: localPath || '' },
});
console.log('[Admin] Download client settings updated');
// Invalidate qBittorrent service singleton to force reload of credentials and URL
+3
View File
@@ -72,6 +72,9 @@ export async function GET(request: NextRequest) {
username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')),
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true',
remotePath: configMap.get('download_client_remote_path') || '',
localPath: configMap.get('download_client_local_path') || '',
},
paths: {
downloadDir: configMap.get('download_dir') || '/downloads',
@@ -12,7 +12,15 @@ export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
const {
type,
url,
username,
password,
remotePathMappingEnabled,
remotePath,
localPath,
} = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
@@ -52,6 +60,33 @@ export async function POST(request: NextRequest) {
actualPassword
);
// If path mapping enabled, validate local path exists
if (remotePathMappingEnabled) {
if (!remotePath || !localPath) {
return NextResponse.json(
{
success: false,
error: 'Remote path and local path are required when path mapping is enabled',
},
{ status: 400 }
);
}
// Check if local path is accessible
const fs = await import('fs/promises');
try {
await fs.access(localPath, fs.constants.R_OK);
} catch (accessError) {
return NextResponse.json(
{
success: false,
error: `Local path "${localPath}" is not accessible. Please verify the path exists and has correct permissions.`,
},
{ status: 400 }
);
}
}
return NextResponse.json({
success: true,
version,
@@ -79,10 +79,10 @@ export async function POST(request: NextRequest) {
// Rank torrents using the ranking algorithm
const rankedResults = rankTorrents(results, { title, author });
// Filter out results below minimum score threshold (30/100)
const filteredResults = rankedResults.filter(result => result.score >= 30);
// Filter out results below minimum score threshold (50/100)
const filteredResults = rankedResults.filter(result => result.score >= 50);
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
@@ -114,10 +114,10 @@ export async function POST(
author: requestRecord.audiobook.author,
});
// Filter out results below minimum score threshold (30/100)
const filteredResults = rankedResults.filter(result => result.score >= 30);
// Filter out results below minimum score threshold (50/100)
const filteredResults = rankedResults.filter(result => result.score >= 50);
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
+22
View File
@@ -356,6 +356,28 @@ export async function POST(request: NextRequest) {
create: { key: 'download_client_password', value: downloadClient.password },
});
// 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',
},
});
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path' },
update: { value: downloadClient.remotePath || '' },
create: { key: 'download_client_remote_path', value: downloadClient.remotePath || '' },
});
await prisma.configuration.upsert({
where: { key: 'download_client_local_path' },
update: { value: downloadClient.localPath || '' },
create: { key: 'download_client_local_path', value: downloadClient.localPath || '' },
});
// Path configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },