Add indexer flag bonuses and SSL verify toggle

Implements configurable indexer flag bonuses/penalties for torrent ranking, including UI for admin settings and support in ranking-algorithm. Adds an option to disable SSL certificate verification for qBittorrent connections (for self-signed certs), with UI in both setup and admin settings, and persists the setting. Updates documentation, API routes, and ranking logic to support these features. Also includes minor UI improvements and bug fixes.
This commit is contained in:
kikootwo
2026-01-06 20:10:33 -05:00
parent ca7cac0c88
commit 23881eb670
26 changed files with 921 additions and 141 deletions
@@ -17,6 +17,7 @@ export async function PUT(request: NextRequest) {
url,
username,
password,
disableSSLVerify,
remotePathMappingEnabled,
remotePath,
localPath,
@@ -92,6 +93,16 @@ export async function PUT(request: NextRequest) {
});
}
// Save SSL verification setting
await prisma.configuration.upsert({
where: { key: 'download_client_disable_ssl_verify' },
update: { value: disableSSLVerify ? 'true' : 'false' },
create: {
key: 'download_client_disable_ssl_verify',
value: disableSSLVerify ? 'true' : 'false',
},
});
// Save remote path mapping configuration
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path_mapping_enabled' },
@@ -34,6 +34,10 @@ export async function GET(request: NextRequest) {
const savedConfigStr = await configService.get('prowlarr_indexers');
const savedIndexers: SavedIndexerConfig[] = savedConfigStr ? JSON.parse(savedConfigStr) : [];
// Get saved flag configuration
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Merge with defaults (wizard format: array of {id, name, priority, seedingTimeMinutes})
const savedIndexersMap = new Map<number, SavedIndexerConfig>(
savedIndexers.map((idx) => [idx.id, idx])
@@ -58,6 +62,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
success: true,
indexers: indexersWithConfig,
flagConfigs,
});
} catch (error) {
console.error('[Prowlarr] Failed to fetch indexers:', error);
@@ -76,13 +81,13 @@ export async function GET(request: NextRequest) {
/**
* PUT /api/admin/settings/prowlarr/indexers
* Save indexer configuration
* Save indexer configuration and flag configs
*/
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { indexers } = await req.json();
const { indexers, flagConfigs } = await req.json();
// Filter to only enabled indexers and convert to wizard format
const enabledIndexers = indexers
@@ -97,14 +102,26 @@ export async function PUT(request: NextRequest) {
// Save to configuration (matches wizard format)
const configService = getConfigService();
await configService.setMany([
const configUpdates = [
{
key: 'prowlarr_indexers',
value: JSON.stringify(enabledIndexers),
category: 'indexer',
description: 'Prowlarr indexer settings (enabled, priority, seeding time)',
},
]);
];
// Save flag configs if provided
if (flagConfigs !== undefined) {
configUpdates.push({
key: 'indexer_flag_config',
value: JSON.stringify(flagConfigs),
category: 'indexer',
description: 'Indexer flag bonus/penalty configuration',
});
}
await configService.setMany(configUpdates);
return NextResponse.json({
success: true,
+1
View File
@@ -71,6 +71,7 @@ export async function GET(request: NextRequest) {
url: configMap.get('download_client_url') || '',
username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')),
disableSSLVerify: configMap.get('download_client_disable_ssl_verify') === 'true',
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') || '',
@@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
url,
username,
password,
disableSSLVerify,
remotePathMappingEnabled,
remotePath,
localPath,
@@ -57,7 +58,8 @@ export async function POST(request: NextRequest) {
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
actualPassword
actualPassword,
disableSSLVerify || false
);
// If path mapping enabled, validate local path exists
@@ -55,6 +55,15 @@ export async function POST(request: NextRequest) {
);
}
// Build indexer priorities map (indexerId -> priority 1-25, default 10)
const indexerPriorities = new Map<number, number>(
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
);
// Get flag configurations
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService();
const searchQuery = title; // Title only - cast wide net
@@ -76,13 +85,24 @@ export async function POST(request: NextRequest) {
});
}
// Rank torrents using the ranking algorithm
const rankedResults = rankTorrents(results, { title, author });
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
const rankedResults = rankTorrents(results, { title, author }, indexerPriorities, flagConfigs);
// Filter out results below minimum score threshold (50/100)
const filteredResults = rankedResults.filter(result => result.score >= 50);
// Dual threshold filtering:
// 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
const filteredResults = rankedResults.filter(result =>
result.score >= 50 && result.finalScore >= 50
);
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
const disqualifiedByNegativeBonus = rankedResults.filter(result =>
result.score >= 50 && result.finalScore < 50
).length;
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
if (disqualifiedByNegativeBonus > 0) {
console.log(`[AudiobookSearch] ${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
}
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
@@ -94,12 +114,22 @@ export async function POST(request: NextRequest) {
console.log(`[AudiobookSearch] --------------------------------------------------------`);
top3.forEach((result, index) => {
console.log(`[AudiobookSearch] ${index + 1}. "${result.title}"`);
console.log(`[AudiobookSearch] Indexer: ${result.indexer}`);
console.log(`[AudiobookSearch] Total Score: ${result.score.toFixed(1)}/100`);
console.log(`[AudiobookSearch] Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Base Score: ${result.score.toFixed(1)}/100`);
console.log(`[AudiobookSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
console.log(`[AudiobookSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
console.log(`[AudiobookSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
console.log(`[AudiobookSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
result.bonusModifiers.forEach(mod => {
console.log(`[AudiobookSearch] - ${mod.reason}: +${mod.points.toFixed(1)}`);
});
}
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Final Score: ${result.finalScore.toFixed(1)}`);
if (result.breakdown.notes.length > 0) {
console.log(`[AudiobookSearch] Notes: ${result.breakdown.notes.join(', ')}`);
}
@@ -82,6 +82,15 @@ export async function POST(
);
}
// Build indexer priorities map (indexerId -> priority 1-25, default 10)
const indexerPriorities = new Map<number, number>(
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
);
// Get flag configurations
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService();
// Use custom title if provided, otherwise use audiobook's title
@@ -107,17 +116,28 @@ export async function POST(
});
}
// Rank torrents using the ranking algorithm
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query)
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
});
}, indexerPriorities, flagConfigs);
// Filter out results below minimum score threshold (50/100)
const filteredResults = rankedResults.filter(result => result.score >= 50);
// Dual threshold filtering:
// 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
const filteredResults = rankedResults.filter(result =>
result.score >= 50 && result.finalScore >= 50
);
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
const disqualifiedByNegativeBonus = rankedResults.filter(result =>
result.score >= 50 && result.finalScore < 50
).length;
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
if (disqualifiedByNegativeBonus > 0) {
console.log(`[InteractiveSearch] ${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
}
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
@@ -130,12 +150,22 @@ export async function POST(
console.log(`[InteractiveSearch] --------------------------------------------------------`);
top3.forEach((result, index) => {
console.log(`[InteractiveSearch] ${index + 1}. "${result.title}"`);
console.log(`[InteractiveSearch] Indexer: ${result.indexer}`);
console.log(`[InteractiveSearch] Total Score: ${result.score.toFixed(1)}/100`);
console.log(`[InteractiveSearch] Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Base Score: ${result.score.toFixed(1)}/100`);
console.log(`[InteractiveSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
console.log(`[InteractiveSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
console.log(`[InteractiveSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
console.log(`[InteractiveSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
result.bonusModifiers.forEach(mod => {
console.log(`[InteractiveSearch] - ${mod.reason}: +${mod.points.toFixed(1)}`);
});
}
console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Final Score: ${result.finalScore.toFixed(1)}`);
if (result.breakdown.notes.length > 0) {
console.log(`[InteractiveSearch] Notes: ${result.breakdown.notes.join(', ')}`);
}
+9
View File
@@ -356,6 +356,15 @@ export async function POST(request: NextRequest) {
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' },
@@ -8,7 +8,7 @@ import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function POST(request: NextRequest) {
try {
const { type, url, username, password } = await request.json();
const { type, url, username, password, disableSSLVerify } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
@@ -28,7 +28,8 @@ export async function POST(request: NextRequest) {
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
password
password,
disableSSLVerify || false
);
return NextResponse.json({