feat(web): MP-style media detail with seasons and episodes, shared by library and TMDB preview (MD-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:28:16 +08:00
1 parent 9cd5a28943
commit 97ac59c7ac
9 files changed
+1250 -190

No files matched your search

+403 -92
View File
@@ -5,7 +5,9 @@ import type {
LibraryAssetView,
LibraryDetail,
LibraryDownloadView,
MediaCredit,
MediaEntityDetail,
SubscriptionCoverage,
} from "@umt/contracts";
import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts";
import {
@@ -22,8 +24,9 @@ import {
PencilLine,
RefreshCw,
SearchX,
Tv,
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { toast } from "sonner";
import { mediaServers } from "~/api/instances.ts";
@@ -34,6 +37,7 @@ import {
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";
@@ -55,20 +59,23 @@ import {
UNIT_KIND,
VERIFY_STATUS,
} from "~/components/library/labels.ts";
import {
Chip,
ChipList,
CoverPlaceholder,
Panel,
ProgressLine,
posterPathOf,
relTime,
Tone,
} from "~/components/library/parts.tsx";
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";
@@ -76,6 +83,8 @@ 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");
@@ -251,7 +260,52 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
// ── 元数据字段来源与锁 ──
const ENRICH_ONLY_FIELDS = new Set<string>(["platform", "episodeCount", "genres", "posterUrl"]);
/** 只在有值时显示的字段:补全专有(平台 / 集数 / 类型 / 网页海报)与 MD-1 TMDB 详情字段 */
const ENRICH_ONLY_FIELDS = new Set<string>([
"platform",
"episodeCount",
"genres",
"posterUrl",
"titleZh",
"tagline",
"lastAirDate",
"airStatus",
"voteAverage",
"voteCount",
"runtime",
"seasonCount",
"cast",
"crew",
"externalIds",
]);
/** 结构化值(列表 / 对象)不走文本覆盖对话框 */
const NOT_EDITABLE = new Set<string>(["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<string, unknown>)
.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 }) {
@@ -307,7 +361,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
return (
<Panel
title="元数据"
title="字段来源与锁"
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
actions={
<Button
@@ -315,10 +369,13 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
size="sm"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(undefined, {
onSuccess: () => toast.success("已提交刷新"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
})
refresh.mutate(
{ full: true },
{
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
},
)
}
>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
@@ -333,16 +390,8 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
<li key={field} className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
<div className="w-24 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENTITY_FIELD_LABEL[field]}</div>
<div className="min-w-0 flex-1 basis-48">
<p className="text-sm break-words text-foreground">
{final ? (
Array.isArray(final.value) ? (
final.value.join("、")
) : (
String(final.value ?? "—")
)
) : (
<span className="text-muted-foreground">未提供</span>
)}
<p className="line-clamp-4 text-sm break-words text-foreground">
{final ? fieldText(field, final.value) : <span className="text-muted-foreground">未提供</span>}
</p>
{final ? (
<p className="text-meta text-muted-foreground">
@@ -354,7 +403,9 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
{cands.length ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground">
候选:
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
{cands
.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${fieldText(field, c.value)}」`)
.join(" / ")}
</p>
) : null}
</div>
@@ -386,14 +437,16 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
/>
</>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
onClick={() => setEditing(field)}
>
<PencilLine />
</Button>
{NOT_EDITABLE.has(field) ? null : (
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
onClick={() => setEditing(field)}
>
<PencilLine />
</Button>
)}
</div>
</li>
);
@@ -540,6 +593,210 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
);
}
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按版本目标 ──
const CELL_TONE: Record<CellKind, "success" | "warning" | "destructive" | "neutral"> = {
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<string, EpisodeStatus>();
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<ContentUnit | null>(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<Set<number> | 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 (
<Panel
title="季与集"
description={
sub
? "库内状态按订阅的覆盖矩阵;标题与简介按语言回退链取值"
: "没有订阅时只看是否已入库;标题与简介按语言回退链取值"
}
actions={
<Button variant="outline" size="sm" disabled={refresh.isPending} onClick={refreshFull}>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
更新每集信息
</Button>
}
>
{rows.length > 1 ? (
<fieldset aria-label="按版本目标看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
{rows.map((r, i) => (
<button
key={r.slotId ?? "any"}
type="button"
aria-pressed={i === rowIdx}
onClick={() => setRowIdx(i)}
className={
i === rowIdx
? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground"
: "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
}
>
{r.name}
</button>
))}
</fieldset>
) : null}
{seasonNums.length === 0 ? (
<EmptyState
plain
icon={Tv}
title="还没有每集信息"
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
className="py-8"
/>
) : (
<div className="space-y-2">
{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 (
<SeasonSection
key={n}
s={{
seasonNumber: n,
name: typeof nameV === "string" ? nameV : (su?.title ?? null),
airDate: typeof airV === "string" ? airV : null,
episodeCount: total,
posterPath: typeof posterV === "string" ? posterV : null,
overview: typeof ovV === "string" ? ovV : null,
summary: (
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
已入库 {have}/{total ?? "?"}
</StatusBadge>
),
}}
open={openSet.has(n)}
onToggle={() => toggle(n)}
>
{eps.length === 0 ? (
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
) : (
<ul className="divide-y divide-border/60">
{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 (
<EpisodeRow
key={u.id}
e={{
key: u.id,
seasonNumber: n,
episodeNumber: u.episodeNumber!,
name: str(v("name")) ?? u.title,
overview: str(v("overview")),
airDate: str(v("airDate")) ?? u.airDate,
runtime: num(v("runtime")),
stillPath: str(v("stillPath")),
voteAverage: num(v("voteAverage")),
status: statusOf(u),
locked: u.fieldLocks.length > 0,
onEdit: () => setEditing(u),
}}
/>
);
})}
</ul>
)}
</SeasonSection>
);
})}
</div>
)}
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
</Panel>
);
}
// ── 下载历史与校验 ──
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
const columns: DataColumn<LibraryDownloadView>[] = [
@@ -628,6 +885,71 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
);
}
/** media_entity.metadata 某字段的最终值 */
function metaValue(metadata: unknown, field: string): unknown {
const p = FinalFieldValue.safeParse((metadata as Record<string, unknown> | 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);
@@ -671,6 +993,10 @@ export default function LibraryDetailPage() {
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}
@@ -685,66 +1011,51 @@ export default function LibraryDetailPage() {
{redirected ? (
<p className="mb-4 text-meta text-muted-foreground">旧 ID {redirected} 已跳转到当前作品。</p>
) : null}
<header className="mb-6 flex flex-wrap items-start gap-4">
<CoverPlaceholder
title={e.title}
kind={e.kind}
posterPath={posterPathOf(e.metadata)}
className="h-28 w-20 sm:h-36 sm:w-24"
/>
<div className="min-w-0 flex-1 basis-56">
<div className="flex flex-wrap items-center gap-2">
<h1 className="min-w-0 text-title-lg font-semibold tracking-tight break-words text-foreground">
{e.title}
</h1>
<MediaHero
facts={factsOf(d, entity.data)}
badges={
<>
<Tone v={ENTITY_STATUS[e.status]} />
</div>
<p className="mt-1 text-body text-muted-foreground">
{MEDIA_KIND[e.kind]}
{e.year ? ` · ${e.year}` : ""}
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
</p>
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
{(ms.data?.items.length ?? 0) > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
<EmbyBadge refOf={{ mediaEntityId: e.id }} />
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{e.status !== "merged" ? (
<>
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button
variant="outline"
size="sm"
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
>
<PackageSearch />
搜索资源
</Button>
</>
) : null}
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
查看订阅
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并作品
{(ms.data?.items.length ?? 0) > 0 ? <EmbyBadge refOf={{ mediaEntityId: e.id }} /> : null}
</>
}
/>
<div className="mt-4 mb-5 flex flex-wrap items-center gap-2">
{e.status !== "merged" ? (
<>
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
</div>
</header>
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}>
<PackageSearch />
搜索资源
</Button>
</>
) : null}
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
查看订阅
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并作品
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
<span className="font-mono text-meta break-all text-muted-foreground sm:ml-auto">{e.id}</span>
</div>
<div className="space-y-5">
{credits.cast.length || credits.crew.length ? (
<Panel title="主创">
<CreditsStrip cast={credits.cast} crew={credits.crew} />
</Panel>
) : null}
{e.kind !== "movie" ? <EpisodesPanel detail={d} units={entity.data?.contentUnits ?? []} /> : null}
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
<div className="grid gap-5 xl:grid-cols-2">
<IdentityPanel detail={d} />