mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -81,6 +81,7 @@ interface Settings {
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
};
|
||||
ebook: {
|
||||
enabled: boolean;
|
||||
@@ -1974,6 +1975,37 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter Merging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="chapter-merging-settings"
|
||||
checked={settings.paths.chapterMergingEnabled}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
paths: { ...settings.paths, chapterMergingEnabled: e.target.checked },
|
||||
});
|
||||
setValidated({ ...validated, paths: false });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="chapter-merging-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-merge chapters to M4B
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter
|
||||
markers. Improves playback experience and library organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testPaths}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -84,6 +84,7 @@ interface SetupState {
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
bookdateProvider: string;
|
||||
bookdateApiKey: string;
|
||||
bookdateModel: string;
|
||||
@@ -153,6 +154,7 @@ export default function SetupWizard() {
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media/audiobooks',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
bookdateProvider: 'openai',
|
||||
bookdateApiKey: '',
|
||||
bookdateModel: '',
|
||||
@@ -228,6 +230,7 @@ export default function SetupWizard() {
|
||||
download_dir: state.downloadDir,
|
||||
media_dir: state.mediaDir,
|
||||
metadata_tagging_enabled: state.metadataTaggingEnabled,
|
||||
chapter_merging_enabled: state.chapterMergingEnabled,
|
||||
},
|
||||
bookdate: state.bookdateConfigured ? {
|
||||
provider: state.bookdateProvider,
|
||||
@@ -525,6 +528,7 @@ export default function SetupWizard() {
|
||||
downloadDir={state.downloadDir}
|
||||
mediaDir={state.mediaDir}
|
||||
metadataTaggingEnabled={state.metadataTaggingEnabled}
|
||||
chapterMergingEnabled={state.chapterMergingEnabled}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface PathsStepProps {
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
onUpdate: (field: string, value: string | boolean) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
@@ -22,6 +23,7 @@ export function PathsStep({
|
||||
downloadDir,
|
||||
mediaDir,
|
||||
metadataTaggingEnabled,
|
||||
chapterMergingEnabled,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -235,6 +237,31 @@ export function PathsStep({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter Merging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="chapter-merging"
|
||||
checked={chapterMergingEnabled}
|
||||
onChange={(e) => onUpdate('chapterMergingEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="chapter-merging"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-merge chapters to M4B
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter
|
||||
markers. Improves playback experience and library organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={testPaths}
|
||||
loading={testing}
|
||||
|
||||
Reference in New Issue
Block a user