Files
UHD_Media_Tool/apps/web/app/routes/console/library.$id.tsx
T
rootandClaude Opus 5.5 f4666a4671 feat(identity): web metadata enrichment for titles missing on TMDB (META-1)
- metadata.enrich (propose, low, replay safe): normalized-title cache (kv_cache
  region enrich, 7 days) -> daily budget -> web.search (title + 短剧 / 电视剧 平台,
  second query limited to whitelist via site:) -> web.fetch of up to 3 relevant
  whitelisted pages (registered site domains never read, redirects out of the
  whitelist dropped) -> upstream model extraction with rule fallback -> server-side
  evidence check (rule_confirmed / multi_evidence / needs_review). Suggestion only.
- POST /identity-pool/{id}/enrich (suggest) and /enrich/apply (console session):
  provisional entity or same-title merge, season/episode units, field-level
  metadata with source web/agent + evidence, aliases, release assignment.
  Poster proxy GET /img/enrich/{id} (og:image from whitelisted pages only).
- Auto-enrich once per pool entry when subscription/rule lookups find nothing on
  TMDB (identity.autoEnrich, off by default); identity.enrichDailyBudget,
  identity.enrichDomains settings; web.fetch now returns og:image.
- Pool sheet "元数据补全" section; entity detail shows evidence links.
- Migration 0049_enrichment (metadata_enrichment, metadata_field.evidence, source
  web). Requires image rebuild / api + worker restart to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:57:48 +08:00

