From 0d99953e99114be05a14337a46b267df31faf617 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 29 Apr 2026 02:24:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=B5=E5=AD=90=E4=B9=A6?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/007_books.sql | 43 ++ migrations/postgres/007_books.sql | 43 ++ src/app/admin/page.tsx | 307 +++++++++++ src/app/api/admin/opds/route.ts | 121 +++++ src/app/api/books/_utils.ts | 26 + src/app/api/books/catalog/route.ts | 25 + src/app/api/books/detail/route.ts | 46 ++ src/app/api/books/file/route.ts | 61 +++ src/app/api/books/history/route.ts | 102 ++++ src/app/api/books/read/manifest/route.ts | 74 +++ src/app/api/books/search/route.ts | 25 + src/app/api/books/shelf/route.ts | 73 +++ src/app/api/books/sources/route.ts | 19 + src/app/books/catalog/page.tsx | 123 +++++ src/app/books/detail/page.tsx | 128 +++++ src/app/books/history/page.tsx | 79 +++ src/app/books/layout.tsx | 5 + src/app/books/page.tsx | 72 +++ src/app/books/read/page.tsx | 660 +++++++++++++++++++++++ src/app/books/search/page.tsx | 83 +++ src/app/books/shelf/page.tsx | 42 ++ src/app/layout.tsx | 10 + src/app/page.tsx | 22 +- src/components/books/BookCard.tsx | 27 + src/components/books/BooksLayout.tsx | 66 +++ src/lib/admin.types.ts | 18 + src/lib/book-cache.client.ts | 123 +++++ src/lib/book.db.client.ts | 89 +++ src/lib/book.types.ts | 139 +++++ src/lib/config.ts | 43 ++ src/lib/d1.db.ts | 249 ++++++++- src/lib/db.client.ts | 2 +- src/lib/db.ts | 35 ++ src/lib/feature-permissions.ts | 1 + src/lib/opds.client.ts | 492 +++++++++++++++++ src/lib/postgres.db.ts | 274 +++++++++- src/lib/redis-base.db.ts | 71 +++ src/lib/types.ts | 14 + 38 files changed, 3826 insertions(+), 6 deletions(-) create mode 100644 migrations/007_books.sql create mode 100644 migrations/postgres/007_books.sql create mode 100644 src/app/api/admin/opds/route.ts create mode 100644 src/app/api/books/_utils.ts create mode 100644 src/app/api/books/catalog/route.ts create mode 100644 src/app/api/books/detail/route.ts create mode 100644 src/app/api/books/file/route.ts create mode 100644 src/app/api/books/history/route.ts create mode 100644 src/app/api/books/read/manifest/route.ts create mode 100644 src/app/api/books/search/route.ts create mode 100644 src/app/api/books/shelf/route.ts create mode 100644 src/app/api/books/sources/route.ts create mode 100644 src/app/books/catalog/page.tsx create mode 100644 src/app/books/detail/page.tsx create mode 100644 src/app/books/history/page.tsx create mode 100644 src/app/books/layout.tsx create mode 100644 src/app/books/page.tsx create mode 100644 src/app/books/read/page.tsx create mode 100644 src/app/books/search/page.tsx create mode 100644 src/app/books/shelf/page.tsx create mode 100644 src/components/books/BookCard.tsx create mode 100644 src/components/books/BooksLayout.tsx create mode 100644 src/lib/book-cache.client.ts create mode 100644 src/lib/book.db.client.ts create mode 100644 src/lib/book.types.ts create mode 100644 src/lib/opds.client.ts diff --git a/migrations/007_books.sql b/migrations/007_books.sql new file mode 100644 index 0000000..b59bc18 --- /dev/null +++ b/migrations/007_books.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS book_shelf ( + username TEXT NOT NULL, + key TEXT NOT NULL, + source_id TEXT NOT NULL, + source_name TEXT NOT NULL, + book_id TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + cover TEXT, + format TEXT, + detail_href TEXT, + acquisition_href TEXT, + progress_percent REAL, + last_read_time INTEGER, + last_locator_type TEXT, + last_locator_value TEXT, + last_chapter_title TEXT, + save_time INTEGER NOT NULL, + PRIMARY KEY (username, key) +); +CREATE INDEX IF NOT EXISTS idx_book_shelf_user_time ON book_shelf(username, save_time DESC); + +CREATE TABLE IF NOT EXISTS book_read_records ( + username TEXT NOT NULL, + key TEXT NOT NULL, + source_id TEXT NOT NULL, + source_name TEXT NOT NULL, + book_id TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + cover TEXT, + format TEXT NOT NULL, + detail_href TEXT, + acquisition_href TEXT, + locator_type TEXT NOT NULL, + locator_value TEXT NOT NULL, + chapter_title TEXT, + chapter_href TEXT, + progress_percent REAL NOT NULL DEFAULT 0, + save_time INTEGER NOT NULL, + PRIMARY KEY (username, key) +); +CREATE INDEX IF NOT EXISTS idx_book_read_records_user_time ON book_read_records(username, save_time DESC); diff --git a/migrations/postgres/007_books.sql b/migrations/postgres/007_books.sql new file mode 100644 index 0000000..43ea84d --- /dev/null +++ b/migrations/postgres/007_books.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS book_shelf ( + username TEXT NOT NULL, + key TEXT NOT NULL, + source_id TEXT NOT NULL, + source_name TEXT NOT NULL, + book_id TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + cover TEXT, + format TEXT, + detail_href TEXT, + acquisition_href TEXT, + progress_percent DOUBLE PRECISION, + last_read_time BIGINT, + last_locator_type TEXT, + last_locator_value TEXT, + last_chapter_title TEXT, + save_time BIGINT NOT NULL, + PRIMARY KEY (username, key) +); +CREATE INDEX IF NOT EXISTS idx_book_shelf_user_time ON book_shelf(username, save_time DESC); + +CREATE TABLE IF NOT EXISTS book_read_records ( + username TEXT NOT NULL, + key TEXT NOT NULL, + source_id TEXT NOT NULL, + source_name TEXT NOT NULL, + book_id TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + cover TEXT, + format TEXT NOT NULL, + detail_href TEXT, + acquisition_href TEXT, + locator_type TEXT NOT NULL, + locator_value TEXT NOT NULL, + chapter_title TEXT, + chapter_href TEXT, + progress_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + save_time BIGINT NOT NULL, + PRIMARY KEY (username, key) +); +CREATE INDEX IF NOT EXISTS idx_book_read_records_user_time ON book_read_records(username, save_time DESC); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ef36899..8f824ca 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -24,6 +24,7 @@ import { CSS } from '@dnd-kit/utilities'; import { AlertCircle, AlertTriangle, + BookMarked, BookOpen, Bot, Cat, @@ -44,12 +45,15 @@ import { UserPlus, Users, Video, + Plus, + Trash2, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; +import { BookSource } from '@/lib/book.types'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { ALL_FEATURE_PERMISSION_KEYS, @@ -11231,6 +11235,297 @@ const SuwayomiConfigComponent = ({ ); }; + +const OPDSConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [enabled, setEnabled] = useState(false); + const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000); + const [sources, setSources] = useState([]); + + useEffect(() => { + if (config?.OPDSConfig) { + setEnabled(config.OPDSConfig.Enabled || false); + setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000); + setSources( + (config.OPDSConfig.Sources || []).map((item, index) => ({ + id: item.id || `source_${index + 1}`, + name: item.name || `书源 ${index + 1}`, + url: item.url || '', + enabled: item.enabled !== false, + authMode: item.authMode || 'none', + username: item.username || '', + password: item.password || '', + headerName: item.headerName || '', + headerValue: item.headerValue || '', + searchTemplate: item.searchTemplate || '', + preferFormat: item.preferFormat || ['epub', 'pdf'], + language: item.language || '', + })) + ); + } + }, [config]); + + const updateSource = (index: number, patch: Partial) => { + setSources((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))); + }; + + const addSource = () => { + setSources((prev) => [ + ...prev, + { + id: `source_${prev.length + 1}`, + name: `书源 ${prev.length + 1}`, + url: '', + enabled: true, + authMode: 'none', + username: '', + password: '', + headerName: '', + headerValue: '', + searchTemplate: '', + preferFormat: ['epub', 'pdf'], + language: '', + }, + ]); + }; + + const removeSource = (index: number) => { + setSources((prev) => prev.filter((_, idx) => idx !== index)); + }; + + const buildConfig = () => ({ + Enabled: enabled, + CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), + Sources: sources + .map((source, index) => ({ + id: source.id?.trim() || `source_${index + 1}`, + name: source.name?.trim() || `书源 ${index + 1}`, + url: source.url?.trim() || '', + enabled: source.enabled !== false, + authMode: source.authMode || 'none', + username: source.authMode === 'none' ? '' : source.username?.trim() || '', + password: source.authMode === 'none' ? '' : source.password || '', + headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '', + headerValue: source.authMode === 'header' ? source.headerValue || '' : '', + searchTemplate: source.searchTemplate?.trim() || '', + preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'], + language: source.language?.trim() || '', + })) + .filter((source) => !!source.url), + }); + + const handleSave = async () => { + await withLoading('saveOPDSConfig', async () => { + try { + if (!config) throw new Error('配置未加载'); + const response = await fetch('/api/admin/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...config, + OPDSConfig: buildConfig(), + }), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '保存失败'); + } + showSuccess('电子书 OPDS 配置已保存', showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + throw error; + } + }); + }; + + const handleTest = async () => { + await withLoading('testOPDSConfig', async () => { + try { + const response = await fetch('/api/admin/opds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildConfig()), + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.message || data.error || '测试连接失败'); + } + const summary = Array.isArray(data.results) + ? data.results + .map((item: { name: string; capability: { catalogSupported: boolean; searchSupported: boolean; lastError?: string } }) => + `${item.name}: 分类${item.capability.catalogSupported ? '√' : '×'} / 搜索${item.capability.searchSupported ? '√' : '×'}${item.capability.lastError ? ` (${item.capability.lastError})` : ''}` + ) + .join('\n') + : ''; + showSuccess(summary || data.message || '测试成功', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '测试连接失败', showAlert); + throw error; + } + }); + }; + + return ( +
+
+

关于电子书馆 / OPDS

+
+

• 支持多书源,每个源可独立配置认证、搜索模板与默认格式偏好。

+

• 有些源只支持分类浏览,有些源只支持搜索,测试连接会自动探测能力。

+

• 目前前台优先支持 EPUB 在线阅读,PDF 走内嵌预览。

+
+
+ +
+
+

启用电子书馆

+

关闭后不会展示 OPDS 电子书入口。

+
+ +
+ +
+ + setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)} + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+ +
+
+

书源列表

+ +
+ + {sources.length === 0 && ( +
+ 暂无 OPDS 书源,点击“添加书源”开始配置。 +
+ )} + + {sources.map((source, index) => ( +
+
+
书源 #{index + 1}
+
+ + +
+
+ +
+
+ + updateSource(index, { id: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ + updateSource(index, { name: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ +
+ + updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+ +
+
+ + +
+
+ + updateSource(index, { language: e.target.value })} placeholder='zh / en' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ + updateSource(index, { searchTemplate: e.target.value })} placeholder='https://...{searchTerms}' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ + {source.authMode === 'basic' && ( +
+
+ + updateSource(index, { username: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ + updateSource(index, { password: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ )} + + {source.authMode === 'header' && ( +
+
+ + updateSource(index, { headerName: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ + updateSource(index, { headerValue: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' /> +
+
+ )} +
+ ))} +
+ +
+ + +
+ + +
+ ); +}; + const XiaoyaConfigComponent = ({ config, refreshConfig, @@ -13908,6 +14203,7 @@ function AdminPageClient() { embyConfig: false, xiaoyaConfig: false, suwayomiConfig: false, + opdsConfig: false, animeSubscription: false, aiConfig: false, liveSource: false, @@ -14311,6 +14607,17 @@ function AdminPageClient() { + + } + isExpanded={expandedTabs.opdsConfig} + onToggle={() => toggleTab('opdsConfig')} + > + + + {/* 电视直播源配置标签 */} ; + 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 { + 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 } + ); + } +} diff --git a/src/app/api/books/_utils.ts b/src/app/api/books/_utils.ts new file mode 100644 index 0000000..17c4426 --- /dev/null +++ b/src/app/api/books/_utils.ts @@ -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 { + 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; +} diff --git a/src/app/api/books/catalog/route.ts b/src/app/api/books/catalog/route.ts new file mode 100644 index 0000000..b0d9cd7 --- /dev/null +++ b/src/app/api/books/catalog/route.ts @@ -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 }); + } +} diff --git a/src/app/api/books/detail/route.ts b/src/app/api/books/detail/route.ts new file mode 100644 index 0000000..8de0caa --- /dev/null +++ b/src/app/api/books/detail/route.ts @@ -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 }); + } +} diff --git a/src/app/api/books/file/route.ts b/src/app/api/books/file/route.ts new file mode 100644 index 0000000..4bf36f2 --- /dev/null +++ b/src/app/api/books/file/route.ts @@ -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 }); + } +} diff --git a/src/app/api/books/history/route.ts b/src/app/api/books/history/route.ts new file mode 100644 index 0000000..bddc887 --- /dev/null +++ b/src/app/api/books/history/route.ts @@ -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 }); + } +} diff --git a/src/app/api/books/read/manifest/route.ts b/src/app/api/books/read/manifest/route.ts new file mode 100644 index 0000000..beffc80 --- /dev/null +++ b/src/app/api/books/read/manifest/route.ts @@ -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 }); + } +} diff --git a/src/app/api/books/search/route.ts b/src/app/api/books/search/route.ts new file mode 100644 index 0000000..879fe1f --- /dev/null +++ b/src/app/api/books/search/route.ts @@ -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 }); + } +} diff --git a/src/app/api/books/shelf/route.ts b/src/app/api/books/shelf/route.ts new file mode 100644 index 0000000..e49ef7c --- /dev/null +++ b/src/app/api/books/shelf/route.ts @@ -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 }); + } +} diff --git a/src/app/api/books/sources/route.ts b/src/app/api/books/sources/route.ts new file mode 100644 index 0000000..da29e4d --- /dev/null +++ b/src/app/api/books/sources/route.ts @@ -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 }); + } +} diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx new file mode 100644 index 0000000..3a5d6c5 --- /dev/null +++ b/src/app/books/catalog/page.tsx @@ -0,0 +1,123 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import BookCard from '@/components/books/BookCard'; +import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types'; + +function makeHref(sourceId: string, item: BookListItem) { + const params = new URLSearchParams({ + sourceId, + href: item.detailHref || '', + bookId: item.id, + title: item.title, + author: item.author || '', + cover: item.cover || '', + summary: item.summary || '', + acquisitionLinks: JSON.stringify(item.acquisitionLinks || []), + }); + return `/books/detail?${params.toString()}`; +} + +function CatalogSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+ {Array.from({ length: 12 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+
+ ); +} + +export default function BooksCatalogPage() { + const searchParams = useSearchParams(); + const sourceId = searchParams.get('sourceId') || ''; + const href = searchParams.get('href') || ''; + const [sources, setSources] = useState([]); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])); + }, []); + + useEffect(() => { + if (!sourceId) return; + const params = new URLSearchParams({ sourceId }); + if (href) params.set('href', href); + fetch(`/api/books/catalog?${params.toString()}`) + .then(async (res) => { + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取目录失败'); + setData(json); + }) + .catch((err) => setError(err.message || '获取目录失败')); + }, [sourceId, href]); + + return ( +
+
+ {sources.map((source) => ( + + {source.name} + + ))} +
+ {error ?
{error}
: null} + {data ? ( + <> +
+

{data.title}

+ {data.subtitle ?

{data.subtitle}

: null} +
+ {data.previousHref ? 上一页 : null} + {data.nextHref ? 下一页 : null} +
+
+ {data.navigation.length > 0 ? ( +
+
目录
+
+ {data.navigation.map((item, index) => ( + +
{item.title}
+
点击进入子目录
+ + ))} +
+
+ ) : null} +
+ {data.entries.map((item) => )} +
+ + ) : !error ? : null} +
+ ); +} diff --git a/src/app/books/detail/page.tsx b/src/app/books/detail/page.tsx new file mode 100644 index 0000000..fc173d7 --- /dev/null +++ b/src/app/books/detail/page.tsx @@ -0,0 +1,128 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { BookDetail, BookShelfItem } from '@/lib/book.types'; +import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client'; + +function DetailSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default function BookDetailPage() { + const searchParams = useSearchParams(); + const sourceId = searchParams.get('sourceId') || ''; + const href = searchParams.get('href') || ''; + const [detail, setDetail] = useState(null); + const [shelf, setShelf] = useState>({}); + const [error, setError] = useState(''); + + + useEffect(() => { + getAllBookShelf().then(setShelf).catch(() => undefined); + }, []); + + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()); + fetch(`/api/books/detail?${params.toString()}`) + .then(async (res) => { + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取详情失败'); + setDetail(json); + }) + .catch((err) => setError(err.message || '获取详情失败')); + }, [searchParams]); + + const toggleShelf = async () => { + if (!detail) return; + const bookKey = `${detail.sourceId}+${detail.id}`; + if (shelf[bookKey]) { + await deleteBookShelf(detail.sourceId, detail.id); + setShelf((prev) => { + const next = { ...prev }; + delete next[bookKey]; + return next; + }); + return; + } + const item: BookShelfItem = { + sourceId: detail.sourceId, + sourceName: detail.sourceName, + bookId: detail.id, + title: detail.title, + author: detail.author, + cover: detail.cover, + detailHref: detail.detailHref, + acquisitionHref: readable?.href, + saveTime: Date.now(), + }; + await saveBookShelf(detail.sourceId, detail.id, item); + setShelf((prev) => ({ ...prev, [bookKey]: item })); + }; + + if (error) return
{error}
; + if (!detail) return ; + + const readable = detail.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf')); + const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub'; + + return ( +
+
+
+ {detail.cover ? {detail.title} :
无封面
} +
+
+
+

{detail.title}

+
{detail.author || detail.sourceName}
+
+ {detail.summary ?
{detail.summary}
: null} +
+ {(detail.categories || detail.tags || []).map((tag) => {tag})} +
+
+ {readable ? 在线阅读 : null} + + {detail.acquisitionLinks[0] ? 下载文件 : null} +
+
+
+
+

可用格式

+
+ {detail.acquisitionLinks.map((item) => ( +
+
+
{item.title || item.type}
+
{item.rel}
+
+ 打开 +
+ ))} +
+
+
+ ); +} diff --git a/src/app/books/history/page.tsx b/src/app/books/history/page.tsx new file mode 100644 index 0000000..ce45132 --- /dev/null +++ b/src/app/books/history/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; + +import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client'; +import { BookReadRecord, BookShelfItem } from '@/lib/book.types'; + +export default function BookHistoryPage() { + const [records, setRecords] = useState>({}); + const [shelf, setShelf] = useState>({}); + + useEffect(() => { + getAllBookReadRecords().then(setRecords).catch(() => undefined); + getAllBookShelf().then(setShelf).catch(() => undefined); + }, []); + + const items = useMemo(() => Object.entries(records) + .map(([key, item]) => { + const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+'); + const shelfItem = shelf[key]; + return { + ...item, + storageKey: key, + sourceId: item.sourceId || shelfItem?.sourceId || fallbackSourceId, + bookId: item.bookId || shelfItem?.bookId || fallbackBookId, + sourceName: item.sourceName || shelfItem?.sourceName || '', + detailHref: item.detailHref || shelfItem?.detailHref, + acquisitionHref: item.acquisitionHref || shelfItem?.acquisitionHref, + cover: item.cover || shelfItem?.cover, + author: item.author || shelfItem?.author, + format: item.format || shelfItem?.format || 'epub', + }; + }) + .sort((a, b) => b.saveTime - a.saveTime), [records, shelf]); + + return ( +
+ {items.map((item) => ( +
+
+
{item.cover ? {item.title} : null}
+
+
{item.title}
+
{item.author || item.sourceName}
+
已读 {Math.round(item.progressPercent || 0)}% · {item.chapterTitle || item.locator.chapterTitle || '定位已保存'}
+
+ {item.sourceId ? ( + + 继续阅读 + + ) : ( + 历史记录缺少书源信息 + )} + +
+
+
+
+ ))} + {items.length === 0 ?
暂无阅读历史
: null} +
+ ); +} diff --git a/src/app/books/layout.tsx b/src/app/books/layout.tsx new file mode 100644 index 0000000..848cb8a --- /dev/null +++ b/src/app/books/layout.tsx @@ -0,0 +1,5 @@ +import BooksLayout from '@/components/books/BooksLayout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/app/books/page.tsx b/src/app/books/page.tsx new file mode 100644 index 0000000..151fae2 --- /dev/null +++ b/src/app/books/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; + +import { BookSource } from '@/lib/book.types'; + +function BooksHomeSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +export default function BooksHomePage() { + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + if (typeof window !== 'undefined' && !(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } }).RUNTIME_CONFIG?.BOOKS_ENABLED) { + window.location.href = '/'; + return; + } + fetch('/api/books/sources') + .then((res) => res.json()) + .then((data) => setSources(data.sources || [])) + .catch((err) => setError(err.message || '加载书源失败')) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+

