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
381 lines
No EOL
11 KiB
TypeScript
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',
|
|
},
|
|
});
|
|
} |