openreader/src/app/api/documents/library/route.ts

144 lines
4.1 KiB
TypeScript

import type { Dirent } from 'fs';
import { readdir, stat } from 'fs/promises';
import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { parseLibraryRoots } from '@/lib/server/storage/library-mount';
import type { DocumentType } from '@/types/documents';
import { auth } from '@/lib/server/auth/auth';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
type LibraryDocument = {
id: string;
name: string;
size: number;
lastModified: number;
type: DocumentType;
};
const SUPPORTED_EXTENSIONS = new Set([
'.pdf',
'.epub',
'.html',
'.htm',
'.txt',
'.md',
'.mdown',
'.markdown',
]);
const IGNORE_DIR_NAMES = new Set(['.git', 'node_modules', 'model', 'tmp', 'documents', 'documents_v1', 'audiobooks_v1']);
function toPosixPath(filePath: string): string {
return filePath.split(path.sep).join('/');
}
function libraryDocumentTypeFromName(name: string): DocumentType {
const ext = path.extname(name).toLowerCase();
if (ext === '.pdf') return 'pdf';
if (ext === '.epub') return 'epub';
return 'html';
}
let cache:
| {
cacheKey: string;
cachedAt: number;
documents: LibraryDocument[];
}
| undefined;
async function scanLibraryRoot(root: string, rootIndex: number, limit: number): Promise<LibraryDocument[]> {
const results: LibraryDocument[] = [];
const resolvedRoot = path.resolve(root);
async function walk(currentDir: string): Promise<void> {
if (results.length >= limit) return;
let entries: Dirent[];
try {
entries = await readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (results.length >= limit) return;
if (entry.isDirectory()) {
if (entry.name.startsWith('.')) continue;
if (IGNORE_DIR_NAMES.has(entry.name)) continue;
if (entry.name.endsWith('-audiobook')) continue;
await walk(path.join(currentDir, entry.name));
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
const fullPath = path.join(currentDir, entry.name);
let fileStat: Awaited<ReturnType<typeof stat>>;
try {
fileStat = await stat(fullPath);
} catch {
continue;
}
const relativePath = toPosixPath(path.relative(resolvedRoot, fullPath));
const id = Buffer.from(`${rootIndex}:${relativePath}`, 'utf8').toString('base64url');
results.push({
id,
name: relativePath,
size: fileStat.size,
lastModified: Math.floor(fileStat.mtimeMs),
type: libraryDocumentTypeFromName(relativePath),
});
}
}
await walk(resolvedRoot);
return results;
}
export async function GET(req: NextRequest) {
// Auth check - require session
try {
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
} catch (error) {
serverLogger.error({
event: 'documents.library.auth_check.failed',
error: errorToLog(error),
}, 'Failed to check auth for library route');
return NextResponse.json({ error: 'Error checking auth' }, { status: 500 });
}
const url = new URL(req.url);
const refresh = url.searchParams.get('refresh') === '1';
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit') ?? '5000'), 10000));
const roots = parseLibraryRoots();
const cacheKey = `${roots.join('|')}::${limit}`;
if (!refresh && cache && cache.cacheKey === cacheKey && Date.now() - cache.cachedAt < 30_000) {
return NextResponse.json({ documents: cache.documents });
}
const documents: LibraryDocument[] = [];
for (let i = 0; i < roots.length; i++) {
const rootDocs = await scanLibraryRoot(roots[i], i, Math.max(0, limit - documents.length));
documents.push(...rootDocs);
if (documents.length >= limit) break;
}
documents.sort((a, b) => a.name.localeCompare(b.name));
cache = { cacheKey, cachedAt: Date.now(), documents };
return NextResponse.json({ documents });
}