Files
UHD_Media_Tool/apps/web/app/routes/console/library.$id.tsx
T

1212 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 { 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 { 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 { 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 (
<Panel title="文件位置">
{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 ? null : <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">
{l.locationKind !== "library" ? (
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
) : null}
{l.status !== "available" ? <Tone v={LOCATION_STATUS[l.status]} /> : null}
{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>
);
}
const PROVIDER_LABEL: Record<string, string> = { 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 (
<Panel
title="匹配的 TMDB 条目"
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="text-caption break-all text-foreground"
title={`${i.provider}:${i.entityType}:${i.externalId}`}
>
{PROVIDER_LABEL[i.provider] ?? i.provider} {i.externalId}
</span>
{i.status !== "accepted" ? <Tone v={IDENTITY_STATUS[i.status]} /> : null}
{i.locked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
人工锁
</StatusBadge>
) : null}
</div>
<p
className="mt-1 text-meta text-muted-foreground"
title={`证据 ${evidenceLabel(i.evidenceLevel)} · ${VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus} · r${i.revision}`}
>
{i.locked || i.evidenceLevel === "manual" ? "你确认的" : "自动匹配"}
{i.verifiedAt ? ` · ${relTime(i.verifiedAt)}` : ""}
</p>
{reason ? <p className="mt-0.5 text-meta break-words text-muted-foreground">{reason}</p> : null}
</li>
);
}
// ── 元数据字段来源与锁 ──
/** 只在有值时显示的字段:补全专有(平台 / 集数 / 类型 / 网页海报)与 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 }) {
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;
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 (
<Panel
title="元数据"
description={`更新于 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
actions={
<>
<Button variant="ghost" size="sm" aria-pressed={showSources} onClick={() => setShowSources((v) => !v)}>
{showSources ? "隐藏来源" : "显示来源"}
</Button>
<Button
variant="outline"
size="sm"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(
{ full: true },
{
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="line-clamp-4 text-sm break-words text-foreground">
{final ? fieldText(field, final.value) : <span className="text-muted-foreground">未提供</span>}
</p>
{final && showSources ? (
<p className="text-meta text-muted-foreground">
来源 {METADATA_SOURCE[final.source] ?? final.source}
{final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}
</p>
) : null}
{final && showSources ? <EvidenceLine field={cands.find((c) => c.source === final.source)} /> : null}
{cands.length && showSources ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground">
候选:
{cands
.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${fieldText(field, 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}
{NOT_EDITABLE.has(field) ? 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={shownAliases.map((a) => (
<span key={a.id}>
{a.alias}
{a.locked ? <Lock className="ml-1 inline h-3 w-3" /> : null}
</span>
))}
/>
{moreAliases > 0 || allAliases ? (
<Button variant="ghost" size="xs" className="mt-1" onClick={() => setAllAliases((v) => !v)}>
{allAliases ? "收起" : `还有 ${moreAliases} 个`}
</Button>
) : null}
</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="季与集" 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" ? undefined : "识别到新的一集后会出现在这里。",
}}
/>
</Panel>
);
}
// ── 季与集(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]);
// 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<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,
detail: (() => {
const vs = versionSeasons.find((x) => x.seasonNumber === n);
return vs ? seasonLine(vs, !anyVersion) : 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),
extra: (() => {
const ev = versionsOf(u);
return ev ? (
<EpisodeVersionList
versions={ev.versions}
match={anyVersion ? undefined : ev.matches?.[0]}
/>
) : null;
})(),
}}
/>
);
})}
</ul>
)}
</SeasonSection>
);
})}
</div>
)}
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
</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 ? `${PURPOSE_LABEL[d.plan.purpose]} · ` : d.downloadTask.origin === "adopted" ? "接管 · " : ""}
{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>
),
},
];
// 没有下载任务的整理记录(文件管理按路径手动整理等),下载任务的记录已在上表
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 (
<Panel title="下载历史与校验结果" bodyClassName="p-0 sm:p-0">
{detail.downloads.length || !dropped.length ? (
<DataTable
plain
columns={columns}
rows={detail.downloads}
rowKey={(d) => d.downloadTask.id}
empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }}
/>
) : null}
{dropped.length ? (
<div className={cn("px-4 py-3 sm:px-5", detail.downloads.length ? "border-t border-border/60" : "")}>
<p className="mb-2 text-meta text-muted-foreground">没提交到下载器</p>
<ul className="space-y-2">
{dropped.map(({ plan, releaseTitle }) => {
const st = DROPPED_STATUS[plan.status as keyof typeof DROPPED_STATUS] ?? DROPPED_STATUS.failed;
const why = droppedReason(plan.statusReason);
return (
<li key={plan.id} className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
<Link
to={`${consolePath("downloads")}?plan=${plan.id}`}
className="min-w-0 max-w-full truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
title={releaseTitle ?? undefined}
>
{releaseTitle ?? "未知资源"}
</Link>
<span className="text-meta text-muted-foreground tabular-nums">
{PURPOSE_LABEL[plan.purpose]} · {formatDateTime(plan.updatedAt)}
</span>
</div>
{why ? <p className="mt-0.5 text-meta text-muted-foreground">{why}</p> : null}
<TechInfo
items={[
{ label: "计划 ID", value: plan.id, wide: true },
{ label: "原因码", value: plan.statusReason, wide: true },
]}
/>
</li>
);
})}
</ul>
</div>
) : null}
{manual.length ? (
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
<p className="mb-2 text-meta text-muted-foreground">其他整理记录</p>
<ul className="space-y-1.5">
{manual.map((r) => (
<li key={r.id} className="flex min-w-0 flex-wrap items-center gap-2">
<Tone v={ORG_STATUS[r.status]} />
<Link
to={consolePath("organize", r.id)}
className="min-w-0 max-w-full truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
title={r.sourcePath ?? r.id}
>
{r.targetPath ?? r.sourcePath ?? r.id}
</Link>
<span className="text-meta text-muted-foreground tabular-nums">
{r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "智能体" : "自动"} · {r.fileSucceeded}/
{r.fileTotal} 个文件 · {formatDateTime(r.createdAt)}
</span>
</li>
))}
</ul>
</div>
) : null}
</Panel>
);
}
/** 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);
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;
const credits = {
cast: creditList(metaValue(e.metadata, "cast")),
crew: creditList(metaValue(e.metadata, "crew")),
};
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}
<MediaHero
facts={factsOf(d, entity.data)}
badges={
<>
<Tone v={ENTITY_STATUS[e.status]} />
{(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>
<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>
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
<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}
{e.kind === "movie" ? <MovieVersionsPanel entityId={e.id} /> : null}
<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} />
<ScrapePanel 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}
</>
);
}