新增电子书架

This commit is contained in:
mtvpls
2026-04-29 02:24:29 +08:00
parent 6d9afd8072
commit 0d99953e99
38 changed files with 3826 additions and 6 deletions
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { BookSource, BookSourceCapabilities } from '@/lib/book.types';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
export const runtime = 'nodejs';
interface TestSourceInput {
id?: string;
name?: string;
url?: string;
enabled?: boolean;
authMode?: 'none' | 'basic' | 'header';
username?: string;
password?: string;
headerName?: string;
headerValue?: string;
searchTemplate?: string;
preferFormat?: Array<'epub' | 'pdf'>;
language?: string;
}
async function ensureAdmin(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
const userInfo = await db.getUserInfoV2(authInfo.username);
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
return authInfo.username;
}
async function detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
try {
const result = await opdsClient.getCatalogFromSource(source);
return {
searchSupported: !!source.searchTemplate || result.searchHref !== undefined,
catalogSupported: result.navigation.length > 0 || result.entries.length > 0,
searchMode: result.searchHref ? 'opds' : source.searchTemplate ? 'template' : 'disabled',
catalogMode: result.navigation.length > 0 ? 'navigation' : result.entries.length > 0 ? 'flat' : 'disabled',
acquisitionTypes: Array.from(new Set(result.entries.flatMap((item) => item.acquisitionLinks.map((link) => link.type)))),
lastCheckedAt: Date.now(),
};
} catch (error) {
return {
searchSupported: !!source.searchTemplate,
catalogSupported: false,
searchMode: source.searchTemplate ? 'template' : 'disabled',
catalogMode: 'disabled',
acquisitionTypes: [],
lastCheckedAt: Date.now(),
lastError: error instanceof Error ? error.message : '测试失败',
};
}
}
export async function POST(request: NextRequest) {
const ensured = await ensureAdmin(request);
if (ensured instanceof NextResponse) return ensured;
try {
const body = await request.json();
const inputSources = (body?.Sources || []) as TestSourceInput[];
if (!Array.isArray(inputSources) || inputSources.length === 0) {
return NextResponse.json({ success: false, message: '请至少填写一个 OPDS 书源' }, { status: 400 });
}
const sources: BookSource[] = inputSources
.filter((item) => item?.url?.trim())
.map((item, index) => ({
id: item.id?.trim() || `source_${index + 1}`,
name: item.name?.trim() || `书源 ${index + 1}`,
url: (item.url || '').trim(),
enabled: item.enabled !== false,
authMode: item.authMode || 'none',
username: item.username?.trim() || '',
password: item.password || '',
headerName: item.headerName?.trim() || '',
headerValue: item.headerValue || '',
searchTemplate: item.searchTemplate?.trim() || '',
preferFormat: item.preferFormat || ['epub', 'pdf'],
language: item.language?.trim() || '',
}));
if (sources.length === 0) {
return NextResponse.json({ success: false, message: '没有可测试的有效书源地址' }, { status: 400 });
}
const results = await Promise.all(
sources.map(async (source) => ({
id: source.id,
name: source.name,
url: source.url,
capability: await detectCapabilitiesFromSource(source),
}))
);
const successCount = results.filter((item) => item.capability.catalogSupported || item.capability.searchSupported).length;
return NextResponse.json({
success: successCount > 0,
message: `测试完成,${successCount}/${results.length} 个书源可用`,
results,
});
} catch (error) {
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : '测试连接失败',
},
{ status: 400 }
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
import { hasFeaturePermission } from '@/lib/permissions';
export async function getAuthorizedBooksUsername(request: NextRequest): Promise<string | NextResponse> {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
const user = await db.getUserInfoV2(authInfo.username);
if (!user || user.banned) {
return NextResponse.json({ error: '用户不存在或已被封禁' }, { status: 401 });
}
}
const allowed = await hasFeaturePermission(authInfo.username, 'books');
if (!allowed) {
return NextResponse.json({ error: '无权限访问电子书功能' }, { status: 403 });
}
return authInfo.username;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
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;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim() || undefined;
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const result = await opdsClient.getCatalog(sourceId, href);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
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;
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 NextResponse.json(detail);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
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;
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,
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+102
View File
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookReadRecord } from '@/lib/book.types';
import { db } from '@/lib/db';
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;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
const record = await db.getBookReadRecord(username, sourceId, bookId);
return NextResponse.json(record, { status: 200 });
}
const records = await db.getAllBookReadRecords(username);
return NextResponse.json(records, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, record }: { key: string; record: BookReadRecord } = await request.json();
if (!key || !record?.locator?.value) {
return NextResponse.json({ error: 'Missing key or record' }, { status: 400 });
}
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
const shelfItem = await db.getBookShelf(username, sourceId, bookId);
const existingRecord = await db.getBookReadRecord(username, sourceId, bookId);
const normalizedRecord: BookReadRecord = {
...record,
sourceId: record.sourceId || sourceId,
bookId: record.bookId || bookId,
sourceName: record.sourceName || shelfItem?.sourceName || existingRecord?.sourceName || '',
detailHref: record.detailHref || shelfItem?.detailHref || existingRecord?.detailHref,
acquisitionHref: record.acquisitionHref || shelfItem?.acquisitionHref || existingRecord?.acquisitionHref,
author: record.author || shelfItem?.author || existingRecord?.author,
cover: record.cover || shelfItem?.cover || existingRecord?.cover,
saveTime: record.saveTime ?? Date.now(),
};
await db.saveBookReadRecord(username, sourceId, bookId, normalizedRecord);
if (shelfItem) {
await db.saveBookShelf(username, sourceId, bookId, {
...shelfItem,
format: normalizedRecord.format,
progressPercent: normalizedRecord.progressPercent,
lastReadTime: normalizedRecord.saveTime,
lastLocatorType: normalizedRecord.locator.type,
lastLocatorValue: normalizedRecord.locator.value,
lastChapterTitle: normalizedRecord.chapterTitle || normalizedRecord.locator.chapterTitle,
});
}
if ((db as any).storage.cleanupOldBookReadRecords) {
(db as any).storage.cleanupOldBookReadRecords(username).catch((err: Error) => {
console.error('异步清理电子书阅读历史失败:', err);
});
}
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
await db.deleteBookReadRecord(username, sourceId, bookId);
} else {
const all = await db.getAllBookReadRecords(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, bookId] = itemKey.split('+');
if (sourceId && bookId) await db.deleteBookReadRecord(username, sourceId, bookId);
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { opdsClient } from '@/lib/opds.client';
import { db } from '@/lib/db';
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;
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();
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const existingRecord = bookId ? await db.getBookReadRecord(username, sourceId, bookId) : null;
const shelfItem = bookId ? await db.getBookShelf(username, sourceId, bookId) : null;
const resolvedHref = href || existingRecord?.detailHref || shelfItem?.detailHref || '';
const resolvedAcquisitionHref = acquisitionHref || existingRecord?.acquisitionHref || shelfItem?.acquisitionHref || '';
const resolvedFormat = format || existingRecord?.format || shelfItem?.format || 'epub';
if (!resolvedHref && !resolvedAcquisitionHref) {
return NextResponse.json({ error: '缺少 href / acquisitionHref,且历史记录中也没有可恢复的下载链接' }, { status: 400 });
}
const fallbackAcquisitionLinks: BookAcquisitionLink[] = resolvedAcquisitionHref
? [{
rel: 'http://opds-spec.org/acquisition',
type: resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip',
href: resolvedAcquisitionHref,
}]
: [];
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,
detailHref: resolvedHref || undefined,
acquisitionLinks: fallbackAcquisitionLinks,
});
const preferred = resolvedHref
? await opdsClient.getPreferredAcquisition(sourceId, resolvedHref)
: {
format: resolvedFormat === 'pdf' ? 'pdf' : 'epub',
href: resolvedAcquisitionHref || '',
};
const lastRecord = await db.getBookReadRecord(username, sourceId, detail.id);
return NextResponse.json({
book: detail,
format: preferred.format,
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(preferred.href)}`,
acquisitionHref: preferred.href,
cacheKey: `${sourceId}::${detail.id}::${preferred.href}`,
coverUrl: detail.cover,
lastRecord,
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
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;
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim();
const sourceId = searchParams.get('sourceId')?.trim() || undefined;
if (!q) {
return NextResponse.json({ results: [], failedSources: [] });
}
const result = await opdsClient.searchBooks(q, sourceId);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookShelfItem } from '@/lib/book.types';
import { db } from '@/lib/db';
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;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
const item = await db.getBookShelf(username, sourceId, bookId);
return NextResponse.json(item, { status: 200 });
}
const records = await db.getAllBookShelf(username);
return NextResponse.json(records, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, item }: { key: string; item: BookShelfItem } = await request.json();
if (!key || !item?.title) return NextResponse.json({ error: 'Missing key or item' }, { status: 400 });
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
await db.saveBookShelf(username, sourceId, bookId, {
...item,
sourceId: item.sourceId || sourceId,
bookId: item.bookId || bookId,
saveTime: item.saveTime ?? Date.now(),
});
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
await db.deleteBookShelf(username, sourceId, bookId);
} else {
const all = await db.getAllBookShelf(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, bookId] = itemKey.split('+');
if (sourceId && bookId) await db.deleteBookShelf(username, sourceId, bookId);
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
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;
try {
const sources = await opdsClient.getSources();
return NextResponse.json({ sources });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}