import { useEffect, useState } from "react"; import { SOURCES } from "../../sources/registry"; import { cachedSearch } from "../../sources/cache"; import { HttpError } from "../../util/net"; import type { SourceId, TorrentResult } from "../../sources/types"; export interface SourceState { loading: boolean; error: string | null; code: string | null; count: number; } function errorCode(e: unknown): string { if (e instanceof HttpError && e.status > 0) return `HTTP ${e.status}`; return "no response"; } export interface ConcurrentSearchState { results: TorrentResult[]; perSource: Record; loading: boolean; done: number; total: number; } function blankPerSource(loading: boolean): Record { const out = {} as Record; for (const s of SOURCES) out[s.id] = { loading, error: null, code: null, count: 0 }; return out; } function dedupe(list: TorrentResult[]): TorrentResult[] { const byHash = new Map(); for (const r of list) { const existing = byHash.get(r.infoHash); if (!existing || r.seeders > existing.seeders) byHash.set(r.infoHash, r); } return [...byHash.values()]; } // torlink's default ordering: healthiest first. The results view can re-sort // on demand (the `s` key), and its "none"/default state preserves this order. function defaultOrder(list: TorrentResult[]): TorrentResult[] { return list.sort((a, b) => { if (b.seeders !== a.seeders) return b.seeders - a.seeders; return (b.added ?? 0) - (a.added ?? 0); }); } function idleState(): ConcurrentSearchState { return { results: [], perSource: blankPerSource(false), loading: false, done: 0, total: SOURCES.length, }; } export function useConcurrentSearch(query: string): ConcurrentSearchState { const [state, setState] = useState(idleState); useEffect(() => { const ctrl = new AbortController(); let alive = true; const collected: TorrentResult[] = []; const per = blankPerSource(true); let done = 0; setState({ results: [], perSource: { ...per }, loading: true, done: 0, total: SOURCES.length, }); for (const source of SOURCES) { cachedSearch(source, query, { signal: ctrl.signal }) .then((res) => { if (!alive) return; collected.push(...res); per[source.id] = { loading: false, error: null, code: null, count: res.length }; }) .catch((e: unknown) => { if (!alive || ctrl.signal.aborted) return; per[source.id] = { loading: false, error: e instanceof Error ? e.message : String(e), code: errorCode(e), count: 0, }; }) .finally(() => { if (!alive) return; done += 1; setState({ results: defaultOrder(dedupe(collected.slice())), perSource: { ...per }, loading: done < SOURCES.length, done, total: SOURCES.length, }); }); } return () => { alive = false; ctrl.abort(); }; }, [query]); return state; }