import { useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2 } from 'lucide-react' import toast from 'react-hot-toast' import { mediaAPI } from '../api/library' import { playbackAPI } from '../api/playback' import { recycleAPI } from '../api/recycle' import { imageURL } from '../api/client' import { useAuthStore } from '../stores/auth' import { api } from '../api/client' import type { Media } from '../types' function fmtDuration(sec: number): string { if (!sec || sec <= 0) return '—' const h = Math.floor(sec / 3600) const m = Math.floor((sec % 3600) / 60) return h > 0 ? `${h}h ${m}m` : `${m}m` } function fmtSize(bytes: number): string { if (!bytes || bytes <= 0) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = bytes let i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } return `${v.toFixed(2)} ${units[i]}` } // Detail screen for a single media item. // // Buttons: // - Play (direct play) // - HLS (force transcoded play) // - Favourite (toggle, all users) // - Re-scrape (admin only, triggers TMDb lookup) // - Re-probe (admin only, refreshes ffprobe metadata) export function MediaDetailPage() { const { id = '' } = useParams() const navigate = useNavigate() const role = useAuthStore((s) => s.user?.role) const [media, setMedia] = useState(null) const [favourite, setFavourite] = useState(false) const [loading, setLoading] = useState(true) const refresh = async () => { if (!id) return setLoading(true) try { const m = await mediaAPI.get(id) setMedia(m) const favs = await playbackAPI.listFavourites().catch(() => []) setFavourite(favs.some((f) => f.id === m.id)) } finally { setLoading(false) } } useEffect(() => { refresh().catch(() => undefined) }, [id]) const toggleFav = async () => { if (!media) return const state = await playbackAPI.toggleFavourite(media.id) setFavourite(state) toast.success(state ? '已加入收藏' : '已取消收藏') } const rescrape = async () => { if (!media) return await api.post(`/media/${media.id}/scrape`) toast.success('已刮削') await refresh() } const reprobe = async () => { if (!media) return await api.post(`/media/${media.id}/probe`) toast.success('已重新探测') await refresh() } const exportNFO = async () => { if (!media) return try { const r = await recycleAPI.exportNFO(media.id) toast.success(`NFO 已写入 ${r.path}`) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导出失败' toast.error(msg) } } const softDelete = async () => { if (!media) return if (!confirm(`将「${media.title}」移至回收站? (磁盘文件保留)`)) return await recycleAPI.softDelete(media.id) toast.success('已移至回收站') navigate(-1) } if (loading) return

加载中…

if (!media) return

媒体不存在

return (
{media.poster_url ? ( {media.title} ) : (
无海报
)}

{media.title}

{media.year > 0 &&

{media.year}

} {media.overview &&

{media.overview}

}
{media.video_codec && {media.video_codec.toUpperCase()}} {media.width > 0 && ( {media.width}×{media.height} )} {fmtDuration(media.duration_sec)} {fmtSize(media.size_bytes)} {media.container && {media.container.toUpperCase()}} {media.scrape_status}
播放 HLS 转码播放 {role === 'admin' && ( <> )}
) } function Badge({ children }: { children: React.ReactNode }) { return ( {children} ) }