import { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useParams, useSearchParams } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import toast from 'react-hot-toast' import { ArrowLeft, Play, Film } from 'lucide-react' import { libraryAPI } from '../api/library' import type { Library, Media } from '../types' import { MediaCard } from '../components/MediaCard' import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { imageURL } from '../api/client' import { useAuthStore } from '../stores/auth' import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries' import { useWebSocket } from '../hooks/useWebSocket' export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() const role = useAuthStore((s) => s.user?.role) const [library, setLibrary] = useState(null) const [items, setItems] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [loadingAll, setLoadingAll] = useState(false) const [scanning, setScanning] = useState(false) const [scanProgress, setScanProgress] = useState('') const [scraping, setScraping] = useState(false) // 剧集模式:选中某个剧集后展开详情 const [selectedSeries, setSelectedSeries] = useState(null) const [selectedSeason, setSelectedSeason] = useState(null) const hasEpisodicItems = useMemo(() => items.some(isEpisodeLike), [items]) const isSeries = library?.type === 'tv' || library?.type === 'anime' || library?.type === 'variety' || hasEpisodicItems // 折叠后的剧集卡片 const seriesCards = useMemo(() => { if (!isSeries || items.length === 0) return [] return groupSeries(items) }, [isSeries, items]) // 选中的剧集:所有集按季分组 const selectedEpisodes = useMemo(() => { if (!selectedSeries || items.length === 0) return [] const eps = items.filter((m) => getSeriesKey(m) === selectedSeries.key) // 按季分组,按集排序 const seasons = new Map() for (const ep of eps) { const s = ep.season_num || 1 if (!seasons.has(s)) seasons.set(s, []) seasons.get(s)!.push(ep) } for (const [, list] of seasons) { list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0)) } return Array.from(seasons.entries()) .sort(([a], [b]) => a - b) .map(([season, episodes]) => ({ season, episodes })) }, [selectedSeries, items]) const visibleEpisodes = useMemo(() => { if (selectedSeason == null) return selectedEpisodes[0]?.episodes ?? [] return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? [] }, [selectedEpisodes, selectedSeason]) useEffect(() => { if (!id) return libraryAPI.list().then((all) => { const lib = all.find((l) => l.id === id) ?? null setLibrary(lib) }) }, [id]) useEffect(() => { if (!id || !library) return let cancelled = false setLoading(true) setLoadingAll(true) setItems([]) const loadAll = async () => { const pageSize = 2000 let page = 1 let collected: Media[] = [] try { for (;;) { const d = await libraryAPI.listMedia(id, page, pageSize) if (cancelled) return collected = collected.concat(d.items) setItems(collected) setTotal(d.total) if (page === 1) setLoading(false) if (collected.length >= d.total || d.items.length < pageSize) break page += 1 } } finally { if (!cancelled) { setLoading(false) setLoadingAll(false) } } } loadAll().catch(() => { if (!cancelled) { toast.error('媒体库加载失败') setLoading(false) } }) return () => { cancelled = true } }, [id, library]) const reloadCurrentLibrary = useCallback(() => { setLibrary((l) => (l ? { ...l } : l)) }, []) const onRealtimeEvent = useCallback((topic: string, payload: unknown) => { if (role !== 'admin') return if (topic !== 'scan' || !payload || typeof payload !== 'object') return const p = payload as Record if (p.library_id !== id) return if (p.error) { setScanning(false) setScanProgress(`扫描失败:${String(p.error)}`) return } if (p.finished) { setScanning(false) const elapsed = Number(p.elapsed_seconds ?? p.elapsed ?? 0) const elapsedText = elapsed > 0 ? ` · 耗时 ${formatDuration(elapsed)}` : '' setScanProgress(`扫描完成:发现 ${p.discovered ?? p.visited ?? 0} · 新增 ${p.added ?? 0} · 更新 ${p.updated ?? 0} · 跳过 ${p.skipped ?? 0}${elapsedText}`) reloadCurrentLibrary() return } if (p.queued) { setScanning(true) setScanProgress(String(p.message ?? '扫描已排队,后台会自动入库')) return } if (p.cloud && p.stage) { const stage = p.stage === 'importing' ? '正在入库' : '正在遍历目录' const speed = Number(p.files_per_second ?? 0) const speedText = speed > 0 ? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒` : '' setScanning(true) setScanProgress(`${stage}:目录 ${p.dirs ?? 0} · 已发现 ${p.discovered ?? 0} · 已入库 ${p.visited ?? 0}${speedText}`) } }, [id, reloadCurrentLibrary, role]) useWebSocket(onRealtimeEvent) useEffect(() => { if (loading) return if (!isSeries) { setSelectedSeries(null) setSelectedSeason(null) return } const key = searchParams.get('series') if (!key) { setSelectedSeries(null) return } const next = seriesCards.find((card) => card.key === key) if (next) setSelectedSeries(next) }, [isSeries, loading, searchParams, seriesCards]) useEffect(() => { if (!selectedSeries || selectedEpisodes.length === 0) { setSelectedSeason(null) return } if (selectedSeason == null || !selectedEpisodes.some((s) => s.season === selectedSeason)) { setSelectedSeason(selectedEpisodes[0].season) } }, [selectedSeries, selectedEpisodes, selectedSeason]) const handleScan = async () => { setScanning(true) setScanProgress('正在提交扫描任务…') let keepScanning = false try { const r = await libraryAPI.scan(id) if (r.queued) { keepScanning = true setScanProgress(`${r.message ?? '云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库'};${r.estimate_message ?? '大目录耗时取决于网盘接口速度'}`) toast.success('云盘扫描已加入后台队列') } else { toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`) setScanProgress(`扫描完成:新增 ${r.added} · 更新 ${r.updated ?? 0}`) reloadCurrentLibrary() setScanning(false) } } catch { toast.error('扫描失败') setScanProgress('扫描失败,请查看日志或稍后重试') setScanning(false) } finally { if (!keepScanning) setScanning(false) } } const handleScrape = async () => { setScraping(true) try { await libraryAPI.scrape(id) toast.success('刮削已加入后台队列') } catch { toast.error('刮削失败') } finally { setScraping(false) } } const handleSeriesClick = (card: SeriesCard) => { setSelectedSeries(card) const next = new URLSearchParams(searchParams) next.set('series', card.key) setSearchParams(next) window.scrollTo({ top: 0, behavior: 'smooth' }) } const clearSelectedSeries = () => { setSelectedSeries(null) setSelectedSeason(null) const next = new URLSearchParams(searchParams) next.delete('series') setSearchParams(next) } if (loading) { return (
加载中…
) } return (
{/* Header */}

{library?.name ?? '媒体库'} ({isSeries ? seriesCards.length : total})

{library &&

{library.type} · {library.path}

} {loadingAll && !loading && total > items.length && (

正在继续加载全部条目:{items.length} / {total}

)} {scanProgress &&

{scanProgress}

}
{role === 'admin' && (
)}
{/* 非剧集:直接展示海报网格 */} {!isSeries && items.length > 0 && (
{items.map((m) => ( ))}
)} {!isSeries && items.length === 0 && (

该媒体库暂无内容,触发一次扫描后再来看看

)} {/* 剧集模式:折叠卡片网格 */} {isSeries && seriesCards.length > 0 && !selectedSeries && (
{seriesCards.map((s) => ( handleSeriesClick(s)} /> ))}
)} {/* 剧集详情:季/集选择器 */} {selectedSeries && ( {/* 返回按钮 + 标题 */}

{seriesTitle(selectedSeries.rep)}

共 {selectedSeries.count} 集
{/* 海报 + 从第一集开始播放 */}
{selectedSeries.rep.poster_url ? ( {selectedSeries.rep.title} ) : (
)}

{selectedSeries.rep.overview || '暂无简介'}

{/* 从第一集开始 */} {(() => { const firstEps = [...(visibleEpisodes.length > 0 ? visibleEpisodes : selectedEpisodes.flatMap((s) => s.episodes))] firstEps.sort((a, b) => (a.season_num || 0) - (b.season_num || 0) || (a.episode_num || 0) - (b.episode_num || 0), ) const first = firstEps.length > 0 ? firstEps[0] : null return first ? (
从第一集开始播放
) : null })()}
{/* 季 / 集列表 */}
{selectedEpisodes.map(({ season, episodes }) => ( ))}

第 {selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季

{visibleEpisodes.map((ep) => (
{ep.episode_num || '—'}

{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}

{ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : formatSize(ep.size_bytes)}

))}
)}
{/* 剧集为空 */} {isSeries && seriesCards.length === 0 && !loading && (

该库尚未发现任何剧集,触发一次扫描后再来看看

)}
) } function formatDuration(seconds: number): string { if (!Number.isFinite(seconds) || seconds <= 0) return '' if (seconds < 60) return `${Math.round(seconds)}秒` const minutes = Math.floor(seconds / 60) const rest = Math.round(seconds % 60) if (minutes < 60) return `${minutes}分${rest}秒` const hours = Math.floor(minutes / 60) return `${hours}小时${minutes % 60}分` } function formatSize(bytes: number): string { if (!bytes || bytes <= 0) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = bytes, i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ } return `${v.toFixed(1)} ${units[i]}` }