import type { ContentUnit, EntityMetadataField, ExternalIdentity, LibraryAssetView, LibraryDetail, LibraryDownloadView, MediaCredit, MediaEntityDetail, SubscriptionCoverage, } from "@umt/contracts"; import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts"; import { ArrowLeft, CalendarClock, CalendarPlus, ChevronRight, ExternalLink, FolderTree, GitMerge, Lock, LockOpen, PackageSearch, PencilLine, RefreshCw, SearchX, Tv, } from "lucide-react"; import { useMemo, useState } from "react"; import { Link, useParams } from "react-router"; import { toast } from "sonner"; import { mediaServers } from "~/api/instances.ts"; import { useEntityDetail, useLibraryDetail, useLibraryRoots, useRefreshMetadata, useUnlockField, } from "~/api/library.ts"; import { useLibraryVersions } from "~/api/library-versions.ts"; import { useSubscriptionCoverage, useSubscriptions } from "~/api/subscriptions.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { PaginatedList } from "~/components/console/paginated-list.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts"; import { EpisodeMappingPanel } from "~/components/episode-mapping/mapping-panel.tsx"; import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx"; import { EpisodeStructureSwitcher } from "~/components/library/episode-structure.tsx"; import { CONTENT_TYPE, DOWNLOAD_STATUS, ENTITY_FIELD_LABEL, ENTITY_STATUS, IDENTITY_STATUS, LOCATION_KIND, LOCATION_STATUS, MEDIA_KIND, METADATA_SOURCE, ORG_STATUS, SEGMENT, UNIT_KIND, VERIFY_STATUS, } from "~/components/library/labels.ts"; import { Chip, ChipList, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx"; import { ScrapePanel } from "~/components/library/scrape-panel.tsx"; import { SlotCard } from "~/components/library/slot-card.tsx"; import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.tsx"; import { EpisodeVersionList, MovieVersionsPanel, seasonLine, UnplacedVersions, } from "~/components/library/versions.tsx"; import { externalLinks } from "~/components/media/external-links.ts"; import { AIR_STATUS, CreditsStrip, EpisodeRow, type MediaFacts, MediaHero, SeasonSection, today, } from "~/components/media/media-detail.tsx"; import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx"; import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx"; import { EmbyBadge } from "~/components/search/work-card.tsx"; import { SlotPanels, useSlotActions } from "~/components/slots/slot-mounts.tsx"; import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts"; import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { useUiPrefs } from "~/lib/ui-prefs.ts"; import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; // 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、 // 内容单元、下载历史与校验结果,同一页同时展示。 // MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按收录版本), // 头部 / 主创 / 季集组件与仅 TMDB 的作品详情页共用(components/media/media-detail)。 const pad = (n: number) => String(n).padStart(2, "0"); function unitCode(u: ContentUnit): string { const seg = SEGMENT[u.segment] ? `(${SEGMENT[u.segment]})` : ""; if (u.kind === "season") return u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : "季"; if (u.kind === "episode") { const s = u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : ""; const e = u.episodeNumber !== null ? `E${pad(u.episodeNumber)}` : ""; return `${s}${e}${seg}` || (u.sourceIssue ?? "集"); } return `${UNIT_KIND[u.kind] ?? u.kind}${u.episodeNumber !== null ? ` ${u.episodeNumber}` : ""}${seg}`; } function deliveryLine(a: LibraryAssetView["asset"]): string { const d = a.delivery; return [ d.resolution, d.sourceType, d.videoCodec, d.hdr?.join("/"), d.releaseGroup, a.contentEdition ? `版本 ${a.contentEdition}` : null, a.sizeBytes !== null ? formatBytes(a.sizeBytes, 1) : null, ] .filter(Boolean) .join(" · "); } // ── 实际路径与库根 ── function PathsPanel({ detail }: { detail: LibraryDetail }) { const roots = useLibraryRoots(); const rootOf = (id: string | null) => roots.data?.items.find((r) => r.id === id); const bindings = detail.pathBindings.filter((b) => b.status !== "superseded"); return ( {bindings.length === 0 && detail.assets.length === 0 ? ( ) : (
{bindings.length ? ( {(b) => { const root = rootOf(b.libraryRootId); return (
  • {b.bindingKind === "season" ? `第 ${b.seasonNumber} 季目录` : "作品目录"} 媒体库目录 {root?.name ?? "未知媒体库目录"} {b.status === "conflict" ? 绑定冲突 : null}

    {root ? `${root.localPath.replace(/\/+$/, "")}/` : ""} {b.relativePath}

  • ); }}
    ) : null} {(a) => (
    {deliveryLine(a.asset) || "未知规格"} {a.asset.status !== "active" ? {a.asset.status} : null} {a.asset.verifiedAt ? null : 未核验}
      {a.locations.map((l) => (
    • {l.locationKind !== "library" ? ( {LOCATION_KIND[l.locationKind] ?? l.locationKind} ) : null} {l.status !== "available" ? : null} {l.libraryRootId ? ( {rootOf(l.libraryRootId)?.name ?? "媒体库目录"} ) : null}

      {l.absolutePath ?? l.relativePath}

    • ))} {a.locations.length === 0 ?
    • 没有登记位置
    • : null}
    )}
    )}
    ); } const PROVIDER_LABEL: Record = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb", douban: "豆瓣" }; // ── 外部身份映射 ── function IdentityPanel({ detail }: { detail: LibraryDetail }) { const [open, setOpen] = useState(false); const list = [...detail.identities].sort((a, b) => order(a) - order(b)); const candidates = detail.identities.filter((i) => i.status === "proposed" || i.status === "conflicted"); return ( setOpen(true)}> 修改 } > {list.length === 0 ? ( ) : ( {(i) => } )} ); } const order = (i: ExternalIdentity) => ["accepted", "conflicted", "proposed", "retired"].indexOf(i.status); function IdentityRow({ i }: { i: ExternalIdentity }) { const reason = typeof i.evidence.reason === "string" ? i.evidence.reason : null; return (
  • {PROVIDER_LABEL[i.provider] ?? i.provider} {i.externalId} {i.status !== "accepted" ? : null} {i.locked ? ( 人工锁 ) : null}

    {i.locked || i.evidenceLevel === "manual" ? "人工确认" : "自动匹配"} {i.verifiedAt ? ` · ${relTime(i.verifiedAt)}` : ""}

    {reason ?

    {reason}

    : null}
  • ); } // ── 元数据字段来源与锁 ── /** 只在有值时显示的字段:补全专有(平台 / 集数 / 类型 / 网页海报)与 MD-1 TMDB 详情字段 */ const ENRICH_ONLY_FIELDS = new Set([ "platform", "episodeCount", "genres", "posterUrl", "titleZh", "tagline", "lastAirDate", "airStatus", "voteAverage", "voteCount", "runtime", "seasonCount", "cast", "crew", "externalIds", ]); /** 结构化值(列表 / 对象)不走文本覆盖对话框 */ const NOT_EDITABLE = new Set(["cast", "crew", "externalIds"]); /** 字段值 → 一行文本 */ function fieldText(field: string, v: unknown): string { if (v === null || v === undefined || v === "") return "—"; if (field === "cast" || field === "crew") { const list = (Array.isArray(v) ? v : []) as MediaCredit[]; return ( list .map((c) => (c.role ? `${c.name}(${c.role})` : c.name)) .slice(0, 6) .join("、") || "—" ); } if (field === "externalIds" && typeof v === "object") { return ( Object.entries(v as Record) .filter(([, x]) => x) .map(([k, x]) => `${k} ${String(x)}`) .join(" · ") || "—" ); } if (field === "airStatus" && typeof v === "string") return AIR_STATUS[v]?.label ?? v; if (Array.isArray(v)) return v.join("、"); if (typeof v === "object") return JSON.stringify(v); return String(v); } /** META-1 补全写入的字段:证据等级 + 出处网页(来源 web = 网页原文,agent = Agent 归纳,都不是 TMDB) */ function EvidenceLine({ field }: { field: MediaEntityDetail["metadataFields"][number] | undefined }) { const ev = field?.evidence; if (!ev) return null; let host: string | null = null; try { host = ev.sourceUrl ? new URL(ev.sourceUrl).hostname.replace(/^www\./, "") : null; } catch {} return (

    证据 {evidenceLabel(ev.evidenceLevel)} {ev.sourceUrl && host ? ( {host} ) : ( 没有出处 )} {field?.field === "posterUrl" && ev.enrichmentId ? ( 海报 ) : null}

    ); } function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: MediaEntityDetail | undefined }) { const id = detail.entity.id; const refresh = useRefreshMetadata(id); const unlock = useUnlockField(id); const [editing, setEditing] = useState(null); const locks = new Set(detail.metadataSources.fieldLocks); const fields = detail.metadataSources.fields; const finals = FieldEnum.options .map((f) => { const parsed = FinalFieldValue.safeParse(fields[f]); return { field: f, final: parsed.success ? parsed.data : null, locked: locks.has(f) }; }) // 补全专有字段(平台 / 集数 / 类型 / 网页海报)只在有值时显示 .filter((x) => x.final || !ENRICH_ONLY_FIELDS.has(x.field)); const candidatesOf = (f: string) => (entity?.metadataFields ?? []).filter((m) => m.field === f); const current = editing ? finals.find((x) => x.field === editing)?.final?.value : undefined; const prefs = useUiPrefs(); const [showSources, setShowSources] = useState(prefs.techInfoExpanded); const [allAliases, setAllAliases] = useState(false); // 别名:默认只列中英文与手动加的,其余折成 +N const aliases = entity?.aliases ?? []; const keyAliases = aliases.filter( (a) => a.locked || !a.language || /^(zh|en|cn)/i.test(a.language) || /[\p{Script=Han}]/u.test(a.alias), ); const shownAliases = allAliases ? aliases : keyAliases.slice(0, 12); const moreAliases = aliases.length - shownAliases.length; return ( } >
      {finals.map(({ field, final, locked }) => { const cands = candidatesOf(field); return (
    • {ENTITY_FIELD_LABEL[field]}

      {final ? fieldText(field, final.value) : 未提供}

      {final && showSources ? (

      来源 {METADATA_SOURCE[final.source] ?? final.source} {final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}

      ) : null} {final && showSources ? c.source === final.source)} /> : null} {cands.length && showSources ? (

      候选: {cands .map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${fieldText(field, c.value)}」`) .join(" / ")}

      ) : null}
      {locked ? ( <> 已锁定 } title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`} description="取消人工修改,下次刷新按来源优先级取值。" confirmLabel="解锁" onConfirm={() => unlock.mutateAsync(field).then( () => toast.success("已解锁"), (err) => { toast.error(errorMessage(err, "解锁失败")); throw err; }, ) } /> ) : null} {NOT_EDITABLE.has(field) ? null : ( )}
    • ); })}
    {entity?.aliases.length ? (

    别名

    ( {a.alias} {a.locked ? : null} ))} /> {moreAliases > 0 || allAliases ? ( ) : null}
    ) : null} !o && setEditing(null)} />
    ); } // ── Slot ── function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) { if (detail.slots.length === 0) { return ( 去订阅 } className="py-8" /> ); } const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id); return ( {(s) => { const cur = assetOf(s.currentAssetId); const unit = units.find((u) => u.id === s.contentUnitId); return ( c.slotId === s.id).slice(0, 5)} /> ); }} ); } // ── 内容单元 ── function UnitsPanel({ entity, detail }: { entity: ReturnType; detail: LibraryDetail }) { const covered = new Set(detail.assets.flatMap((a) => a.contentUnitIds)); const columns: DataColumn[] = [ { key: "code", header: "编号", cell: (u) => {unitCode(u)}, }, { key: "title", header: "标题 / 期号", cell: (u) => (

    {u.title ?? u.sourceIssue ?? "—"}

    {UNIT_KIND[u.kind]} · {CONTENT_TYPE[u.contentType]} {u.airDate ? ` · ${u.airDate}` : ""}

    ), }, { key: "status", header: "状态", cell: (u) => (
    {u.status === "provisional" ? ( 编号待确认 ) : u.status === "retired" ? ( 已退役 ) : ( 已确认 )} {u.numberingLocked ? ( 编号锁定 ) : null}
    ), }, { key: "asset", header: "已有文件", hideBelow: "sm", cell: (u) => covered.has(u.id) ? ( 已整理 ) : ( — ), }, ]; const units = [...(entity.data?.contentUnits ?? [])].sort( (a, b) => (a.seasonNumber ?? 999) - (b.seasonNumber ?? 999) || (a.kind === "season" ? -1 : 0) - (b.kind === "season" ? -1 : 0) || (a.episodeNumber ?? 0) - (b.episodeNumber ?? 0) || a.segment.localeCompare(b.segment), ); return ( u.id} loading={entity.isPending} error={entity.error} onRetry={() => entity.refetch()} empty={{ title: detail.entity.kind === "movie" ? "电影没有季和集" : "还没有季和集", description: detail.entity.kind === "movie" ? undefined : "识别到新的一集后会出现在这里。", }} /> ); } // ── 季与集(MD-1):季折叠 + 每集行,库内状态按收录版本 ── const CELL_TONE: Record = { have: "success", upgradable: "success", inflight: "neutral", deferred: "warning", missing: "destructive", suspected: "warning", not_aired: "neutral", skipped: "neutral", }; type EpisodeStatus = { label: string; tone: "success" | "warning" | "destructive" | "neutral"; have: boolean }; /** 覆盖矩阵 → (季, 集) 的格:按内容单元 ID 或集号对列 */ function coverageLookup(cov: SubscriptionCoverage | undefined, rowIdx: number) { const map = new Map(); if (!cov) return map; const row = cov.rows[rowIdx] ?? cov.rows[0]; if (!row) return map; let i = 0; for (const s of cov.seasons) { for (const col of s.columns) { const cell = row.cells[i++]; if (!cell || s.seasonNumber === null) continue; const k = cellKind(cell); const st = { label: CELL_LABEL[k], tone: CELL_TONE[k], have: k === "have" || k === "upgradable" }; if (col.contentUnitId) map.set(`u:${col.contentUnitId}`, st); if (col.episode !== null) map.set(`e:${s.seasonNumber}:${col.episode}`, st); } } return map; } function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) { const e = detail.entity; const [editing, setEditing] = useState(null); const [rowIdx, setRowIdx] = useState(0); const refresh = useRefreshMetadata(e.id); const subs = useSubscriptions({ mediaEntityId: e.id }); const sub = subs.data?.pages .flatMap((p) => p.items) .find((x) => x.status === "active" || x.status === "pending" || x.status === "paused"); const cov = useSubscriptionCoverage(sub?.id); const live = units.filter((u) => u.status !== "retired"); const seasonUnits = live.filter((u) => u.kind === "season" && u.seasonNumber !== null); const episodes = live .filter((u) => u.kind === "episode" && u.seasonNumber !== null && u.episodeNumber !== null) .sort((a, b) => a.episodeNumber! - b.episodeNumber! || a.createdAt.localeCompare(b.createdAt)); const covered = new Set(detail.assets.filter((a) => a.asset.status === "active").flatMap((a) => a.contentUnitIds)); const lookup = useMemo(() => coverageLookup(cov.data, rowIdx), [cov.data, rowIdx]); // LIBV-1:每集的入库版本,比对跟随上方选中的收录版本 const versions = useLibraryVersions(e.id, cov.data?.rows[rowIdx]?.slotId ?? undefined); const versionSeasons = versions.data?.seasons ?? []; // 选中「库内任一版本」时不按收录版本比对 const anyVersion = cov.data?.rows[rowIdx] !== undefined && cov.data.rows[rowIdx]?.slotId === null; const versionsOf = (u: ContentUnit) => { const s = versionSeasons.find((x) => x.seasonNumber === u.seasonNumber); return s?.episodes.find((x) => x.unitId === u.id) ?? s?.episodes.find((x) => x.episodeNumber === u.episodeNumber); }; const seasonNums = [ ...new Set([...seasonUnits.map((u) => u.seasonNumber!), ...episodes.map((u) => u.seasonNumber!)]), ].sort((a, b) => (a || 999) - (b || 999)); const seasonUnitOf = (n: number) => seasonUnits.find((u) => u.seasonNumber === n); const trackedSeason = sub?.seasonUnitId ? units.find((u) => u.id === sub.seasonUnitId)?.seasonNumber : undefined; const statusOf = (u: ContentUnit): EpisodeStatus | null => { const hit = lookup.get(`u:${u.id}`) ?? lookup.get(`e:${u.seasonNumber}:${u.episodeNumber}`); if (hit) return hit; const season = seasonUnitOf(u.seasonNumber!); if (covered.has(u.id) || (season && covered.has(season.id))) return { label: "已整理", tone: "success", have: true }; const air = finalOf(u, "airDate")?.value ?? u.airDate; if (typeof air === "string" && air > today()) return { label: "未播出", tone: "neutral", have: false }; return { label: sub ? "缺失" : "未整理", tone: sub ? "destructive" : "neutral", have: false }; }; const haveIn = (n: number) => episodes.filter((u) => u.seasonNumber === n && statusOf(u)?.have).length; const initial = trackedSeason ?? [...seasonNums].reverse().find((n) => n > 0 && haveIn(n) > 0) ?? [...seasonNums].reverse().find((n) => n > 0) ?? null; const [open, setOpen] = useState | null>(null); const openSet = open ?? new Set(initial !== null && initial !== undefined ? [initial] : []); const toggle = (n: number) => { const next = new Set(openSet); if (next.has(n)) next.delete(n); else next.add(n); setOpen(next); }; const rows = cov.data?.rows ?? []; const refreshFull = () => refresh.mutate( { full: true }, { onSuccess: () => toast.success("已开始刷新,包括全部季的每集信息"), onError: (err) => toast.error(errorMessage(err, "刷新失败")), }, ); return ( 更新每集信息 } > {rows.length > 1 ? ( {(r, i) => ( )} ) : null} {seasonNums.length === 0 ? ( ) : ( {(n) => { const su = seasonUnitOf(n); const eps = episodes.filter((u) => u.seasonNumber === n); const total = su?.episodeTotal ?? (eps.length || null); const nameV = su ? finalOf(su, "name")?.value : null; const posterV = su ? finalOf(su, "posterPath")?.value : null; const ovV = su ? finalOf(su, "overview")?.value : null; const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null; const have = haveIn(n); return ( { const vs = versionSeasons.find((x) => x.seasonNumber === n); return vs ? seasonLine(vs, !anyVersion) : null; })(), summary: ( = total ? "success" : "neutral"}> 已整理 {have}/{total ?? "?"} ), }} open={openSet.has(n)} onToggle={() => toggle(n)} > {eps.length === 0 ? (

    这一季还没有集信息。

    ) : ( {(u) => { const v = (f: string) => finalOf(u, f)?.value; const str = (x: unknown) => (typeof x === "string" && x ? x : null); const num = (x: unknown) => (typeof x === "number" ? x : null); return ( 0, onEdit: () => setEditing(u), extra: (() => { const ev = versionsOf(u); return ev ? ( ) : null; })(), }} /> ); }} )}
    ); }}
    )}
    !o && setEditing(null)} />
    ); } // ── 下载历史与校验 ── function DownloadsPanel({ detail }: { detail: LibraryDetail }) { const columns: DataColumn[] = [ { key: "name", header: "下载", cell: (d) => (
    {d.downloadTask.name}

    {d.plan ? `${PURPOSE_LABEL[d.plan.purpose]} · ` : d.downloadTask.origin === "adopted" ? "接管 · " : ""} {formatDateTime(d.downloadTask.createdAt)}

    ), }, { key: "status", header: "下载状态", cell: (d) => (
    {d.downloadTask.status !== "imported" ? (
    {Math.round(d.downloadTask.progress * 100)}%
    ) : null}
    ), }, { key: "size", header: "大小", align: "right", hideBelow: "md", cell: (d) => ( {d.downloadTask.sizeBytes !== null ? formatBytes(d.downloadTask.sizeBytes, 1) : "—"} ), }, { key: "verify", header: "整理与校验", cell: (d) => d.organizationRecordIds.length ? (
    {d.lastOrganizationStatus ? : null} {d.organizationRecordIds.map((rid, i) => ( 记录 {i + 1} ))}
    ) : ( 尚未整理 ), }, ]; // 没有下载任务的整理记录(文件管理按路径手动整理等),下载任务的记录已在上表 const listed = new Set(detail.downloads.map((d) => d.downloadTask.id)); const manual = (detail.organizationRecords ?? []).filter((r) => !r.downloadTaskId || !listed.has(r.downloadTaskId)); // DL-3:没提交到下载器就结束的下载(失败 / 取消 / 过期),原因走原因码表,原码进技术信息 const dropped = detail.droppedPlans ?? []; return ( {detail.downloads.length || !dropped.length ? ( d.downloadTask.id} empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }} /> ) : null} {dropped.length ? (

    没提交到下载器

    {({ plan, releaseTitle }) => { const st = DROPPED_STATUS[plan.status as keyof typeof DROPPED_STATUS] ?? DROPPED_STATUS.failed; const why = droppedReason(plan.statusReason); return (
  • {st.label} {releaseTitle ?? "未知资源"} {PURPOSE_LABEL[plan.purpose]} · {formatDateTime(plan.updatedAt)}
    {why ?

    {why}

    : null}
  • ); }}
    ) : null} {manual.length ? (

    其他整理记录

    {(r) => (
  • {r.targetPath ?? r.sourcePath ?? r.id} {r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "智能体" : "自动"} · {r.fileSucceeded}/ {r.fileTotal} 个文件 · {formatDateTime(r.createdAt)}
  • )}
    ) : null}
    ); } /** media_entity.metadata 某字段的最终值 */ function metaValue(metadata: unknown, field: string): unknown { const p = FinalFieldValue.safeParse((metadata as Record | null)?.[field]); return p.success ? p.data.value : undefined; } const strOf = (v: unknown) => (typeof v === "string" && v ? v : null); const numOf = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null); function creditList(v: unknown): MediaCredit[] { return Array.isArray(v) ? (v as MediaCredit[]).filter((c) => c && typeof c.name === "string" && typeof c.id === "number") : []; } /** 本地作品 → 媒体详情头部数据(字段来自最终元数据;外部链接取已接受的外部身份与 externalIds) */ function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): MediaFacts { const e = d.entity; const m = (f: string) => metaValue(e.metadata, f); const accepted = d.identities.filter((i) => i.status === "accepted"); const tmdb = accepted.find((i) => i.provider === "tmdb" && (i.entityType === "movie" || i.entityType === "tv")); const ext = (m("externalIds") ?? {}) as { imdb?: string | null; tvdb?: string | null; wikidata?: string | null }; const genres = m("genres"); const country = m("originCountry"); const seasonCount = numOf(m("seasonCount")) ?? (new Set( (entity?.contentUnits ?? []) .filter((u) => u.kind === "season" && (u.seasonNumber ?? 0) > 0 && u.status !== "retired") .map((u) => u.seasonNumber), ).size || null); return { title: e.title, originalTitle: e.originalTitle, titleZh: strOf(m("titleZh")), year: e.year, kind: e.kind, kindLabel: MEDIA_KIND[e.kind] ?? e.kind, genres: Array.isArray(genres) ? genres.filter((x): x is string => typeof x === "string") : strOf(genres) ? [genres as string] : [], voteAverage: numOf(m("voteAverage")), voteCount: numOf(m("voteCount")), airStatus: strOf(m("airStatus")), runtime: numOf(m("runtime")), seasonCount: e.kind === "movie" ? null : seasonCount, episodeCount: e.kind === "movie" ? null : numOf(m("episodeCount")), releaseDate: strOf(m("releaseDate")), lastAirDate: strOf(m("lastAirDate")), platform: strOf(m("platform")), originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [], overview: strOf(m("overview")), // 头部标语只显示简体中文,由服务端按取值语言 / 文字判断(存储的 metadata.tagline 不变) tagline: d.displayTagline ?? null, posterPath: posterPathOf(e.metadata), backdropPath: strOf(m("backdropPath")), externalLinks: externalLinks({ tmdb: tmdb ? { type: tmdb.entityType as "movie" | "tv", id: tmdb.externalId } : null, imdb: ext.imdb ?? accepted.find((i) => i.provider === "imdb")?.externalId ?? null, tvdb: ext.tvdb ?? accepted.find((i) => i.provider === "tvdb")?.externalId ?? null, wikidata: ext.wikidata ?? null, }), }; } export default function LibraryDetailPage() { const { id } = useParams(); const detail = useLibraryDetail(id); const entity = useEntityDetail(id); const [merging, setMerging] = useState(false); const [subscribing, setSubscribing] = useState(false); const entityActions = useSlotActions("entity.actions"); const launcher = useReleaseSearchLauncher(); const ms = mediaServers.useList(); const back = ( 媒体库 ); if (detail.isPending) { return ( <> {back} ); } if (detail.isError) { const notFound = (detail.error as { code?: string }).code === "not_found"; return ( <> {back} detail.refetch()} /> ); } const d = detail.data; const e = d.entity; const slotEntity = { id: e.id, title: e.title, kind: e.kind, status: e.status }; const redirected = entity.data?.redirectedFrom; const credits = { cast: creditList(metaValue(e.metadata, "cast")), crew: creditList(metaValue(e.metadata, "crew")), }; return ( <> {back} {e.status === "merged" && e.mergedIntoId ? (
    这部作品已合并。 查看合并后的作品
    ) : null} {redirected ? (

    旧 ID {redirected} 已跳转到当前作品。

    ) : null} {(ms.data?.items.length ?? 0) > 0 ? : null} } actions={ <> {e.status !== "merged" ? ( <> {/* 头图区唯一的主操作(棱镜高光);其余是次操作 */} ) : null} {e.status !== "merged" ? ( ) : null} {/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */} {entityActions.items({ entity: slotEntity }).map((a) => ( ))} } />
    {credits.cast.length || credits.crew.length ? ( ) : null} {e.kind !== "movie" ? : null} {e.kind === "movie" ? : null} {e.kind === "series" || e.kind === "variety" ? : null}
    {e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? ( ) : null} {/* 作品页面板区插槽(ADR-006) */}
    {subscribing ? ( setSubscribing(false)} /> ) : null} {entityActions.dialog} {launcher.dialog} ); }