Implement chapter merging feature and update ranking algorithm

Added automatic chapter merging to M4B with admin/config toggles, UI controls, and backend logic. Updated documentation to reflect implementation. Refactored ranking algorithm: increased Title/Author match points, removed size scoring, and improved Usenet/torrent handling. Enhanced Prowlarr integration for protocol detection and filtering. Improved file organizer to support chapter merging. Various bug fixes and logging improvements.
This commit is contained in:
kikootwo
2026-01-08 16:26:26 -05:00
parent 722a78ac33
commit 288421012d
21 changed files with 922 additions and 128 deletions
+13 -1
View File
@@ -11,7 +11,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, metadataTaggingEnabled } = await request.json();
const { downloadDir, mediaDir, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -53,6 +53,18 @@ export async function PUT(request: NextRequest) {
},
});
// Update chapter merging setting
await prisma.configuration.upsert({
where: { key: 'chapter_merging_enabled' },
update: { value: String(chapterMergingEnabled ?? false) },
create: {
key: 'chapter_merging_enabled',
value: String(chapterMergingEnabled ?? false),
category: 'automation',
description: 'Automatically merge multi-file chapter downloads into single M4B with chapter markers',
},
});
console.log('[Admin] Paths settings updated');
// Invalidate qBittorrent service singleton to force reload of download_dir
+1
View File
@@ -81,6 +81,7 @@ export async function GET(request: NextRequest) {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
},
ebook: {
enabled: configMap.get('ebook_sidecar_enabled') === 'true',
@@ -28,8 +28,8 @@ const RequestWithTorrentSchema = z.object({
guid: z.string(),
title: z.string(),
size: z.number(),
seeders: z.number(),
leechers: z.number(),
seeders: z.number().optional(), // Optional for NZB/Usenet results
leechers: z.number().optional(), // Optional for NZB/Usenet results
indexer: z.string(),
downloadUrl: z.string(),
publishDate: z.string().transform((str) => new Date(str)),
+11 -24
View File
@@ -88,39 +88,26 @@ export async function POST(request: NextRequest) {
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
const rankedResults = rankTorrents(results, { title, author }, indexerPriorities, flagConfigs);
// 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
);
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`);
}
// No threshold filtering - show all results like interactive search
// User can see scores and make their own decision
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results (no threshold filter - user decides)`);
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) {
console.log(`[AudiobookSearch] ==================== RANKING DEBUG ====================`);
console.log(`[AudiobookSearch] Requested Title: "${title}"`);
console.log(`[AudiobookSearch] Requested Author: "${author}"`);
console.log(`[AudiobookSearch] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
console.log(`[AudiobookSearch] Top ${top3.length} results (out of ${rankedResults.length} total):`);
console.log(`[AudiobookSearch] --------------------------------------------------------`);
top3.forEach((result, index) => {
console.log(`[AudiobookSearch] ${index + 1}. "${result.title}"`);
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] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
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] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
@@ -141,7 +128,7 @@ export async function POST(request: NextRequest) {
}
// Add rank position to each result
const resultsWithRank = filteredResults.map((result, index) => ({
const resultsWithRank = rankedResults.map((result, index) => ({
...result,
rank: index + 1,
}));
@@ -149,9 +136,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
results: resultsWithRank,
message: filteredResults.length > 0
? `Found ${filteredResults.length} quality matches`
: 'No quality matches found',
message: rankedResults.length > 0
? `Found ${rankedResults.length} results`
: 'No results found',
});
} catch (error) {
console.error('Failed to search for torrents:', error);
@@ -141,10 +141,9 @@ export async function POST(
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] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
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) {
+12
View File
@@ -412,6 +412,18 @@ export async function POST(request: NextRequest) {
},
});
// Chapter merging configuration
await prisma.configuration.upsert({
where: { key: 'chapter_merging_enabled' },
update: { value: String(paths.chapter_merging_enabled ?? false) },
create: {
key: 'chapter_merging_enabled',
value: String(paths.chapter_merging_enabled ?? false),
category: 'automation',
description: 'Automatically merge multi-file chapter downloads into single M4B with chapter markers'
},
});
// BookDate configuration (optional, global for all users)
// Note: libraryScope and customPrompt are now per-user settings, not required here
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {