Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
1254 lines
50 KiB
TypeScript
1254 lines
50 KiB
TypeScript
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,
|
||
Captions,
|
||
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 { PaginatedList } from "~/components/console/paginated-list.tsx";
|
||
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 { EpisodeMappingPanel } from "~/components/episode-mapping/mapping-panel.tsx";
|
||
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
|
||
import { EpisodeStructureSwitcher } from "~/components/library/episode-structure.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 { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.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 ? (
|
||
<PaginatedList rows={bindings} as="ul" label="目录绑定" resetKey={detail.entity.id} className="space-y-2">
|
||
{(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>
|
||
);
|
||
}}
|
||
</PaginatedList>
|
||
) : null}
|
||
<PaginatedList rows={detail.assets} label="文件资产" resetKey={detail.entity.id} className="space-y-4">
|
||
{(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>
|
||
)}
|
||
</PaginatedList>
|
||
</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"
|
||
/>
|
||
) : (
|
||
<PaginatedList rows={list} as="ul" label="外部身份" resetKey={detail.entity.id} className="space-y-2">
|
||
{(i) => <IdentityRow key={i.id} i={i} />}
|
||
</PaginatedList>
|
||
)}
|
||
<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="每个目标单独判断硬条件、软偏好和升级">
|
||
<PaginatedList
|
||
rows={detail.slots}
|
||
label="收录版本"
|
||
resetKey={detail.entity.id}
|
||
className="grid gap-3 xl:grid-cols-2"
|
||
>
|
||
{(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)}
|
||
/>
|
||
);
|
||
}}
|
||
</PaginatedList>
|
||
</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>
|
||
}
|
||
>
|
||
<EpisodeStructureSwitcher entityId={e.id}>
|
||
{rows.length > 1 ? (
|
||
<PaginatedList
|
||
rows={rows}
|
||
as="fieldset"
|
||
label="按收录版本看状态"
|
||
resetKey={e.id}
|
||
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
|
||
>
|
||
{(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>
|
||
)}
|
||
</PaginatedList>
|
||
) : null}
|
||
{seasonNums.length === 0 ? (
|
||
<EmptyState
|
||
plain
|
||
icon={Tv}
|
||
title="还没有每集信息"
|
||
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
|
||
className="py-8"
|
||
/>
|
||
) : (
|
||
<PaginatedList rows={seasonNums} label="季列表" resetKey={`${e.id}:${rowIdx}`} className="space-y-2">
|
||
{(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>
|
||
) : (
|
||
<PaginatedList
|
||
rows={eps}
|
||
as="ul"
|
||
compact
|
||
label={`第 ${n} 季集列表`}
|
||
resetKey={`${e.id}:${n}:${rowIdx}`}
|
||
className="divide-y divide-border/60"
|
||
>
|
||
{(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;
|
||
})(),
|
||
}}
|
||
/>
|
||
);
|
||
}}
|
||
</PaginatedList>
|
||
)}
|
||
</SeasonSection>
|
||
);
|
||
}}
|
||
</PaginatedList>
|
||
)}
|
||
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
|
||
</EpisodeStructureSwitcher>
|
||
<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>
|
||
<PaginatedList
|
||
rows={dropped}
|
||
as="ul"
|
||
label="未提交下载计划"
|
||
resetKey={detail.entity.id}
|
||
className="space-y-2"
|
||
>
|
||
{({ 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>
|
||
);
|
||
}}
|
||
</PaginatedList>
|
||
</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>
|
||
<PaginatedList rows={manual} as="ul" label="其他整理记录" resetKey={detail.entity.id} className="space-y-1.5">
|
||
{(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>
|
||
)}
|
||
</PaginatedList>
|
||
</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 [subtitling, setSubtitling] = 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}
|
||
{e.status !== "merged" ? (
|
||
<Button variant="outline" size="sm" onClick={() => setSubtitling(true)}>
|
||
<Captions />
|
||
补字幕
|
||
</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 ?? []} />
|
||
{e.kind === "series" || e.kind === "variety" ? <EpisodeMappingPanel entityId={e.id} /> : null}
|
||
<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}
|
||
{subtitling ? (
|
||
<SubtitleDialog entityId={e.id} title={e.title} open onOpenChange={(o) => !o && setSubtitling(false)} />
|
||
) : null}
|
||
{launcher.dialog}
|
||
</>
|
||
);
|
||
}
|