mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
4dc9cbe2e7
- 后端新增远程库→本地结构映射(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 转码等不适用操作;后台管理面板过滤远程库
230 lines
6.4 KiB
TypeScript
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}
|
|
/>
|
|
)
|
|
}
|