openreader/src/app/api/audiobook/status/route.ts
Richard R 81d249ed52 feat(storage): implement S3/SeaweedFS blob storage for documents
Replaces local filesystem document storage with S3-compatible object storage.
Adds embedded SeaweedFS 'weed mini' for local development and Docker deployments.
Updates document upload flow to use presigned URLs with a server fallback proxy.
Refactors auth configuration to use BASE_URL and AUTH_SECRET.

BREAKING CHANGE: Renamed BETTER_AUTH_URL to BASE_URL and BETTER_AUTH_SECRET to AUTH_SECRET. Removed legacy document upload/content endpoints. Requires S3 environment variables (auto-configured for embedded SeaweedFS).
2026-02-10 12:29:15 -07:00

126 lines
4.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { existsSync } from 'fs';
import { join } from 'path';
import { getAudiobooksRootDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { listStoredChapters } from '@/lib/server/audiobook';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts';
import { readFile } from 'fs/promises';
import { requireAuthContext } from '@/lib/server/auth';
import { db } from '@/db';
import { audiobooks } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { ensureDbIndexed } from '@/lib/server/db-indexing';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { pruneAudiobookChaptersNotOnDisk, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune';
export const dynamic = 'force-dynamic';
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
function isSafeId(value: string): boolean {
return SAFE_ID_REGEX.test(value);
}
export async function GET(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
if (!bookId || !isSafeId(bookId)) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
await ensureAudiobooksV1Ready();
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
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 storageUserId = userId ?? unclaimedUserId;
const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
await ensureDbIndexed();
// Check if audiobook exists for user OR is unclaimed (similar to documents)
const [existingBook] = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
if (!existingBook) {
// Book doesn't exist for this user or unclaimed - return empty state
return NextResponse.json({
chapters: [],
exists: false,
hasComplete: false,
bookId: null,
settings: null,
});
}
const intermediateDir = join(
getAudiobooksRootDir({
userId: existingBook.userId,
authEnabled,
namespace: testNamespace,
}),
`${bookId}-audiobook`,
);
if (!existsSync(intermediateDir)) {
await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false);
return NextResponse.json({
chapters: [],
exists: false,
hasComplete: false,
bookId: null,
settings: null,
});
}
const stored = await listStoredChapters(intermediateDir, request.signal);
await pruneAudiobookChaptersNotOnDisk(
bookId,
existingBook.userId,
stored.map((c) => c.index),
);
const chapters: TTSAudiobookChapter[] = stored.map((chapter) => ({
index: chapter.index,
title: chapter.title,
duration: chapter.durationSec,
status: 'completed',
bookId,
format: chapter.format as TTSAudiobookFormat,
}));
let settings: AudiobookGenerationSettings | null = null;
try {
settings = JSON.parse(await readFile(join(intermediateDir, 'audiobook.meta.json'), 'utf8')) as AudiobookGenerationSettings;
} catch {
settings = null;
}
const hasComplete = existsSync(join(intermediateDir, 'complete.mp3')) || existsSync(join(intermediateDir, 'complete.m4b'));
return NextResponse.json({
chapters,
exists: true,
hasComplete,
bookId,
settings,
});
} catch (error) {
console.error('Error fetching chapters:', error);
return NextResponse.json(
{ error: 'Failed to fetch chapters' },
{ status: 500 }
);
}
}