电子书架
This commit is contained in:
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user