漫画搜索增加流式输出功能
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { suwayomiClient } from '@/lib/suwayomi.client';
|
||||||
|
|
||||||
|
import { getAuthorizedUsername } from '../../_utils';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
function sse(data: unknown): string {
|
||||||
|
return `data: ${JSON.stringify(data)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const username = await getAuthorizedUsername(request);
|
||||||
|
if (username instanceof NextResponse) return username;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const q = searchParams.get('q')?.trim();
|
||||||
|
const sourceId = searchParams.get('sourceId')?.trim() || undefined;
|
||||||
|
const page = Number(searchParams.get('page') || '1');
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (payload: unknown) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(sse(payload)));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sources = await suwayomiClient.getSearchSources(sourceId);
|
||||||
|
let completedSources = 0;
|
||||||
|
let totalResults = 0;
|
||||||
|
const failedSources: Array<{ sourceId: string; sourceName: string; error: string }> = [];
|
||||||
|
|
||||||
|
send({ type: 'start', totalSources: sources.length });
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
sources.map(async (source) => {
|
||||||
|
try {
|
||||||
|
const result = await suwayomiClient.searchMangaSource(q, source, page);
|
||||||
|
completedSources += 1;
|
||||||
|
totalResults += result.results.length;
|
||||||
|
send({
|
||||||
|
type: 'source_result',
|
||||||
|
sourceId: String(source.id),
|
||||||
|
sourceName: source.displayName || source.name || String(source.id),
|
||||||
|
results: result.results,
|
||||||
|
completedSources,
|
||||||
|
totalSources: sources.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '未知错误';
|
||||||
|
completedSources += 1;
|
||||||
|
const failure = {
|
||||||
|
sourceId: String(source.id),
|
||||||
|
sourceName: source.displayName || source.name || String(source.id),
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
failedSources.push(failure);
|
||||||
|
send({
|
||||||
|
type: 'source_error',
|
||||||
|
...failure,
|
||||||
|
completedSources,
|
||||||
|
totalSources: sources.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
send({
|
||||||
|
type: 'complete',
|
||||||
|
completedSources,
|
||||||
|
totalSources: sources.length,
|
||||||
|
totalResults,
|
||||||
|
failedSources,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
send({
|
||||||
|
type: 'error',
|
||||||
|
error: error instanceof Error ? error.message : '搜索失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close races when the client disconnects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
closed = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+206
-18
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
||||||
import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types';
|
import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types';
|
||||||
@@ -43,6 +43,14 @@ export default function MangaSearchPage() {
|
|||||||
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
||||||
const [lastSearchedSourceId, setLastSearchedSourceId] = useState('');
|
const [lastSearchedSourceId, setLastSearchedSourceId] = useState('');
|
||||||
const restoredRef = useRef(false);
|
const restoredRef = useRef(false);
|
||||||
|
const forceNextUrlSearchRef = useRef(false);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const currentSearchKeyRef = useRef('');
|
||||||
|
const pendingResultsRef = useRef<MangaSearchItem[]>([]);
|
||||||
|
const flushTimerRef = useRef<number | null>(null);
|
||||||
|
const [totalSources, setTotalSources] = useState(0);
|
||||||
|
const [completedSources, setCompletedSources] = useState(0);
|
||||||
|
const [useFluidSearch, setUseFluidSearch] = useState(true);
|
||||||
|
|
||||||
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => {
|
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => {
|
||||||
return `manga_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`;
|
return `manga_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`;
|
||||||
@@ -73,6 +81,52 @@ export default function MangaSearchPage() {
|
|||||||
[getCacheKey]
|
[getCacheKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const readFluidSearchSetting = useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
try {
|
||||||
|
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||||
|
if (savedFluidSearch !== null) return JSON.parse(savedFluidSearch) !== false;
|
||||||
|
} catch {
|
||||||
|
// ignore invalid localStorage values
|
||||||
|
}
|
||||||
|
return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeEventSource = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
try {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close failures
|
||||||
|
}
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearPendingResults = useCallback(() => {
|
||||||
|
pendingResultsRef.current = [];
|
||||||
|
if (flushTimerRef.current) {
|
||||||
|
window.clearTimeout(flushTimerRef.current);
|
||||||
|
flushTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendBufferedResults = useCallback((nextResults: MangaSearchItem[]) => {
|
||||||
|
if (nextResults.length === 0) return;
|
||||||
|
pendingResultsRef.current.push(...nextResults);
|
||||||
|
if (!flushTimerRef.current) {
|
||||||
|
flushTimerRef.current = window.setTimeout(() => {
|
||||||
|
const toAppend = pendingResultsRef.current;
|
||||||
|
pendingResultsRef.current = [];
|
||||||
|
startTransition(() => {
|
||||||
|
setResults((prev) => prev.concat(toAppend));
|
||||||
|
});
|
||||||
|
flushTimerRef.current = null;
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const saveSearchState = useCallback((nextState: { query: string; sourceId: string; results: MangaSearchItem[] }) => {
|
const saveSearchState = useCallback((nextState: { query: string; sourceId: string; results: MangaSearchItem[] }) => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
@@ -99,52 +153,169 @@ export default function MangaSearchPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setUseFluidSearch(readFluidSearchSetting());
|
||||||
|
|
||||||
fetch('/api/manga/sources')
|
fetch('/api/manga/sources')
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => setSources(data.sources || []))
|
.then((data) => setSources(data.sources || []))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
||||||
}, []);
|
|
||||||
|
return () => {
|
||||||
|
closeEventSource();
|
||||||
|
clearPendingResults();
|
||||||
|
};
|
||||||
|
}, [clearPendingResults, closeEventSource, readFluidSearchSetting]);
|
||||||
|
|
||||||
const performSearch = useCallback(
|
const performSearch = useCallback(
|
||||||
async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
|
async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
|
||||||
const trimmedQuery = keyword.trim();
|
const trimmedQuery = keyword.trim();
|
||||||
if (!trimmedQuery) return;
|
if (!trimmedQuery) return;
|
||||||
|
const normalizedSourceId = selectedSourceId || '';
|
||||||
|
const searchKey = `${normalizedSourceId}::${trimmedQuery}`;
|
||||||
const forceRefresh = options?.forceRefresh === true;
|
const forceRefresh = options?.forceRefresh === true;
|
||||||
|
|
||||||
|
closeEventSource();
|
||||||
|
clearPendingResults();
|
||||||
|
currentSearchKeyRef.current = searchKey;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
setLastSearchedQuery(trimmedQuery);
|
setLastSearchedQuery(trimmedQuery);
|
||||||
setLastSearchedSourceId(selectedSourceId);
|
setLastSearchedSourceId(normalizedSourceId);
|
||||||
|
setTotalSources(0);
|
||||||
|
setCompletedSources(0);
|
||||||
|
|
||||||
const cached = forceRefresh ? null : getCachedResults(trimmedQuery, selectedSourceId);
|
const cached = forceRefresh ? null : getCachedResults(trimmedQuery, normalizedSourceId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setResults(cached);
|
setResults(cached);
|
||||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached });
|
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: cached });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setTotalSources(1);
|
||||||
|
setCompletedSources(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults([]);
|
||||||
|
|
||||||
|
const currentFluidSearch = readFluidSearchSetting();
|
||||||
|
setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch));
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ q: trimmedQuery });
|
||||||
|
if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
|
||||||
|
|
||||||
|
if (currentFluidSearch) {
|
||||||
|
const es = new EventSource(`/api/manga/search/ws?${params.toString()}`);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
if (!event.data || currentSearchKeyRef.current !== searchKey) return;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
switch (payload.type) {
|
||||||
|
case 'start':
|
||||||
|
setTotalSources(payload.totalSources || 0);
|
||||||
|
setCompletedSources(0);
|
||||||
|
break;
|
||||||
|
case 'source_result':
|
||||||
|
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
||||||
|
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||||
|
appendBufferedResults(payload.results as MangaSearchItem[]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'source_error':
|
||||||
|
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
setError(payload.error || '搜索失败');
|
||||||
|
setLoading(false);
|
||||||
|
closeEventSource();
|
||||||
|
break;
|
||||||
|
case 'complete': {
|
||||||
|
setCompletedSources(payload.completedSources || payload.totalSources || 0);
|
||||||
|
if (pendingResultsRef.current.length > 0) {
|
||||||
|
const toAppend = pendingResultsRef.current;
|
||||||
|
pendingResultsRef.current = [];
|
||||||
|
if (flushTimerRef.current) {
|
||||||
|
window.clearTimeout(flushTimerRef.current);
|
||||||
|
flushTimerRef.current = null;
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
|
setResults((prev) => {
|
||||||
|
const nextResults = prev.concat(toAppend);
|
||||||
|
setCachedResults(trimmedQuery, normalizedSourceId, nextResults);
|
||||||
|
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: nextResults });
|
||||||
|
return nextResults;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setResults((prev) => {
|
||||||
|
setCachedResults(trimmedQuery, normalizedSourceId, prev);
|
||||||
|
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: prev });
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
closeEventSource();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed SSE payloads
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
if (currentSearchKeyRef.current !== searchKey) return;
|
||||||
|
if (pendingResultsRef.current.length > 0) {
|
||||||
|
const toAppend = pendingResultsRef.current;
|
||||||
|
pendingResultsRef.current = [];
|
||||||
|
if (flushTimerRef.current) {
|
||||||
|
window.clearTimeout(flushTimerRef.current);
|
||||||
|
flushTimerRef.current = null;
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
|
setResults((prev) => prev.concat(toAppend));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
closeEventSource();
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ q: trimmedQuery });
|
|
||||||
if (selectedSourceId) params.set('sourceId', selectedSourceId);
|
|
||||||
const res = await fetch(`/api/manga/search?${params.toString()}`);
|
const res = await fetch(`/api/manga/search?${params.toString()}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (currentSearchKeyRef.current !== searchKey) return;
|
||||||
if (!res.ok) throw new Error(data.error || '搜索失败');
|
if (!res.ok) throw new Error(data.error || '搜索失败');
|
||||||
const nextResults = data.results || [];
|
const nextResults = data.results || [];
|
||||||
setResults(nextResults);
|
setResults(nextResults);
|
||||||
setCachedResults(trimmedQuery, selectedSourceId, nextResults);
|
setTotalSources(1);
|
||||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: nextResults });
|
setCompletedSources(1);
|
||||||
|
setCachedResults(trimmedQuery, normalizedSourceId, nextResults);
|
||||||
|
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: nextResults });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (currentSearchKeyRef.current !== searchKey) return;
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (currentSearchKeyRef.current === searchKey) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getCachedResults, saveSearchState, setCachedResults]
|
[
|
||||||
|
appendBufferedResults,
|
||||||
|
clearPendingResults,
|
||||||
|
closeEventSource,
|
||||||
|
getCachedResults,
|
||||||
|
readFluidSearchSetting,
|
||||||
|
saveSearchState,
|
||||||
|
setCachedResults,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -169,16 +340,23 @@ export default function MangaSearchPage() {
|
|||||||
setSourceId(urlSourceId);
|
setSourceId(urlSourceId);
|
||||||
|
|
||||||
if (!urlQuery) {
|
if (!urlQuery) {
|
||||||
|
closeEventSource();
|
||||||
|
clearPendingResults();
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setLoading(false);
|
||||||
setHasSearched(false);
|
setHasSearched(false);
|
||||||
setLastSearchedQuery('');
|
setLastSearchedQuery('');
|
||||||
setLastSearchedSourceId('');
|
setLastSearchedSourceId('');
|
||||||
|
setTotalSources(0);
|
||||||
|
setCompletedSources(0);
|
||||||
setError('');
|
setError('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void performSearch(urlQuery, urlSourceId);
|
const forceRefresh = forceNextUrlSearchRef.current;
|
||||||
}, [performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
forceNextUrlSearchRef.current = false;
|
||||||
|
void performSearch(urlQuery, urlSourceId, { forceRefresh });
|
||||||
|
}, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -187,8 +365,13 @@ 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()}`);
|
const nextUrl = `/manga/search?${params.toString()}`;
|
||||||
await performSearch(trimmedQuery, sourceId, { forceRefresh: true });
|
if (urlQuery === trimmedQuery && urlSourceId === sourceId) {
|
||||||
|
await performSearch(trimmedQuery, sourceId, { forceRefresh: true });
|
||||||
|
} else {
|
||||||
|
forceNextUrlSearchRef.current = true;
|
||||||
|
router.replace(nextUrl);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const returnTo = useMemo(() => {
|
const returnTo = useMemo(() => {
|
||||||
@@ -257,11 +440,16 @@ export default function MangaSearchPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className='mb-4 flex items-center justify-between'>
|
<div className='mb-4 flex items-center justify-between gap-3'>
|
||||||
<h2 className='text-lg font-semibold'>搜索结果</h2>
|
<h2 className='text-lg font-semibold'>搜索结果{results.length > 0 ? `(${results.length})` : ''}</h2>
|
||||||
|
{loading && useFluidSearch && totalSources > 0 && (
|
||||||
|
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
搜索中 {completedSources}/{totalSources}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
|
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
|
||||||
{loading ? (
|
{loading && results.length === 0 ? (
|
||||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||||
{Array.from({ length: 12 }).map((_, index) => (
|
{Array.from({ length: 12 }).map((_, index) => (
|
||||||
<MangaCardSkeleton key={index} withButton />
|
<MangaCardSkeleton key={index} withButton />
|
||||||
|
|||||||
+89
-63
@@ -338,26 +338,39 @@ export class SuwayomiClient {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
|
async getSearchSources(sourceId?: string): Promise<Array<{ id: string; displayName?: string; name?: string }>> {
|
||||||
const resolved = await resolveSuwayomiConfig(this.options);
|
const resolved = await resolveSuwayomiConfig(this.options);
|
||||||
let sources: Array<{ id: string; displayName?: string; name?: string }>;
|
|
||||||
if (sourceId) {
|
if (sourceId) {
|
||||||
sources = [{ id: sourceId, displayName: sourceId, name: sourceId }];
|
const matched = (await this.getSources()).find((item) => item.id === sourceId);
|
||||||
} else {
|
return [
|
||||||
try {
|
{
|
||||||
sources = (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources);
|
id: sourceId,
|
||||||
} catch (error) {
|
displayName: matched?.displayName || matched?.name || sourceId,
|
||||||
if (resolved.sourceIds.length === 0) {
|
name: matched?.name || matched?.displayName || sourceId,
|
||||||
throw error;
|
},
|
||||||
}
|
];
|
||||||
sources = resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({
|
|
||||||
id,
|
|
||||||
displayName: id,
|
|
||||||
name: id,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources);
|
||||||
|
} catch (error) {
|
||||||
|
if (resolved.sourceIds.length === 0) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({
|
||||||
|
id,
|
||||||
|
displayName: id,
|
||||||
|
name: id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchMangaSource(
|
||||||
|
keyword: string,
|
||||||
|
source: { id: string; displayName?: string; name?: string },
|
||||||
|
page = 1
|
||||||
|
): Promise<{ source: { id: string; displayName?: string; name?: string }; results: MangaSearchItem[] }> {
|
||||||
const query = `
|
const query = `
|
||||||
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
||||||
fetchSourceManga(input: $input) {
|
fetchSourceManga(input: $input) {
|
||||||
@@ -376,6 +389,60 @@ export class SuwayomiClient {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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 seen = new Set<string>();
|
||||||
|
const sourceName = source.displayName || source.name || String(source.id);
|
||||||
|
const results = (data.fetchSourceManga?.mangas || [])
|
||||||
|
.filter((manga) => {
|
||||||
|
const key = `${source.id}:${manga.id}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((manga) => ({
|
||||||
|
id: String(manga.id),
|
||||||
|
sourceId: String(manga.sourceId || source.id),
|
||||||
|
sourceName,
|
||||||
|
title: manga.title || '未命名漫画',
|
||||||
|
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
||||||
|
description: manga.description,
|
||||||
|
author: manga.author,
|
||||||
|
artist: manga.artist,
|
||||||
|
genre: manga.genre,
|
||||||
|
status: normalizeMangaStatus(manga.status),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { source, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
|
||||||
|
const sources = await this.getSearchSources(sourceId);
|
||||||
const results: MangaSearchItem[] = [];
|
const results: MangaSearchItem[] = [];
|
||||||
const failedSources: MangaSearchFailure[] = [];
|
const failedSources: MangaSearchFailure[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -383,37 +450,7 @@ export class SuwayomiClient {
|
|||||||
const perSourceResults = await Promise.all(
|
const perSourceResults = await Promise.all(
|
||||||
sources.map(async (source) => {
|
sources.map(async (source) => {
|
||||||
try {
|
try {
|
||||||
const data = await this.graphqlRequest<{
|
return await this.searchMangaSource(keyword, source, page);
|
||||||
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'
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
source,
|
|
||||||
mangas: data.fetchSourceManga?.mangas || [],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '未知错误';
|
const message = error instanceof Error ? error.message : '未知错误';
|
||||||
console.warn(`[Suwayomi] manga search source failed: ${source.id} - ${message}`);
|
console.warn(`[Suwayomi] manga search source failed: ${source.id} - ${message}`);
|
||||||
@@ -424,29 +461,18 @@ export class SuwayomiClient {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
mangas: [],
|
results: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const { source, mangas } of perSourceResults) {
|
for (const { results: sourceResults } of perSourceResults) {
|
||||||
for (const manga of mangas) {
|
for (const manga of sourceResults) {
|
||||||
const key = `${source.id}:${manga.id}`;
|
const key = `${manga.sourceId}:${manga.id}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
results.push({
|
results.push(manga);
|
||||||
id: String(manga.id),
|
|
||||||
sourceId: String(manga.sourceId || source.id),
|
|
||||||
sourceName: source.displayName || source.name || String(source.id),
|
|
||||||
title: manga.title || '未命名漫画',
|
|
||||||
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
|
||||||
description: manga.description,
|
|
||||||
author: manga.author,
|
|
||||||
artist: manga.artist,
|
|
||||||
genre: manga.genre,
|
|
||||||
status: normalizeMangaStatus(manga.status),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user