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
+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 });