diff --git a/web/src/pages/MediaDetailPage.tsx b/web/src/pages/MediaDetailPage.tsx index 42739fd..13bc28f 100644 --- a/web/src/pages/MediaDetailPage.tsx +++ b/web/src/pages/MediaDetailPage.tsx @@ -1,236 +1,59 @@ -import { useCallback, useEffect, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router-dom' -import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react' -import toast from 'react-hot-toast' +import { useNavigate, useParams } from 'react-router-dom' -import { mediaAPI } from '../api/library' -import { playbackAPI } from '../api/playback' -import { recycleAPI } from '../api/recycle' import { useAuthStore } from '../stores/auth' -import { api } from '../api/client' -import type { Media } from '../types' -import { confirmAction } from '../components/confirmAction' -import { ExternalPlayerButton } from '../components/ExternalPlayerButton' -import { ManualScrapeDialog } from '../components/ManualScrapeDialog' -import { MetadataEditDialog } from '../components/MetadataEditDialog' -import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog' -import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries' -import { MediaDetailAdminPanel } from './MediaDetailAdminPanel' -import { MediaDetailBackdrop, MediaDetailPoster } from './MediaDetailArtwork' -import { MediaDetailMetadata } from './MediaDetailMetadata' - -function mediaLibraryBackTarget(media: Media): string { - const libraryID = media.display_library_id || media.library_id - if (!libraryID) return '' - if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}` - - const seriesKey = getSeriesKey(media) - const target = `/library/${encodeURIComponent(libraryID)}` - return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target -} +import { MediaDetailBackdrop } from './MediaDetailArtwork' +import { + MediaDetailBackButton, + MediaDetailDialogs, + MediaDetailLoading, + MediaDetailMainContent, + MediaDetailMissing, +} from './MediaDetailPageSections' +import { useMediaDetailPageState } from './useMediaDetailPageState' 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 [manualScrapeOpen, setManualScrapeOpen] = useState(false) - const [metadataEditOpen, setMetadataEditOpen] = useState(false) - const [organizeOpen, setOrganizeOpen] = useState(false) - const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false) + const detail = useMediaDetailPageState({ id, navigate }) - const refresh = useCallback(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) - } - }, [id]) - - useEffect(() => { - refresh().catch(() => undefined) - }, [refresh]) - - 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`, { - episode_images: scrapeEpisodeArtwork, - refresh_matched: true, - include_matched: true, - }) - toast.success('已触发重新刮削') - await refresh() - } - - const reprobe = async () => { - if (!media) return - try { - const r = await api.post(`/media/${media.id}/probe`) - if (r.data?.code === 0) { - toast.success('重新探测成功') - } else { - toast.error(r.data?.error || '探测失败') - } - await refresh() - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '探测失败,请检查 ffprobe 是否已安装' - toast.error(msg) - } - } - - 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 (!(await confirmAction({ title: '移入回收站', message: `将「${media.title}」移至回收站? (磁盘文件保留)`, confirmText: '移入回收站' }))) return - await recycleAPI.softDelete(media.id) - toast.success('已移至回收站') - const backTarget = mediaLibraryBackTarget(media) - if (backTarget) navigate(backTarget, { replace: true }) - else navigate(-1) - } - - if (loading) { - return ( -
-
-
- ) - } - if (!media) { - return ( -
-

媒体资源已被移除或不存在

-
- ) - } + if (detail.loading) return + if (!detail.media) return + const media = detail.media return (
-
- -
+ -
- - -
- - -
- -
-
- - - 立即播放 - - - - - HLS 兼容转码播放 - - - - - -
- - {role === 'admin' && ( - setManualScrapeOpen(true)} - onMetadataEdit={() => setMetadataEditOpen(true)} - onOrganize={() => setOrganizeOpen(true)} - onProbe={reprobe} - onExportNFO={exportNFO} - onSoftDelete={softDelete} - /> - )} -
-
-
- 0 || media.episode_num > 0 ? 'tv' : undefined} - scopeLabel={media.title} - episodeArtwork={scrapeEpisodeArtwork} - onClose={() => setManualScrapeOpen(false)} - onApplied={refresh} + isAdmin={role === 'admin'} + favourite={detail.favourite} + scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork} + onToggleFavourite={detail.toggleFavourite} + onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork} + onSmartScrape={detail.rescrape} + onManualScrape={() => detail.setManualScrapeOpen(true)} + onMetadataEdit={() => detail.setMetadataEditOpen(true)} + onOrganize={() => detail.setOrganizeOpen(true)} + onProbe={detail.reprobe} + onExportNFO={detail.exportNFO} + onSoftDelete={detail.softDelete} /> - setMetadataEditOpen(false)} - onSaved={async (next) => { - setMedia(next) - await refresh() - }} - /> - setOrganizeOpen(false)} - onOrganized={refresh} + manualScrapeOpen={detail.manualScrapeOpen} + metadataEditOpen={detail.metadataEditOpen} + organizeOpen={detail.organizeOpen} + scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork} + onManualScrapeClose={() => detail.setManualScrapeOpen(false)} + onMetadataEditClose={() => detail.setMetadataEditOpen(false)} + onOrganizeClose={() => detail.setOrganizeOpen(false)} + onManualScrapeApplied={detail.refresh} + onMetadataSaved={detail.handleMetadataSaved} + onOrganized={detail.refresh} />
) diff --git a/web/src/pages/MediaDetailPageModel.ts b/web/src/pages/MediaDetailPageModel.ts new file mode 100644 index 0000000..3d34f35 --- /dev/null +++ b/web/src/pages/MediaDetailPageModel.ts @@ -0,0 +1,16 @@ +import type { Media } from '../types' +import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries' + +export function mediaLibraryBackTarget(media: Media): string { + const libraryID = media.display_library_id || media.library_id + if (!libraryID) return '' + if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}` + + const seriesKey = getSeriesKey(media) + const target = `/library/${encodeURIComponent(libraryID)}` + return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target +} + +export function mediaDetailScrapeMediaType(media: Media): string | undefined { + return media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined +} diff --git a/web/src/pages/MediaDetailPageSections.tsx b/web/src/pages/MediaDetailPageSections.tsx new file mode 100644 index 0000000..485606e --- /dev/null +++ b/web/src/pages/MediaDetailPageSections.tsx @@ -0,0 +1,225 @@ +import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react' +import { Link } from 'react-router-dom' + +import { ExternalPlayerButton } from '../components/ExternalPlayerButton' +import { ManualScrapeDialog } from '../components/ManualScrapeDialog' +import { MetadataEditDialog } from '../components/MetadataEditDialog' +import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog' +import type { Media } from '../types' +import { MediaDetailAdminPanel } from './MediaDetailAdminPanel' +import { MediaDetailPoster } from './MediaDetailArtwork' +import { MediaDetailMetadata } from './MediaDetailMetadata' +import { mediaDetailScrapeMediaType } from './MediaDetailPageModel' + +interface MediaDetailPlaybackActionsProps { + media: Media + favourite: boolean + onToggleFavourite: () => void +} + +interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps { + isAdmin: boolean + scrapeEpisodeArtwork: boolean + onScrapeEpisodeArtworkChange: (checked: boolean) => void + onSmartScrape: () => void + onManualScrape: () => void + onMetadataEdit: () => void + onOrganize: () => void + onProbe: () => void + onExportNFO: () => void + onSoftDelete: () => void +} + +interface MediaDetailDialogsProps { + media: Media + manualScrapeOpen: boolean + metadataEditOpen: boolean + organizeOpen: boolean + scrapeEpisodeArtwork: boolean + onManualScrapeClose: () => void + onMetadataEditClose: () => void + onOrganizeClose: () => void + onManualScrapeApplied: () => void + onMetadataSaved: (media: Media) => void | Promise + onOrganized: () => void +} + +interface MediaDetailManualScrapeDialogProps { + open: boolean + media: Media + episodeArtwork: boolean + onClose: () => void + onApplied: () => void +} + +export function MediaDetailLoading() { + return ( +
+
+
+ ) +} + +export function MediaDetailMissing() { + return ( +
+

媒体资源已被移除或不存在

+
+ ) +} + +export function MediaDetailBackButton({ onBack }: { onBack: () => void }) { + return ( +
+ +
+ ) +} + +export function MediaDetailPlaybackActions({ + media, + favourite, + onToggleFavourite, +}: MediaDetailPlaybackActionsProps) { + return ( +
+ + + 立即播放 + + + + + HLS 兼容转码播放 + + + + + +
+ ) +} + +export function MediaDetailMainContent({ + media, + isAdmin, + favourite, + scrapeEpisodeArtwork, + onToggleFavourite, + onScrapeEpisodeArtworkChange, + onSmartScrape, + onManualScrape, + onMetadataEdit, + onOrganize, + onProbe, + onExportNFO, + onSoftDelete, +}: MediaDetailMainContentProps) { + return ( +
+ + +
+ +
+
+ + {isAdmin && ( + + )} +
+
+
+ ) +} + +export function MediaDetailDialogs({ + media, + manualScrapeOpen, + metadataEditOpen, + organizeOpen, + scrapeEpisodeArtwork, + onManualScrapeClose, + onMetadataEditClose, + onOrganizeClose, + onManualScrapeApplied, + onMetadataSaved, + onOrganized, +}: MediaDetailDialogsProps) { + return ( + <> + + + + + ) +} + +function MediaDetailManualScrapeDialog({ + open, + media, + episodeArtwork, + onClose, + onApplied, +}: MediaDetailManualScrapeDialogProps) { + return ( + + ) +} diff --git a/web/src/pages/useMediaDetailPageState.ts b/web/src/pages/useMediaDetailPageState.ts new file mode 100644 index 0000000..1020944 --- /dev/null +++ b/web/src/pages/useMediaDetailPageState.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, 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 { recycleAPI } from '../api/recycle' +import { confirmAction } 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> +} + +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 [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 }) + const actions = useMediaDetailActions({ + media, + scrapeEpisodeArtwork, + navigate, + refresh, + setFavourite, + }) + + useEffect(() => { + refresh().catch(() => undefined) + }, [refresh]) + + const handleMetadataSaved = useCallback(async (next: Media) => { + setMedia(next) + await refresh() + }, [refresh]) + + return { + media, + favourite, + loading, + manualScrapeOpen, + metadataEditOpen, + organizeOpen, + scrapeEpisodeArtwork, + refresh, + handleMetadataSaved, + setManualScrapeOpen, + setMetadataEditOpen, + setOrganizeOpen, + setScrapeEpisodeArtwork, + ...actions, + } +} + +function useMediaDetailRefresh({ + id, + setMedia, + setFavourite, + setLoading, +}: MediaDetailRefreshParams): () => Promise { + return useCallback(async () => { + if (!id) return + setLoading(true) + try { + const nextMedia = await mediaAPI.get(id) + setMedia(nextMedia) + const favourites = await playbackAPI.listFavourites().catch(() => []) + setFavourite(favourites.some((item) => item.id === nextMedia.id)) + } finally { + setLoading(false) + } + }, [id, setFavourite, setLoading, setMedia]) +} + +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 softDelete = useCallback( + () => softDeleteMedia(media, navigate), + [media, navigate], + ) + return { goBack, toggleFavourite, rescrape, reprobe, exportNFO, softDelete } +} + +function goBackFromMediaDetail(media: Media | null, navigate: NavigateFunction, replace = false): void { + if (!media) return + const backTarget = mediaLibraryBackTarget(media) + if (backTarget) navigate(backTarget, replace ? { replace: true } : undefined) + else navigate(-1) +} + +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 recycleAPI.exportNFO(media.id) + toast.success(`NFO 已成功写入 ${result.path}`) + } catch (err: unknown) { + toast.error(apiErrorMessage(err, '导出失败')) + } +} + +async function softDeleteMedia(media: Media | null, navigate: NavigateFunction): Promise { + if (!media) return + const confirmed = await confirmAction({ + title: '移入回收站', + message: `将「${media.title}」移至回收站? (磁盘文件保留)`, + confirmText: '移入回收站', + }) + if (!confirmed) return + await recycleAPI.softDelete(media.id) + toast.success('已移至回收站') + goBackFromMediaDetail(media, navigate, true) +} + +function apiErrorMessage(err: unknown, fallback: string): string { + return (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback +}