import { useEffect, useMemo, useState } from 'react' import toast from 'react-hot-toast' import { AlertTriangle, Download, Info, Rss, Sparkles, X } from 'lucide-react' import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover' import { imageURL } from '../api/client' import { subscriptionsAPI } from '../api/subscriptions' const defaultSections = [ 'tmdb_trending_day', 'douban_hot_movie', 'douban_hot_tv', 'bangumi_calendar', ] const storageKey = 'mediastation.discover.sections' export function DiscoverPage() { const [sections, setSections] = useState([]) const [selected, setSelected] = useState(defaultSections) const [rows, setRows] = useState>({}) const [error, setError] = useState('') const [loading, setLoading] = useState(true) const [activeItem, setActiveItem] = useState(null) useEffect(() => { discoverAPI .sections() .then((items) => { setSections(items) const saved = readSavedSections(items) setSelected(saved.length > 0 ? saved : defaultSections) }) .catch(() => { setSections(defaultSectionDefs) setSelected(defaultSections) }) }, []) useEffect(() => { if (selected.length === 0) { setRows({}) setLoading(false) return } setLoading(true) setError('') window.localStorage.setItem(storageKey, JSON.stringify(selected)) discoverAPI .feed(selected) .then((feed) => { const next: Record = {} for (const key of selected) { next[key] = feed[key] ?? [] } setRows(next) }) .catch((err) => { setRows({}) setError(err instanceof Error ? err.message : String(err)) }) .finally(() => setLoading(false)) }, [selected]) const sectionMap = useMemo( () => new Map(sections.map((section) => [section.key, section])), [sections], ) const hasContent = selected.some((key) => (rows[key] ?? []).length > 0) const toggleSection = (key: string) => { setSelected((current) => { if (current.includes(key)) { return current.filter((item) => item !== key) } return [...current, key] }) } return (

发现

多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示

{sections.map((section) => { const active = selected.includes(section.key) return ( ) })}
{loading && } {!loading && error && (

{error}

)} {!loading && selected.length === 0 && (
至少选择一个推荐源,小宇宙才会开始转动。
)} {!loading && !error && selected.length > 0 && (
{selected.map((key) => { const items = rows[key] ?? [] if (items.length === 0) return null return ( ) })} {!hasContent && (

当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。

)}
)} {activeItem && ( setActiveItem(null)} /> )}
) } function ContentRow({ title, items, onSelect, }: { title: string items: DiscoverItem[] onSelect: (item: DiscoverItem) => void }) { return (

{title}

{items.map((item, index) => ( ))}
) } function DiscoverCard({ item, onSelect }: { item: DiscoverItem; onSelect: (item: DiscoverItem) => void }) { const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb') return ( ) } function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) { const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb') const keyword = item.subscribe_keyword || buildSubscribeKeyword(item) const [form, setForm] = useState({ keyword, search_mode: 'keyword', imdb_id: '', media_type: item.media_type || '', resolution: 'best', quality: '', effects: '', release_groups: '', exclude_words: 'cam,ts,tc,枪版', wash_enabled: false, wash_priority: 'balanced', save_path: '', media_category: '', priority: 50, run_now: true, }) const [busy, setBusy] = useState(false) const submit = async () => { const finalKeyword = form.keyword.trim() || keyword const feed = `site-search://search?keyword=${encodeURIComponent(finalKeyword)}&source=${encodeURIComponent(source)}` setBusy(true) try { const sub = await subscriptionsAPI.create({ name: `${item.title} 自动订阅`, feed_url: feed, filter: finalKeyword, media_type: form.media_type || undefined, media_category: form.media_category || undefined, save_path: form.save_path || undefined, search_mode: form.search_mode, imdb_id: form.imdb_id || undefined, source, poster_url: item.poster_url || undefined, backdrop_url: item.backdrop_url || undefined, overview: item.overview || undefined, resolution: form.resolution === 'best' ? 'best' : form.resolution, quality: form.quality || undefined, effects: form.effects || undefined, release_groups: form.release_groups || undefined, exclude_words: form.exclude_words || undefined, wash_enabled: form.wash_enabled, wash_priority: form.wash_priority, priority: form.priority, enabled: true, }) if (form.run_now) { const run = await subscriptionsAPI.runNow(sub.id) toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源') } else { toast.success('已创建订阅') } onClose() } catch (err) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '订阅失败' toast.error(msg) } finally { setBusy(false) } } return (

{source}

{item.title}

{[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : ''] .filter(Boolean) .join(' · ')}

{item.poster_url ? ( {item.title} ) : (
无海报
)}
{item.backdrop_url && ( )}

简介

{item.overview || '当前数据源没有返回简介。'}

订阅下载规则

) } function buildSubscribeKeyword(item: DiscoverItem): string { return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ') } function DiscoverSkeleton() { return (
{[1, 2, 3].map((section) => (
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
))}
))}
) } function discoverKey(item: DiscoverItem, index: number): string { return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}` } function readSavedSections(sections: DiscoverSection[]): string[] { try { const raw = window.localStorage.getItem(storageKey) if (!raw) return [] const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] const allowed = new Set(sections.map((section) => section.key)) return parsed.filter((key) => typeof key === 'string' && allowed.has(key)) } catch { return [] } } const defaultSectionDefs: DiscoverSection[] = [ { key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' }, { key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' }, { key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' }, { key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' }, { key: 'bangumi_calendar', label: 'Bangumi 每日放送', provider: 'bangumi' }, ]