openreader/src/app/api/audio/convert/route.ts
Richard Roberson 42665884d7 feat(audiobook): add chapter-based export with UI and API
Introduce end-to-end chapterized audiobook generation with persistent
storage, resumable workflows, and MP3/M4B support.

API:
- add /api/audio/convert/chapter (GET/DELETE) for per-chapter ops
- add /api/audio/convert/chapters (GET/DELETE) for listing/reset
- enhance /api/audio/convert:
  - accept mp3|m4b, stream combined file, cache complete output
  - robust chapter indexing, docstore persistence, list concat
  - AbortSignal-aware ffmpeg/ffprobe, 499 on cancel

UI/UX:
- add AudiobookExportModal with progress, resume, regenerate, download
- add ProgressCard, enhance ProgressPopup (click-to-focus, richer info)
- add Header, ZoomControl; move TTSPlayer to sticky bottom bar
- redesign EPUB/HTML/PDF pages for full-height layout and controls
- add HomeContent; compact uploader variant; document list polish

TTS/Contexts:
- EPUB/PDF contexts now generate per-chapter to disk, return bookId
- support chapter regeneration; progress/cancel propagation
- pass provider/model/instructions; standardize MP3 TTS output
- HTML context updates for model/instructions

Styling:
- globals: overlay-dim, scrollbar styles, prism gradient utilities
- theme vars: secondary-accent, prism-gradient; tailwind color addition

Misc:
- fix PDF scale calc to use container height
- EPUB theme reader area fills height
- time estimation update cadence stab
- audio util passes format to combine endpoint
2025-11-11 13:32:30 -07:00

381 lines
No EOL
11 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import { writeFile, readFile, mkdir, unlink, readdir } from 'fs/promises';
import { existsSync, createReadStream } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
interface ConversionRequest {
chapterTitle: string;
buffer: number[];
bookId?: string;
format?: 'mp3' | 'm4b';
chapterIndex?: number;
}
async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> {
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<void> {
return new Promise<void>((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);
});
});
}
export async function POST(request: NextRequest) {
try {
// Parse the request body
const data: ConversionRequest = await request.json();
const format = data.format || 'm4b';
// Use docstore directory
const docstoreDir = join(process.cwd(), 'docstore');
if (!existsSync(docstoreDir)) {
await mkdir(docstoreDir);
}
// Generate or use existing book ID
const bookId = data.bookId || randomUUID();
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
// Create intermediate directory
if (!existsSync(intermediateDir)) {
await mkdir(intermediateDir);
}
// Use provided chapter index or find the next available index robustly (handles gaps)
let chapterIndex: number;
if (data.chapterIndex !== undefined) {
chapterIndex = data.chapterIndex;
} else {
const files = await readdir(intermediateDir);
const indices = files
.map(f => f.match(/^(\d+)-chapter\.(m4b|mp3)$/))
.filter((m): m is RegExpMatchArray => Boolean(m))
.map(m => parseInt(m[1], 10))
.sort((a, b) => a - b);
// 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 chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
// Write the chapter audio to a temp file
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
if (format === 'mp3') {
// For MP3, re-encode to ensure proper headers and consistent format
await runFFmpeg([
'-y', // Overwrite output file without asking
'-i', inputPath,
'-c:a', 'libmp3lame',
'-b:a', '64k',
'-metadata', `title=${data.chapterTitle}`,
chapterOutputPath
], request.signal);
} else {
// Convert MP3 to M4B container with proper encoding and metadata
await runFFmpeg([
'-y', // Overwrite output file without asking
'-i', inputPath,
'-c:a', 'aac',
'-b:a', '64k',
'-metadata', `title=${data.chapterTitle}`,
'-f', 'mp4',
chapterOutputPath
], request.signal);
}
// Get the duration and save metadata
const duration = await getAudioDuration(chapterOutputPath, request.signal);
await writeFile(metadataPath, JSON.stringify({
title: data.chapterTitle,
duration,
index: chapterIndex,
format
}));
// Clean up input file
await unlink(inputPath).catch(console.error);
return NextResponse.json({
bookId,
chapterIndex,
duration
});
} 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 'mp3' | 'm4b' | null;
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
if (!existsSync(intermediateDir)) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
// Read all chapter metadata
const files = await readdir(intermediateDir);
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
const chapters: { title: string; duration: number; index: number; format: string }[] = [];
for (const metaFile of metaFiles) {
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
chapters.push(meta);
}
if (chapters.length === 0) {
return NextResponse.json({ error: 'No chapters found' }, { status: 404 });
}
// Sort chapters by index
chapters.sort((a, b) => a.index - b.index);
// Determine output format from existing chapter metadata to avoid mismatches
const chapterFormat = (chapters[0]?.format === 'mp3' || chapters[0]?.format === 'm4b') ? chapters[0].format : 'm4b';
if (requestedFormat && requestedFormat !== chapterFormat) {
console.warn(`Requested format ${requestedFormat} differs from chapter format ${chapterFormat}. Using ${chapterFormat}.`);
}
const format = chapterFormat;
const outputPath = join(intermediateDir, `complete.${format}`);
const metadataPath = join(intermediateDir, 'metadata.txt');
const listPath = join(intermediateDir, 'list.txt');
// Check if combined file already exists
if (existsSync(outputPath)) {
// Stream the existing file
return streamFile(outputPath, format);
}
// Create chapter metadata file for M4B
const metadata: string[] = [];
let currentTime = 0;
chapters.forEach((chapter) => {
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=${chapter.title}`
);
});
await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n'));
// Create list file for concat
await writeFile(
listPath,
chapters.map(c => `file '${join(intermediateDir, `${c.index}-chapter.${format}`)}'`).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', 'copy',
'-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)
]);
// 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',
},
});
}