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

944 lines
34 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 { randomUUID } from 'crypto';
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 { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
deleteAudiobookObject,
getAudiobookObjectBuffer,
isMissingBlobError,
listAudiobookObjects,
putAudiobookObject,
} from '@/lib/server/audiobooks/blobstore';
import {
decodeChapterFileName,
encodeChapterFileName,
encodeChapterTitleTag,
ffprobeAudio,
} from '@/lib/server/audiobooks/chapters';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
import { generateTTSBuffer } from '@/lib/server/tts/generate';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { defaultVoiceForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { listAdminProviders } from '@/lib/server/admin/providers';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat } from '@/types/tts';
import {
canonicalizeAudiobookSettingsForRuntime,
coerceAudiobookGenerationSettings,
type SharedProviderPolicyEntry,
} from '@/lib/server/audiobooks/settings';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
interface ConversionRequest {
chapterTitle: string;
text: string;
bookId?: string;
format?: TTSAudiobookFormat;
chapterIndex?: number;
settings?: unknown;
}
type ChapterObject = {
index: number;
title: string;
format: TTSAudiobookFormat;
fileName: string;
};
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
const PROBLEM_TYPES = {
dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded',
upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited',
} as const;
type ProblemDetails = {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
code?: string;
[key: string]: unknown;
};
function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) {
if (didCreate && deviceId) {
setDeviceIdCookie(response, deviceId);
}
}
function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
if (limit >= 1_000_000) {
const m = limit / 1_000_000;
return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`;
}
if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`;
return String(limit);
}
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 normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): AudiobookGenerationSettings {
return resolveTtsProviderModelPolicy({
providerRef: settings.providerRef,
providerType: settings.providerType,
model: settings.ttsModel,
}).supportsNativeModelSpeed
? settings
: { ...settings, nativeSpeed: 1 };
}
function chapterFileMimeType(format: TTSAudiobookFormat): string {
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
}
function buildAtempoFilter(speed: number): string {
const clamped = Math.max(0.5, Math.min(speed, 3));
if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`;
const second = clamped / 2;
return `atempo=2.0,atempo=${second.toFixed(3)}`;
}
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 || 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.chapter.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);
});
});
}
function chapterEncodeArgs(
inputPath: string,
outputPath: string,
format: TTSAudiobookFormat,
postSpeed: number,
titleTag: string,
): string[] {
if (format === 'mp3') {
return [
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'libmp3lame',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
outputPath,
];
}
return [
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'aac',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
'-f',
'mp4',
outputPath,
];
}
function findChapterFileNameByIndex(fileNames: string[], index: number): { fileName: string; title: string; format: 'mp3' | 'm4b' } | null {
const matches = fileNames
.map((fileName) => {
const decoded = decodeChapterFileName(fileName);
if (!decoded) return null;
if (decoded.index !== index) return null;
return { fileName, title: decoded.title, format: decoded.format };
})
.filter((value): value is { fileName: string; title: string; format: 'mp3' | 'm4b' } => Boolean(value))
.sort((a, b) => a.fileName.localeCompare(b.fileName));
return matches.at(-1) ?? null;
}
export async function POST(request: NextRequest) {
let workDir: string | null = null;
let didCreateDeviceIdCookie = false;
let deviceIdToSet: string | null = null;
let providerForError: string | null = null;
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const data: ConversionRequest = await request.json();
const requestedFormat = data.format || 'm4b';
if (!data.text || typeof data.text !== 'string') {
return NextResponse.json({ error: 'Missing text for TTS generation' }, { status: 400 });
}
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled, user } = ctxOrRes;
const runtimeConfig = await getResolvedRuntimeConfig();
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const bookId = data.bookId || randomUUID();
if (!isSafeId(bookId)) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
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,
) ?? preferredUserId;
await db
.insert(audiobooks)
.values({
id: bookId,
userId: storageUserId,
title: data.chapterTitle || 'Untitled Audiobook',
})
.onConflictDoNothing();
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
const objectNames = objects.map((item) => item.fileName);
const existingChapters = listChapterObjects(objectNames);
const hasChapters = existingChapters.length > 0;
let normalizedExistingSettings: AudiobookGenerationSettings | undefined;
let existingSettingsNeedsMigration = false;
try {
const parsedSettings = JSON.parse(
(await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'),
) as unknown;
const existingResult = coerceAudiobookGenerationSettings(parsedSettings, {
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
});
if (!existingResult.settings) {
serverLogger.error({
event: 'audiobook.chapter.meta_settings.invalid',
bookId,
storageUserId,
error: {
name: 'AudiobookMetaSettingsInvalid',
message: 'Invalid audiobook.meta.json settings payload',
},
}, 'Invalid audiobook.meta.json settings payload');
return errorResponse(new Error('Invalid audiobook metadata settings payload'), {
apiErrorMessage: 'Invalid audiobook metadata settings',
normalize: { code: 'AUDIOBOOK_CHAPTER_META_SETTINGS_INVALID', errorClass: 'validation', httpStatus: 500 },
});
}
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
existingSettingsNeedsMigration = existingResult.migrated;
} catch (error) {
if (!isMissingBlobError(error)) throw error;
normalizedExistingSettings = undefined;
}
const incomingSettings = (() => {
if (data.settings === undefined) {
return undefined;
}
const incomingResult = coerceAudiobookGenerationSettings(data.settings, {
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
});
if (!incomingResult.settings) {
return null;
}
return normalizeNativeSpeedForSettings(incomingResult.settings);
})();
if (incomingSettings === null) {
return NextResponse.json({ error: 'Invalid audiobook settings payload' }, { status: 400 });
}
const sharedProviders: SharedProviderPolicyEntry[] = runtimeConfig.restrictUserApiKeys
? (await listAdminProviders())
.filter((entry) => entry.enabled)
.map((entry) => ({
slug: entry.slug,
providerType: entry.providerType,
defaultModel: entry.defaultModel,
defaultInstructions: entry.defaultInstructions,
}))
: [];
if (runtimeConfig.restrictUserApiKeys && normalizedExistingSettings) {
const next = canonicalizeAudiobookSettingsForRuntime({
settings: normalizedExistingSettings,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
showAllProviderModels: runtimeConfig.showAllProviderModels,
sharedProviders,
});
if (JSON.stringify(next) !== JSON.stringify(normalizedExistingSettings)) {
existingSettingsNeedsMigration = true;
}
normalizedExistingSettings = next;
}
let normalizedIncomingSettings = incomingSettings;
if (runtimeConfig.restrictUserApiKeys && normalizedIncomingSettings) {
normalizedIncomingSettings = canonicalizeAudiobookSettingsForRuntime({
settings: normalizedIncomingSettings,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
showAllProviderModels: runtimeConfig.showAllProviderModels,
sharedProviders,
});
}
if (normalizedExistingSettings && existingSettingsNeedsMigration) {
try {
await putAudiobookObject(
bookId,
storageUserId,
'audiobook.meta.json',
Buffer.from(JSON.stringify(normalizedExistingSettings, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);
} catch (error) {
serverLogger.warn({
event: 'audiobook.chapter.meta_settings.persist_migration_failed',
degraded: true,
step: 'persist_migrated_settings',
bookId,
storageUserId,
error: errorToLog(error),
}, 'Failed to persist migrated audiobook metadata settings');
}
}
const mergedSettings = normalizedExistingSettings && normalizedIncomingSettings
? normalizeNativeSpeedForSettings({
...normalizedExistingSettings,
...normalizedIncomingSettings,
})
: normalizedExistingSettings ?? normalizedIncomingSettings;
if (normalizedExistingSettings && hasChapters && normalizedIncomingSettings) {
const mismatch =
normalizedExistingSettings.providerRef !== normalizedIncomingSettings.providerRef ||
normalizedExistingSettings.providerType !== normalizedIncomingSettings.providerType ||
normalizedExistingSettings.ttsModel !== normalizedIncomingSettings.ttsModel ||
normalizedExistingSettings.voice !== normalizedIncomingSettings.voice ||
normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed ||
normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed ||
normalizedExistingSettings.format !== normalizedIncomingSettings.format ||
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '');
if (mismatch) {
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 });
}
}
const existingFormats = new Set(existingChapters.map((chapter) => chapter.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) ??
mergedSettings?.format ??
requestedFormat;
const rawPostSpeed = mergedSettings?.postSpeed ?? 1;
const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1;
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);
let next = 0;
for (const idx of indices) {
if (idx === next) {
next++;
} else if (idx > next) {
break;
}
}
chapterIndex = next;
}
const requestedProvider = request.headers.get('x-tts-provider')
|| mergedSettings?.providerRef
|| 'openai';
providerForError = requestedProvider;
const credResolved = await resolveTtsCredentials({
providerHeader: requestedProvider,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in credResolved) {
if (credResolved.error === 'no_shared_provider_configured') {
return NextResponse.json(
{ error: 'User API keys are restricted and no shared provider is configured.' },
{ status: 503 },
);
}
return NextResponse.json(
{ error: `Unknown or disabled TTS provider: ${credResolved.slug}` },
{ status: 404 },
);
}
const provider = credResolved.provider;
if (!isBuiltInTtsProviderId(provider)) {
return errorResponse(new Error(`Unsupported TTS provider type: ${provider}`), {
apiErrorMessage: `Unsupported TTS provider type: ${provider}`,
normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 },
});
}
const openApiKey = credResolved.apiKey || 'none';
const openApiBaseUrl = credResolved.baseUrl;
const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider;
const model = resolveTtsModelForProvider({
providerRef: effectiveProviderRef,
providerType: provider,
model: mergedSettings?.ttsModel,
sharedProviders: credResolved.adminRecord ? [credResolved.adminRecord] : [],
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
showAllProviderModels: runtimeConfig.showAllProviderModels,
});
const voice = mergedSettings?.voice || defaultVoiceForProviderType(provider);
const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1;
const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1;
const instructions = resolveEffectiveTtsInstructions({
model,
requestInstructions: mergedSettings?.ttsInstructions,
sharedDefaultInstructions: credResolved.adminRecord?.defaultInstructions,
});
if (authEnabled && userId && isTtsRateLimitEnabled()) {
const isAnonymous = Boolean(user?.isAnonymous);
const charCount = data.text.length;
const ip = getClientIp(request);
const device = isAnonymous ? getOrCreateDeviceId(request) : null;
if (device?.didCreate) {
didCreateDeviceIdCookie = true;
deviceIdToSet = device.deviceId;
}
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
{ id: userId, isAnonymous },
charCount,
{
deviceId: device?.deviceId ?? null,
ip,
},
);
if (!rateLimitResult.allowed) {
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(
0,
Math.ceil((resetTimeMs - Date.now()) / 1000),
);
const problem: ProblemDetails = {
type: PROBLEM_TYPES.dailyQuotaExceeded,
title: 'Daily quota exceeded',
status: 429,
detail: 'Daily character limit exceeded',
code: 'USER_DAILY_QUOTA_EXCEEDED',
currentCount: rateLimitResult.currentCount,
limit: rateLimitResult.limit,
remainingChars: rateLimitResult.remainingChars,
resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
: undefined,
instance: request.nextUrl.pathname,
};
const response = new NextResponse(JSON.stringify(problem), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
'Retry-After': String(retryAfterSeconds),
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
}
const ttsBuffer = await generateTTSBuffer(
{
text: data.text,
voice,
speed: nativeSpeed,
format: 'mp3',
model,
instructions,
provider,
apiKey: openApiKey,
baseUrl: openApiBaseUrl,
testNamespace,
},
request.signal,
);
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-'));
const inputPath = join(workDir, `${chapterIndex}-input.mp3`);
const chapterOutputTempPath = join(workDir, `${chapterIndex}-chapter.tmp.${format}`);
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
await writeFile(inputPath, ttsBuffer);
const canCopyMp3WithoutReencode = format === 'mp3' && postSpeed === 1;
if (canCopyMp3WithoutReencode) {
try {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
'-c:a',
'copy',
'-map_metadata',
'-1',
'-id3v2_version',
'3',
'-metadata',
`title=${titleTag}`,
chapterOutputTempPath,
],
request.signal,
);
} catch (copyError) {
serverLogger.warn({
event: 'audiobook.chapter.remux.failed',
degraded: true,
fallbackPath: 'mp3_reencode',
error: errorToLog(copyError),
}, 'Chapter remux failed; falling back to mp3 re-encode');
await runFFmpeg(
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
request.signal,
);
}
} else {
await runFFmpeg(
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
request.signal,
);
}
const probe = await ffprobeAudio(chapterOutputTempPath, request.signal);
const duration = probe.durationSec ?? 0;
const finalChapterName = encodeChapterFileName(chapterIndex, data.chapterTitle, format);
const finalChapterBytes = await readFile(chapterOutputTempPath);
await putAudiobookObject(bookId, storageUserId, finalChapterName, finalChapterBytes, chapterFileMimeType(format), testNamespace);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
for (const fileName of objectNames) {
if (!fileName.startsWith(chapterPrefix)) continue;
if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue;
if (fileName === finalChapterName) continue;
await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {});
}
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
if (!normalizedExistingSettings && incomingSettings) {
const settingsToPersist: AudiobookGenerationSettings = {
...incomingSettings,
providerRef: effectiveProviderRef,
providerType: provider,
ttsModel: model,
...(resolveTtsProviderModelPolicy({
providerRef: effectiveProviderRef,
providerType: provider,
model,
}).supportsInstructions
? { ttsInstructions: instructions ?? '' }
: { ttsInstructions: '' }),
};
await putAudiobookObject(
bookId,
storageUserId,
'audiobook.meta.json',
Buffer.from(JSON.stringify(settingsToPersist, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);
}
await db
.insert(audiobookChapters)
.values({
id: `${bookId}-${chapterIndex}`,
bookId,
userId: storageUserId,
chapterIndex,
title: data.chapterTitle,
duration,
format,
filePath: finalChapterName,
})
.onConflictDoUpdate({
target: [audiobookChapters.id, audiobookChapters.userId],
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName },
});
const response = NextResponse.json({
index: chapterIndex,
title: data.chapterTitle,
duration,
status: 'completed' as const,
bookId,
format,
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || (error as Error)?.name === 'AbortError' || request.signal.aborted) {
const response = NextResponse.json({ error: 'cancelled' }, { status: 499 });
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
const upstreamStatus = getUpstreamStatus(error);
if (upstreamStatus === 429) {
const retryAfterSeconds = getUpstreamRetryAfterSeconds(error);
const problem: ProblemDetails = {
type: PROBLEM_TYPES.upstreamRateLimited,
title: 'Upstream rate limited',
status: 429,
detail: retryAfterSeconds
? `The TTS provider is rate limiting requests. Please retry in about ${retryAfterSeconds}s.`
: 'The TTS provider is rate limiting requests. Please try again shortly.',
code: 'UPSTREAM_RATE_LIMIT',
provider: providerForError ?? undefined,
upstreamStatus,
retryAfterSeconds,
instance: request.nextUrl.pathname,
};
const response = new NextResponse(JSON.stringify(problem), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
...(retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : {}),
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
serverLogger.error({
event: 'audiobook.chapter.process.failed',
error: errorToLog(error),
}, 'Failed to process audio chapter');
const response = errorResponse(error, {
apiErrorMessage: 'Failed to process audio chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', errorClass: 'upstream' },
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
export async function GET(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const bookId = request.nextUrl.searchParams.get('bookId');
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
if (!bookId || !chapterIndexStr) {
return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 });
}
const chapterIndex = Number.parseInt(chapterIndexStr, 10);
if (!Number.isInteger(chapterIndex) || chapterIndex < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex 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 chapter = findChapterFileNameByIndex(
objects.map((object) => object.fileName),
chapterIndex,
);
if (!chapter) {
await db
.delete(audiobookChapters)
.where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, existingBookUserId),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
}
let buffer: Buffer;
try {
buffer = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
} catch (error) {
if (isMissingBlobError(error)) {
await db
.delete(audiobookChapters)
.where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, existingBookUserId),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
}
throw error;
}
const mimeType = chapter.format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
const sanitizedTitle = chapter.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
return new NextResponse(streamBuffer(buffer), {
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${sanitizedTitle}.${chapter.format}"`,
'Cache-Control': 'no-cache',
},
});
} catch (error) {
serverLogger.error({
event: 'audiobook.chapter.download.failed',
error: errorToLog(error),
}, 'Failed to download chapter');
return errorResponse(error, {
apiErrorMessage: 'Failed to download chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_DOWNLOAD_FAILED', errorClass: 'storage' },
});
}
}
export async function DELETE(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const bookId = request.nextUrl.searchParams.get('bookId');
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
if (!bookId || !chapterIndexStr) {
return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 });
}
const chapterIndex = Number.parseInt(chapterIndexStr, 10);
if (!Number.isInteger(chapterIndex) || chapterIndex < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex 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),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
const objectNames = (await listAudiobookObjects(bookId, storageUserId, testNamespace)).map((object) => object.fileName);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
for (const fileName of objectNames) {
if (!fileName.startsWith(chapterPrefix)) continue;
if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue;
await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {});
}
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
return NextResponse.json({ success: true });
} catch (error) {
serverLogger.error({
event: 'audiobook.chapter.delete.failed',
error: errorToLog(error),
}, 'Failed to delete chapter');
return errorResponse(error, {
apiErrorMessage: 'Failed to delete chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_DELETE_FAILED', errorClass: 'db' },
});
}
}