openreader/src/app/api/audiobook/route.ts

443 lines
15 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
audiobookPrefix,
deleteAudiobookObject,
deleteAudiobookPrefix,
getAudiobookObjectBuffer,
listAudiobookObjects,
putAudiobookObject,
} from '@/lib/server/audiobooks/blobstore';
import {
decodeChapterFileName,
escapeFFMetadata,
ffprobeAudio,
} from '@/lib/server/audiobooks/chapters';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import type { TTSAudiobookFormat } from '@/types/tts';
export const dynamic = 'force-dynamic';
type ChapterObject = {
index: number;
title: string;
format: TTSAudiobookFormat;
fileName: string;
};
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
function isSafeId(value: string): boolean {
return SAFE_ID_REGEX.test(value);
}
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Audiobooks storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
function chapterFileMimeType(format: TTSAudiobookFormat): string {
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
}
function listChapterObjects(objectNames: string[]): ChapterObject[] {
const chapters = objectNames
.filter((name) => !name.startsWith('complete.'))
.map((fileName) => {
const decoded = decodeChapterFileName(fileName);
if (!decoded) return null;
return {
index: decoded.index,
title: decoded.title,
format: decoded.format,
fileName,
} satisfies ChapterObject;
})
.filter((value): value is ChapterObject => Boolean(value))
.sort((a, b) => a.index - b.index);
const deduped = new Map<number, ChapterObject>();
for (const chapter of chapters) {
const existing = deduped.get(chapter.index);
if (!existing) {
deduped.set(chapter.index, chapter);
continue;
}
if (chapter.fileName > existing.fileName) {
deduped.set(chapter.index, chapter);
}
}
return Array.from(deduped.values()).sort((a, b) => a.index - b.index);
}
function streamBuffer(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const ffmpeg = spawn(getFFmpegPath(), 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) => {
serverLogger.warn({
event: 'audiobook.ffmpeg.stderr',
degraded: true,
step: 'ffmpeg',
stderr: String(data),
}, 'ffmpeg stderr');
});
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);
});
});
}
async function ensurePositiveDuration(filePath: string, signal?: AbortSignal): Promise<void> {
const probe = await ffprobeAudio(filePath, signal);
if (!probe.durationSec || probe.durationSec <= 0) {
throw new Error(`Invalid duration for output file: ${filePath}`);
}
}
export async function GET(request: NextRequest) {
let workDir: string | null = null;
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
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 });
}
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const existingBookUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBookUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
const objectNames = objects.map((item) => item.fileName);
const chapters = listChapterObjects(objectNames);
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 });
}
const format: TTSAudiobookFormat = requestedFormat ?? chapters[0].format;
const completeName = `complete.${format}`;
const manifestName = `${completeName}.manifest.json`;
const signature = chapters.map((chapter) => ({ index: chapter.index, fileName: chapter.fileName }));
if (objectNames.includes(completeName) && objectNames.includes(manifestName)) {
try {
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, manifestName, testNamespace)).toString('utf8'));
if (JSON.stringify(manifest) === JSON.stringify(signature)) {
const cached = await getAudiobookObjectBuffer(bookId, existingBookUserId, completeName, testNamespace);
return new NextResponse(streamBuffer(cached), {
headers: {
'Content-Type': chapterFileMimeType(format),
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
'Cache-Control': 'no-cache',
},
});
}
} catch {
// Force regeneration below.
}
await deleteAudiobookObject(bookId, existingBookUserId, completeName, testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, existingBookUserId, manifestName, testNamespace).catch(() => {});
}
const chapterRows = await db
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
.from(audiobookChapters)
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
const durationByIndex = new Map<number, number>();
for (const row of chapterRows) {
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
}
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-combine-'));
const metadataPath = join(workDir, 'metadata.txt');
const listPath = join(workDir, 'list.txt');
const outputPath = join(workDir, completeName);
const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = [];
for (const chapter of chapters) {
const localPath = join(workDir, chapter.fileName);
const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
await writeFile(localPath, bytes);
let duration = 0;
try {
const probe = await ffprobeAudio(localPath, request.signal);
if (probe.durationSec && probe.durationSec > 0) {
duration = probe.durationSec;
}
} catch {
duration = 0;
}
if (!duration || duration <= 0) {
duration = durationByIndex.get(chapter.index) ?? 0;
}
localChapters.push({
index: chapter.index,
title: chapter.title,
localPath,
duration,
});
}
const metadata: string[] = [];
let currentTime = 0;
for (const chapter of localChapters) {
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'));
await writeFile(
listPath,
localChapters
.map((chapter) => `file '${chapter.localPath.replace(/'/g, "'\\''")}'`)
.join('\n'),
);
if (format === 'mp3') {
try {
await runFFmpeg(
['-f', 'concat', '-safe', '0', '-i', listPath, '-map_metadata', '-1', '-c:a', 'copy', outputPath],
request.signal,
);
} catch (copyError) {
serverLogger.warn({
event: 'audiobook.concat_copy.mp3.failed',
degraded: true,
fallbackPath: 'reencode',
error: errorToLog(copyError),
}, 'MP3 concat copy failed; falling back to re-encode');
await runFFmpeg(
['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath],
request.signal,
);
}
} else {
try {
await runFFmpeg(
[
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-i',
metadataPath,
'-map_metadata',
'1',
'-c:a',
'copy',
'-f',
'mp4',
outputPath,
],
request.signal,
);
} catch (copyError) {
serverLogger.warn({
event: 'audiobook.concat_copy.m4b.failed',
degraded: true,
fallbackPath: 'reencode',
error: errorToLog(copyError),
}, 'M4B concat copy failed; falling back to re-encode');
await runFFmpeg(
[
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-i',
metadataPath,
'-map_metadata',
'1',
'-c:a',
'aac',
'-b:a',
'64k',
'-f',
'mp4',
outputPath,
],
request.signal,
);
}
}
await ensurePositiveDuration(outputPath, request.signal);
const outputBytes = await readFile(outputPath);
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
await putAudiobookObject(
bookId,
existingBookUserId,
manifestName,
Buffer.from(JSON.stringify(signature, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);
return new NextResponse(streamBuffer(outputBytes), {
headers: {
'Content-Type': chapterFileMimeType(format),
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
'Cache-Control': 'no-cache',
},
});
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
}
serverLogger.error({
event: 'audiobook.create.failed',
error: errorToLog(error),
}, 'Failed to create full audiobook');
return errorResponse(error, {
apiErrorMessage: 'Failed to create full audiobook file',
normalize: { code: 'AUDIOBOOK_CREATE_FAILED', errorClass: 'upstream' },
});
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
export async function DELETE(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
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 });
}
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const storageUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!storageUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
await db
.delete(audiobookChapters)
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId)));
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0);
return NextResponse.json({ success: true, existed: deleted > 0 });
} catch (error) {
serverLogger.error({
event: 'audiobook.reset.failed',
error: errorToLog(error),
}, 'Failed to reset audiobook');
return errorResponse(error, {
apiErrorMessage: 'Failed to reset audiobook',
normalize: { code: 'AUDIOBOOK_RESET_FAILED', errorClass: 'db' },
});
}
}