Files
MeBox/web/src/pages/MediaDetailPageSections.tsx
T
truewhile 4dc9cbe2e7 feat: 网页端完整浏览/播放远程 Emby 挂载库
- 后端新增远程库→本地结构映射(emby_remote_web.go):Library/Media/SeriesCard
  与本地完全同构,前端无感知
- /api/libraries(含 with_preview)、/api/libraries/:id、/api/libraries/:id/media、
  /api/libraries/:id/series、/api/libraries/:id/series/episodes、/api/media/:id、
  /api/media/:id/episodes 全部支持远程伪装 ID 透传映射
- /api/stream/:id 对远程条目 302 直连远程 Emby 原地址(播放不经过本机)
- 库内容按 CollectionType 过滤(电影库取 Movie、剧集库取 Series),
  RecursiveItemCount 作为系列集数;修复伪装 ID 双重编码导致的图片/详情 500
- SPA fallback 放行 /library/embyremote~ 前缀(不再被当作 Emby API 路由 404)
- 前端:远程库/条目标记 is_remote_emby,隐藏扫描/刮削/编辑/NFO/回收站/
  HLS 转码等不适用操作;后台管理面板过滤远程库
2026-09-01 11:32:54 +08:00

230 lines
6.4 KiB
TypeScript

import { isRemoteEmbyID } from '../utils/remoteEmby'
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<void>
onOrganized: () => void
}
interface MediaDetailManualScrapeDialogProps {
open: boolean
media: Media
episodeArtwork: boolean
onClose: () => void
onApplied: () => void
}
export function MediaDetailLoading() {
return (
<div className="flex items-center justify-center py-48">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-100 border-t-gray-900" />
</div>
)
}
export function MediaDetailMissing() {
return (
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
<p className="text-gray-500">媒体资源已被移除或不存在</p>
</div>
)
}
export function MediaDetailBackButton({ onBack }: { onBack: () => void }) {
return (
<div className="relative z-20 px-6 pt-6 sm:px-10 sm:pt-8">
<button
type="button"
onClick={onBack}
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
>
<ArrowLeft size={16} />
<span>返回媒体库</span>
</button>
</div>
)
}
export function MediaDetailPlaybackActions({
media,
favourite,
onToggleFavourite,
}: MediaDetailPlaybackActionsProps) {
return (
<div className="flex flex-wrap gap-3">
<Link to={`/play/${media.id}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
{!isRemoteEmbyID(media.id) && (
<Link
to={`/play/${media.id}?mode=hls`}
state={{ from: `/media/${media.id}` }}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
)}
<ExternalPlayerButton mediaId={media.id} />
<button
onClick={onToggleFavourite}
className={
'btn-outline gap-2 ' +
(favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
}
>
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
</button>
</div>
)
}
export function MediaDetailMainContent({
media,
isAdmin,
favourite,
scrapeEpisodeArtwork,
onToggleFavourite,
onScrapeEpisodeArtworkChange,
onSmartScrape,
onManualScrape,
onMetadataEdit,
onOrganize,
onProbe,
onExportNFO,
onSoftDelete,
}: MediaDetailMainContentProps) {
return (
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
<MediaDetailPoster media={media} />
<div className="flex-1 space-y-6">
<MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" />
<div className="flex flex-col gap-5">
<MediaDetailPlaybackActions media={media} favourite={favourite} onToggleFavourite={onToggleFavourite} />
{isAdmin && (
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
onSmartScrape={onSmartScrape}
onManualScrape={onManualScrape}
onMetadataEdit={onMetadataEdit}
onOrganize={onOrganize}
onProbe={onProbe}
onExportNFO={onExportNFO}
onSoftDelete={onSoftDelete}
/>
)}
</div>
</div>
</div>
)
}
export function MediaDetailDialogs({
media,
manualScrapeOpen,
metadataEditOpen,
organizeOpen,
scrapeEpisodeArtwork,
onManualScrapeClose,
onMetadataEditClose,
onOrganizeClose,
onManualScrapeApplied,
onMetadataSaved,
onOrganized,
}: MediaDetailDialogsProps) {
return (
<>
<MediaDetailManualScrapeDialog
open={manualScrapeOpen}
media={media}
onClose={onManualScrapeClose}
onApplied={onManualScrapeApplied}
episodeArtwork={scrapeEpisodeArtwork}
/>
<MetadataEditDialog
open={metadataEditOpen}
media={media}
onClose={onMetadataEditClose}
onSaved={onMetadataSaved}
/>
<OrganizeMediaDialog
open={organizeOpen}
media={media}
onClose={onOrganizeClose}
onOrganized={onOrganized}
/>
</>
)
}
function MediaDetailManualScrapeDialog({
open,
media,
episodeArtwork,
onClose,
onApplied,
}: MediaDetailManualScrapeDialogProps) {
return (
<ManualScrapeDialog
open={open}
media={media}
defaultQuery={media.title}
mediaType={mediaDetailScrapeMediaType(media)}
scopeLabel={media.title}
episodeArtwork={episodeArtwork}
onClose={onClose}
onApplied={onApplied}
/>
)
}