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 { 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 { 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, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.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 { 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 { 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 ? (
{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)}
) : (
未核验
)}
))}
)}
);
}
// ── 外部身份映射 ──
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 ? (
) : (
)}
);
}
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}
);
}
// ── 元数据字段来源与锁 ──
/** 只在有值时显示的字段:补全专有(平台 / 集数 / 类型 / 网页海报)与 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;
return (
refresh.mutate(
{ full: true },
{
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
},
)
}
>
刷新元数据
}
>
{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" ? "电影按内容版本核对完整性。" : "识别到新集后会建临时单元。",
}}
/>
);
}
// ── 季与集(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]);
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 ? (
) : null}
{seasonNums.length === 0 ? (
) : (
{seasonNums.map((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 (
= total ? "success" : "neutral"}>
已入库 {have}/{total ?? "?"}
),
}}
open={openSet.has(n)}
onToggle={() => toggle(n)}
>
{eps.length === 0 ? (
这一季还没有集信息。
) : (
{eps.map((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),
}}
/>
);
})}
)}
);
})}
)}
!o && setEditing(null)} />
);
}
// ── 下载历史与校验 ──
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: "还没有下载记录" }}
/>
);
}
/** 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")),
tagline: strOf(m("tagline")),
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 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;
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}
>
}
/>
{e.status !== "merged" ? (
<>
>
) : null}
{e.status !== "merged" ? (
) : null}
{e.id}
{credits.cast.length || credits.crew.length ? (
) : null}
{e.kind !== "movie" ?
: null}
{e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? (
) : null}
{subscribing ? (
setSubscribing(false)}
/>
) : null}
{launcher.dialog}
>
);
}