legado初步支持

This commit is contained in:
mtvpls
2026-05-19 19:25:56 +08:00
parent b949b6da91
commit 87c3da046b
22 changed files with 1433 additions and 66 deletions
+236 -16
View File
@@ -12217,6 +12217,8 @@ const OPDSConfigComponent = ({
const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000);
const [sources, setSources] = useState<BookSource[]>([]);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [legadoImportText, setLegadoImportText] = useState('');
const [legadoRuleDrafts, setLegadoRuleDrafts] = useState<Record<number, string>>({});
useEffect(() => {
if (config?.OPDSConfig) {
@@ -12226,6 +12228,7 @@ const OPDSConfigComponent = ({
(config.OPDSConfig.Sources || []).map((item, index) => ({
id: item.id || `source_${index + 1}`,
name: item.name || `书源 ${index + 1}`,
type: item.type || 'opds',
url: item.url || '',
enabled: item.enabled !== false,
authMode: item.authMode || 'none',
@@ -12236,9 +12239,11 @@ const OPDSConfigComponent = ({
searchTemplate: item.searchTemplate || '',
preferFormat: item.preferFormat || ['epub', 'pdf'],
language: item.language || '',
legado: item.legado,
}))
);
setEditingIndex(null);
setLegadoRuleDrafts({});
}
}, [config]);
@@ -12264,6 +12269,7 @@ const OPDSConfigComponent = ({
{
id: `source_${prev.length + 1}`,
name: `书源 ${prev.length + 1}`,
type: 'opds',
url: '',
enabled: true,
authMode: 'none',
@@ -12274,13 +12280,76 @@ const OPDSConfigComponent = ({
searchTemplate: '',
preferFormat: ['epub', 'pdf'],
language: '',
legado: undefined,
},
];
});
};
const makeLegadoSourceId = (name: string, url: string, index: number) => {
const raw = `${name}|${url}|${index}`;
let hash = 0;
for (let i = 0; i < raw.length; i += 1) {
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
}
return `legado_${Math.abs(hash).toString(36)}`;
};
const importLegadoSources = () => {
try {
const parsed = JSON.parse(legadoImportText);
const list = Array.isArray(parsed) ? parsed : [parsed];
const imported = list
.filter((item) => item && typeof item === 'object')
.map((rule: any, index) => {
const name = rule.bookSourceName || `Legado 书源 ${index + 1}`;
const url = rule.bookSourceUrl || '';
return {
id: makeLegadoSourceId(name, url, index),
name,
type: 'legado' as const,
url,
enabled: rule.enabled !== false,
authMode: 'none' as const,
username: '',
password: '',
headerName: '',
headerValue: '',
searchTemplate: '',
preferFormat: ['epub' as const],
language: '',
legado: rule,
} satisfies BookSource;
})
.filter((source) => !!source.url);
if (imported.length === 0) {
throw new Error('没有识别到有效 Legado 书源,请确认 JSON 内含 bookSourceUrl');
}
setSources((prev) => {
const existed = new Set(prev.map((item) => `${item.type || 'opds'}|${item.url}|${item.name}`));
const next = imported.filter((item) => !existed.has(`${item.type}|${item.url}|${item.name}`));
return [...prev, ...next];
});
setLegadoImportText('');
showSuccess(`已导入 ${imported.length} 个 Legado 书源`, showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : 'Legado JSON 解析失败', showAlert);
}
};
const removeSource = (index: number) => {
setSources((prev) => prev.filter((_, idx) => idx !== index));
setLegadoRuleDrafts((prev) => {
const next: Record<number, string> = {};
Object.entries(prev).forEach(([key, value]) => {
const numericKey = Number(key);
if (numericKey < index) next[numericKey] = value;
if (numericKey > index) next[numericKey - 1] = value;
});
return next;
});
setEditingIndex((prev) => {
if (prev === null) return prev;
if (prev === index) return null;
@@ -12291,6 +12360,7 @@ const OPDSConfigComponent = ({
const normalizeSource = (source: BookSource, index: number) => ({
id: source.id?.trim() || `source_${index + 1}`,
name: source.name?.trim() || `书源 ${index + 1}`,
type: source.type || 'opds',
url: source.url?.trim() || '',
enabled: source.enabled !== false,
authMode: source.authMode || 'none',
@@ -12299,13 +12369,28 @@ const OPDSConfigComponent = ({
headerName:
source.authMode === 'header' ? source.headerName?.trim() || '' : '',
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
searchTemplate: source.searchTemplate?.trim() || '',
searchTemplate: source.type === 'legado' ? '' : source.searchTemplate?.trim() || '',
preferFormat: source.preferFormat?.length
? source.preferFormat
: ['epub', 'pdf'],
language: source.language?.trim() || '',
legado: source.type === 'legado' ? source.legado : undefined,
});
const updateLegadoRuleJson = (index: number, value: string) => {
setLegadoRuleDrafts((prev) => ({ ...prev, [index]: value }));
try {
const rule = JSON.parse(value);
updateSource(index, {
legado: rule,
name: rule.bookSourceName || sources[index]?.name,
url: rule.bookSourceUrl || sources[index]?.url,
});
} catch {
// 允许用户继续编辑尚未完成的 JSON,保存前需修正为合法 JSON
}
};
const buildConfig = () => ({
Enabled: enabled,
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
@@ -12316,6 +12401,14 @@ const OPDSConfigComponent = ({
await withLoading('saveOPDSConfig', async () => {
try {
if (!config) throw new Error('配置未加载');
for (const [index, draft] of Object.entries(legadoRuleDrafts)) {
if (!draft.trim()) continue;
try {
JSON.parse(draft);
} catch {
throw new Error(`${Number(index) + 1} 个 Legado 书源 JSON 格式不正确`);
}
}
const response = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -12385,10 +12478,10 @@ const OPDSConfigComponent = ({
<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
/ OPDS / Legado
</h3>
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
<p> </p>
<p> OPDS Legado</p>
<p>
</p>
@@ -12449,6 +12542,34 @@ const OPDSConfigComponent = ({
</button>
</div>
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
<div className='mb-2 flex items-center justify-between gap-3'>
<div>
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>
Legado
</h4>
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'>
/Legado JSON
</p>
</div>
<button
type='button'
onClick={importLegadoSources}
disabled={!legadoImportText.trim()}
className={buttonStyles.primarySmall}
>
Legado
</button>
</div>
<textarea
value={legadoImportText}
onChange={(e) => setLegadoImportText(e.target.value)}
placeholder='[{ "bookSourceName": "...", "bookSourceUrl": "...", "searchUrl": "...", "ruleSearch": { ... } }]'
rows={5}
className='w-full rounded-lg border border-amber-200 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100'
/>
</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
@@ -12499,6 +12620,14 @@ const OPDSConfigComponent = ({
</div>
<div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'>
<div className='flex items-start justify-between gap-3'>
<span className='shrink-0 text-gray-500 dark:text-gray-400'>
</span>
<span className='min-w-0 text-right'>
{source.type === 'legado' ? 'Legado' : 'OPDS'}
</span>
</div>
<div className='flex items-start justify-between gap-3'>
<span className='shrink-0 text-gray-500 dark:text-gray-400'>
@@ -12524,7 +12653,11 @@ const OPDSConfigComponent = ({
</span>
<span>
{source.searchTemplate?.trim()
{source.type === 'legado'
? source.legado?.searchUrl
? '已配置'
: '未配置'
: source.searchTemplate?.trim()
? '已配置'
: '未配置'}
</span>
@@ -12585,6 +12718,27 @@ const OPDSConfigComponent = ({
</div>
<div className='grid grid-cols-1 gap-4'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<select
value={source.type || 'opds'}
onChange={(e) => {
const nextType = e.target.value as BookSource['type'];
updateSource(index, {
type: nextType,
legado: nextType === 'legado'
? source.legado || { bookSourceName: source.name, bookSourceUrl: source.url }
: undefined,
});
}}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
>
<option value='opds'>OPDS</option>
<option value='legado'>Legado</option>
</select>
</div>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
ID
@@ -12620,14 +12774,35 @@ const OPDSConfigComponent = ({
<input
type='text'
value={source.url}
onChange={(e) =>
updateSource(index, { url: e.target.value })
}
placeholder='https://example.com/opds'
onChange={(e) => {
const url = e.target.value;
updateSource(index, {
url,
legado: source.type === 'legado' ? { ...(source.legado || {}), bookSourceUrl: url } : source.legado,
});
}}
placeholder={source.type === 'legado' ? 'https://example.com' : 'https://example.com/opds'}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div>
{source.type === 'legado' ? (
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
Legado JSON
</label>
<textarea
value={legadoRuleDrafts[index] ?? JSON.stringify(source.legado || {}, null, 2)}
onChange={(e) => updateLegadoRuleJson(index, e.target.value)}
rows={10}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
JSON/
</p>
</div>
) : (
<>
<div className='grid grid-cols-1 gap-4'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
@@ -12751,6 +12926,8 @@ const OPDSConfigComponent = ({
</div>
</div>
)}
</>
)}
</div>
)}
</div>
@@ -12823,7 +13000,7 @@ const OPDSConfigComponent = ({
{source.name || `书源 ${index + 1}`}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{source.language || '未设置语言'}
{source.type === 'legado' ? 'Legado' : 'OPDS'} · {source.language || '未设置语言'}
</div>
</td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
@@ -12845,7 +13022,11 @@ const OPDSConfigComponent = ({
: '自定义 Header'}
</td>
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
{source.searchTemplate?.trim()
{source.type === 'legado'
? source.legado?.searchUrl
? '已配置'
: '未配置'
: source.searchTemplate?.trim()
? '已配置'
: '未配置'}
</td>
@@ -12933,6 +13114,27 @@ const OPDSConfigComponent = ({
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<select
value={source.type || 'opds'}
onChange={(e) => {
const nextType = e.target.value as BookSource['type'];
updateSource(index, {
type: nextType,
legado: nextType === 'legado'
? source.legado || { bookSourceName: source.name, bookSourceUrl: source.url }
: undefined,
});
}}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
>
<option value='opds'>OPDS</option>
<option value='legado'>Legado</option>
</select>
</div>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
ID
@@ -12972,16 +13174,32 @@ const OPDSConfigComponent = ({
<input
type='text'
value={source.url}
onChange={(e) =>
onChange={(e) => {
const url = e.target.value;
updateSource(index, {
url: e.target.value,
})
}
placeholder='https://example.com/opds'
url,
legado: source.type === 'legado' ? { ...(source.legado || {}), bookSourceUrl: url } : source.legado,
});
}}
placeholder={source.type === 'legado' ? 'https://example.com' : 'https://example.com/opds'}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div>
{source.type === 'legado' ? (
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
Legado JSON
</label>
<textarea
value={legadoRuleDrafts[index] ?? JSON.stringify(source.legado || {}, null, 2)}
onChange={(e) => updateLegadoRuleJson(index, e.target.value)}
rows={12}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
/>
</div>
) : (
<>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
@@ -13109,6 +13327,8 @@ const OPDSConfigComponent = ({
</div>
</div>
)}
</>
)}
</div>
</td>
</tr>
@@ -13130,7 +13350,7 @@ const OPDSConfigComponent = ({
disabled={isLoading('saveOPDSConfig')}
className={buttonStyles.success}
>
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
{isLoading('saveOPDSConfig') ? '保存中...' : '保存电子书源配置'}
</button>
</div>
+7 -1
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { BookSource, BookSourceCapabilities } from '@/lib/book.types';
import { db } from '@/lib/db';
import { legadoClient } from '@/lib/legado.client';
import { opdsClient } from '@/lib/opds.client';
export const runtime = 'nodejs';
@@ -10,6 +11,7 @@ export const runtime = 'nodejs';
interface TestSourceInput {
id?: string;
name?: string;
type?: 'opds' | 'legado';
url?: string;
enabled?: boolean;
authMode?: 'none' | 'basic' | 'header';
@@ -20,6 +22,7 @@ interface TestSourceInput {
searchTemplate?: string;
preferFormat?: Array<'epub' | 'pdf'>;
language?: string;
legado?: BookSource['legado'];
}
async function ensureAdmin(request: NextRequest) {
@@ -39,6 +42,7 @@ async function ensureAdmin(request: NextRequest) {
}
async function detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
if (source.type === 'legado') return legadoClient.detectCapabilitiesFromSource(source);
try {
const result = await opdsClient.getCatalogFromSource(source);
return {
@@ -70,7 +74,7 @@ export async function POST(request: NextRequest) {
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 });
return NextResponse.json({ success: false, message: '请至少填写一个电子书源' }, { status: 400 });
}
const sources: BookSource[] = inputSources
@@ -78,6 +82,7 @@ export async function POST(request: NextRequest) {
.map((item, index) => ({
id: item.id?.trim() || `source_${index + 1}`,
name: item.name?.trim() || `书源 ${index + 1}`,
type: item.type || 'opds',
url: (item.url || '').trim(),
enabled: item.enabled !== false,
authMode: item.authMode || 'none',
@@ -88,6 +93,7 @@ export async function POST(request: NextRequest) {
searchTemplate: item.searchTemplate?.trim() || '',
preferFormat: item.preferFormat || ['epub', 'pdf'],
language: item.language?.trim() || '',
legado: item.legado,
}));
if (sources.length === 0) {
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { bookProvider } from '@/lib/book-provider';
import { getAuthorizedBooksUsername } from '../_utils';
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const result = await opdsClient.getCatalog(sourceId, href);
const result = await bookProvider.getCatalog(sourceId, href);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { bookProvider } from '@/lib/book-provider';
import { getAuthorizedBooksUsername } from '../_utils';
@@ -49,7 +49,7 @@ async function resolveDetail(username: string, payload: DetailPayload) {
}]
: undefined;
const detail = await opdsClient.getBookDetail(sourceId, href, {
const detail = await bookProvider.getBookDetail(sourceId, href, {
id: bookId,
title: payload.title || shelfItem?.title || readRecord?.title || undefined,
author: payload.author || shelfItem?.author || readRecord?.author || undefined,
+8 -6
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { bookProvider } from '@/lib/book-provider';
import { getAuthorizedBooksUsername } from '../_utils';
@@ -11,12 +11,13 @@ type FilePayload = {
sourceId?: string;
bookId?: string;
href?: string;
format?: 'epub' | 'pdf' | null;
format?: 'epub' | 'pdf' | 'chapters' | null;
};
async function resolveFileHref(username: string, payload: FilePayload): Promise<{ sourceId: string; href: string }> {
const sourceId = payload.sourceId?.trim();
if (!sourceId) throw new Error('缺少 sourceId');
if (payload.format === 'chapters') throw new Error('章节型书源不提供文件下载');
if (payload.href?.trim()) {
return { sourceId, href: payload.href.trim() };
@@ -35,9 +36,10 @@ async function resolveFileHref(username: string, payload: FilePayload): Promise<
const detailHref = shelfItem?.detailHref || readRecord?.detailHref;
if (!detailHref) throw new Error('找不到可下载文件');
const preferred = await opdsClient.getPreferredAcquisition(sourceId, detailHref);
const preferred = await bookProvider.getPreferredAcquisition(sourceId, detailHref);
if (preferred.format === 'chapters') throw new Error('章节型书源不提供文件下载');
if (payload.format && preferred.format !== payload.format) {
const detail = await opdsClient.getBookDetail(sourceId, detailHref);
const detail = await bookProvider.getBookDetail(sourceId, detailHref);
const matched = detail.acquisitionLinks.find((item) => (payload.format === 'pdf' ? item.type.toLowerCase().includes('pdf') : item.type.toLowerCase().includes('epub')));
if (!matched?.href) throw new Error('找不到对应格式文件');
return { sourceId, href: matched.href };
@@ -47,7 +49,7 @@ async function resolveFileHref(username: string, payload: FilePayload): Promise<
}
async function proxyFile(request: NextRequest, sourceId: string, href: string) {
const source = await opdsClient.getSourceById(sourceId);
const source = await bookProvider.getSourceById(sourceId);
const headers = new Headers();
if (source.authMode === 'basic' && source.username) {
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
@@ -98,7 +100,7 @@ export async function GET(request: NextRequest) {
sourceId: searchParams.get('sourceId') || undefined,
bookId: searchParams.get('bookId') || undefined,
href: searchParams.get('href') || undefined,
format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | null) || null,
format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | 'chapters' | null) || null,
});
return await proxyFile(request, resolved.sourceId, resolved.href);
} catch (error) {
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { legadoClient } from '@/lib/legado.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();
const tocHref = searchParams.get('tocHref')?.trim() || undefined;
if (!sourceId || !href) return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 });
const chapter = await legadoClient.getChapterContent(sourceId, href, tocHref);
return NextResponse.json(chapter);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { legadoClient } from '@/lib/legado.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 bookId = searchParams.get('bookId')?.trim();
const href = searchParams.get('href')?.trim() || '';
if (!sourceId) return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
const chapters = bookId
? await legadoClient.getChaptersByBookId(sourceId, bookId)
: href
? await legadoClient.getChapters(sourceId, href)
: null;
if (!chapters) return NextResponse.json({ error: '缺少 bookId 或 href,无法定位章节目录' }, { status: 400 });
return NextResponse.json({ chapters }, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+8 -7
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { bookProvider } from '@/lib/book-provider';
import { getAuthorizedBooksUsername } from '../../_utils';
@@ -13,7 +13,7 @@ type ManifestPayload = {
bookId?: string;
href?: string;
acquisitionHref?: string;
format?: 'epub' | 'pdf' | null;
format?: 'epub' | 'pdf' | 'chapters' | null;
title?: string;
author?: string;
cover?: string;
@@ -45,12 +45,12 @@ async function resolveManifest(username: string, payload: ManifestPayload) {
const fallbackAcquisitionLinks: BookAcquisitionLink[] = resolvedAcquisitionHref
? [{
rel: 'http://opds-spec.org/acquisition',
type: resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip',
type: resolvedFormat === 'chapters' ? 'application/x-legado-chapters+json' : resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip',
href: resolvedAcquisitionHref,
}]
: [];
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
const detail = await bookProvider.getBookDetail(sourceId, resolvedHref || '', {
id: bookId || resolvedAcquisitionHref || undefined,
title: payload.title || existingRecord?.title || shelfItem?.title || undefined,
author: payload.author || existingRecord?.author || shelfItem?.author || undefined,
@@ -60,9 +60,9 @@ async function resolveManifest(username: string, payload: ManifestPayload) {
acquisitionLinks: fallbackAcquisitionLinks,
});
const preferred = resolvedHref
? await opdsClient.getPreferredAcquisition(sourceId, resolvedHref)
? await bookProvider.getPreferredAcquisition(sourceId, resolvedHref)
: {
format: resolvedFormat === 'pdf' ? 'pdf' : 'epub',
format: resolvedFormat === 'chapters' ? 'chapters' : resolvedFormat === 'pdf' ? 'pdf' : 'epub',
href: resolvedAcquisitionHref || '',
};
const lastRecord = await db.getBookReadRecord(username, sourceId, detail.id);
@@ -70,7 +70,8 @@ async function resolveManifest(username: string, payload: ManifestPayload) {
return NextResponse.json({
book: detail,
format: preferred.format,
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`,
fileUrl: preferred.format === 'chapters' ? undefined : `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`,
chaptersUrl: preferred.format === 'chapters' ? `/api/books/read/chapters?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}` : undefined,
acquisitionHref: preferred.href,
cacheKey: `${sourceId}::${detail.id}::${preferred.format}`,
coverUrl: detail.cover,
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { bookProvider } from '@/lib/book-provider';
import { getAuthorizedBooksUsername } from '../_utils';
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
if (!q) {
return NextResponse.json({ results: [], failedSources: [] });
}
const result = await opdsClient.searchBooks(q, sourceId);
const result = await bookProvider.searchBooks(q, sourceId);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
+3 -3
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { bookProvider } from '@/lib/book-provider';
import { getAuthorizedBooksUsername } from '../../_utils';
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
};
try {
const sources = await opdsClient.getSearchSources(sourceId);
const sources = await bookProvider.getSearchSources(sourceId);
let completedSources = 0;
let totalResults = 0;
const failedSources: Array<{ sourceId: string; sourceName: string; error: string }> = [];
@@ -47,7 +47,7 @@ export async function GET(request: NextRequest) {
await Promise.all(
sources.map(async (source) => {
try {
const result = await opdsClient.searchBooksSource(q, source);
const result = await bookProvider.searchBooksSource(q, source);
completedSources += 1;
totalResults += result.results.length;
send({
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { bookProvider } from '@/lib/book-provider';
import { getAuthorizedBooksUsername } from '../_utils';
@@ -11,7 +11,7 @@ export async function GET(request: NextRequest) {
if (username instanceof NextResponse) return username;
try {
const sources = await opdsClient.getSources();
const sources = await bookProvider.getSources();
return NextResponse.json({ sources });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
+83 -7
View File
@@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react';
import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
import { BookDetail, BookShelfItem } from '@/lib/book.types';
import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types';
function DetailSkeleton() {
return (
@@ -47,7 +47,7 @@ function sanitizeFilename(name: string) {
return name.replace(/[\/:*?"<>|]/g, '_').trim();
}
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string, title?: string) {
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf' | 'chapters', download = false, href?: string, title?: string) {
const response = await fetch('/api/books/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -86,6 +86,9 @@ export default function BookDetailPage() {
const bookId = searchParams.get('bookId') || '';
const [detail, setDetail] = useState<BookDetail | null>(null);
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
const [chapters, setChapters] = useState<BookChapter[]>([]);
const [chaptersLoading, setChaptersLoading] = useState(false);
const [chaptersError, setChaptersError] = useState('');
const [error, setError] = useState('');
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
@@ -122,8 +125,50 @@ export default function BookDetailPage() {
.catch((err) => setError(err.message || '获取详情失败'));
}, [sourceId, bookId, cached]);
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';
const readable = detail?.acquisitionLinks.find((item) => {
const type = item.type.toLowerCase();
return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters';
});
const readableFormat = readable?.type.toLowerCase().includes('pdf')
? 'pdf'
: readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters'
? 'chapters'
: 'epub';
useEffect(() => {
if (!detail || !readable || readableFormat !== 'chapters') {
setChapters([]);
setChaptersError('');
setChaptersLoading(false);
return;
}
let cancelled = false;
setChapters([]);
setChaptersLoading(true);
setChaptersError('');
const params = new URLSearchParams({
sourceId: detail.sourceId,
bookId: detail.id,
});
fetch(`/api/books/read/chapters?${params.toString()}`, { cache: 'no-store' })
.then(async (res) => {
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取章节失败');
if (cancelled) return;
setChapters((json.chapters || []) as BookChapter[]);
})
.catch((err) => {
if (cancelled) return;
setChapters([]);
setChaptersError(err.message || '获取章节失败');
})
.finally(() => {
if (!cancelled) setChaptersLoading(false);
});
return () => {
cancelled = true;
};
}, [detail, readable, readableFormat]);
const toggleShelf = async () => {
if (!detail) return;
@@ -176,7 +221,7 @@ export default function BookDetailPage() {
<div className='flex flex-wrap gap-3'>
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} 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>
{readable ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
{readable && readableFormat !== 'chapters' ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
</div>
</div>
</section>
@@ -184,7 +229,8 @@ export default function BookDetailPage() {
<h2 className='text-lg font-semibold'></h2>
<div className='mt-4 space-y-3'>
{detail.acquisitionLinks.map((item) => {
const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined;
const type = item.type.toLowerCase();
const format = type.includes('pdf') ? 'pdf' : type.includes('epub') ? 'epub' : type.includes('legado-chapters') || item.rel === 'legado:chapters' ? 'chapters' : undefined;
return (
<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>
@@ -193,7 +239,7 @@ export default function BookDetailPage() {
</div>
<button disabled={!format || fileBusy !== ''} onClick={async () => {
if (!format) return;
if (format === 'epub') {
if (format === 'epub' || format === 'chapters') {
cacheBookDetail(detail);
window.location.href = buildBookReadPath(detail.sourceId, detail.id);
return;
@@ -212,6 +258,36 @@ export default function BookDetailPage() {
})}
</div>
</section>
{readableFormat === 'chapters' ? (
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='flex items-center justify-between gap-3'>
<h2 className='text-lg font-semibold'></h2>
<div className='text-sm text-gray-500'>{chaptersLoading ? '加载中...' : `${chapters.length}`}</div>
</div>
{chaptersError ? <div className='mt-4 text-sm text-red-500'>{chaptersError}</div> : null}
{!chaptersLoading && !chaptersError && chapters.length === 0 ? (
<div className='mt-4 rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
EPUB
</div>
) : null}
{chapters.length > 0 ? (
<div className='mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3'>
{chapters.slice(0, 60).map((chapter) => (
<Link
key={`${chapter.href}-${chapter.order}`}
href={buildBookReadPath(detail.sourceId, detail.id, chapter.href)}
onClick={() => cacheBookDetail(detail)}
className='truncate rounded-2xl bg-gray-50 px-4 py-3 text-sm hover:bg-sky-50 hover:text-sky-600 dark:bg-gray-900 dark:hover:bg-sky-950/40'
title={chapter.title}
>
{chapter.title}
</Link>
))}
</div>
) : null}
{chapters.length > 60 ? <div className='mt-3 text-xs text-gray-500'> 60 </div> : null}
</section>
) : null}
</div>
);
}
+3 -2
View File
@@ -45,8 +45,8 @@ export default function BooksHomePage() {
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>
<h1 className='text-lg font-semibold'></h1>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'> OPDS Legado 线</p>
</section>
{loading ? <BooksHomeSkeleton /> : null}
@@ -56,6 +56,7 @@ export default function BooksHomePage() {
{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-1 text-xs text-gray-400'>{source.type === 'legado' ? 'Legado' : 'OPDS'}</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>
+153 -2
View File
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { saveBookReadRecord } from '@/lib/book.db.client';
import { BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
import { BookChapter, BookChapterContent, BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
import {
buildBookCacheKey,
enforceBookCacheLimit,
@@ -370,6 +370,153 @@ async function downloadBookWithProgress(
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
}
function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
const searchParams = useSearchParams();
const initialChapterHref = searchParams.get('chapterHref') || '';
const [chapters, setChapters] = useState<BookChapter[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [chapter, setChapter] = useState<BookChapterContent | null>(null);
const [tocOpen, setTocOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const handleToggleChapters = () => setTocOpen((prev) => !prev);
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
return () => window.removeEventListener('books-read-toggle-chapters', handleToggleChapters);
}, []);
useEffect(() => {
if (!manifest.chaptersUrl && !manifest.acquisitionHref) return;
let cancelled = false;
setChapters([]);
setChapter(null);
setCurrentIndex(0);
setLoading(true);
setError('');
const url = manifest.chaptersUrl || `/api/books/read/chapters?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`;
fetch(url, { cache: 'no-store' })
.then(async (res) => {
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取目录失败');
if (cancelled) return;
const list = (json.chapters || []) as BookChapter[];
setChapters(list);
const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value || '';
const savedIndex = list.findIndex((item) => item.href === savedHref);
setCurrentIndex(savedIndex >= 0 ? savedIndex : 0);
})
.catch((err) => {
if (!cancelled) setError(err.message || '获取目录失败');
});
return () => {
cancelled = true;
};
}, [initialChapterHref, manifest]);
useEffect(() => {
const item = chapters[currentIndex];
if (!item) {
if (chapters.length === 0) setLoading(false);
return;
}
setLoading(true);
setError('');
const params = new URLSearchParams({
sourceId: manifest.book.sourceId,
href: item.href,
});
if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref);
fetch(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' })
.then(async (res) => {
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取章节失败');
setChapter({ ...(json as BookChapterContent), title: (json as BookChapterContent).title || item.title });
const progressPercent = chapters.length > 0 ? Math.round(((currentIndex + 1) / chapters.length) * 100) : 0;
const record: BookReadRecord = {
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: 'chapters',
locator: { type: 'chapter', value: item.href, href: item.href, chapterTitle: item.title },
chapterTitle: item.title,
chapterHref: item.href,
progressPercent,
saveTime: Date.now(),
};
void saveBookReadRecord(record.sourceId, record.bookId, record);
})
.catch((err) => setError(err.message || '获取章节失败'))
.finally(() => setLoading(false));
}, [chapters, currentIndex, manifest]);
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
if (loading && !chapter) return <div className='p-4 text-sm text-gray-500'>...</div>;
if (!chapters.length) {
return (
<div className='mx-auto max-w-2xl p-4'>
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
Legado / EPUB 0
</div>
</div>
);
}
return (
<div className='min-h-[calc(100vh-3.5rem)] bg-gray-50 dark:bg-black'>
{tocOpen && typeof document !== 'undefined' ? createPortal(
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
<div
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] 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='sticky top-0 border-b border-gray-200 bg-white/95 p-4 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
<div className='text-base font-semibold'></div>
<div className='mt-1 text-xs text-gray-500'>{manifest.book.title} · {chapters.length} </div>
</div>
<div className='space-y-2 p-4'>
{chapters.map((item, index) => {
const active = index === currentIndex;
return (
<button
key={`${item.href}-${item.order}-${index}`}
onClick={() => {
setCurrentIndex(index);
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'}`}
title={item.title}
>
<span className='block truncate'>{item.title}</span>
</button>
);
})}
</div>
</div>
</div>,
document.body
) : null}
<div className='mx-auto max-w-3xl px-4 py-6'>
<article className='text-lg leading-9 text-gray-800 dark:text-gray-100'>
{loading ? '加载中...' : chapter?.content?.includes('<img')
? <div className='space-y-2' dangerouslySetInnerHTML={{ __html: chapter.content }} />
: <div className='whitespace-pre-wrap'>{chapter?.content || '本章暂无内容'}</div>}
</article>
<div className='mt-5 flex justify-between gap-3'>
<button disabled={currentIndex <= 0} onClick={() => setCurrentIndex((prev) => Math.max(0, prev - 1))} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'></button>
<button disabled={currentIndex >= chapters.length - 1} onClick={() => setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1))} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'></button>
</div>
</div>
</div>
);
}
function normalizeHrefForMatch(href?: string) {
if (!href) return '';
@@ -1174,7 +1321,7 @@ export default function BookReadPage() {
sourceId: manifest.book.sourceId,
bookId: manifest.book.id,
title: manifest.book.title,
format: manifest.format,
format: 'epub',
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
blob,
size: blob.size,
@@ -1655,6 +1802,10 @@ export default function BookReadPage() {
);
}
if (manifest.format === 'chapters') {
return <ChapterReader manifest={manifest} />;
}
if (manifest.format === 'pdf') {
if (!pdfBlobUrl) return <div className='p-4 text-sm text-gray-500'>PDF ... {progressLabel}</div>;
return <iframe src={pdfBlobUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;