优化漫画展馆的搜索速度,修复无法重新搜索
This commit is contained in:
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user