优化漫画展馆的搜索速度,修复无法重新搜索

This commit is contained in:
mtvpls
2026-04-25 21:38:15 +08:00
parent 436b7164a0
commit d8d7e57eab
4 changed files with 112 additions and 46 deletions
+2 -2
View File
@@ -20,8 +20,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ results: [] }); return NextResponse.json({ results: [] });
} }
const results = await suwayomiClient.searchManga(q, sourceId, page); const result = await suwayomiClient.searchManga(q, sourceId, page);
return NextResponse.json({ results }); return NextResponse.json(result);
} catch (error) { } catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 }); return NextResponse.json({ error: (error as Error).message }, { status: 500 });
} }
+4 -3
View File
@@ -108,9 +108,10 @@ export default function MangaSearchPage() {
}, []); }, []);
const performSearch = useCallback( const performSearch = useCallback(
async (keyword: string, selectedSourceId: string) => { async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
const trimmedQuery = keyword.trim(); const trimmedQuery = keyword.trim();
if (!trimmedQuery) return; if (!trimmedQuery) return;
const forceRefresh = options?.forceRefresh === true;
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -118,7 +119,7 @@ export default function MangaSearchPage() {
setLastSearchedQuery(trimmedQuery); setLastSearchedQuery(trimmedQuery);
setLastSearchedSourceId(selectedSourceId); setLastSearchedSourceId(selectedSourceId);
const cached = getCachedResults(trimmedQuery, selectedSourceId); const cached = forceRefresh ? null : getCachedResults(trimmedQuery, selectedSourceId);
if (cached) { if (cached) {
setResults(cached); setResults(cached);
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached }); saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached });
@@ -187,7 +188,7 @@ export default function MangaSearchPage() {
const params = new URLSearchParams({ q: trimmedQuery }); const params = new URLSearchParams({ q: trimmedQuery });
if (sourceId) params.set('sourceId', sourceId); if (sourceId) params.set('sourceId', sourceId);
router.replace(`/manga/search?${params.toString()}`); router.replace(`/manga/search?${params.toString()}`);
await performSearch(trimmedQuery, sourceId); await performSearch(trimmedQuery, sourceId, { forceRefresh: true });
}; };
const returnTo = useMemo(() => { const returnTo = useMemo(() => {
+11
View File
@@ -18,6 +18,17 @@ export interface MangaSearchItem {
genre?: string; genre?: string;
} }
export interface MangaSearchFailure {
sourceId: string;
sourceName: string;
error: string;
}
export interface MangaSearchResult {
results: MangaSearchItem[];
failedSources: MangaSearchFailure[];
}
export type MangaRecommendType = 'POPULAR' | 'LATEST'; export type MangaRecommendType = 'POPULAR' | 'LATEST';
export interface MangaRecommendResult { export interface MangaRecommendResult {
+63 -9
View File
@@ -7,8 +7,10 @@ import {
MangaChapter, MangaChapter,
MangaDetail, MangaDetail,
MangaRecommendResult, MangaRecommendResult,
MangaSearchFailure,
MangaRecommendType, MangaRecommendType,
MangaSearchItem, MangaSearchItem,
MangaSearchResult,
MangaSource, MangaSource,
} from './manga.types'; } from './manga.types';
@@ -41,6 +43,7 @@ interface SuwayomiSessionCacheEntry {
} }
const SUWAYOMI_SESSION_TTL_MS = 25 * 60 * 1000; const SUWAYOMI_SESSION_TTL_MS = 25 * 60 * 1000;
const DEFAULT_SUWAYOMI_TIMEOUT_MS = Number(process.env.SUWAYOMI_TIMEOUT_MS || 20000);
const suwayomiSessionCache = new Map<string, SuwayomiSessionCacheEntry>(); const suwayomiSessionCache = new Map<string, SuwayomiSessionCacheEntry>();
function normalizeSuwayomiAuthMode(value?: string | null): 'none' | 'basic_auth' | 'simple_login' { function normalizeSuwayomiAuthMode(value?: string | null): 'none' | 'basic_auth' | 'simple_login' {
@@ -216,16 +219,26 @@ async function suwayomiFetch(
input: string, input: string,
init: RequestInit = {} init: RequestInit = {}
): Promise<Response> { ): Promise<Response> {
const timeoutMs = DEFAULT_SUWAYOMI_TIMEOUT_MS;
const execute = async (forceSimpleLoginRefresh: boolean) => { const execute = async (forceSimpleLoginRefresh: boolean) => {
const authHeaders = await getSuwayomiRequestHeaders(resolved, forceSimpleLoginRefresh); const authHeaders = await getSuwayomiRequestHeaders(resolved, forceSimpleLoginRefresh);
return fetch(input, { const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(new Error(`Suwayomi 请求超时(${timeoutMs}ms)`)), timeoutMs);
try {
return await fetch(input, {
...init, ...init,
headers: { headers: {
...(authHeaders || {}), ...(authHeaders || {}),
...(init.headers || {}), ...(init.headers || {}),
}, },
cache: 'no-store', cache: 'no-store',
signal: controller.signal,
}); });
} finally {
clearTimeout(timeoutId);
}
}; };
let response = await execute(false); let response = await execute(false);
@@ -325,11 +338,26 @@ export class SuwayomiClient {
})); }));
} }
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchItem[]> { async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
const resolved = await resolveSuwayomiConfig(this.options); const resolved = await resolveSuwayomiConfig(this.options);
const sources = sourceId let sources: Array<{ id: string; displayName?: string; name?: string }>;
? [{ id: sourceId, displayName: sourceId, name: sourceId }] if (sourceId) {
: (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources); 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 = ` const query = `
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) { mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
fetchSourceManga(input: $input) { fetchSourceManga(input: $input) {
@@ -349,9 +377,12 @@ export class SuwayomiClient {
`; `;
const results: MangaSearchItem[] = []; const results: MangaSearchItem[] = [];
const failedSources: MangaSearchFailure[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const source of sources) { const perSourceResults = await Promise.all(
sources.map(async (source) => {
try {
const data = await this.graphqlRequest<{ const data = await this.graphqlRequest<{
fetchSourceManga?: { fetchSourceManga?: {
mangas?: Array<{ mangas?: Array<{
@@ -377,9 +408,29 @@ export class SuwayomiClient {
}, },
}, },
'GET_SOURCE_MANGAS_FETCH' 'GET_SOURCE_MANGAS_FETCH'
).catch(() => ({ fetchSourceManga: { mangas: [] } })); );
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) { for (const manga of mangas) {
const key = `${source.id}:${manga.id}`; const key = `${source.id}:${manga.id}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
@@ -399,7 +450,10 @@ export class SuwayomiClient {
} }
} }
return results; return {
results,
failedSources,
};
} }
async getRecommendedManga( async getRecommendedManga(