优化漫画展馆的搜索速度,修复无法重新搜索
This commit is contained in:
@@ -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 {
|
||||
|
||||
+95
-41
@@ -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<string, SuwayomiSessionCacheEntry>();
|
||||
|
||||
function normalizeSuwayomiAuthMode(value?: string | null): 'none' | 'basic_auth' | 'simple_login' {
|
||||
@@ -216,16 +219,26 @@ async function suwayomiFetch(
|
||||
input: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
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<MangaSearchItem[]> {
|
||||
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
|
||||
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<string>();
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user