776 lines
28 KiB
TypeScript

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 (
<Panel title="实际路径与库根" description="路径绑定建立后不随译名变化">
{bindings.length === 0 && detail.assets.length === 0 ? (
<EmptyState
plain
icon={FolderTree}
title="还没有入库文件"
description="版本目标满足后,文件位置显示在这里。"
className="py-8"
/>
) : (
<div className="space-y-4">
{bindings.length ? (
<ul className="space-y-2">
{bindings.map((b) => {
const root = rootOf(b.libraryRootId);
return (
<li key={b.id} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<Chip>{b.bindingKind === "season" ? `第 ${b.seasonNumber} 季目录` : "作品目录"}</Chip>
<span className="text-meta text-muted-foreground">库根 {root?.name ?? "未知库根"}</span>
{b.status === "conflict" ? <StatusBadge tone="destructive">绑定冲突</StatusBadge> : null}
</div>
<p className="mt-1 font-mono text-caption break-all text-foreground">
{root ? `${root.localPath.replace(/\/+$/, "")}/` : ""}
{b.relativePath}
</p>
</li>
);
})}
</ul>
) : null}
{detail.assets.map((a) => (
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
{a.asset.verifiedAt ? (
<StatusBadge tone="success">已核验 {relTime(a.asset.verifiedAt)}</StatusBadge>
) : (
<StatusBadge>未核验</StatusBadge>
)}
</div>
<ul className="mt-2 space-y-1.5">
{a.locations.map((l) => (
<li key={l.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
{l.isPrimary ? <Chip>主位置</Chip> : null}
<Tone v={LOCATION_STATUS[l.status]} />
{l.libraryRootId ? (
<span className="text-meta text-muted-foreground">
{rootOf(l.libraryRootId)?.name ?? "库根"}
</span>
) : null}
</div>
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
{l.absolutePath ?? l.relativePath}
</p>
</li>
))}
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
</ul>
</div>
))}
</div>
)}
</Panel>
);
}
// ── 外部身份映射 ──
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 (
<Panel
title="外部身份"
description="以本地 ID 为准,外部身份可修改"
actions={
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<PencilLine />
修改
</Button>
}
>
{list.length === 0 ? (
<EmptyState
plain
icon={SearchX}
title="还没有外部身份"
description="点「修改」手动指定 TMDB ID。"
className="py-8"
/>
) : (
<ul className="space-y-2">
{list.map((i) => (
<IdentityRow key={i.id} i={i} />
))}
</ul>
)}
<IdentityDialog
entityId={detail.entity.id}
entityKind={detail.entity.kind}
open={open}
onOpenChange={setOpen}
candidates={candidates}
/>
</Panel>
);
}
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 (
<li className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-caption break-all text-foreground">
{i.provider}:{i.entityType}:{i.externalId}
</span>
<Tone v={IDENTITY_STATUS[i.status]} />
{i.locked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
人工锁
</StatusBadge>
) : null}
</div>
<p className="mt-1 text-meta text-muted-foreground">
证据 {evidenceLabel(i.evidenceLevel)} · {VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus}
{i.verifiedAt ? `(${relTime(i.verifiedAt)})` : ""} · r{i.revision}
</p>
{reason ? <p className="mt-0.5 text-meta break-words text-muted-foreground">{reason}</p> : null}
</li>
);
}
// ── 元数据字段来源与锁 ──
const ENRICH_ONLY_FIELDS = new Set<string>(["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 (
<p className="flex flex-wrap items-center gap-x-1.5 text-meta text-muted-foreground">
<span>证据 {evidenceLabel(ev.evidenceLevel)}</span>
{ev.sourceUrl && host ? (
<a
href={ev.sourceUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 break-all underline-offset-4 hover:text-foreground hover:underline"
>
{host}
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
) : (
<span>无出处</span>
)}
{field?.field === "posterUrl" && ev.enrichmentId ? (
<img
src={`/api/v1/img/enrich/${ev.enrichmentId}`}
alt="海报"
loading="lazy"
className="mt-1 h-24 w-auto rounded-tight border border-border object-cover"
/>
) : null}
</p>
);
}
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<EntityMetadataField | null>(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 (
<Panel
title="元数据"
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
actions={
<Button
variant="outline"
size="sm"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(undefined, {
onSuccess: () => toast.success("已提交刷新"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
})
}
>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
刷新元数据
</Button>
}
>
<ul className="divide-y divide-border/60">
{finals.map(({ field, final, locked }) => {
const cands = candidatesOf(field);
return (
<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>
{final ? (
<p className="text-meta text-muted-foreground">
来源 {METADATA_SOURCE[final.source] ?? final.source}
{final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}
</p>
) : null}
{final ? <EvidenceLine field={cands.find((c) => c.source === final.source)} /> : null}
{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(" / ")}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{locked ? (
<>
<StatusBadge>
<Lock className="h-3 w-3" />
已锁定
</StatusBadge>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label={`解锁${ENTITY_FIELD_LABEL[field]}`}>
<LockOpen />
</Button>
}
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
description="取消人工修改,下次刷新按来源优先级取值。"
confirmLabel="解锁"
onConfirm={() =>
unlock.mutateAsync(field).then(
() => toast.success("已解锁"),
(err) => {
toast.error(errorMessage(err, "解锁失败"));
throw err;
},
)
}
/>
</>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
onClick={() => setEditing(field)}
>
<PencilLine />
</Button>
</div>
</li>
);
})}
</ul>
{entity?.aliases.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
<ChipList
items={entity.aliases.map((a) => (
<span key={a.id}>
{a.alias}
{a.locked ? <Lock className="ml-1 inline h-3 w-3" /> : null}
</span>
))}
/>
</div>
) : null}
<FieldLockDialog entityId={id} field={editing} current={current} onOpenChange={(o) => !o && setEditing(null)} />
</Panel>
);
}
// ── Slot ──
function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
if (detail.slots.length === 0) {
return (
<Panel title="版本目标">
<EmptyState
plain
icon={CalendarClock}
title="还没有版本目标"
description="新建订阅或版本目标后,按目标补齐和升级。"
action={
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>去订阅</Link>
</Button>
}
className="py-8"
/>
</Panel>
);
}
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
return (
<Panel title="版本目标" description="每个目标单独判断硬条件、软偏好和升级">
<div className="grid gap-3 xl:grid-cols-2">
{detail.slots.map((s) => {
const cur = assetOf(s.currentAssetId);
const unit = units.find((u) => u.id === s.contentUnitId);
return (
<SlotCard
key={s.id}
slot={s}
unitLabel={unit ? unitCode(unit) : undefined}
currentAsset={cur ? deliveryLine(cur.asset) || "未知规格" : undefined}
candidates={detail.candidates.filter((c) => c.slotId === s.id).slice(0, 5)}
/>
);
})}
</div>
</Panel>
);
}
// ── 内容单元 ──
function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDetail>; detail: LibraryDetail }) {
const covered = new Set(detail.assets.flatMap((a) => a.contentUnitIds));
const columns: DataColumn<ContentUnit>[] = [
{
key: "code",
header: "编号",
cell: (u) => <span className="font-mono text-caption whitespace-nowrap">{unitCode(u)}</span>,
},
{
key: "title",
header: "标题 / 期号",
cell: (u) => (
<div className="min-w-0">
<p className="max-w-[12rem] truncate text-sm sm:max-w-xs">{u.title ?? u.sourceIssue ?? "—"}</p>
<p className="text-meta text-muted-foreground">
{UNIT_KIND[u.kind]} · {CONTENT_TYPE[u.contentType]}
{u.airDate ? ` · ${u.airDate}` : ""}
</p>
</div>
),
},
{
key: "status",
header: "状态",
cell: (u) => (
<div className="flex flex-wrap gap-1">
{u.status === "provisional" ? (
<StatusBadge tone="warning">临时单元</StatusBadge>
) : u.status === "retired" ? (
<StatusBadge>已退役</StatusBadge>
) : (
<StatusBadge tone="success">已确认</StatusBadge>
)}
{u.numberingLocked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
编号锁定
</StatusBadge>
) : null}
</div>
),
},
{
key: "asset",
header: "已有文件",
hideBelow: "sm",
cell: (u) =>
covered.has(u.id) ? (
<StatusBadge tone="success">已入库</StatusBadge>
) : (
<span className="text-muted-foreground">—</span>
),
},
];
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 (
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
<DataTable
plain
columns={columns}
rows={entity.data ? units : undefined}
rowKey={(u) => u.id}
loading={entity.isPending}
error={entity.error}
onRetry={() => entity.refetch()}
empty={{
title: detail.entity.kind === "movie" ? "电影没有季和集" : "还没有内容单元",
description: detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集后会建临时单元。",
}}
/>
</Panel>
);
}
// ── 下载历史与校验 ──
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
const columns: DataColumn<LibraryDownloadView>[] = [
{
key: "name",
header: "下载",
cell: (d) => (
<div className="min-w-0">
<Link
to={consolePath("downloads", d.downloadTask.id)}
className="block max-w-[14rem] truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
title={d.downloadTask.name}
>
{d.downloadTask.name}
</Link>
<p className="text-meta text-muted-foreground">
{d.plan
? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · `
: ""}
{formatDateTime(d.downloadTask.createdAt)}
</p>
</div>
),
},
{
key: "status",
header: "下载状态",
cell: (d) => (
<div className="min-w-24 space-y-1">
<Tone v={DOWNLOAD_STATUS[d.downloadTask.status]} />
{d.downloadTask.status !== "imported" ? (
<div className="flex items-center gap-2">
<ProgressLine value={d.downloadTask.progress} className="w-16" />
<span className="text-meta text-muted-foreground tabular-nums">
{Math.round(d.downloadTask.progress * 100)}%
</span>
</div>
) : null}
</div>
),
},
{
key: "size",
header: "大小",
align: "right",
hideBelow: "md",
cell: (d) => (
<span className="text-meta tabular-nums">
{d.downloadTask.sizeBytes !== null ? formatBytes(d.downloadTask.sizeBytes, 1) : "—"}
</span>
),
},
{
key: "verify",
header: "整理与校验",
cell: (d) =>
d.organizationRecordIds.length ? (
<div className="flex flex-wrap items-center gap-1.5">
{d.lastOrganizationStatus ? <Tone v={ORG_STATUS[d.lastOrganizationStatus]} /> : null}
{d.organizationRecordIds.map((rid, i) => (
<Link
key={rid}
to={consolePath("organize", rid)}
className="inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
>
记录 {i + 1}
<ChevronRight className="h-3 w-3" />
</Link>
))}
</div>
) : (
<span className="text-meta text-muted-foreground">尚未整理</span>
),
},
];
return (
<Panel title="下载历史与校验结果" bodyClassName="p-0 sm:p-0">
<DataTable
plain
columns={columns}
rows={detail.downloads}
rowKey={(d) => d.downloadTask.id}
empty={{ title: "还没有下载记录" }}
/>
</Panel>
);
}
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 = (
<Link
to={consolePath("library")}
className="mb-4 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
媒体库
</Link>
);
if (detail.isPending) {
return (
<>
{back}
<LoadingState rows={8} />
</>
);
}
if (detail.isError) {
const notFound = (detail.error as { code?: string }).code === "not_found";
return (
<>
{back}
<ErrorState
title={notFound ? "作品不存在" : "作品加载失败"}
error={notFound ? "找不到这个 ID 的作品。" : detail.error}
onRetry={notFound ? undefined : () => detail.refetch()}
/>
</>
);
}
const d = detail.data;
const e = d.entity;
const redirected = entity.data?.redirectedFrom;
return (
<>
{back}
{e.status === "merged" && e.mergedIntoId ? (
<div className="mb-4 rounded-xl border border-warning/30 bg-warning/10 px-4 py-3 text-caption text-warning">
这部作品已合并。
<Link to={consolePath("library", e.mergedIntoId)} className="ml-1 underline">
查看合并后的作品
</Link>
</div>
) : null}
{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>
<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 />
合并作品
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
</div>
</header>
<div className="space-y-5">
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
<div className="grid gap-5 xl:grid-cols-2">
<IdentityPanel detail={d} />
<MetadataPanel detail={d} entity={entity.data} />
</div>
<PathsPanel detail={d} />
{e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? (
<UnitsPanel entity={entity} detail={d} />
) : null}
<DownloadsPanel detail={d} />
</div>
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
{subscribing ? (
<SubscriptionFormDialog
initialTarget={{
kind: "entity",
id: e.id,
title: e.title,
year: e.year,
mediaKind: e.kind === "movie" ? "movie" : e.kind === "variety" ? "variety" : "series",
}}
onClose={() => setSubscribing(false)}
/>
) : null}
{launcher.dialog}
</>
);
}