新增电子书架

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
+43
View File
@@ -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);
+43
View File
@@ -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);
+307
View File
@@ -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<void>;
}) => {
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<BookSource[]>([]);
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<BookSource>) => {
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 (
<div className='space-y-6'>
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'>
<h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'> / OPDS</h3>
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
<p> </p>
<p> </p>
<p> EPUB 线PDF </p>
</div>
</div>
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> OPDS </p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Feed </label>
<input
type='number'
min='60000'
value={cacheTTL}
onChange={(e) => 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'
/>
</div>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3>
<button type='button' onClick={addSource} className={buttonStyles.primary}>
<Plus size={16} className='inline mr-1' />
</button>
</div>
{sources.length === 0 && (
<div className='rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-500 dark:text-gray-400'>
OPDS
</div>
)}
{sources.map((source, index) => (
<div key={`${source.id}-${index}`} className='rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4'>
<div className='flex items-center justify-between gap-3'>
<div className='text-sm font-medium text-gray-900 dark:text-white'> #{index + 1}</div>
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => updateSource(index, { enabled: source.enabled === false })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.danger}>
<Trash2 size={16} />
</button>
</div>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> ID</label>
<input type='text' value={source.id} onChange={(e) => 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' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.name} onChange={(e) => 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' />
</div>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.url} onChange={(e) => 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' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} 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'>
<option value='none'></option>
<option value='basic'>Basic Auth</option>
<option value='header'> Header</option>
</select>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.language || ''} onChange={(e) => 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' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.searchTemplate || ''} onChange={(e) => 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' />
</div>
</div>
{source.authMode === 'basic' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.username || ''} onChange={(e) => 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' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='password' value={source.password || ''} onChange={(e) => 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' />
</div>
</div>
)}
{source.authMode === 'header' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Header </label>
<input type='text' value={source.headerName || ''} onChange={(e) => 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' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Header </label>
<input type='password' value={source.headerValue || ''} onChange={(e) => 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' />
</div>
</div>
)}
</div>
))}
</div>
<div className='flex gap-3'>
<button onClick={handleTest} disabled={isLoading('testOPDSConfig')} className={buttonStyles.primary}>
{isLoading('testOPDSConfig') ? '测试中...' : '测试书源'}
</button>
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}>
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
</button>
</div>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
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() {
<SuwayomiConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
<CollapsibleTab
title='电子书配置'
icon={
<BookMarked size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.opdsConfig}
onToggle={() => toggleTab('opdsConfig')}
>
<OPDSConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 电视直播源配置标签 */}
<CollapsibleTab
title='电视直播源配置'
+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 });
}
}
+123
View File
@@ -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 (
<div className='space-y-6 animate-pulse'>
<div className='flex gap-2 overflow-x-auto pb-1'>
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800' />
))}
</div>
<div className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='h-6 w-40 rounded bg-gray-200 dark:bg-gray-800' />
<div className='mt-3 h-4 w-72 rounded bg-gray-200 dark:bg-gray-800' />
</div>
<div className='flex gap-3 overflow-x-auto pb-2'>
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className='h-20 min-w-[180px] rounded-2xl bg-gray-200 dark:bg-gray-800' />
))}
</div>
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className='space-y-3'>
<div className='aspect-[3/4] rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-3 w-1/2 rounded bg-gray-200 dark:bg-gray-800' />
</div>
))}
</div>
</div>
);
}
export default function BooksCatalogPage() {
const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || '';
const [sources, setSources] = useState<BookSource[]>([]);
const [data, setData] = useState<BookCatalogResult | null>(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 (
<div className='space-y-6'>
<div className='flex flex-wrap gap-2'>
{sources.map((source) => (
<Link key={source.id} href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className={`rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}>
{source.name}
</Link>
))}
</div>
{error ? <div className='text-sm text-red-500'>{error}</div> : null}
{data ? (
<>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h1 className='text-lg font-semibold'>{data.title}</h1>
{data.subtitle ? <p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{data.subtitle}</p> : null}
<div className='mt-4 flex flex-wrap gap-2'>
{data.previousHref ? <Link href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(data.previousHref)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></Link> : null}
{data.nextHref ? <Link href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(data.nextHref)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></Link> : null}
</div>
</section>
{data.navigation.length > 0 ? (
<section className='space-y-3'>
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'></div>
<div className='flex gap-3 overflow-x-auto pb-2'>
{data.navigation.map((item, index) => (
<Link
key={`${item.href}-${index}`}
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
className='min-w-[180px] rounded-2xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950'
>
<div className='line-clamp-2 font-medium'>{item.title}</div>
<div className='mt-2 text-xs text-gray-500 dark:text-gray-400'></div>
</Link>
))}
</div>
</section>
) : null}
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{data.entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={makeHref(sourceId, item)} />)}
</section>
</>
) : !error ? <CatalogSkeleton /> : null}
</div>
);
}
+128
View File
@@ -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 (
<div className='space-y-6 animate-pulse'>
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'>
<div className='aspect-[3/4] rounded-3xl bg-gray-200 dark:bg-gray-800' />
<div className='space-y-4'>
<div className='h-8 w-2/3 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-800' />
<div className='space-y-2'>
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-11/12 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-10/12 rounded bg-gray-200 dark:bg-gray-800' />
</div>
<div className='flex gap-3'>
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
</div>
</div>
</section>
</div>
);
}
export default function BookDetailPage() {
const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || '';
const [detail, setDetail] = useState<BookDetail | null>(null);
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
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 <div className='text-sm text-red-500'>{error}</div>;
if (!detail) return <DetailSkeleton />;
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 (
<div className='space-y-6'>
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'>
<div className='overflow-hidden rounded-3xl bg-gray-100 dark:bg-gray-900'>
{detail.cover ? <img src={detail.cover} alt={detail.title} className='h-full w-full object-cover' /> : <div className='flex aspect-[3/4] items-center justify-center text-sm text-gray-400'></div>}
</div>
<div className='space-y-4'>
<div>
<h1 className='text-2xl font-semibold'>{detail.title}</h1>
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{detail.author || detail.sourceName}</div>
</div>
{detail.summary ? <div className='text-sm leading-7 text-gray-700 dark:text-gray-300'>{detail.summary}</div> : null}
<div className='flex flex-wrap gap-2'>
{(detail.categories || detail.tags || []).map((tag) => <span key={tag} className='rounded-full bg-gray-100 px-3 py-1 text-xs dark:bg-gray-900'>{tag}</span>)}
</div>
<div className='flex flex-wrap gap-3'>
{readable ? <Link href={`/books/read?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(href || detail.detailHref || '')}&acquisitionHref=${encodeURIComponent(readable.href)}&format=${encodeURIComponent(readableFormat)}&bookId=${encodeURIComponent(detail.id)}&title=${encodeURIComponent(detail.title)}&author=${encodeURIComponent(detail.author || '')}&cover=${encodeURIComponent(detail.cover || '')}`} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>线</Link> : null}
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
{detail.acquisitionLinks[0] ? <a href={`/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(detail.acquisitionLinks[0].href)}`} target='_blank' rel='noreferrer' className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></a> : null}
</div>
</div>
</section>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h2 className='text-lg font-semibold'></h2>
<div className='mt-4 space-y-3'>
{detail.acquisitionLinks.map((item) => (
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
<div>
<div>{item.title || item.type}</div>
<div className='text-xs text-gray-500'>{item.rel}</div>
</div>
<a href={`/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`} target='_blank' rel='noreferrer' className='text-sky-600'></a>
</div>
))}
</div>
</section>
</div>
);
}
+79
View File
@@ -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<Record<string, BookReadRecord>>({});
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
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 (
<div className='space-y-4'>
{items.map((item) => (
<div key={item.storageKey} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='flex gap-4'>
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{item.title}</div>
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
<div className='mt-1 text-xs text-gray-500'> {Math.round(item.progressPercent || 0)}% · {item.chapterTitle || item.locator.chapterTitle || '定位已保存'}</div>
<div className='mt-3 flex flex-wrap gap-2'>
{item.sourceId ? (
<Link
href={{
pathname: '/books/read',
query: {
sourceId: item.sourceId,
href: item.detailHref || '',
acquisitionHref: item.acquisitionHref || '',
format: item.format,
bookId: item.bookId,
title: item.title,
author: item.author || '',
cover: item.cover || '',
},
}}
className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'
>
</Link>
) : (
<span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'></span>
)}
<button onClick={async () => { const [deleteSourceId = item.sourceId, deleteBookId = item.bookId] = item.storageKey.split('+'); await deleteBookReadRecord(deleteSourceId, deleteBookId); setRecords((prev) => { const next = { ...prev }; delete next[item.storageKey]; return next; }); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'></button>
</div>
</div>
</div>
</div>
))}
{items.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import BooksLayout from '@/components/books/BooksLayout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <BooksLayout>{children}</BooksLayout>;
}
+72
View File
@@ -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 (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 animate-pulse'>
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='h-5 w-32 rounded bg-gray-200 dark:bg-gray-800' />
<div className='mt-3 flex gap-2'>
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' />
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' />
</div>
<div className='mt-4 flex gap-2'>
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
</div>
</div>
))}
</div>
);
}
export default function BooksHomePage() {
const [sources, setSources] = useState<BookSource[]>([]);
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 (
<div className='space-y-6'>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h1 className='text-lg font-semibold'>OPDS </h1>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'> EPUB 线</p>
</section>
{loading ? <BooksHomeSkeleton /> : null}
{error ? <div className='text-sm text-red-500'>{error}</div> : null}
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
{sources.map((source) => (
<div key={source.id} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='text-base font-semibold'>{source.name}</div>
<div className='mt-2 flex flex-wrap gap-2 text-xs'>
<span className={`rounded-full px-2 py-1 ${source.capabilities?.catalogSupported ? 'bg-sky-100 text-sky-700 dark:bg-sky-950/50 dark:text-sky-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>{source.capabilities?.catalogSupported ? '可用' : '不可用'}</span>
<span className={`rounded-full px-2 py-1 ${source.capabilities?.searchSupported ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>{source.capabilities?.searchSupported ? '可用' : '不可用'}</span>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
{source.capabilities?.catalogSupported && <Link href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'></Link>}
{source.capabilities?.searchSupported && <Link href={`/books/search?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></Link>}
</div>
</div>
))}
</div>
</div>
);
}
+660
View File
@@ -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<string, Record<string, string>>) => void;
override?: (name: string, value: string) => void;
}
interface EpubBookInstance {
renderTo: (element: HTMLElement, options: Record<string, string | boolean>) => EpubRendition;
locations?: {
percentageFromCfi?: (cfi: string) => number;
generate?: (chars?: number) => Promise<void>;
};
loaded?: {
navigation?: Promise<EpubNavigation>;
};
navigation?: EpubNavigation;
ready?: Promise<unknown>;
destroy?: () => void;
}
interface EpubRendition {
display: (target?: string) => Promise<void>;
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<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
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<void>((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/[email protected]/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<ReaderSettings>) };
} 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<Blob> {
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<BookReadManifest | null>(null);
const [error, setError] = useState('');
const [ready, setReady] = useState(false);
const [fileLoadState, setFileLoadState] = useState<FileLoadState>('preparing');
const [downloadedBytes, setDownloadedBytes] = useState(0);
const [totalBytes, setTotalBytes] = useState<number | null>(null);
const [cacheHit, setCacheHit] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [tocOpen, setTocOpen] = useState(false);
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
const [tocItems, setTocItems] = useState<TocItem[]>([]);
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<HTMLDivElement | null>(null);
const bookRef = useRef<EpubBookInstance | null>(null);
const renditionRef = useRef<EpubRendition | null>(null);
const saveTimerRef = useRef<number | null>(null);
const lastLocationRef = useRef<EpubLocation | null>(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 <div className='p-4 text-sm text-red-500'>{error}</div>;
if (!manifest) return <div className='p-4 text-sm text-gray-500'>...</div>;
if (manifest.format === 'pdf') {
return <iframe src={manifest.fileUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
}
return (
<div className='relative h-[calc(100vh-4rem)] overflow-hidden bg-white dark:bg-gray-950'>
<div className={`flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 text-sm shadow-sm transition-all dark:border-gray-800 dark:bg-gray-950 ${controlsVisible ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0 pointer-events-none'}`}>
<div className='min-w-0'>
<div className='truncate font-medium'>{manifest.book.title}</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
{currentChapter || manifest.book.author || 'EPUB 阅读'} · {Math.round(progressPercent)}%
</div>
</div>
<div className='flex items-center gap-2'>
<button onClick={() => setTocOpen((prev) => !prev)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'>
<List className='h-4 w-4' />
</button>
<button onClick={() => setSettingsOpen((prev) => !prev)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'>
<Settings2 className='h-4 w-4' />
</button>
</div>
</div>
{restoredMessage ? (
<div className='absolute left-1/2 top-16 z-30 -translate-x-1/2 rounded-full bg-sky-600 px-4 py-2 text-xs text-white shadow-lg'>
{restoredMessage}
</div>
) : null}
{!ready ? (
<div className='absolute inset-x-0 top-14 z-10 p-4'>
<div className='mx-auto max-w-3xl space-y-4'>
<div className='space-y-2 rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950/90'>
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
{fileLoadState === 'checking-cache'
? '检查本地缓存'
: fileLoadState === 'downloading'
? '下载电子书'
: fileLoadState === 'opening'
? '正在打开电子书'
: '准备阅读器'}
</div>
<div className='h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800'>
<div
className='h-full rounded-full bg-sky-600 transition-all'
style={{ width: totalBytes ? `${Math.min(100, (downloadedBytes / totalBytes) * 100)}%` : fileLoadState === 'opening' ? '92%' : fileLoadState === 'checking-cache' ? '20%' : '45%' }}
/>
</div>
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span>{cacheHit ? '已命中本地缓存' : '首次打开将缓存到当前浏览器'}</span>
<span>{progressLabel}</span>
</div>
</div>
<div className='space-y-3 rounded-3xl bg-gray-50 p-6 dark:bg-gray-900 animate-pulse'>
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-11/12 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-10/12 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-9/12 rounded bg-gray-200 dark:bg-gray-800' />
</div>
</div>
</div>
) : null}
{tocOpen && (
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
<div
className='absolute right-0 top-14 h-[calc(100vh-3.5rem)] w-full max-w-sm overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='p-4'>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><BookOpen className='h-4 w-4' /></div>
<button onClick={() => setTocOpen(false)} className='text-xs text-gray-500'></button>
</div>
<div className='space-y-2'>
{flatToc.length === 0 ? (
<div className='p-3 text-sm text-gray-500'> EPUB </div>
) : (
flatToc.map((item) => {
const active = activeTocHref === item.href;
return (
<button
key={`${item.href}-${item.label}`}
onClick={() => {
void navigateToTarget(item.href);
setTocOpen(false);
}}
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`}
>
{item.label}
</button>
);
})
)}
</div>
</div>
</div>
</div>
)}
{settingsOpen && (
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
<div
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='mb-4'>
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'></div>
<div className='mt-1 text-xs text-gray-500'> EPUB </div>
</div>
<div className='space-y-6 p-1 text-sm'>
<div>
<div className='mb-2 font-medium'></div>
<div className='grid grid-cols-3 gap-2'>
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => (
<button
key={theme}
onClick={() => setSettings((prev) => ({ ...prev, theme }))}
className={`rounded-2xl border px-3 py-2 ${settings.theme === theme ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}
>
<div className='mb-1 flex justify-center'>{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}</div>
{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'}
</button>
))}
</div>
</div>
<div>
<div className='mb-2 flex items-center justify-between font-medium'> <span>{settings.fontSize}%</span></div>
<input type='range' min='85' max='140' step='5' value={settings.fontSize} onChange={(e) => setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' />
</div>
<div>
<div className='mb-2 flex items-center justify-between font-medium'> <span>{settings.lineHeight.toFixed(1)}</span></div>
<input type='range' min='1.4' max='2.2' step='0.1' value={settings.lineHeight} onChange={(e) => setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' />
</div>
<div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
{cacheHit ? '已命中本地缓存' : '本次为网络加载'}
</div>
<div className='flex justify-end'>
<button
type='button'
className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white'
onClick={() => setSettingsOpen(false)}
>
</button>
</div>
</div>
</div>
</div>
)}
{ready && !tocOpen && !settingsOpen ? (
<div className='absolute inset-x-0 top-14 bottom-0 z-10 grid grid-cols-3'>
<button aria-label='上一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('left')} />
<button aria-label='切换工具栏' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('center')} />
<button aria-label='下一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('right')} />
</div>
) : null}
<div ref={viewerRef} className='h-[calc(100%-3.5rem)] w-full' style={{ backgroundColor: THEME_STYLES[settings.theme].panelBg }} />
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import BookCard from '@/components/books/BookCard';
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
function detailHref(item: BookListItem) {
const params = new URLSearchParams({
sourceId: item.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 SearchSkeleton() {
return (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6 animate-pulse'>
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className='space-y-3'>
<div className='aspect-[3/4] rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-3 w-1/2 rounded bg-gray-200 dark:bg-gray-800' />
</div>
))}
</div>
);
}
export default function BooksSearchPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [q, setQ] = useState(searchParams.get('q') || '');
const [sourceId, setSourceId] = useState(searchParams.get('sourceId') || '');
const [sources, setSources] = useState<BookSource[]>([]);
const [result, setResult] = useState<BookSearchResult>({ results: [], failedSources: [] });
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
}, []);
useEffect(() => {
const keyword = searchParams.get('q') || '';
const source = searchParams.get('sourceId') || '';
setQ(keyword);
setSourceId(source);
if (!keyword) return;
setLoading(true);
fetch(`/api/books/search?${new URLSearchParams({ q: keyword, ...(source ? { sourceId: source } : {}) }).toString()}`)
.then((res) => res.json())
.then((json) => setResult(json))
.finally(() => setLoading(false));
}, [searchParams]);
return (
<div className='space-y-6'>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<form onSubmit={(e) => { e.preventDefault(); const params = new URLSearchParams(); if (q.trim()) params.set('q', q.trim()); if (sourceId) params.set('sourceId', sourceId); router.push(`/books/search?${params.toString()}`); }} className='space-y-3'>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
<option value=''></option>
{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}
</select>
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'></button>
</form>
</section>
{loading ? <SearchSkeleton /> : null}
{result.failedSources.length > 0 ? <div className='rounded-2xl bg-amber-50 p-4 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300'>{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join('')}</div> : null}
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} />)}
</section>
{!loading && result.results.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
import { BookShelfItem } from '@/lib/book.types';
export default function BookShelfPage() {
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
useEffect(() => {
getAllBookShelf().then(setShelf).catch(() => undefined);
}, []);
const items = useMemo(() => Object.values(shelf).sort((a, b) => (b.lastReadTime || b.saveTime) - (a.lastReadTime || a.saveTime)), [shelf]);
return (
<div className='space-y-4'>
<div className='text-sm text-gray-500'> {items.length} </div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
{items.map((item) => (
<div key={`${item.sourceId}-${item.bookId}`} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='flex gap-4'>
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{item.title}</div>
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
<div className='mt-2 text-xs text-gray-500'> {Math.round(item.progressPercent || 0)}%</div>
<div className='mt-3 flex flex-wrap gap-2'>
<Link href={`/books/detail?sourceId=${encodeURIComponent(item.sourceId)}&href=${encodeURIComponent(item.detailHref || '')}&bookId=${encodeURIComponent(item.bookId)}&title=${encodeURIComponent(item.title)}&author=${encodeURIComponent(item.author || '')}&cover=${encodeURIComponent(item.cover || '')}`} className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'></Link>
<button onClick={async () => { await deleteBookShelf(item.sourceId, item.bookId); setShelf((prev) => { const next = { ...prev }; delete next[`${item.sourceId}+${item.bookId}`]; return next; }); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'></button>
</div>
</div>
</div>
</div>
))}
</div>
{items.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
</div>
);
}
+10
View File
@@ -101,6 +101,7 @@ export default async function RootLayout({
let customAdFilterVersion = 0;
let musicFeatureEnabled = false;
let suwayomiEnabled = false;
let booksEnabled = process.env.OPDS_ENABLED === 'true' && !!(process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL || process.env.OPDS_SOURCES_JSON);
let musicProxyEnabled = true;
let advancedRecommendationEnabled = false;
let userFeatureAccess =
@@ -175,6 +176,14 @@ export default async function RootLayout({
config.SuwayomiConfig?.Enabled &&
config.SuwayomiConfig?.ServerURL
);
// 电子书功能配置
const opdsConfig = config.OPDSConfig;
const rawOpdsSources = opdsConfig?.Sources;
const opdsSources = Array.isArray(rawOpdsSources) ? rawOpdsSources : [];
booksEnabled = !!(
opdsConfig?.Enabled &&
opdsSources.some((source) => source?.enabled !== false && !!source?.url)
);
// 高级推荐功能配置:存在已启用视频源脚本时显示
advancedRecommendationEnabled =
(await listEnabledSourceScripts()).length > 0;
@@ -257,6 +266,7 @@ export default async function RootLayout({
MUSIC_ENABLED: musicFeatureEnabled && userFeatureAccess.music,
MUSIC_PROXY_ENABLED: musicProxyEnabled,
SUWAYOMI_ENABLED: suwayomiEnabled && userFeatureAccess.manga,
BOOKS_ENABLED: booksEnabled && userFeatureAccess.books,
NETDISK_SEARCH_ENABLED: userFeatureAccess.netdisk_search,
MAGNET_SEARCH_ENABLED: userFeatureAccess.magnet_search,
MAGNET_SAVE_PRIVATE_LIBRARY_ENABLED:
+21 -1
View File
@@ -2,7 +2,7 @@
'use client';
import { BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import { BookMarked, BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import Link from 'next/link';
import { Suspense, useEffect, useState } from 'react';
@@ -66,6 +66,7 @@ function HomeClient() {
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
const [musicEnabled, setMusicEnabled] = useState(false);
const [mangaEnabled, setMangaEnabled] = useState(false);
const [booksEnabled, setBooksEnabled] = useState(false);
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
const [directPlayUrl, setDirectPlayUrl] = useState('');
const [directPlaySubmitting, setDirectPlaySubmitting] = useState(false);
@@ -299,6 +300,14 @@ function HomeClient() {
}
}, []);
// 检查电子书功能是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
const enabled = !!(window as any).RUNTIME_CONFIG?.BOOKS_ENABLED;
setBooksEnabled(enabled);
}
}, []);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -777,6 +786,17 @@ function HomeClient() {
</Link>
)}
{booksEnabled && (
<Link href='/books'>
<button
className='p-1.5 rounded-lg text-amber-500 hover:text-amber-600 transition-colors'
title='电子书馆'
>
<BookMarked size={18} />
</button>
</Link>
)}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
<Link href='/source-search'>
+27
View File
@@ -0,0 +1,27 @@
'use client';
import Link from 'next/link';
import { BookListItem } from '@/lib/book.types';
export default function BookCard({ item, href, extra }: { item: BookListItem; href: string; extra?: React.ReactNode }) {
return (
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<Link href={href}>
<div className='aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
{item.cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.cover} alt={item.title} className='h-full w-full object-cover' />
) : (
<div className='flex h-full items-center justify-center text-sm text-gray-400'></div>
)}
</div>
</Link>
<div className='space-y-2 p-3'>
<Link href={href} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || item.sourceName}</div>
{extra}
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { BookOpen, ChevronLeft, History, Library, Search } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const tabs = [
{ href: '/books', label: '发现', icon: Library },
{ href: '/books/search', label: '搜索', icon: Search },
{ href: '/books/shelf', label: '书架', icon: BookOpen },
{ href: '/books/history', label: '历史', icon: History },
];
export default function BooksLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isRead = pathname === '/books/read';
return (
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'>
<header className='fixed inset-x-0 top-0 z-40 border-b border-gray-200/70 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90'>
<div className='mx-auto flex h-14 max-w-6xl items-center gap-3 px-4'>
{isRead ? (
<Link href='/books' className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'>
<ChevronLeft className='h-5 w-5' />
</Link>
) : (
<Link href='/' className='text-sm font-semibold text-sky-600'>MoonTV+</Link>
)}
<div className='min-w-0 flex-1'>
<div className='truncate text-sm font-semibold sm:text-base'>{isRead ? '电子书阅读' : '电子书馆'}</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>OPDS </div>
</div>
{!isRead && (
<nav className='hidden items-center gap-2 md:flex'>
{tabs.map((tab) => {
const active = pathname === tab.href;
const Icon = tab.icon;
return (
<Link key={tab.href} href={tab.href} className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm ${active ? 'bg-sky-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}`}>
<Icon className='h-4 w-4' />
{tab.label}
</Link>
);
})}
</nav>
)}
</div>
</header>
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-16' : 'px-4 pb-24 pt-20'}`}>{children}</main>
{!isRead && (
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200/70 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 md:hidden'>
{tabs.map((tab) => {
const active = pathname === tab.href;
const Icon = tab.icon;
return (
<Link key={tab.href} href={tab.href} className='flex min-h-16 flex-col items-center justify-center gap-1 text-xs'>
<Icon className={`h-5 w-5 ${active ? 'text-sky-600' : 'text-gray-500'}`} />
<span className={active ? 'text-sky-600' : 'text-gray-600 dark:text-gray-300'}>{tab.label}</span>
</Link>
);
})}
</nav>
)}
</div>
);
}
+18
View File
@@ -279,6 +279,24 @@ export interface AdminConfig {
SourceIds?: string[]; // 限制可用源
MaxSources?: number; // 搜索时最多查询多少个源
};
OPDSConfig?: {
Enabled: boolean; // 是否启用电子书馆
Sources?: Array<{
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;
}>;
CacheTTL?: number;
};
EmailConfig?: {
enabled: boolean; // 是否启用邮件通知
provider: 'smtp' | 'resend'; // 邮件发送方式
+123
View File
@@ -0,0 +1,123 @@
'use client';
const DB_NAME = 'moontv_books_cache';
const STORE_NAME = 'epub_files';
const DB_VERSION = 1;
const DEFAULT_CACHE_LIMIT = 500 * 1024 * 1024;
export interface CachedBookFile {
key: string;
sourceId: string;
bookId: string;
title: string;
format: 'epub' | 'pdf';
acquisitionHref: string;
blob: Blob;
size: number;
mimeType: string;
updatedAt: number;
lastOpenTime: number;
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
store.createIndex('lastOpenTime', 'lastOpenTime', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('打开 IndexedDB 失败'));
});
}
export function buildBookCacheKey(sourceId: string, bookId: string, acquisitionHref: string) {
return `${sourceId}::${bookId}::${acquisitionHref}`;
}
export async function getCachedBookFile(key: string): Promise<CachedBookFile | null> {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(key);
request.onsuccess = () => {
db.close();
resolve((request.result as CachedBookFile | undefined) || null);
};
request.onerror = () => {
db.close();
reject(request.error || new Error('读取缓存失败'));
};
});
}
export async function putCachedBookFile(record: CachedBookFile): Promise<void> {
const db = await openDatabase();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error || new Error('写入缓存失败'));
};
});
}
export async function touchCachedBookFile(key: string): Promise<void> {
const current = await getCachedBookFile(key);
if (!current) return;
await putCachedBookFile({ ...current, lastOpenTime: Date.now() });
}
export async function deleteCachedBookFile(key: string): Promise<void> {
const db = await openDatabase();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(key);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error || new Error('删除缓存失败'));
};
});
}
export async function listCachedBookFiles(): Promise<CachedBookFile[]> {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).getAll();
request.onsuccess = () => {
db.close();
resolve((request.result as CachedBookFile[]) || []);
};
request.onerror = () => {
db.close();
reject(request.error || new Error('读取缓存列表失败'));
};
});
}
export async function enforceBookCacheLimit(limit = DEFAULT_CACHE_LIMIT): Promise<void> {
const items = await listCachedBookFiles();
const total = items.reduce((sum, item) => sum + item.size, 0);
if (total <= limit) return;
let current = total;
const sorted = [...items].sort((a, b) => a.lastOpenTime - b.lastOpenTime);
for (const item of sorted) {
if (current <= limit) break;
await deleteCachedBookFile(item.key);
current -= item.size;
}
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { BookReadRecord, BookShelfItem } from './book.types';
import { fetchWithAuth, generateStorageKey } from './db.client';
const BOOK_SHELF_KEY = 'moontv_book_shelf';
const BOOK_HISTORY_KEY = 'moontv_book_history';
const MAX_BOOK_HISTORY = 100;
const MAX_BOOK_HISTORY_THRESHOLD = MAX_BOOK_HISTORY + 10;
function isRemoteStorage() {
return ((window as Window & { RUNTIME_CONFIG?: { STORAGE_TYPE?: string } }).RUNTIME_CONFIG?.STORAGE_TYPE || process.env.STORAGE_TYPE || 'localstorage') !== 'localstorage';
}
function trimRecords(records: Record<string, BookReadRecord>) {
const entries = Object.entries(records);
if (entries.length <= MAX_BOOK_HISTORY_THRESHOLD) return records;
return Object.fromEntries(entries.sort(([, a], [, b]) => b.saveTime - a.saveTime).slice(0, MAX_BOOK_HISTORY));
}
export async function getAllBookShelf(): Promise<Record<string, BookShelfItem>> {
if (typeof window === 'undefined') return {};
if (isRemoteStorage()) {
return (await (await fetchWithAuth('/api/books/shelf')).json()) as Record<string, BookShelfItem>;
}
const raw = localStorage.getItem(BOOK_SHELF_KEY);
return raw ? (JSON.parse(raw) as Record<string, BookShelfItem>) : {};
}
export async function saveBookShelf(sourceId: string, bookId: string, item: BookShelfItem): Promise<void> {
const key = generateStorageKey(sourceId, bookId);
if (isRemoteStorage()) {
await fetchWithAuth('/api/books/shelf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, item }),
});
return;
}
const data = await getAllBookShelf();
data[key] = item;
localStorage.setItem(BOOK_SHELF_KEY, JSON.stringify(data));
}
export async function deleteBookShelf(sourceId: string, bookId: string): Promise<void> {
const key = generateStorageKey(sourceId, bookId);
if (isRemoteStorage()) {
await fetchWithAuth(`/api/books/shelf?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
return;
}
const data = await getAllBookShelf();
delete data[key];
localStorage.setItem(BOOK_SHELF_KEY, JSON.stringify(data));
}
export async function getAllBookReadRecords(): Promise<Record<string, BookReadRecord>> {
if (typeof window === 'undefined') return {};
if (isRemoteStorage()) {
return (await (await fetchWithAuth('/api/books/history')).json()) as Record<string, BookReadRecord>;
}
const raw = localStorage.getItem(BOOK_HISTORY_KEY);
return raw ? (JSON.parse(raw) as Record<string, BookReadRecord>) : {};
}
export async function saveBookReadRecord(sourceId: string, bookId: string, record: BookReadRecord): Promise<void> {
const key = generateStorageKey(sourceId, bookId);
if (isRemoteStorage()) {
await fetchWithAuth('/api/books/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, record }),
});
return;
}
const data = await getAllBookReadRecords();
data[key] = record;
localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(trimRecords(data)));
}
export async function deleteBookReadRecord(sourceId: string, bookId: string): Promise<void> {
const key = generateStorageKey(sourceId, bookId);
if (isRemoteStorage()) {
await fetchWithAuth(`/api/books/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
return;
}
const data = await getAllBookReadRecords();
delete data[key];
localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(data));
}
+139
View File
@@ -0,0 +1,139 @@
export interface BookSourceCapabilities {
searchSupported: boolean;
catalogSupported: boolean;
searchMode: 'opds' | 'template' | 'disabled';
catalogMode: 'navigation' | 'acquisition' | 'flat' | 'disabled';
acquisitionTypes: string[];
lastCheckedAt?: number;
lastError?: string;
}
export interface BookSource {
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;
capabilities?: BookSourceCapabilities;
}
export interface BookAcquisitionLink {
rel: string;
type: string;
href: string;
title?: string;
isIndirect?: boolean;
}
export interface BookNavLink {
title: string;
href: string;
rel?: string;
type?: string;
}
export interface BookListItem {
id: string;
sourceId: string;
sourceName: string;
title: string;
author?: string;
cover?: string;
summary?: string;
language?: string;
published?: string;
updated?: string;
tags?: string[];
detailHref?: string;
acquisitionLinks: BookAcquisitionLink[];
}
export interface BookDetail extends BookListItem {
publisher?: string;
identifier?: string;
series?: string;
categories?: string[];
navigation?: BookNavLink[];
}
export interface BookCatalogResult {
sourceId: string;
sourceName: string;
title: string;
subtitle?: string;
href: string;
entries: BookListItem[];
navigation: BookNavLink[];
nextHref?: string;
previousHref?: string;
}
export interface BookSearchFailure {
sourceId: string;
sourceName: string;
error: string;
}
export interface BookSearchResult {
results: BookListItem[];
failedSources: BookSearchFailure[];
}
export interface BookLocator {
type: 'epub-cfi' | 'pdf-page' | 'href';
value: string;
href?: string;
chapterTitle?: string;
}
export interface BookShelfItem {
sourceId: string;
sourceName: string;
bookId: string;
title: string;
author?: string;
cover?: string;
format?: 'epub' | 'pdf';
detailHref?: string;
acquisitionHref?: string;
progressPercent?: number;
lastReadTime?: number;
lastLocatorType?: BookLocator['type'];
lastLocatorValue?: string;
lastChapterTitle?: string;
saveTime: number;
}
export interface BookReadRecord {
sourceId: string;
sourceName: string;
bookId: string;
title: string;
author?: string;
cover?: string;
format: 'epub' | 'pdf';
detailHref?: string;
acquisitionHref?: string;
locator: BookLocator;
progressPercent: number;
chapterTitle?: string;
chapterHref?: string;
saveTime: number;
}
export interface BookReadManifest {
book: BookDetail;
format: 'epub' | 'pdf';
fileUrl: string;
acquisitionHref?: string;
cacheKey?: string;
coverUrl?: string;
lastRecord?: BookReadRecord | null;
}
+43
View File
@@ -663,6 +663,49 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
adminConfig.SuwayomiConfig.MaxSources = 10;
}
if (!adminConfig.OPDSConfig) {
adminConfig.OPDSConfig = {
Enabled: process.env.OPDS_ENABLED === 'true',
Sources: (() => {
const json = process.env.OPDS_SOURCES_JSON;
if (json) {
try {
const parsed = JSON.parse(json);
if (Array.isArray(parsed)) return parsed;
} catch {
// ignore invalid env json
}
}
const envUrl = process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL;
if (!envUrl) return [];
return [{
id: 'default',
name: process.env.OPDS_NAME || '默认书源',
url: envUrl,
enabled: true,
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none',
username: process.env.OPDS_USERNAME || '',
password: process.env.OPDS_PASSWORD || '',
headerName: process.env.OPDS_HEADER_NAME || '',
headerValue: process.env.OPDS_HEADER_VALUE || '',
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
}];
})(),
CacheTTL: Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000),
};
}
if (adminConfig.OPDSConfig.Enabled === undefined) {
adminConfig.OPDSConfig.Enabled = false;
}
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
adminConfig.OPDSConfig.Sources = [];
}
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
}
if (!adminConfig.NetDiskConfig) {
adminConfig.NetDiskConfig = {
Quark: {
+247 -2
View File
@@ -17,6 +17,7 @@ import {
} from './types';
import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types';
import { DatabaseAdapter } from './d1-adapter';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
import { userInfoCache } from './user-cache';
@@ -784,7 +785,7 @@ export class D1Storage implements IStorage {
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, song_id) DO UPDATE SET
source = excluded.source,
songmid = excluded.songmid,
@@ -2096,6 +2097,248 @@ export class D1Storage implements IStorage {
}
}
// ==================== 电子书书架 ====================
async getBookShelf(userName: string, key: string): Promise<BookShelfItem | null> {
try {
const result = await this.db.prepare('SELECT * FROM book_shelf WHERE username = ? AND key = ?').bind(userName, key).first();
if (!result) return null;
return {
sourceId: result.source_id as string,
sourceName: result.source_name as string,
bookId: result.book_id as string,
title: result.title as string,
author: (result.author as string) || undefined,
cover: (result.cover as string) || undefined,
format: (result.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (result.detail_href as string) || undefined,
acquisitionHref: (result.acquisition_href as string) || undefined,
progressPercent: result.progress_percent === null || result.progress_percent === undefined ? undefined : Number(result.progress_percent),
lastReadTime: result.last_read_time === null || result.last_read_time === undefined ? undefined : Number(result.last_read_time),
lastLocatorType: (result.last_locator_type as BookShelfItem['lastLocatorType']) || undefined,
lastLocatorValue: (result.last_locator_value as string) || undefined,
lastChapterTitle: (result.last_chapter_title as string) || undefined,
saveTime: Number(result.save_time || 0),
};
} catch (err) {
console.error('D1Storage.getBookShelf error:', err);
throw err;
}
}
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> {
try {
await this.db.prepare(`
INSERT INTO book_shelf (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET
source_id = excluded.source_id,
source_name = excluded.source_name,
book_id = excluded.book_id,
title = excluded.title,
author = excluded.author,
cover = excluded.cover,
format = excluded.format,
detail_href = excluded.detail_href,
acquisition_href = excluded.acquisition_href,
progress_percent = excluded.progress_percent,
last_read_time = excluded.last_read_time,
last_locator_type = excluded.last_locator_type,
last_locator_value = excluded.last_locator_value,
last_chapter_title = excluded.last_chapter_title,
save_time = excluded.save_time
`).bind(
userName, key, item.sourceId, item.sourceName, item.bookId, item.title, item.author || null,
item.cover || null, item.format || null, item.detailHref || null, item.acquisitionHref || null, item.progressPercent ?? null,
item.lastReadTime ?? null, item.lastLocatorType || null, item.lastLocatorValue || null,
item.lastChapterTitle || null, item.saveTime
).run();
} catch (err) {
console.error('D1Storage.setBookShelf error:', err);
throw err;
}
}
async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> {
try {
const results = await this.db.prepare('SELECT * FROM book_shelf WHERE username = ? ORDER BY COALESCE(last_read_time, save_time) DESC').bind(userName).all();
const shelves: { [key: string]: BookShelfItem } = {};
if (!results.results) return shelves;
for (const row of results.results) {
shelves[row.key as string] = {
sourceId: row.source_id as string,
sourceName: row.source_name as string,
bookId: row.book_id as string,
title: row.title as string,
author: (row.author as string) || undefined,
cover: (row.cover as string) || undefined,
format: (row.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (row.detail_href as string) || undefined,
acquisitionHref: (row.acquisition_href as string) || undefined,
progressPercent: row.progress_percent === null || row.progress_percent === undefined ? undefined : Number(row.progress_percent),
lastReadTime: row.last_read_time === null || row.last_read_time === undefined ? undefined : Number(row.last_read_time),
lastLocatorType: (row.last_locator_type as BookShelfItem['lastLocatorType']) || undefined,
lastLocatorValue: (row.last_locator_value as string) || undefined,
lastChapterTitle: (row.last_chapter_title as string) || undefined,
saveTime: Number(row.save_time || 0),
};
}
return shelves;
} catch (err) {
console.error('D1Storage.getAllBookShelf error:', err);
throw err;
}
}
async deleteBookShelf(userName: string, key: string): Promise<void> {
try {
await this.db.prepare('DELETE FROM book_shelf WHERE username = ? AND key = ?').bind(userName, key).run();
} catch (err) {
console.error('D1Storage.deleteBookShelf error:', err);
throw err;
}
}
// ==================== 电子书阅读历史 ====================
async getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null> {
try {
const result = await this.db.prepare('SELECT * FROM book_read_records WHERE username = ? AND key = ?').bind(userName, key).first();
if (!result) return null;
return {
sourceId: result.source_id as string,
sourceName: result.source_name as string,
bookId: result.book_id as string,
title: result.title as string,
author: (result.author as string) || undefined,
cover: (result.cover as string) || undefined,
format: result.format as 'epub' | 'pdf',
detailHref: (result.detail_href as string) || undefined,
acquisitionHref: (result.acquisition_href as string) || undefined,
locator: {
type: result.locator_type as BookReadRecord['locator']['type'],
value: result.locator_value as string,
href: (result.chapter_href as string) || undefined,
chapterTitle: (result.chapter_title as string) || undefined,
},
progressPercent: Number(result.progress_percent || 0),
chapterTitle: (result.chapter_title as string) || undefined,
chapterHref: (result.chapter_href as string) || undefined,
saveTime: Number(result.save_time || 0),
};
} catch (err) {
console.error('D1Storage.getBookReadRecord error:', err);
throw err;
}
}
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> {
try {
await this.db.prepare(`
INSERT INTO book_read_records (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET
source_id = excluded.source_id,
source_name = excluded.source_name,
book_id = excluded.book_id,
title = excluded.title,
author = excluded.author,
cover = excluded.cover,
format = excluded.format,
detail_href = excluded.detail_href,
acquisition_href = excluded.acquisition_href,
locator_type = excluded.locator_type,
locator_value = excluded.locator_value,
chapter_title = excluded.chapter_title,
chapter_href = excluded.chapter_href,
progress_percent = excluded.progress_percent,
save_time = excluded.save_time
`).bind(
userName, key, record.sourceId, record.sourceName, record.bookId, record.title, record.author || null,
record.cover || null, record.format, record.detailHref || null, record.acquisitionHref || null, record.locator.type, record.locator.value,
record.chapterTitle || record.locator.chapterTitle || null, record.chapterHref || record.locator.href || null,
record.progressPercent, record.saveTime
).run();
} catch (err) {
console.error('D1Storage.setBookReadRecord error:', err);
throw err;
}
}
async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> {
try {
const results = await this.db.prepare('SELECT * FROM book_read_records WHERE username = ? ORDER BY save_time DESC').bind(userName).all();
const records: { [key: string]: BookReadRecord } = {};
if (!results.results) return records;
for (const row of results.results) {
records[row.key as string] = {
sourceId: row.source_id as string,
sourceName: row.source_name as string,
bookId: row.book_id as string,
title: row.title as string,
author: (row.author as string) || undefined,
cover: (row.cover as string) || undefined,
format: row.format as 'epub' | 'pdf',
detailHref: (row.detail_href as string) || undefined,
acquisitionHref: (row.acquisition_href as string) || undefined,
locator: {
type: row.locator_type as BookReadRecord['locator']['type'],
value: row.locator_value as string,
href: (row.chapter_href as string) || undefined,
chapterTitle: (row.chapter_title as string) || undefined,
},
progressPercent: Number(row.progress_percent || 0),
chapterTitle: (row.chapter_title as string) || undefined,
chapterHref: (row.chapter_href as string) || undefined,
saveTime: Number(row.save_time || 0),
};
}
return records;
} catch (err) {
console.error('D1Storage.getAllBookReadRecords error:', err);
throw err;
}
}
async deleteBookReadRecord(userName: string, key: string): Promise<void> {
try {
await this.db.prepare('DELETE FROM book_read_records WHERE username = ? AND key = ?').bind(userName, key).run();
} catch (err) {
console.error('D1Storage.deleteBookReadRecord error:', err);
throw err;
}
}
async cleanupOldBookReadRecords(userName: string): Promise<void> {
try {
const maxRecords = parseInt(process.env.MAX_BOOK_HISTORY_PER_USER || '100', 10);
const threshold = maxRecords + 10;
const countResult = await this.db.prepare('SELECT COUNT(*) as count FROM book_read_records WHERE username = ?').bind(userName).first();
const count = Number(countResult?.count || 0);
if (count <= threshold) return;
await this.db.prepare(`
DELETE FROM book_read_records
WHERE username = ?
AND key NOT IN (
SELECT key FROM book_read_records
WHERE username = ?
ORDER BY save_time DESC
LIMIT ?
)
`).bind(userName, userName, maxRecords).run();
} catch (err) {
console.error('D1Storage.cleanupOldBookReadRecords error:', err);
throw err;
}
}
// ==================== 跳过配置 ====================
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
@@ -2373,7 +2616,7 @@ export class D1Storage implements IStorage {
requested_by, request_count, status, created_at, updated_at,
fulfilled_at, fulfilled_source, fulfilled_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
.bind(
request.id,
@@ -2558,6 +2801,8 @@ export class D1Storage implements IStorage {
'search_history',
'manga_shelf',
'manga_read_records',
'book_shelf',
'book_read_records',
'skip_configs',
'music_play_records',
'music_playlists',
+1 -1
View File
@@ -631,7 +631,7 @@ if (typeof window !== 'undefined') {
/**
* 通用的 fetch 函数,处理 401 状态码自动跳转登录
*/
async function fetchWithAuth(
export async function fetchWithAuth(
url: string,
options?: RequestInit
): Promise<Response> {
+35
View File
@@ -4,6 +4,7 @@ import { AdminConfig } from './admin.types';
import { MusicPlayRecord } from './db.client';
import { KvrocksStorage } from './kvrocks.db';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
import { RedisStorage } from './redis.db';
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
@@ -761,6 +762,40 @@ export class DbManager {
await this.storage.deleteMangaReadRecord(userName, generateStorageKey(sourceId, mangaId));
}
// ---------- 电子书书架 ----------
async getBookShelf(userName: string, sourceId: string, bookId: string): Promise<BookShelfItem | null> {
return this.storage.getBookShelf(userName, generateStorageKey(sourceId, bookId));
}
async saveBookShelf(userName: string, sourceId: string, bookId: string, item: BookShelfItem): Promise<void> {
await this.storage.setBookShelf(userName, generateStorageKey(sourceId, bookId), item);
}
async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> {
return this.storage.getAllBookShelf(userName);
}
async deleteBookShelf(userName: string, sourceId: string, bookId: string): Promise<void> {
await this.storage.deleteBookShelf(userName, generateStorageKey(sourceId, bookId));
}
// ---------- 电子书阅读历史 ----------
async getBookReadRecord(userName: string, sourceId: string, bookId: string): Promise<BookReadRecord | null> {
return this.storage.getBookReadRecord(userName, generateStorageKey(sourceId, bookId));
}
async saveBookReadRecord(userName: string, sourceId: string, bookId: string, record: BookReadRecord): Promise<void> {
await this.storage.setBookReadRecord(userName, generateStorageKey(sourceId, bookId), record);
}
async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> {
return this.storage.getAllBookReadRecords(userName);
}
async deleteBookReadRecord(userName: string, sourceId: string, bookId: string): Promise<void> {
await this.storage.deleteBookReadRecord(userName, generateStorageKey(sourceId, bookId));
}
// 获取全部用户名
async getAllUsers(): Promise<string[]> {
if (typeof (this.storage as any).getAllUsers === 'function') {
+1
View File
@@ -12,6 +12,7 @@ export const FEATURE_PERMISSION_OPTIONS = [
{ key: 'web_live', label: '网络直播', description: '网络直播观看' },
{ key: 'music', label: '音乐', description: '音乐视听功能' },
{ key: 'manga', label: '漫画展馆', description: '漫画搜索、阅读与书架' },
{ key: 'books', label: '电子书馆', description: 'OPDS 电子书浏览、阅读与书架' },
] as const;
export type FeaturePermissionKey = (typeof FEATURE_PERMISSION_OPTIONS)[number]['key'];
+492
View File
@@ -0,0 +1,492 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { parseStringPromise } from 'xml2js';
import { getConfig } from './config';
import {
BookAcquisitionLink,
BookCatalogResult,
BookDetail,
BookListItem,
BookSearchFailure,
BookSearchResult,
BookSource,
BookSourceCapabilities,
} from './book.types';
interface ResolvedOPDSConfig {
enabled: boolean;
sources: BookSource[];
cacheTTL: number;
}
interface ParsedFeedLink {
href: string;
rel?: string;
type?: string;
title?: string;
}
interface ParsedFeedEntry {
id: string;
title: string;
author?: string;
summary?: string;
content?: string;
language?: string;
published?: string;
updated?: string;
categories: string[];
links: ParsedFeedLink[];
}
interface ParsedFeed {
title: string;
subtitle?: string;
id?: string;
links: ParsedFeedLink[];
entries: ParsedFeedEntry[];
}
const DEFAULT_TIMEOUT_MS = Number(process.env.OPDS_TIMEOUT_MS || 20000);
const feedCache = new Map<string, { expiresAt: number; data: ParsedFeed }>();
const sourceCapabilityCache = new Map<string, { expiresAt: number; data: BookSourceCapabilities }>();
function asArray<T>(value: T | T[] | undefined | null): T[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function textValue(value: any): string {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number') return String(value);
if (value && typeof value._ === 'string') return value._.trim();
return '';
}
function normalizeUrl(base: string, href?: string): string {
if (!href) return base;
return new URL(href, base).toString();
}
function buildProxyUrl(sourceId: string, href: string): string {
return `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(href)}`;
}
function mapFormat(type: string): 'epub' | 'pdf' | null {
const lower = type.toLowerCase();
if (lower.includes('epub')) return 'epub';
if (lower.includes('pdf')) return 'pdf';
return null;
}
function isAcquisitionRel(rel?: string): boolean {
return !!rel && rel.includes('opds-spec.org/acquisition');
}
function isNavigationRel(rel?: string): boolean {
return rel === 'subsection' || rel === 'collection' || rel === 'start';
}
function isNavigationLink(link: ParsedFeedLink): boolean {
const type = (link.type || '').toLowerCase();
return isNavigationRel(link.rel) || type.includes('kind=navigation') || (type.includes('opds-catalog') && !isAcquisitionRel(link.rel));
}
function pickCoverLink(links: ParsedFeedLink[]): string | undefined {
const cover = links.find((link) => link.rel?.includes('image/thumbnail'))
|| links.find((link) => link.rel?.includes('image'));
return cover?.href;
}
function pickDetailHref(links: ParsedFeedLink[]): string | undefined {
const preferred = links.find((link) => link.rel === 'alternate' && (link.type || '').includes('atom+xml'))
|| links.find((link) => link.rel === 'self' && (link.type || '').includes('atom+xml'))
|| links.find((link) => isNavigationLink(link));
return preferred?.href;
}
function extractAcquisitionLinks(entry: ParsedFeedEntry): BookAcquisitionLink[] {
return entry.links
.filter((link) => isAcquisitionRel(link.rel) || mapFormat(link.type || '') !== null)
.map((link) => ({
rel: link.rel || 'http://opds-spec.org/acquisition',
type: link.type || 'application/octet-stream',
href: link.href,
title: link.title,
isIndirect: !!link.rel?.includes('indirect'),
}));
}
function isLikelyNavigationEntry(entry: ParsedFeedEntry): boolean {
const hasAcquisition = extractAcquisitionLinks(entry).length > 0;
const hasNavigationLink = entry.links.some((link) => isNavigationLink(link));
return hasNavigationLink && !hasAcquisition;
}
function mapEntryToItem(source: BookSource, entry: ParsedFeedEntry): BookListItem {
const acquisitionLinks = extractAcquisitionLinks(entry);
return {
id: entry.id || pickDetailHref(entry.links) || acquisitionLinks[0]?.href || entry.title,
sourceId: source.id,
sourceName: source.name,
title: entry.title || '未命名电子书',
author: entry.author,
cover: (() => { const coverHref = pickCoverLink(entry.links); return coverHref ? buildProxyUrl(source.id, coverHref) : undefined; })(),
summary: entry.summary || entry.content || undefined,
language: entry.language,
published: entry.published,
updated: entry.updated,
tags: entry.categories,
detailHref: pickDetailHref(entry.links),
acquisitionLinks,
};
}
function mapEntryToDetail(source: BookSource, entry: ParsedFeedEntry): BookDetail {
const item = mapEntryToItem(source, entry);
return {
...item,
categories: entry.categories,
navigation: entry.links
.filter((link) => isNavigationRel(link.rel))
.map((link) => ({ title: link.title || entry.title, href: link.href, rel: link.rel, type: link.type })),
};
}
async function resolveOPDSConfig(): Promise<ResolvedOPDSConfig> {
let enabled = process.env.OPDS_ENABLED === 'true';
let sources: BookSource[] = [];
const cacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
const envJson = process.env.OPDS_SOURCES_JSON;
if (envJson) {
try {
sources = JSON.parse(envJson) as BookSource[];
} catch {
// ignore invalid json
}
} else if (process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL) {
sources = [{
id: 'default',
name: process.env.OPDS_NAME || '默认书源',
url: process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL || '',
authMode: (process.env.OPDS_AUTH_MODE as BookSource['authMode']) || 'none',
username: process.env.OPDS_USERNAME || '',
password: process.env.OPDS_PASSWORD || '',
headerName: process.env.OPDS_HEADER_NAME || '',
headerValue: process.env.OPDS_HEADER_VALUE || '',
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
enabled: true,
}];
}
try {
const config = await getConfig();
if (config.OPDSConfig) {
enabled = config.OPDSConfig.Enabled ?? enabled;
if (Array.isArray(config.OPDSConfig.Sources) && config.OPDSConfig.Sources.length > 0) {
sources = config.OPDSConfig.Sources as BookSource[];
}
}
} catch {
// ignore and fallback to env
}
return {
enabled,
cacheTTL,
sources: (sources || []).filter((source) => !!source?.url && source.enabled !== false),
};
}
function buildHeaders(source: BookSource): HeadersInit {
if (source.authMode === 'basic' && source.username) {
return {
Authorization: `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`,
};
}
if (source.authMode === 'header' && source.headerName && source.headerValue) {
return {
[source.headerName]: source.headerValue,
};
}
return {};
}
async function fetchText(url: string, headers: HeadersInit): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers,
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) throw new Error(`请求失败: ${response.status}`);
return await response.text();
} finally {
clearTimeout(timeout);
}
}
function parseLinks(value: any[], baseUrl: string): ParsedFeedLink[] {
return value.map((item) => ({
href: normalizeUrl(baseUrl, item?.$?.href),
rel: item?.$?.rel,
type: item?.$?.type,
title: item?.$?.title,
})).filter((item) => !!item.href);
}
function parseEntries(value: any[], baseUrl: string): ParsedFeedEntry[] {
return value.map((entry) => ({
id: textValue(entry.id?.[0] || entry.id),
title: textValue(entry.title?.[0] || entry.title),
author: textValue(entry.author?.[0]?.name?.[0] || entry.author?.[0]?.name || entry.author?.name),
summary: textValue(entry.summary?.[0] || entry.summary),
content: textValue(entry.content?.[0] || entry.content),
language: textValue(entry.language?.[0] || entry['dc:language']?.[0]),
published: textValue(entry.published?.[0] || entry['dc:issued']?.[0]),
updated: textValue(entry.updated?.[0]),
categories: asArray(entry.category).map((item) => item?.$?.label || item?.$?.term).filter(Boolean),
links: parseLinks(asArray(entry.link), baseUrl),
}));
}
async function parseFeed(xml: string, baseUrl: string): Promise<ParsedFeed> {
const parsed = await parseStringPromise(xml, { explicitArray: true, trim: true });
const feed = parsed.feed || parsed.entry;
if (!feed) throw new Error('无法解析 OPDS feed');
const feedNode = parsed.feed ? feed : { entry: [feed] };
return {
title: textValue(feedNode.title?.[0] || '电子书目录'),
subtitle: textValue(feedNode.subtitle?.[0] || ''),
id: textValue(feedNode.id?.[0] || ''),
links: parseLinks(asArray(feedNode.link), baseUrl),
entries: parseEntries(asArray(feedNode.entry), baseUrl),
};
}
async function getFeed(source: BookSource, href?: string): Promise<ParsedFeed> {
const target = normalizeUrl(source.url, href || source.url);
const cacheKey = `${source.id}|${target}`;
const cached = feedCache.get(cacheKey);
const { cacheTTL } = await resolveOPDSConfig();
if (cached && cached.expiresAt > Date.now()) return cached.data;
const xml = await fetchText(target, buildHeaders(source));
const data = await parseFeed(xml, target);
feedCache.set(cacheKey, { data, expiresAt: Date.now() + cacheTTL });
return data;
}
async function getSourceById(sourceId: string): Promise<BookSource> {
const config = await resolveOPDSConfig();
const source = config.sources.find((item) => item.id === sourceId);
if (!source) throw new Error('未找到对应的 OPDS 书源');
return source;
}
async function detectCapabilities(source: BookSource): Promise<BookSourceCapabilities> {
const cached = sourceCapabilityCache.get(source.id);
const { cacheTTL } = await resolveOPDSConfig();
if (cached && cached.expiresAt > Date.now()) return cached.data;
try {
const feed = await getFeed(source);
const searchLink = feed.links.find((link) => link.rel === 'search');
const navigationEntries = feed.entries.filter((entry) => isLikelyNavigationEntry(entry));
const bookEntries = feed.entries.filter((entry) => !isLikelyNavigationEntry(entry));
const acquisitionTypes = Array.from(new Set(bookEntries.flatMap((entry) => entry.links
.map((link) => mapFormat(link.type || ''))
.filter(Boolean) as string[])));
const navigationCount = feed.links.filter((link) => isNavigationRel(link.rel)).length + navigationEntries.length;
const entryCount = bookEntries.length;
const data: BookSourceCapabilities = {
searchSupported: !!searchLink || !!source.searchTemplate,
catalogSupported: navigationCount > 0 || entryCount > 0,
searchMode: searchLink ? 'opds' : source.searchTemplate ? 'template' : 'disabled',
catalogMode: navigationCount > 0 ? 'navigation' : entryCount > 0 ? 'flat' : 'disabled',
acquisitionTypes,
lastCheckedAt: Date.now(),
};
sourceCapabilityCache.set(source.id, { data, expiresAt: Date.now() + cacheTTL });
return data;
} catch (error) {
const data: BookSourceCapabilities = {
searchSupported: !!source.searchTemplate,
catalogSupported: false,
searchMode: source.searchTemplate ? 'template' : 'disabled',
catalogMode: 'disabled',
acquisitionTypes: [],
lastCheckedAt: Date.now(),
lastError: (error as Error).message,
};
sourceCapabilityCache.set(source.id, { data, expiresAt: Date.now() + cacheTTL / 2 });
return data;
}
}
export async function getOPDSConfig() {
return resolveOPDSConfig();
}
export class OPDSClient {
async getSources(): Promise<BookSource[]> {
const config = await resolveOPDSConfig();
if (!config.enabled) return [];
const withCapabilities = await Promise.all(config.sources.map(async (source) => ({
...source,
capabilities: await detectCapabilities(source),
})));
return withCapabilities;
}
async getCatalog(sourceId: string, href?: string): Promise<BookCatalogResult> {
const source = await getSourceById(sourceId);
return this.getCatalogFromSource(source, href);
}
async getCatalogFromSource(source: BookSource, href?: string): Promise<BookCatalogResult & { searchHref?: string }> {
const feed = await getFeed(source, href);
const navigationEntries = feed.entries.filter((entry) => isLikelyNavigationEntry(entry));
const bookEntries = feed.entries.filter((entry) => !isLikelyNavigationEntry(entry));
return {
sourceId: source.id,
sourceName: source.name,
title: feed.title,
subtitle: feed.subtitle,
href: normalizeUrl(source.url, href || source.url),
entries: bookEntries.map((entry) => mapEntryToItem(source, entry)),
navigation: [
...feed.links.filter((link) => isNavigationLink(link)).map((link) => ({
title: link.title || '目录',
href: link.href,
rel: link.rel,
type: link.type,
})),
...navigationEntries
.map((entry) => ({
title: entry.title,
href: pickDetailHref(entry.links) || entry.links.find((link) => isNavigationLink(link))?.href || '',
rel: entry.links.find((link) => isNavigationLink(link))?.rel,
type: entry.links.find((link) => isNavigationLink(link))?.type,
}))
.filter((item) => !!item.href),
],
nextHref: feed.links.find((link) => link.rel === 'next')?.href,
previousHref: feed.links.find((link) => link.rel === 'previous')?.href,
searchHref: feed.links.find((link) => link.rel === 'search')?.href,
};
}
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
const sources = sourceId ? [await getSourceById(sourceId)] : await this.getSources();
const results: BookListItem[] = [];
const failedSources: BookSearchFailure[] = [];
await Promise.all(sources.map(async (source) => {
try {
const capabilities = source.capabilities || await detectCapabilities(source);
if (!capabilities.searchSupported) {
failedSources.push({ sourceId: source.id, sourceName: source.name, error: '该书源不支持搜索' });
return;
}
let targetUrl = '';
if (capabilities.searchMode === 'opds') {
const rootFeed = await getFeed(source);
const searchLink = rootFeed.links.find((link) => link.rel === 'search');
if (!searchLink?.href) throw new Error('未找到 search link');
targetUrl = searchLink.href.includes('{searchTerms}')
? searchLink.href.replace('{searchTerms}', encodeURIComponent(q))
: `${searchLink.href}${searchLink.href.includes('?') ? '&' : '?'}q=${encodeURIComponent(q)}`;
} else if (source.searchTemplate) {
targetUrl = source.searchTemplate.replace('{searchTerms}', encodeURIComponent(q));
}
if (!targetUrl) throw new Error('未配置可用的搜索地址');
const feed = await getFeed(source, targetUrl);
results.push(...feed.entries.map((entry) => mapEntryToItem(source, entry)));
} catch (error) {
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
}
}));
return { results, failedSources };
}
async getBookDetail(sourceId: string, href: string, fallback?: Partial<BookDetail>): Promise<BookDetail> {
const source = await getSourceById(sourceId);
if (!href) {
if (!fallback?.title) throw new Error('缺少详情链接');
return {
id: fallback.id || `${sourceId}:${fallback.title}`,
sourceId,
sourceName: source.name,
title: fallback.title,
author: fallback.author,
cover: fallback.cover,
summary: fallback.summary,
acquisitionLinks: fallback.acquisitionLinks || [],
detailHref: fallback.detailHref,
tags: fallback.tags,
categories: fallback.categories,
navigation: fallback.navigation || [],
} as BookDetail;
}
const feed = await getFeed(source, href);
const entry = feed.entries[0];
if (!entry) {
if (fallback?.title) {
return {
id: fallback.id || href,
sourceId,
sourceName: source.name,
title: fallback.title,
author: fallback.author,
cover: fallback.cover,
summary: fallback.summary,
acquisitionLinks: fallback.acquisitionLinks || [],
detailHref: href,
tags: fallback.tags,
categories: fallback.categories,
navigation: fallback.navigation || [],
} as BookDetail;
}
throw new Error('详情页没有可用书籍条目');
}
const detail = mapEntryToDetail(source, entry);
return {
...detail,
detailHref: href,
summary: detail.summary || feed.subtitle || fallback?.summary,
acquisitionLinks: detail.acquisitionLinks.length > 0 ? detail.acquisitionLinks : fallback?.acquisitionLinks || [],
cover: detail.cover || fallback?.cover,
};
}
async getPreferredAcquisition(sourceId: string, href: string): Promise<{ format: 'epub' | 'pdf'; href: string }> {
const detail = await this.getBookDetail(sourceId, href);
const preferred = detail.acquisitionLinks
.map((item) => ({ ...item, format: mapFormat(item.type || '') }))
.find((item) => item.format === 'epub' || item.format === 'pdf');
if (!preferred?.format) {
throw new Error('当前书籍没有可在线阅读的 EPUB/PDF 资源');
}
return { format: preferred.format, href: preferred.href };
}
async getSourceById(sourceId: string): Promise<BookSource> {
return getSourceById(sourceId);
}
}
export const opdsClient = new OPDSClient();
export { buildProxyUrl };
+272 -2
View File
@@ -19,6 +19,7 @@ import {
} from './types';
import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types';
import { DatabaseAdapter } from './d1-adapter';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
@@ -1439,7 +1440,7 @@ export class PostgresStorage implements IStorage {
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT(username, song_id) DO UPDATE SET
source = EXCLUDED.source,
songmid = EXCLUDED.songmid,
@@ -2065,6 +2066,273 @@ export class PostgresStorage implements IStorage {
}
}
// ==================== 电子书书架 ====================
async getBookShelf(userName: string, key: string): Promise<BookShelfItem | null> {
try {
const result = await this.db
.prepare('SELECT * FROM book_shelf WHERE username = $1 AND key = $2')
.bind(userName, key)
.first();
if (!result) return null;
return {
sourceId: result.source_id as string,
sourceName: result.source_name as string,
bookId: result.book_id as string,
title: result.title as string,
author: (result.author as string) || undefined,
cover: (result.cover as string) || undefined,
format: (result.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (result.detail_href as string) || undefined,
acquisitionHref: (result.acquisition_href as string) || undefined,
progressPercent: result.progress_percent === null || result.progress_percent === undefined ? undefined : Number(result.progress_percent),
lastReadTime: result.last_read_time === null || result.last_read_time === undefined ? undefined : Number(result.last_read_time),
lastLocatorType: (result.last_locator_type as BookShelfItem['lastLocatorType']) || undefined,
lastLocatorValue: (result.last_locator_value as string) || undefined,
lastChapterTitle: (result.last_chapter_title as string) || undefined,
saveTime: Number(result.save_time || 0),
};
} catch (err) {
console.error('PostgresStorage.getBookShelf error:', err);
throw err;
}
}
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> {
try {
await this.db
.prepare(`
INSERT INTO book_shelf (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (username, key) DO UPDATE SET
source_id = EXCLUDED.source_id,
source_name = EXCLUDED.source_name,
book_id = EXCLUDED.book_id,
title = EXCLUDED.title,
author = EXCLUDED.author,
cover = EXCLUDED.cover,
format = EXCLUDED.format,
detail_href = EXCLUDED.detail_href,
acquisition_href = EXCLUDED.acquisition_href,
progress_percent = EXCLUDED.progress_percent,
last_read_time = EXCLUDED.last_read_time,
last_locator_type = EXCLUDED.last_locator_type,
last_locator_value = EXCLUDED.last_locator_value,
last_chapter_title = EXCLUDED.last_chapter_title,
save_time = EXCLUDED.save_time
`)
.bind(
userName, key, item.sourceId, item.sourceName, item.bookId, item.title, item.author || null,
item.cover || null, item.format || null, item.detailHref || null, item.acquisitionHref || null, item.progressPercent ?? null,
item.lastReadTime ?? null, item.lastLocatorType || null, item.lastLocatorValue || null,
item.lastChapterTitle || null, item.saveTime
)
.run();
} catch (err) {
console.error('PostgresStorage.setBookShelf error:', err);
throw err;
}
}
async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> {
try {
const results = await this.db
.prepare('SELECT * FROM book_shelf WHERE username = $1 ORDER BY COALESCE(last_read_time, save_time) DESC')
.bind(userName)
.all();
const shelves: { [key: string]: BookShelfItem } = {};
if (!results.results) return shelves;
for (const row of results.results) {
shelves[row.key as string] = {
sourceId: row.source_id as string,
sourceName: row.source_name as string,
bookId: row.book_id as string,
title: row.title as string,
author: (row.author as string) || undefined,
cover: (row.cover as string) || undefined,
format: (row.format as 'epub' | 'pdf' | null) || undefined,
detailHref: (row.detail_href as string) || undefined,
acquisitionHref: (row.acquisition_href as string) || undefined,
progressPercent: row.progress_percent === null || row.progress_percent === undefined ? undefined : Number(row.progress_percent),
lastReadTime: row.last_read_time === null || row.last_read_time === undefined ? undefined : Number(row.last_read_time),
lastLocatorType: (row.last_locator_type as BookShelfItem['lastLocatorType']) || undefined,
lastLocatorValue: (row.last_locator_value as string) || undefined,
lastChapterTitle: (row.last_chapter_title as string) || undefined,
saveTime: Number(row.save_time || 0),
};
}
return shelves;
} catch (err) {
console.error('PostgresStorage.getAllBookShelf error:', err);
throw err;
}
}
async deleteBookShelf(userName: string, key: string): Promise<void> {
try {
await this.db.prepare('DELETE FROM book_shelf WHERE username = $1 AND key = $2').bind(userName, key).run();
} catch (err) {
console.error('PostgresStorage.deleteBookShelf error:', err);
throw err;
}
}
// ==================== 电子书阅读历史 ====================
async getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null> {
try {
const result = await this.db
.prepare('SELECT * FROM book_read_records WHERE username = $1 AND key = $2')
.bind(userName, key)
.first();
if (!result) return null;
return {
sourceId: result.source_id as string,
sourceName: result.source_name as string,
bookId: result.book_id as string,
title: result.title as string,
author: (result.author as string) || undefined,
cover: (result.cover as string) || undefined,
format: result.format as 'epub' | 'pdf',
detailHref: (result.detail_href as string) || undefined,
acquisitionHref: (result.acquisition_href as string) || undefined,
locator: {
type: result.locator_type as BookReadRecord['locator']['type'],
value: result.locator_value as string,
href: (result.chapter_href as string) || undefined,
chapterTitle: (result.chapter_title as string) || undefined,
},
progressPercent: Number(result.progress_percent || 0),
chapterTitle: (result.chapter_title as string) || undefined,
chapterHref: (result.chapter_href as string) || undefined,
saveTime: Number(result.save_time || 0),
};
} catch (err) {
console.error('PostgresStorage.getBookReadRecord error:', err);
throw err;
}
}
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> {
try {
await this.db
.prepare(`
INSERT INTO book_read_records (
username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href,
locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (username, key) DO UPDATE SET
source_id = EXCLUDED.source_id,
source_name = EXCLUDED.source_name,
book_id = EXCLUDED.book_id,
title = EXCLUDED.title,
author = EXCLUDED.author,
cover = EXCLUDED.cover,
format = EXCLUDED.format,
detail_href = EXCLUDED.detail_href,
acquisition_href = EXCLUDED.acquisition_href,
locator_type = EXCLUDED.locator_type,
locator_value = EXCLUDED.locator_value,
chapter_title = EXCLUDED.chapter_title,
chapter_href = EXCLUDED.chapter_href,
progress_percent = EXCLUDED.progress_percent,
save_time = EXCLUDED.save_time
`)
.bind(
userName, key, record.sourceId, record.sourceName, record.bookId, record.title, record.author || null,
record.cover || null, record.format, record.detailHref || null, record.acquisitionHref || null, record.locator.type, record.locator.value,
record.chapterTitle || record.locator.chapterTitle || null, record.chapterHref || record.locator.href || null,
record.progressPercent, record.saveTime
)
.run();
} catch (err) {
console.error('PostgresStorage.setBookReadRecord error:', err);
throw err;
}
}
async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> {
try {
const results = await this.db
.prepare('SELECT * FROM book_read_records WHERE username = $1 ORDER BY save_time DESC')
.bind(userName)
.all();
const records: { [key: string]: BookReadRecord } = {};
if (!results.results) return records;
for (const row of results.results) {
records[row.key as string] = {
sourceId: row.source_id as string,
sourceName: row.source_name as string,
bookId: row.book_id as string,
title: row.title as string,
author: (row.author as string) || undefined,
cover: (row.cover as string) || undefined,
format: row.format as 'epub' | 'pdf',
detailHref: (row.detail_href as string) || undefined,
acquisitionHref: (row.acquisition_href as string) || undefined,
locator: {
type: row.locator_type as BookReadRecord['locator']['type'],
value: row.locator_value as string,
href: (row.chapter_href as string) || undefined,
chapterTitle: (row.chapter_title as string) || undefined,
},
progressPercent: Number(row.progress_percent || 0),
chapterTitle: (row.chapter_title as string) || undefined,
chapterHref: (row.chapter_href as string) || undefined,
saveTime: Number(row.save_time || 0),
};
}
return records;
} catch (err) {
console.error('PostgresStorage.getAllBookReadRecords error:', err);
throw err;
}
}
async deleteBookReadRecord(userName: string, key: string): Promise<void> {
try {
await this.db.prepare('DELETE FROM book_read_records WHERE username = $1 AND key = $2').bind(userName, key).run();
} catch (err) {
console.error('PostgresStorage.deleteBookReadRecord error:', err);
throw err;
}
}
async cleanupOldBookReadRecords(userName: string): Promise<void> {
try {
const maxRecords = parseInt(process.env.MAX_BOOK_HISTORY_PER_USER || '100', 10);
const threshold = maxRecords + 10;
const countResult = await this.db
.prepare('SELECT COUNT(*) as count FROM book_read_records WHERE username = $1')
.bind(userName)
.first();
const count = Number(countResult?.count || 0);
if (count <= threshold) return;
await this.db
.prepare(`
DELETE FROM book_read_records
WHERE username = $1
AND key NOT IN (
SELECT key FROM book_read_records
WHERE username = $1
ORDER BY save_time DESC
LIMIT $2
)
`)
.bind(userName, maxRecords)
.run();
} catch (err) {
console.error('PostgresStorage.cleanupOldBookReadRecords error:', err);
throw err;
}
}
// ==================== 跳过配置 ====================
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
@@ -2339,7 +2607,7 @@ export class PostgresStorage implements IStorage {
requested_by, request_count, status, created_at, updated_at,
fulfilled_at, fulfilled_source, fulfilled_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`)
.bind(
request.id,
@@ -2525,6 +2793,8 @@ export class PostgresStorage implements IStorage {
'search_history',
'manga_shelf',
'manga_read_records',
'book_shelf',
'book_read_records',
'skip_configs',
'music_play_records',
'music_playlists',
+71
View File
@@ -4,6 +4,7 @@ import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
import { RedisAdapter } from './redis-adapter';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
@@ -1569,6 +1570,76 @@ export abstract class BaseRedisStorage implements IStorage {
}
}
// ---------- 电子书书架 ----------
private bookShelfHashKey(user: string) {
return `u:${user}:book:shelf`;
}
async getBookShelf(userName: string, key: string): Promise<BookShelfItem | null> {
const val = await this.withRetry(() => this.adapter.hGet(this.bookShelfHashKey(userName), key));
return val ? (JSON.parse(val) as BookShelfItem) : null;
}
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> {
await this.withRetry(() => this.adapter.hSet(this.bookShelfHashKey(userName), key, JSON.stringify(item)));
}
async getAllBookShelf(userName: string): Promise<Record<string, BookShelfItem>> {
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookShelfHashKey(userName)));
const result: Record<string, BookShelfItem> = {};
for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as BookShelfItem;
}
return result;
}
async deleteBookShelf(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.bookShelfHashKey(userName), key));
}
// ---------- 电子书阅读历史 ----------
private bookReadHashKey(user: string) {
return `u:${user}:book:history`;
}
async getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null> {
const val = await this.withRetry(() => this.adapter.hGet(this.bookReadHashKey(userName), key));
return val ? (JSON.parse(val) as BookReadRecord) : null;
}
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> {
await this.withRetry(() => this.adapter.hSet(this.bookReadHashKey(userName), key, JSON.stringify(record)));
}
async getAllBookReadRecords(userName: string): Promise<Record<string, BookReadRecord>> {
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookReadHashKey(userName)));
const result: Record<string, BookReadRecord> = {};
for (const [key, value] of Object.entries(hashData)) {
if (value) result[key] = JSON.parse(value) as BookReadRecord;
}
return result;
}
async deleteBookReadRecord(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.bookReadHashKey(userName), key));
}
async cleanupOldBookReadRecords(userName: string): Promise<void> {
const records = await this.getAllBookReadRecords(userName);
const maxRecords = parseInt(process.env.MAX_BOOK_HISTORY_PER_USER || '100', 10);
const threshold = maxRecords + 10;
if (Object.keys(records).length <= threshold) return;
const keys = Object.entries(records)
.sort(([, a], [, b]) => b.saveTime - a.saveTime)
.slice(maxRecords)
.map(([key]) => key);
if (keys.length > 0) {
await this.withRetry(() => this.adapter.hDel(this.bookReadHashKey(userName), ...keys));
}
}
// ---------- 获取全部用户 ----------
async getAllUsers(): Promise<string[]> {
// 从新版用户列表获取
+14
View File
@@ -1,5 +1,6 @@
import { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types';
// 播放记录数据结构
export interface PlayRecord {
@@ -89,6 +90,19 @@ export interface IStorage {
deleteMangaReadRecord(userName: string, key: string): Promise<void>;
cleanupOldMangaReadRecords?(userName: string): Promise<void>;
// 电子书书架相关
getBookShelf(userName: string, key: string): Promise<BookShelfItem | null>;
setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void>;
getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }>;
deleteBookShelf(userName: string, key: string): Promise<void>;
// 电子书阅读历史相关
getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null>;
setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void>;
getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }>;
deleteBookReadRecord(userName: string, key: string): Promise<void>;
cleanupOldBookReadRecords?(userName: string): Promise<void>;
// 用户列表
getAllUsers(): Promise<string[]>;