import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react' import type { NavigateFunction } from 'react-router-dom' import toast from 'react-hot-toast' import { api } from '../api/client' import { mediaAPI } from '../api/library' import { playbackAPI } from '../api/playback' import { confirmActionResult } from '../components/confirmAction' import type { Media } from '../types' import { mediaLibraryBackTarget } from './MediaDetailPageModel' interface MediaDetailPageStateParams { id: string navigate: NavigateFunction } interface MediaDetailRefreshParams { id: string setMedia: Dispatch> setFavourite: Dispatch> setLoading: Dispatch> setEpisodes: Dispatch> setLoadingEpisodes: Dispatch> } interface MediaDetailActionsParams { media: Media | null scrapeEpisodeArtwork: boolean navigate: NavigateFunction refresh: () => Promise setFavourite: Dispatch> } export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStateParams) { const [media, setMedia] = useState(null) const [favourite, setFavourite] = useState(false) const [loading, setLoading] = useState(true) const [episodes, setEpisodes] = useState([]) const [loadingEpisodes, setLoadingEpisodes] = useState(false) const [selectedSeason, setSelectedSeason] = useState(null) const [manualScrapeOpen, setManualScrapeOpen] = useState(false) const [metadataEditOpen, setMetadataEditOpen] = useState(false) const [organizeOpen, setOrganizeOpen] = useState(false) const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false) const refresh = useMediaDetailRefresh({ id, setMedia, setFavourite, setLoading, setEpisodes, setLoadingEpisodes, }) const actions = useMediaDetailActions({ media, scrapeEpisodeArtwork, navigate, refresh, setFavourite, }) useEffect(() => { refresh().catch(() => undefined) }, [refresh]) const seasonGroups = useMemo(() => { if (episodes.length === 0) return [] const seasons = new Map() for (const ep of episodes) { const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (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, list]) => ({ season, episodes: list })) }, [episodes]) const visibleEpisodes = useMemo(() => { if (selectedSeason == null) return seasonGroups[0]?.episodes ?? [] return seasonGroups.find((s) => s.season === selectedSeason)?.episodes ?? [] }, [seasonGroups, selectedSeason]) const hasEpisodes = useMemo(() => { if (episodes.length > 1) return true if (episodes.length === 1 && media) { // 只有单条时,如果明确是集(有 episode_num > 0)或 ID 与自身不同,则按剧集渲染 return episodes[0].episode_num > 0 || episodes[0].id !== media.id } return false }, [episodes, media]) const firstPlayableEpisode = useMemo(() => { if (episodes.length === 0) return null return (visibleEpisodes.length > 0 ? visibleEpisodes[0] : episodes[0]) || null }, [episodes, visibleEpisodes]) const handleMetadataSaved = useCallback(async (next: Media) => { setMedia(next) await refresh() }, [refresh]) return { media, favourite, loading, episodes, loadingEpisodes, seasonGroups, selectedSeason, visibleEpisodes, hasEpisodes, firstPlayableEpisode, setSelectedSeason, manualScrapeOpen, metadataEditOpen, organizeOpen, scrapeEpisodeArtwork, refresh, handleMetadataSaved, setManualScrapeOpen, setMetadataEditOpen, setOrganizeOpen, setScrapeEpisodeArtwork, ...actions, } } function useMediaDetailRefresh({ id, setMedia, setFavourite, setLoading, setEpisodes, setLoadingEpisodes, }: MediaDetailRefreshParams): () => Promise { return useCallback(async () => { if (!id) return setLoading(true) setLoadingEpisodes(true) try { // 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表 // 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。 const nextMediaPromise = mediaAPI.get(id) const favouritesPromise = playbackAPI.listFavourites().catch(() => []) const episodesPromise = mediaAPI .getEpisodes(id) .then((r) => r.items ?? []) .catch(() => []) const nextMedia = await nextMediaPromise setMedia(nextMedia) setLoading(false) const favourites = await favouritesPromise setFavourite(favourites.some((item) => item.id === nextMedia.id)) const episodes = await episodesPromise setEpisodes(episodes) } finally { setLoading(false) setLoadingEpisodes(false) } }, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes]) } function useMediaDetailActions({ media, scrapeEpisodeArtwork, navigate, refresh, setFavourite, }: MediaDetailActionsParams) { const goBack = useCallback(() => goBackFromMediaDetail(media, navigate), [media, navigate]) const toggleFavourite = useCallback( () => toggleMediaFavourite(media, setFavourite), [media, setFavourite], ) const rescrape = useCallback( () => rescrapeMedia(media, scrapeEpisodeArtwork, refresh), [media, refresh, scrapeEpisodeArtwork], ) const reprobe = useCallback(() => reprobeMedia(media, refresh), [media, refresh]) const exportNFO = useCallback(() => exportMediaNFO(media), [media]) const deleteMedia = useCallback( () => deleteMediaFromLibrary(media, navigate), [media, navigate], ) return { goBack, toggleFavourite, rescrape, reprobe, exportNFO, deleteMedia } } function goBackFromMediaDetail(media: Media | null, navigate: NavigateFunction, replace = false): void { if (!media) { navigate('/libraries') return } const backTarget = mediaLibraryBackTarget(media) navigate(backTarget || '/libraries', replace ? { replace: true } : undefined) } async function toggleMediaFavourite( media: Media | null, setFavourite: Dispatch>, ): Promise { if (!media) return const state = await playbackAPI.toggleFavourite(media.id) setFavourite(state) toast.success(state ? '已加入我的收藏' : '已取消收藏') } async function rescrapeMedia( media: Media | null, scrapeEpisodeArtwork: boolean, refresh: () => Promise, ): Promise { if (!media) return await api.post(`/media/${media.id}/scrape`, { episode_images: scrapeEpisodeArtwork, refresh_matched: true, include_matched: true, }) toast.success('已触发重新刮削') await refresh() } async function reprobeMedia(media: Media | null, refresh: () => Promise): Promise { if (!media) return try { const result = await api.post(`/media/${media.id}/probe`) if (result.data?.code === 0) toast.success('重新探测成功') else toast.error(result.data?.error || '探测失败') await refresh() } catch (err: unknown) { toast.error(apiErrorMessage(err, '探测失败,请检查 ffprobe 是否已安装')) } } async function exportMediaNFO(media: Media | null): Promise { if (!media) return try { const result = await mediaAPI.exportNFO(media.id) toast.success(`NFO 已成功写入 ${result.path}`) } catch (err: unknown) { toast.error(apiErrorMessage(err, '导出失败')) } } async function deleteMediaFromLibrary(media: Media | null, navigate: NavigateFunction): Promise { if (!media) return const result = await confirmActionResult({ title: '删除媒体', message: `确定从媒体库删除「${media.title}」?\n默认仅移除索引记录,磁盘文件会保留。`, confirmText: '删除', checkboxLabel: '同时删除本地文件(含同名 NFO)', }) if (!result.confirmed) return await mediaAPI.delete(media.id, { deleteFiles: result.checked }) toast.success(result.checked ? '已删除媒体及本地文件' : '已从媒体库删除') goBackFromMediaDetail(media, navigate, true) } function apiErrorMessage(err: unknown, fallback: string): string { return (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback }