From d8d7e57eabdbb87cefc5481fa6a1506c810fdb0e Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 25 Apr 2026 21:38:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=BC=AB=E7=94=BB=E5=B1=95?= =?UTF-8?q?=E9=A6=86=E7=9A=84=E6=90=9C=E7=B4=A2=E9=80=9F=E5=BA=A6=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95=E9=87=8D=E6=96=B0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/manga/search/route.ts | 4 +- src/app/manga/search/page.tsx | 7 +- src/lib/manga.types.ts | 11 +++ src/lib/suwayomi.client.ts | 136 +++++++++++++++++++++--------- 4 files changed, 112 insertions(+), 46 deletions(-) diff --git a/src/app/api/manga/search/route.ts b/src/app/api/manga/search/route.ts index 6e74932..9123934 100644 --- a/src/app/api/manga/search/route.ts +++ b/src/app/api/manga/search/route.ts @@ -20,8 +20,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ results: [] }); } - const results = await suwayomiClient.searchManga(q, sourceId, page); - return NextResponse.json({ results }); + const result = await suwayomiClient.searchManga(q, sourceId, page); + return NextResponse.json(result); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 500 }); } diff --git a/src/app/manga/search/page.tsx b/src/app/manga/search/page.tsx index 8804b7c..8036701 100644 --- a/src/app/manga/search/page.tsx +++ b/src/app/manga/search/page.tsx @@ -108,9 +108,10 @@ export default function MangaSearchPage() { }, []); const performSearch = useCallback( - async (keyword: string, selectedSourceId: string) => { + async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => { const trimmedQuery = keyword.trim(); if (!trimmedQuery) return; + const forceRefresh = options?.forceRefresh === true; setLoading(true); setError(''); @@ -118,7 +119,7 @@ export default function MangaSearchPage() { setLastSearchedQuery(trimmedQuery); setLastSearchedSourceId(selectedSourceId); - const cached = getCachedResults(trimmedQuery, selectedSourceId); + const cached = forceRefresh ? null : getCachedResults(trimmedQuery, selectedSourceId); if (cached) { setResults(cached); saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached }); @@ -187,7 +188,7 @@ export default function MangaSearchPage() { const params = new URLSearchParams({ q: trimmedQuery }); if (sourceId) params.set('sourceId', sourceId); router.replace(`/manga/search?${params.toString()}`); - await performSearch(trimmedQuery, sourceId); + await performSearch(trimmedQuery, sourceId, { forceRefresh: true }); }; const returnTo = useMemo(() => { diff --git a/src/lib/manga.types.ts b/src/lib/manga.types.ts index 7179674..1133fca 100644 --- a/src/lib/manga.types.ts +++ b/src/lib/manga.types.ts @@ -18,6 +18,17 @@ export interface MangaSearchItem { genre?: string; } +export interface MangaSearchFailure { + sourceId: string; + sourceName: string; + error: string; +} + +export interface MangaSearchResult { + results: MangaSearchItem[]; + failedSources: MangaSearchFailure[]; +} + export type MangaRecommendType = 'POPULAR' | 'LATEST'; export interface MangaRecommendResult { diff --git a/src/lib/suwayomi.client.ts b/src/lib/suwayomi.client.ts index 3db98f5..0ad288d 100644 --- a/src/lib/suwayomi.client.ts +++ b/src/lib/suwayomi.client.ts @@ -7,8 +7,10 @@ import { MangaChapter, MangaDetail, MangaRecommendResult, + MangaSearchFailure, MangaRecommendType, MangaSearchItem, + MangaSearchResult, MangaSource, } from './manga.types'; @@ -41,6 +43,7 @@ interface SuwayomiSessionCacheEntry { } const SUWAYOMI_SESSION_TTL_MS = 25 * 60 * 1000; +const DEFAULT_SUWAYOMI_TIMEOUT_MS = Number(process.env.SUWAYOMI_TIMEOUT_MS || 20000); const suwayomiSessionCache = new Map(); function normalizeSuwayomiAuthMode(value?: string | null): 'none' | 'basic_auth' | 'simple_login' { @@ -216,16 +219,26 @@ async function suwayomiFetch( input: string, init: RequestInit = {} ): Promise { + const timeoutMs = DEFAULT_SUWAYOMI_TIMEOUT_MS; + const execute = async (forceSimpleLoginRefresh: boolean) => { const authHeaders = await getSuwayomiRequestHeaders(resolved, forceSimpleLoginRefresh); - return fetch(input, { - ...init, - headers: { - ...(authHeaders || {}), - ...(init.headers || {}), - }, - cache: 'no-store', - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new Error(`Suwayomi 请求超时(${timeoutMs}ms)`)), timeoutMs); + + try { + return await fetch(input, { + ...init, + headers: { + ...(authHeaders || {}), + ...(init.headers || {}), + }, + cache: 'no-store', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } }; let response = await execute(false); @@ -325,11 +338,26 @@ export class SuwayomiClient { })); } - async searchManga(keyword: string, sourceId?: string, page = 1): Promise { + async searchManga(keyword: string, sourceId?: string, page = 1): Promise { const resolved = await resolveSuwayomiConfig(this.options); - const sources = sourceId - ? [{ id: sourceId, displayName: sourceId, name: sourceId }] - : (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources); + let sources: Array<{ id: string; displayName?: string; name?: string }>; + if (sourceId) { + sources = [{ id: sourceId, displayName: sourceId, name: sourceId }]; + } else { + try { + sources = (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources); + } catch (error) { + if (resolved.sourceIds.length === 0) { + throw error; + } + sources = resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({ + id, + displayName: id, + name: id, + })); + } + } + const query = ` mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) { fetchSourceManga(input: $input) { @@ -349,37 +377,60 @@ export class SuwayomiClient { `; const results: MangaSearchItem[] = []; + const failedSources: MangaSearchFailure[] = []; const seen = new Set(); - for (const source of sources) { - const data = await this.graphqlRequest<{ - fetchSourceManga?: { - mangas?: Array<{ - id: string | number; - title?: string; - thumbnailUrl?: string; - sourceId?: string | number; - description?: string; - author?: string; - artist?: string; - genre?: string; - status?: string; - }>; - }; - }>( - query, - { - input: { - type: 'SEARCH', - source: source.id, - query: keyword, - page, - }, - }, - 'GET_SOURCE_MANGAS_FETCH' - ).catch(() => ({ fetchSourceManga: { mangas: [] } })); + const perSourceResults = await Promise.all( + sources.map(async (source) => { + try { + const data = await this.graphqlRequest<{ + fetchSourceManga?: { + mangas?: Array<{ + id: string | number; + title?: string; + thumbnailUrl?: string; + sourceId?: string | number; + description?: string; + author?: string; + artist?: string; + genre?: string; + status?: string; + }>; + }; + }>( + query, + { + input: { + type: 'SEARCH', + source: source.id, + query: keyword, + page, + }, + }, + 'GET_SOURCE_MANGAS_FETCH' + ); - const mangas = data.fetchSourceManga?.mangas || []; + return { + source, + mangas: data.fetchSourceManga?.mangas || [], + }; + } catch (error) { + const message = error instanceof Error ? error.message : '未知错误'; + console.warn(`[Suwayomi] manga search source failed: ${source.id} - ${message}`); + failedSources.push({ + sourceId: String(source.id), + sourceName: source.displayName || source.name || String(source.id), + error: message, + }); + return { + source, + mangas: [], + }; + } + }) + ); + + for (const { source, mangas } of perSourceResults) { for (const manga of mangas) { const key = `${source.id}:${manga.id}`; if (seen.has(key)) continue; @@ -399,7 +450,10 @@ export class SuwayomiClient { } } - return results; + return { + results, + failedSources, + }; } async getRecommendedManga(