电子书架

This commit is contained in:
mtvpls
2026-04-29 11:30:45 +08:00
parent 0d99953e99
commit 3acb4e82cd
13 changed files with 891 additions and 255 deletions
+70 -26
View File
@@ -1,45 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
type DetailPayload = {
sourceId?: string;
bookId?: string;
href?: string;
title?: string;
author?: string;
cover?: string;
summary?: string;
acquisitionLinks?: BookAcquisitionLink[];
};
async function resolveDetail(username: string, payload: DetailPayload) {
const sourceId = payload.sourceId?.trim();
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const bookId = payload.bookId?.trim();
if (!bookId) {
return NextResponse.json({ error: '缺少 bookId' }, { status: 400 });
}
const shelfItem = await db.getBookShelf(username, sourceId, bookId);
const readRecord = await db.getBookReadRecord(username, sourceId, bookId);
const href = payload.href?.trim() || shelfItem?.detailHref || readRecord?.detailHref || '';
const acquisitionLinks = (payload.acquisitionLinks && payload.acquisitionLinks.length > 0)
? payload.acquisitionLinks
: shelfItem?.acquisitionHref
? [{
rel: 'http://opds-spec.org/acquisition',
type: shelfItem.format === 'pdf' ? 'application/pdf' : 'application/epub+zip',
href: shelfItem.acquisitionHref,
}]
: readRecord?.acquisitionHref
? [{
rel: 'http://opds-spec.org/acquisition',
type: readRecord.format === 'pdf' ? 'application/pdf' : 'application/epub+zip',
href: readRecord.acquisitionHref,
}]
: undefined;
const detail = await opdsClient.getBookDetail(sourceId, href, {
id: bookId,
title: payload.title || shelfItem?.title || readRecord?.title || undefined,
author: payload.author || shelfItem?.author || readRecord?.author || undefined,
cover: payload.cover || shelfItem?.cover || readRecord?.cover || undefined,
summary: payload.summary || undefined,
detailHref: href || undefined,
acquisitionLinks,
});
return NextResponse.json(detail);
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim() || '';
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const acquisitionLinksRaw = searchParams.get('acquisitionLinks');
let acquisitionLinks: BookAcquisitionLink[] | undefined;
if (acquisitionLinksRaw) {
try {
acquisitionLinks = JSON.parse(acquisitionLinksRaw) as BookAcquisitionLink[];
} catch {
acquisitionLinks = undefined;
}
}
const detail = await opdsClient.getBookDetail(sourceId, href, {
id: searchParams.get('bookId') || undefined,
title: searchParams.get('title') || undefined,
author: searchParams.get('author') || undefined,
cover: searchParams.get('cover') || undefined,
summary: searchParams.get('summary') || undefined,
detailHref: href || undefined,
acquisitionLinks,
return await resolveDetail(username, {
sourceId: searchParams.get('sourceId') || undefined,
bookId: searchParams.get('bookId') || undefined,
});
return NextResponse.json(detail);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const payload = await request.json() as DetailPayload;
return await resolveDetail(username, payload);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
+100 -43
View File
@@ -1,61 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
type FilePayload = {
sourceId?: string;
bookId?: string;
href?: string;
format?: 'epub' | 'pdf' | null;
};
async function resolveFileHref(username: string, payload: FilePayload): Promise<{ sourceId: string; href: string }> {
const sourceId = payload.sourceId?.trim();
if (!sourceId) throw new Error('缺少 sourceId');
if (payload.href?.trim()) {
return { sourceId, href: payload.href.trim() };
}
const bookId = payload.bookId?.trim();
if (!bookId) throw new Error('缺少 bookId 或 href');
const shelfItem = await db.getBookShelf(username, sourceId, bookId);
const readRecord = await db.getBookReadRecord(username, sourceId, bookId);
const directHref = shelfItem?.acquisitionHref || readRecord?.acquisitionHref;
if (directHref) {
return { sourceId, href: directHref };
}
const detailHref = shelfItem?.detailHref || readRecord?.detailHref;
if (!detailHref) throw new Error('找不到可下载文件');
const preferred = await opdsClient.getPreferredAcquisition(sourceId, detailHref);
if (payload.format && preferred.format !== payload.format) {
const detail = await opdsClient.getBookDetail(sourceId, detailHref);
const matched = detail.acquisitionLinks.find((item) => (payload.format === 'pdf' ? item.type.toLowerCase().includes('pdf') : item.type.toLowerCase().includes('epub')));
if (!matched?.href) throw new Error('找不到对应格式文件');
return { sourceId, href: matched.href };
}
return { sourceId, href: preferred.href };
}
async function proxyFile(request: NextRequest, sourceId: string, href: string) {
const source = await opdsClient.getSourceById(sourceId);
const headers = new Headers();
if (source.authMode === 'basic' && source.username) {
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
} else if (source.authMode === 'header' && source.headerName && source.headerValue) {
headers.set(source.headerName, source.headerValue);
}
const range = request.headers.get('range');
if (range) headers.set('Range', range);
const response = await fetch(href, {
headers,
redirect: 'follow',
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status });
}
const outHeaders = new Headers();
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const acceptRanges = response.headers.get('accept-ranges');
const contentRange = response.headers.get('content-range');
const disposition = response.headers.get('content-disposition');
if (contentType) outHeaders.set('Content-Type', contentType);
if (contentLength) outHeaders.set('Content-Length', contentLength);
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
if (contentRange) outHeaders.set('Content-Range', contentRange);
if (disposition) outHeaders.set('Content-Disposition', disposition);
outHeaders.set('Cache-Control', 'private, max-age=300');
return new NextResponse(response.body, {
status: response.status,
headers: outHeaders,
});
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim();
if (!sourceId || !href) {
return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 });
}
const source = await opdsClient.getSourceById(sourceId);
const headers = new Headers();
if (source.authMode === 'basic' && source.username) {
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
} else if (source.authMode === 'header' && source.headerName && source.headerValue) {
headers.set(source.headerName, source.headerValue);
}
const range = request.headers.get('range');
if (range) headers.set('Range', range);
const response = await fetch(href, {
headers,
redirect: 'follow',
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status });
}
const outHeaders = new Headers();
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const acceptRanges = response.headers.get('accept-ranges');
const contentRange = response.headers.get('content-range');
const disposition = response.headers.get('content-disposition');
if (contentType) outHeaders.set('Content-Type', contentType);
if (contentLength) outHeaders.set('Content-Length', contentLength);
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
if (contentRange) outHeaders.set('Content-Range', contentRange);
if (disposition) outHeaders.set('Content-Disposition', disposition);
outHeaders.set('Cache-Control', 'private, max-age=300');
return new NextResponse(response.body, {
status: response.status,
headers: outHeaders,
const resolved = await resolveFileHref(username, {
sourceId: searchParams.get('sourceId') || undefined,
bookId: searchParams.get('bookId') || undefined,
href: searchParams.get('href') || undefined,
format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | null) || null,
});
return await proxyFile(request, resolved.sourceId, resolved.href);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const payload = await request.json() as FilePayload;
const resolved = await resolveFileHref(username, payload);
return await proxyFile(request, resolved.sourceId, resolved.href);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
+47 -16
View File
@@ -1,24 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { opdsClient } from '@/lib/opds.client';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
type ManifestPayload = {
sourceId?: string;
bookId?: string;
href?: string;
acquisitionHref?: string;
format?: 'epub' | 'pdf' | null;
title?: string;
author?: string;
cover?: string;
summary?: string;
};
async function resolveManifest(username: string, payload: ManifestPayload) {
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim();
const acquisitionHref = searchParams.get('acquisitionHref')?.trim();
const format = searchParams.get('format')?.trim() as 'epub' | 'pdf' | null;
const bookId = searchParams.get('bookId')?.trim();
const sourceId = payload.sourceId?.trim();
const href = payload.href?.trim();
const acquisitionHref = payload.acquisitionHref?.trim();
const format = payload.format;
const bookId = payload.bookId?.trim();
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
@@ -44,10 +52,10 @@ export async function GET(request: NextRequest) {
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
id: bookId || resolvedAcquisitionHref || undefined,
title: searchParams.get('title') || existingRecord?.title || shelfItem?.title || undefined,
author: searchParams.get('author') || existingRecord?.author || shelfItem?.author || undefined,
cover: searchParams.get('cover') || existingRecord?.cover || shelfItem?.cover || undefined,
summary: searchParams.get('summary') || undefined,
title: payload.title || existingRecord?.title || shelfItem?.title || undefined,
author: payload.author || existingRecord?.author || shelfItem?.author || undefined,
cover: payload.cover || existingRecord?.cover || shelfItem?.cover || undefined,
summary: payload.summary || undefined,
detailHref: resolvedHref || undefined,
acquisitionLinks: fallbackAcquisitionLinks,
});
@@ -62,9 +70,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
book: detail,
format: preferred.format,
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(preferred.href)}`,
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`,
acquisitionHref: preferred.href,
cacheKey: `${sourceId}::${detail.id}::${preferred.href}`,
cacheKey: `${sourceId}::${detail.id}::${preferred.format}`,
coverUrl: detail.cover,
lastRecord,
});
@@ -72,3 +80,26 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
const { searchParams } = new URL(request.url);
return resolveManifest(username, {
sourceId: searchParams.get('sourceId') || undefined,
bookId: searchParams.get('bookId') || undefined,
});
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const payload = await request.json() as ManifestPayload;
return await resolveManifest(username, payload);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}