OPDS 电子书源

+

支持分类浏览、搜索、书架与 EPUB 在线阅读。

+
+ + {loading ? : null} + {error ?
{error}
: null} + +
+ {sources.map((source) => ( +
+
{source.name}
+
+ 分类{source.capabilities?.catalogSupported ? '可用' : '不可用'} + 搜索{source.capabilities?.searchSupported ? '可用' : '不可用'} +
+
+ {source.capabilities?.catalogSupported && 浏览目录} + {source.capabilities?.searchSupported && 搜索书籍} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx new file mode 100644 index 0000000..10fd0e6 --- /dev/null +++ b/src/app/books/read/page.tsx @@ -0,0 +1,660 @@ +'use client'; + +import { BookOpen, List, Moon, Settings2, Sun } from 'lucide-react'; +import { useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + buildBookCacheKey, + enforceBookCacheLimit, + getCachedBookFile, + putCachedBookFile, + touchCachedBookFile, +} from '@/lib/book-cache.client'; +import { saveBookReadRecord } from '@/lib/book.db.client'; +import { BookReadManifest } from '@/lib/book.types'; + +declare global { + interface Window { + ePub?: (input: string | ArrayBuffer) => EpubBookInstance; + JSZip?: unknown; + } +} + +interface EpubLocation { + start?: { cfi?: string; href?: string; displayed?: { chapter?: string } }; + end?: { cfi?: string }; +} + +interface TocItem { + id?: string; + label: string; + href: string; + subitems?: TocItem[]; +} + +interface EpubNavigation { + toc?: TocItem[]; +} + +interface EpubThemes { + fontSize?: (value: string) => void; + default?: (styles: Record>) => void; + override?: (name: string, value: string) => void; +} + +interface EpubBookInstance { + renderTo: (element: HTMLElement, options: Record) => EpubRendition; + locations?: { + percentageFromCfi?: (cfi: string) => number; + generate?: (chars?: number) => Promise; + }; + loaded?: { + navigation?: Promise; + }; + navigation?: EpubNavigation; + ready?: Promise; + destroy?: () => void; +} + +interface EpubRendition { + display: (target?: string) => Promise; + on: (event: 'relocated', callback: (location: EpubLocation) => void) => void; + prev?: () => void; + next?: () => void; + destroy?: () => void; + themes?: EpubThemes; +} + +type ReaderTheme = 'light' | 'sepia' | 'dark'; +type FileLoadState = 'preparing' | 'checking-cache' | 'downloading' | 'opening' | 'ready'; + +interface ReaderSettings { + fontSize: number; + lineHeight: number; + theme: ReaderTheme; +} + +const SETTINGS_STORAGE_KEY = 'books_epub_reader_settings'; +const DEFAULT_SETTINGS: ReaderSettings = { + fontSize: 100, + lineHeight: 1.7, + theme: 'light', +}; + +const THEME_STYLES: Record = { + light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' }, + sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' }, + dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' }, +}; + +function loadScriptOnce(selector: string, src: string, errorMessage: string) { + return new Promise((resolve, reject) => { + const existing = document.querySelector(selector) as HTMLScriptElement | null; + if (existing) { + if (existing.dataset.loaded === 'true') { + resolve(); + return; + } + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error(errorMessage)), { once: true }); + return; + } + + const script = document.createElement('script'); + script.src = src; + script.async = true; + if (selector.includes('jszip')) script.dataset.jszip = 'true'; + if (selector.includes('epubjs')) script.dataset.epubjs = 'true'; + script.onload = () => { + script.dataset.loaded = 'true'; + resolve(); + }; + script.onerror = () => reject(new Error(errorMessage)); + document.body.appendChild(script); + }); +} + +async function loadEpubScript() { + if (window.ePub && window.JSZip) return; + if (!window.JSZip) { + await loadScriptOnce('script[data-jszip]', 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js', 'JSZip 加载失败'); + } + if (!window.ePub) { + await loadScriptOnce('script[data-epubjs]', 'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js', 'epub.js 加载失败'); + } +} + +function loadReaderSettings(): ReaderSettings { + if (typeof window === 'undefined') return DEFAULT_SETTINGS; + try { + const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); + if (!raw) return DEFAULT_SETTINGS; + return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial) }; + } catch { + return DEFAULT_SETTINGS; + } +} + +function flattenToc(items: TocItem[]): TocItem[] { + return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]); +} + +async function downloadBookWithProgress( + url: string, + onProgress: (received: number, total: number | null) => void +): Promise { + const response = await fetch(url, { cache: 'force-cache' }); + if (!response.ok) throw new Error(`下载电子书失败: ${response.status}`); + const total = Number(response.headers.get('content-length') || '') || null; + if (!response.body) { + const blob = await response.blob(); + onProgress(blob.size, total); + return blob; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + + let done = false; + while (!done) { + const result = await reader.read(); + done = result.done; + const value = result.value; + if (done) break; + if (value) { + chunks.push(value); + received += value.length; + onProgress(received, total); + } + } + + return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' }); +} + +function formatBytes(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +export default function BookReadPage() { + const searchParams = useSearchParams(); + const sourceId = searchParams.get('sourceId') || ''; + const href = searchParams.get('href') || ''; + const [manifest, setManifest] = useState(null); + const [error, setError] = useState(''); + const [ready, setReady] = useState(false); + const [fileLoadState, setFileLoadState] = useState('preparing'); + const [downloadedBytes, setDownloadedBytes] = useState(0); + const [totalBytes, setTotalBytes] = useState(null); + const [cacheHit, setCacheHit] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [tocOpen, setTocOpen] = useState(false); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [tocItems, setTocItems] = useState([]); + const [currentHref, setCurrentHref] = useState(''); + const [currentChapter, setCurrentChapter] = useState(''); + const [progressPercent, setProgressPercent] = useState(0); + const [restoredMessage, setRestoredMessage] = useState(''); + const [controlsVisible, setControlsVisible] = useState(true); + const viewerRef = useRef(null); + const bookRef = useRef(null); + const renditionRef = useRef(null); + const saveTimerRef = useRef(null); + const lastLocationRef = useRef(null); + const lastProgressRef = useRef(0); + const lastChapterRef = useRef(''); + const locationsReadyRef = useRef(false); + + useEffect(() => { + setSettings(loadReaderSettings()); + }, []); + + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } + }, [settings]); + + useEffect(() => { + const params = new URLSearchParams({ + sourceId, + href, + acquisitionHref: searchParams.get('acquisitionHref') || '', + format: searchParams.get('format') || '', + bookId: searchParams.get('bookId') || '', + title: searchParams.get('title') || '', + author: searchParams.get('author') || '', + cover: searchParams.get('cover') || '', + }); + fetch(`/api/books/read/manifest?${params.toString()}`) + .then(async (res) => { + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取阅读信息失败'); + setManifest(json); + }) + .catch((err) => setError(err.message || '获取阅读信息失败')); + }, [sourceId, href, searchParams]); + + const saveProgress = useMemo(() => { + return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { + if (!manifest) return; + const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; + if (!locatorValue) return; + await saveBookReadRecord(manifest.book.sourceId, manifest.book.id, { + sourceId: manifest.book.sourceId, + sourceName: manifest.book.sourceName, + bookId: manifest.book.id, + title: manifest.book.title, + author: manifest.book.author, + cover: manifest.book.cover, + detailHref: manifest.book.detailHref, + acquisitionHref: manifest.acquisitionHref, + format: manifest.format, + locator: { + type: 'epub-cfi', + value: locatorValue, + href: location?.start?.href, + chapterTitle, + }, + chapterTitle, + chapterHref: location?.start?.href, + progressPercent: nextProgress, + saveTime: Date.now(), + }); + }; + }, [manifest]); + + const persistCurrentProgress = useCallback(() => { + if (lastLocationRef.current) { + void saveProgress(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current); + } + }, [saveProgress]); + + const applyReaderTheme = useCallback((nextSettings: ReaderSettings) => { + const rendition = renditionRef.current; + if (!rendition?.themes) return; + const palette = THEME_STYLES[nextSettings.theme]; + rendition.themes.default?.({ + body: { + 'background-color': palette.bodyBg, + color: palette.bodyColor, + 'font-size': `${nextSettings.fontSize}%`, + 'line-height': String(nextSettings.lineHeight), + 'padding-left': '6px', + 'padding-right': '6px', + }, + p: { color: palette.bodyColor }, + a: { color: nextSettings.theme === 'dark' ? '#93c5fd' : '#2563eb' }, + }); + rendition.themes.fontSize?.(`${nextSettings.fontSize}%`); + rendition.themes.override?.('line-height', String(nextSettings.lineHeight)); + }, []); + + useEffect(() => { + applyReaderTheme(settings); + }, [settings, applyReaderTheme]); + + const navigateToTarget = useCallback(async (target?: string) => { + if (!renditionRef.current) return; + await renditionRef.current.display(target); + }, []); + + const handleReaderTap = useCallback((zone: 'left' | 'center' | 'right') => { + if (!ready) return; + if (zone === 'left') { + renditionRef.current?.prev?.(); + return; + } + if (zone === 'right') { + renditionRef.current?.next?.(); + return; + } + setControlsVisible((prev) => !prev); + setTocOpen(false); + setSettingsOpen(false); + }, [ready]); + + useEffect(() => { + if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return; + let destroyed = false; + setReady(false); + setRestoredMessage(''); + locationsReadyRef.current = false; + setProgressPercent(manifest.lastRecord?.progressPercent || 0); + setCurrentChapter(manifest.lastRecord?.chapterTitle || manifest.lastRecord?.locator?.chapterTitle || ''); + setFileLoadState('checking-cache'); + setDownloadedBytes(0); + setTotalBytes(null); + setCacheHit(false); + + loadEpubScript() + .then(async () => { + if (!window.ePub || destroyed || !viewerRef.current) return; + + const cacheKey = manifest.cacheKey || buildBookCacheKey( + manifest.book.sourceId, + manifest.book.id, + manifest.acquisitionHref || manifest.fileUrl + ); + + let fileBuffer: ArrayBuffer; + const cached = await getCachedBookFile(cacheKey).catch(() => null); + if (cached) { + setCacheHit(true); + setFileLoadState('opening'); + setDownloadedBytes(cached.size); + setTotalBytes(cached.size); + await touchCachedBookFile(cacheKey).catch(() => undefined); + fileBuffer = await cached.blob.arrayBuffer(); + } else { + setFileLoadState('downloading'); + const blob = await downloadBookWithProgress(manifest.fileUrl, (received, total) => { + if (!destroyed) { + setDownloadedBytes(received); + setTotalBytes(total); + } + }); + fileBuffer = await blob.arrayBuffer(); + await putCachedBookFile({ + key: cacheKey, + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + title: manifest.book.title, + format: manifest.format, + acquisitionHref: manifest.acquisitionHref || manifest.fileUrl, + blob, + size: blob.size, + mimeType: blob.type || 'application/epub+zip', + updatedAt: Date.now(), + lastOpenTime: Date.now(), + }).catch(() => undefined); + await enforceBookCacheLimit().catch(() => undefined); + if (destroyed) return; + setFileLoadState('opening'); + } + + if (destroyed) return; + const book = window.ePub(fileBuffer); + const readyFallbackTimer = window.setTimeout(() => { + if (!destroyed) { + setReady(true); + setFileLoadState('ready'); + } + }, 4000); + + const rendition = book.renderTo(viewerRef.current, { + width: '100%', + height: '100%', + spread: 'none', + manager: 'default', + flow: 'paginated', + }); + bookRef.current = book; + renditionRef.current = rendition; + applyReaderTheme(settings); + + const restoreTarget = manifest.lastRecord?.locator?.value || undefined; + await navigateToTarget(restoreTarget); + window.clearTimeout(readyFallbackTimer); + if (destroyed) return; + setReady(true); + setFileLoadState('ready'); + + if (restoreTarget) { + setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`); + window.setTimeout(() => setRestoredMessage(''), 3000); + } + + void (async () => { + try { + const navigation = (await book.loaded?.navigation) || book.navigation; + if (!destroyed) setTocItems(navigation?.toc || []); + } catch { + if (!destroyed) setTocItems(book.navigation?.toc || []); + } + })(); + + void (async () => { + try { + await book.ready; + await book.locations?.generate?.(480); + locationsReadyRef.current = true; + if (lastLocationRef.current?.start?.cfi) { + const recomputed = book.locations?.percentageFromCfi?.(lastLocationRef.current.start.cfi) || 0; + const nextProgress = Math.max(0, Math.min(100, recomputed * 100)); + setProgressPercent(nextProgress); + lastProgressRef.current = nextProgress; + } + } catch { + // ignore + } + })(); + + rendition.on('relocated', (location: EpubLocation) => { + lastLocationRef.current = location; + const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title; + const cfi = location?.start?.cfi || ''; + const computedProgress = locationsReadyRef.current && cfi + ? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100)) + : null; + const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0; + setProgressPercent(normalizedProgress); + setCurrentChapter(chapterTitle); + setCurrentHref(location?.start?.href || ''); + lastProgressRef.current = normalizedProgress; + lastChapterRef.current = chapterTitle; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(() => { + void saveProgress(location, normalizedProgress, chapterTitle); + }, locationsReadyRef.current ? 1500 : 3500); + }); + }) + .catch((err) => { + setReady(false); + setError(err.message || '初始化 EPUB 阅读器失败'); + }); + + return () => { + destroyed = true; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + persistCurrentProgress(); + renditionRef.current?.destroy?.(); + bookRef.current?.destroy?.(); + }; + }, [manifest, settings, applyReaderTheme, persistCurrentProgress, saveProgress, navigateToTarget]); + + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState === 'hidden') persistCurrentProgress(); + }; + const handleUnload = () => persistCurrentProgress(); + document.addEventListener('visibilitychange', handleVisibility); + window.addEventListener('beforeunload', handleUnload); + return () => { + document.removeEventListener('visibilitychange', handleVisibility); + window.removeEventListener('beforeunload', handleUnload); + }; + }, [persistCurrentProgress]); + + const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]); + const activeTocHref = useMemo( + () => flatToc.find((item) => currentHref.includes(item.href) || item.href.includes(currentHref))?.href || '', + [flatToc, currentHref] + ); + const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes); + + if (error) return
{error}
; + if (!manifest) return
准备阅读器中...
; + + if (manifest.format === 'pdf') { + return