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).
126 lines
4.2 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|