import type { ContentUnit, EntityMetadataField, ExternalIdentity, LibraryAssetView, LibraryDetail, LibraryDownloadView, MediaEntityDetail, } 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, } from "lucide-react"; import { 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 { 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 { StatusBadge } from "~/components/console/status-badge.tsx"; import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.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, CoverPlaceholder, Panel, ProgressLine, posterPathOf, relTime, Tone, } from "~/components/library/parts.tsx"; import { SlotCard } from "~/components/library/slot-card.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 { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; // 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、 // 内容单元、下载历史与校验结果,同一页同时展示。 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 ? (
    {bindings.map((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} {detail.assets.map((a) => (
{deliveryLine(a.asset) || "未知规格"} {a.asset.status !== "active" ? {a.asset.status} : null} {a.asset.verifiedAt ? ( 已核验 {relTime(a.asset.verifiedAt)} ) : ( 未核验 )}
    {a.locations.map((l) => (
  • {LOCATION_KIND[l.locationKind] ?? l.locationKind} {l.isPrimary ? 主位置 : null} {l.libraryRootId ? ( {rootOf(l.libraryRootId)?.name ?? "库根"} ) : null}

    {l.absolutePath ?? l.relativePath}

  • ))} {a.locations.length === 0 ?
  • 没有登记位置
  • : null}
))}
)}
); } // ── 外部身份映射 ── 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 ? ( ) : (
    {list.map((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 (
  • {i.provider}:{i.entityType}:{i.externalId} {i.locked ? ( 人工锁 ) : null}

    证据 {evidenceLabel(i.evidenceLevel)} · {VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus} {i.verifiedAt ? `(${relTime(i.verifiedAt)})` : ""} · r{i.revision}

    {reason ?

    {reason}

    : null}
  • ); } // ── 元数据字段来源与锁 ── const ENRICH_ONLY_FIELDS = new Set(["platform", "episodeCount", "genres", "posterUrl"]); /** 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; return ( refresh.mutate(undefined, { onSuccess: () => toast.success("已提交刷新"), onError: (err) => toast.error(errorMessage(err, "刷新失败")), }) } > 刷新元数据 } >
      {finals.map(({ field, final, locked }) => { const cands = candidatesOf(field); return (
    • {ENTITY_FIELD_LABEL[field]}

      {final ? ( Array.isArray(final.value) ? ( final.value.join("、") ) : ( String(final.value ?? "—") ) ) : ( 未提供 )}

      {final ? (

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

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

      候选: {cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(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}
    • ); })}
    {entity?.aliases.length ? (

    别名(用于模糊召回)

    ( {a.alias} {a.locked ? : 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 (
    {detail.slots.map((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" ? "电影按内容版本核对完整性。" : "识别到新集后会建临时单元。", }} /> ); } // ── 下载历史与校验 ── function DownloadsPanel({ detail }: { detail: LibraryDetail }) { const columns: DataColumn[] = [ { key: "name", header: "下载", cell: (d) => (
    {d.downloadTask.name}

    {d.plan ? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · ` : ""} {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} ))}
    ) : ( 尚未整理 ), }, ]; return ( d.downloadTask.id} empty={{ title: "还没有下载记录" }} /> ); } 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 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 redirected = entity.data?.redirectedFrom; return ( <> {back} {e.status === "merged" && e.mergedIntoId ? (
    这部作品已合并。 查看合并后的作品
    ) : null} {redirected ? (

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

    ) : null}

    {e.title}

    {MEDIA_KIND[e.kind]} {e.year ? ` · ${e.year}` : ""} {e.originalTitle ? ` · ${e.originalTitle}` : ""}

    {e.id}

    {(ms.data?.items.length ?? 0) > 0 ? (
    ) : null}
    {e.status !== "merged" ? ( <> ) : null} {e.status !== "merged" ? ( ) : null}
    {e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? ( ) : null}
    {subscribing ? ( setSubscribing(false)} /> ) : null} {launcher.dialog} ); }