新增电子书架
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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='电视直播源配置'
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import BooksLayout from '@/components/books/BooksLayout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <BooksLayout>{children}</BooksLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'; // 邮件发送方式
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -631,7 +631,7 @@ if (typeof window !== 'undefined') {
|
||||
/**
|
||||
* 通用的 fetch 函数,处理 401 状态码自动跳转登录
|
||||
*/
|
||||
async function fetchWithAuth(
|
||||
export async function fetchWithAuth(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<Response> {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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[]> {
|
||||
// 从新版用户列表获取
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user