import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises'; import { existsSync, createReadStream } from 'fs'; import { basename, join, resolve } from 'path'; import { randomUUID } from 'crypto'; import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; export const dynamic = 'force-dynamic'; function getAudiobooksRootDir(request: NextRequest): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); if (!raw) return AUDIOBOOKS_V1_DIR; const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { return AUDIOBOOKS_V1_DIR; } const resolved = resolve(AUDIOBOOKS_V1_DIR, safe); if (!resolved.startsWith(resolve(AUDIOBOOKS_V1_DIR) + '/')) { return AUDIOBOOKS_V1_DIR; } return resolved; } interface ConversionRequest { chapterTitle: string; buffer: TTSAudioBytes; bookId?: string; format?: TTSAudiobookFormat; chapterIndex?: number; settings?: AudiobookGenerationSettings; } async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const ffprobe = spawn('ffprobe', [ '-i', filePath, '-show_entries', 'format=duration', '-v', 'quiet', '-of', 'csv=p=0' ]); let output = ''; let finished = false; const onAbort = () => { if (finished) return; finished = true; try { ffprobe.kill('SIGKILL'); } catch {} reject(new Error('ABORTED')); }; const cleanup = () => { if (finished) return; finished = true; signal?.removeEventListener('abort', onAbort); }; if (signal) { if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, { once: true }); } ffprobe.stdout.on('data', (data) => { output += data.toString(); }); ffprobe.on('close', (code) => { if (finished) return; cleanup(); if (code === 0) { const duration = parseFloat(output.trim()); resolve(duration); } else { reject(new Error(`ffprobe process exited with code ${code}`)); } }); ffprobe.on('error', (err) => { if (finished) return; cleanup(); reject(err); }); }); } async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const ffmpeg = spawn('ffmpeg', args); let finished = false; const onAbort = () => { if (finished) return; finished = true; try { ffmpeg.kill('SIGKILL'); } catch {} reject(new Error('ABORTED')); }; if (signal) { if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, { once: true }); } ffmpeg.stderr.on('data', (data) => { console.error(`ffmpeg stderr: ${data}`); }); ffmpeg.on('close', (code) => { if (finished) return; finished = true; signal?.removeEventListener('abort', onAbort); if (code === 0) { resolve(); } else { reject(new Error(`FFmpeg process exited with code ${code}`)); } }); ffmpeg.on('error', (err) => { if (finished) return; finished = true; signal?.removeEventListener('abort', onAbort); reject(err); }); }); } function buildAtempoFilter(speed: number): string { const clamped = Math.max(0.5, Math.min(speed, 3)); // atempo supports 0.5..2.0 per filter; chain for >2.0 if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`; const second = clamped / 2; return `atempo=2.0,atempo=${second.toFixed(3)}`; } const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; function isSafeId(value: string): boolean { return SAFE_ID_REGEX.test(value); } export async function POST(request: NextRequest) { try { // Parse the request body const data: ConversionRequest = await request.json(); const requestedFormat = data.format || 'm4b'; if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, { status: 409 }, ); } // Generate or use existing book ID const bookId = data.bookId || randomUUID(); if (!isSafeId(bookId)) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // Create intermediate directory await mkdir(intermediateDir, { recursive: true }); const existingChapters = await listStoredChapters(intermediateDir, request.signal); const hasChapters = existingChapters.length > 0; const metaPath = join(intermediateDir, 'audiobook.meta.json'); const incomingSettings = data.settings; let existingSettings: AudiobookGenerationSettings | null = null; try { existingSettings = JSON.parse(await readFile(metaPath, 'utf8')) as AudiobookGenerationSettings; } catch { existingSettings = null; } // Only enforce mismatch check if we already have generated chapters. // If no chapters exist, we can overwrite/ignore "existing" settings (which might be stale or partial). if (existingSettings && hasChapters) { if (incomingSettings) { const mismatch = existingSettings.ttsProvider !== incomingSettings.ttsProvider || existingSettings.ttsModel !== incomingSettings.ttsModel || existingSettings.voice !== incomingSettings.voice || existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || existingSettings.postSpeed !== incomingSettings.postSpeed || existingSettings.format !== incomingSettings.format; if (mismatch) { return NextResponse.json( { error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 }, ); } } } // Note: We deliberately do NOT write the meta file here yet. // We wait until a chapter is successfully generated/saved below. const existingFormats = new Set(existingChapters.map((c) => c.format)); if (existingFormats.size > 1) { return NextResponse.json( { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 }, ); } const format: TTSAudiobookFormat = (existingFormats.values().next().value as TTSAudiobookFormat | undefined) ?? existingSettings?.format ?? incomingSettings?.format ?? requestedFormat; const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1; // Use provided chapter index or find the next available index robustly (handles gaps) // Use provided chapter index or find the next available index robustly (handles gaps) let chapterIndex: number; if (data.chapterIndex !== undefined) { const normalized = Number(data.chapterIndex); if (!Number.isInteger(normalized) || normalized < 0) { return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 }); } chapterIndex = normalized; } else { const indices = existingChapters.map((c) => c.index); // Find smallest non-negative integer not present let next = 0; for (const idx of indices) { if (idx === next) { next++; } else if (idx > next) { break; } } chapterIndex = next; } // Write input file (MP3 from TTS) const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`); const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`); const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle); // Write the chapter audio to a temp file await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); // We intentionally do not delete the existing chapter file up-front. This avoids a long // window where the chapter is "missing" while ffmpeg is running (which can lead to // partial/stale "complete.*" downloads). We clean up duplicates and invalidate the // combined output only after the new chapter is written successfully. if (format === 'mp3') { // For MP3, re-encode to ensure proper headers and consistent format await runFFmpeg([ '-y', // Overwrite output file without asking '-i', inputPath, ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), '-c:a', 'libmp3lame', '-b:a', '64k', '-metadata', `title=${titleTag}`, chapterOutputTempPath ], request.signal); } else { // Convert MP3 to M4B container with proper encoding and metadata await runFFmpeg([ '-y', // Overwrite output file without asking '-i', inputPath, ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), '-c:a', 'aac', '-b:a', '64k', '-metadata', `title=${titleTag}`, '-f', 'mp4', chapterOutputTempPath ], request.signal); } const probe = await ffprobeAudio(chapterOutputTempPath, request.signal); const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal)); const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format)); await unlink(finalChapterPath).catch(() => {}); await rename(chapterOutputTempPath, finalChapterPath); // Remove any existing chapter files for this index (e.g., if the title changed and the // filename changed) and invalidate the combined output now that the chapter is updated. const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; const finalChapterName = basename(finalChapterPath); const existingFiles = await readdir(intermediateDir).catch(() => []); for (const file of existingFiles) { if (!file.startsWith(chapterPrefix)) continue; if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; if (file === finalChapterName) continue; await unlink(join(intermediateDir, file)).catch(() => {}); } await unlink(join(intermediateDir, 'complete.mp3')).catch(() => {}); await unlink(join(intermediateDir, 'complete.m4b')).catch(() => {}); await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {}); await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {}); // Ensure meta exists after first successful chapter. if (!existingSettings && incomingSettings) { await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => {}); } // Clean up input file await unlink(inputPath).catch(console.error); return NextResponse.json({ index: chapterIndex, title: data.chapterTitle, duration, status: 'completed' as const, bookId, format }); } catch (error) { if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { return NextResponse.json( { error: 'cancelled' }, { status: 499 } ); } console.error('Error processing audio chapter:', error); return NextResponse.json( { error: 'Failed to process audio chapter' }, { status: 500 } ); } } export async function GET(request: NextRequest) { try { const bookId = request.nextUrl.searchParams.get('bookId'); const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null; if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } if (!isSafeId(bookId)) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, { status: 409 }, ); } const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } const stored = await listStoredChapters(intermediateDir, request.signal); const chapters = stored.map((chapter) => ({ title: chapter.title, duration: chapter.durationSec ?? 0, index: chapter.index, format: chapter.format, filePath: chapter.filePath, })); if (chapters.length === 0) { return NextResponse.json({ error: 'No chapters found' }, { status: 404 }); } const chapterFormats = new Set(chapters.map((chapter) => chapter.format)); if (chapterFormats.size > 1) { return NextResponse.json( { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 }, ); } // Sort chapters by index chapters.sort((a, b) => a.index - b.index); const format: TTSAudiobookFormat = requestedFormat ?? (chapters[0]?.format as TTSAudiobookFormat) ?? 'm4b'; const outputPath = join(intermediateDir, `complete.${format}`); const manifestPath = join(intermediateDir, `complete.${format}.manifest.json`); const metadataPath = join(intermediateDir, 'metadata.txt'); const listPath = join(intermediateDir, 'list.txt'); const signature = chapters.map((chapter) => ({ index: chapter.index, fileName: basename(chapter.filePath), })); if (existsSync(outputPath)) { let cached: typeof signature | null = null; try { cached = JSON.parse(await readFile(manifestPath, 'utf8')) as typeof signature; } catch { cached = null; } if (cached && JSON.stringify(cached) === JSON.stringify(signature)) { return streamFile(outputPath, format); } await unlink(outputPath).catch(() => {}); await unlink(manifestPath).catch(() => {}); } // Ensure we have chapter durations for chapter markers / ordering. for (const chapter of chapters) { if (chapter.duration && chapter.duration > 0) continue; try { const probe = await ffprobeAudio(chapter.filePath, request.signal); if (probe.durationSec && probe.durationSec > 0) { chapter.duration = probe.durationSec; continue; } } catch {} try { chapter.duration = await getAudioDuration(chapter.filePath, request.signal); } catch { chapter.duration = 0; } } // Create chapter metadata file for M4B const metadata: string[] = []; let currentTime = 0; for (const chapter of chapters) { const startMs = Math.floor(currentTime * 1000); currentTime += chapter.duration; const endMs = Math.floor(currentTime * 1000); metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${escapeFFMetadata(chapter.title)}`); } await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); // Create list file for concat await writeFile( listPath, chapters.map(c => `file '${c.filePath}'`).join('\n') ); if (format === 'mp3') { // For MP3, re-encode to properly rebuild headers and duration metadata // Using libmp3lame to ensure proper MP3 structure await runFFmpeg([ '-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath ], request.signal); } else { // Combine all files into a single M4B with chapter metadata await runFFmpeg([ '-f', 'concat', '-safe', '0', '-i', listPath, '-i', metadataPath, '-map_metadata', '1', '-c:a', 'aac', '-b:a', '64k', '-f', 'mp4', outputPath ], request.signal); } // Clean up temporary files (but keep the chapters and complete file) await Promise.all([ unlink(metadataPath).catch(console.error), unlink(listPath).catch(console.error) ]); await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => {}); // Stream the file back to the client return streamFile(outputPath, format); } catch (error) { if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { return NextResponse.json( { error: 'cancelled' }, { status: 499 } ); } console.error('Error creating M4B:', error); return NextResponse.json( { error: 'Failed to create M4B file' }, { status: 500 } ); } } // Helper function to stream file function streamFile(filePath: string, format: string) { const stream = createReadStream(filePath); const readableWebStream = new ReadableStream({ start(controller) { stream.on('data', (chunk) => { controller.enqueue(chunk); }); stream.on('end', () => { controller.close(); }); stream.on('error', (err) => { controller.error(err); }); }, cancel() { stream.destroy(); } }); const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; return new NextResponse(readableWebStream, { headers: { 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="audiobook.${format}"`, 'Cache-Control': 'no-cache', }, }); } export async function DELETE(request: NextRequest) { try { const bookId = request.nextUrl.searchParams.get('bookId'); if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } if (!isSafeId(bookId)) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, { status: 409 }, ); } const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // If directory doesn't exist, consider it already reset if (!existsSync(intermediateDir)) { return NextResponse.json({ success: true, existed: false }); } // Recursively delete the entire audiobook directory await rm(intermediateDir, { recursive: true, force: true }); return NextResponse.json({ success: true, existed: true }); } catch (error) { console.error('Error resetting audiobook:', error); return NextResponse.json( { error: 'Failed to reset audiobook' }, { status: 500 } ); } }