import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react' import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai' import { imageURL } from '../api/client' import { mediaAPI } from '../api/library' import { subscriptionsAPI } from '../api/subscriptions' import { MediaCard } from '../components/MediaCard' import type { Media } from '../types' import { groupSeries, seriesCardLink } from '../utils/groupSeries' const LOCAL_SEARCH_PAGE_SIZE = 2000 export function SearchPage() { const [q, setQ] = useState('') const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [aiOn, setAiOn] = useState(false) const [aiAvailable, setAiAvailable] = useState(false) const [intent, setIntent] = useState(null) const [hasSearched, setHasSearched] = useState(false) const [externalItems, setExternalItems] = useState([]) const [subscribing, setSubscribing] = useState('') const [searchTotal, setSearchTotal] = useState(0) const searchSeq = useRef(0) const localCards = useMemo(() => groupSeries(items), [items]) useEffect(() => { aiAPI .status() .then((s) => setAiAvailable(s.enabled)) .catch(() => setAiAvailable(false)) }, []) const doQuickSearch = useCallback((query: string) => { const seq = ++searchSeq.current if (!query.trim()) { setItems([]) setSearchTotal(0) setHasSearched(false) setLoading(false) return } setHasSearched(true) setError('') const loadAll = async () => { let page = 1 let collected: Media[] = [] for (;;) { const d = await mediaAPI.searchPage(query, page, LOCAL_SEARCH_PAGE_SIZE) if (seq !== searchSeq.current) return const pageItems = d.items ?? [] collected = collected.concat(pageItems) const total = d.total ?? collected.length setItems(collected) setSearchTotal(total) if (collected.length >= total || pageItems.length < LOCAL_SEARCH_PAGE_SIZE) break page += 1 } setExternalItems([]) setIntent(null) } loadAll() .catch((err) => { if (seq !== searchSeq.current) return const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败' setError(msg) toast.error(msg) }) .finally(() => { if (seq === searchSeq.current) setLoading(false) }) }, []) // Fast LIKE search-as-you-type when AI mode is OFF. useEffect(() => { if (aiOn) return setLoading(true) const t = setTimeout(() => doQuickSearch(q), 300) return () => clearTimeout(t) }, [q, aiOn, doQuickSearch]) const onAISubmit = async (e: FormEvent) => { e.preventDefault() if (!q.trim()) return ++searchSeq.current setLoading(true) setError('') setHasSearched(true) try { const data = await aiAPI.smartSearch(q) setItems(data.items ?? []) setSearchTotal((data.items ?? []).length) setExternalItems(data.external_items ?? []) setIntent(data.intent) } catch (err) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'AI 搜索失败' setError(msg) toast.error(msg) } finally { setLoading(false) } } const showEmpty = !loading && !error && hasSearched && localCards.length === 0 const showIdle = !loading && !error && !hasSearched return (

搜索

{aiOn ? (
) => setQ(e.target.value)} />
) : ( ) => setQ(e.target.value)} /> )} {intent && (
AI 解析: {JSON.stringify(intent)}
)} {loading && (
搜索中…
)} {error && (
{error}
)} {showIdle && (

输入关键词开始搜索

支持电影、电视剧、动漫等媒体内容的快速搜索

)} {showEmpty && (

未找到匹配的媒体

尝试其他关键词,或者添加媒体库后执行扫描

)} {localCards.length > 0 && ( <>
本地媒体库 · {localCards.length} 个合集 / {items.length} 个条目 {loading && searchTotal > items.length ? ` · 正在加载全部结果 ${items.length}/${searchTotal}` : ''}
{localCards.map((card) => ( ))}
)} {externalItems.length > 0 && ( { const keyword = item.subscribe_keyword || item.title const key = `${item.source}:${keyword}` setSubscribing(key) try { const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}` const sub = await subscriptionsAPI.create({ name: `${item.title} 自动订阅`, feed_url: feed, filter: keyword, media_type: item.media_type, source: item.source, poster_url: item.poster_url, backdrop_url: item.backdrop_url, overview: item.overview, total_episodes: item.total_episodes, enabled: true, }) const run = await subscriptionsAPI.runNow(sub.id) toast.success( run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未在 PT 站点找到可下载资源', ) } catch (err) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '订阅失败' toast.error(msg) } finally { setSubscribing('') } }} /> )}
) } function ExternalResults({ items, busyKey, onSubscribe, }: { items: ExternalMediaResult[] busyKey: string onSubscribe: (item: ExternalMediaResult) => Promise }) { const [detail, setDetail] = useState(null) return (

外部数据源

来自 TMDb / 豆瓣 / Bangumi。电影入队最佳资源;剧集/动漫优先整季或全集包,否则按集批量入队。

{items.map((item) => { const keyword = item.subscribe_keyword || item.title const key = `${item.source}:${keyword}` return (
setDetail(item)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setDetail(item) }} className="glass-panel flex cursor-pointer gap-3 !p-3 transition hover:-translate-y-0.5 hover:shadow-lg" >
{item.poster_url ? ( {item.title} ) : null}
{item.source} {item.media_type && {item.media_type}} {item.year ? {item.year} : null} {item.rating ? ★ {item.rating.toFixed(1)} : null}

{item.title}

{item.overview || `订阅关键词:${keyword}`}

{item.in_library ? '本地已入库' : '本地未入库'} {isSeriesItem(item) ? ( 已有 {item.downloaded_episodes || 0}/{item.total_episodes || '未知'} 集 ) : null}
) })}
{detail && ( setDetail(null)} onSubscribe={onSubscribe} /> )}
) } function ExternalDetailModal({ item, busy, onClose, onSubscribe, }: { item: ExternalMediaResult busy: boolean onClose: () => void onSubscribe: (item: ExternalMediaResult) => Promise }) { const missing = item.missing_episodes ?? [] return (
event.stopPropagation()}>
{item.poster_url ? ( {item.title} ) : (
)}
{item.source} {item.media_type ? {item.media_type} : null} {item.year ? {item.year} : null} {item.rating ? ★ {item.rating.toFixed(1)} : null}

{item.title}

{item.overview || '暂无简介。'}

{isSeriesItem(item) && (
缺失情况
{missing.length > 0 ? (
{missing.slice(0, 80).map((episode) => ( 第 {episode} 集 ))} {missing.length > 80 ? 还有 {missing.length - 80} 集… : null}
) : item.in_library && item.total_episodes ? (

已完整入库

) : (

总集数未知,订阅时会跳过本地已有单集,优先补新集。

)}
)}
) } function StatusBox({ label, value, ok }: { label: string; value: string; ok?: boolean }) { return (
{label}
{value}
) } function isSeriesItem(item: ExternalMediaResult) { return ['tv', 'anime', 'variety'].includes((item.media_type || '').toLowerCase()) }