feat(web): MP-style media detail with seasons and episodes, shared by library and TMDB preview (MD-1)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9cd5a28943
commit
97ac59c7ac
9 files changed
+1250
-190
No files matched your search
@@ -5,7 +5,9 @@ import type {
|
||||
LibraryAssetView,
|
||||
LibraryDetail,
|
||||
LibraryDownloadView,
|
||||
MediaCredit,
|
||||
MediaEntityDetail,
|
||||
SubscriptionCoverage,
|
||||
} from "@umt/contracts";
|
||||
import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts";
|
||||
import {
|
||||
@@ -22,8 +24,9 @@ import {
|
||||
PencilLine,
|
||||
RefreshCw,
|
||||
SearchX,
|
||||
Tv,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { mediaServers } from "~/api/instances.ts";
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
useRefreshMetadata,
|
||||
useUnlockField,
|
||||
} from "~/api/library.ts";
|
||||
import { useSubscriptionCoverage, useSubscriptions } from "~/api/subscriptions.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -55,20 +59,23 @@ import {
|
||||
UNIT_KIND,
|
||||
VERIFY_STATUS,
|
||||
} from "~/components/library/labels.ts";
|
||||
import {
|
||||
Chip,
|
||||
ChipList,
|
||||
CoverPlaceholder,
|
||||
Panel,
|
||||
ProgressLine,
|
||||
posterPathOf,
|
||||
relTime,
|
||||
Tone,
|
||||
} from "~/components/library/parts.tsx";
|
||||
import { Chip, ChipList, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { SlotCard } from "~/components/library/slot-card.tsx";
|
||||
import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.tsx";
|
||||
import { externalLinks } from "~/components/media/external-links.ts";
|
||||
import {
|
||||
AIR_STATUS,
|
||||
CreditsStrip,
|
||||
EpisodeRow,
|
||||
type MediaFacts,
|
||||
MediaHero,
|
||||
SeasonSection,
|
||||
today,
|
||||
} from "~/components/media/media-detail.tsx";
|
||||
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||
import { EmbyBadge } from "~/components/search/work-card.tsx";
|
||||
import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts";
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -76,6 +83,8 @@ import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
|
||||
// 内容单元、下载历史与校验结果,同一页同时展示。
|
||||
// MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按版本目标),
|
||||
// 头部 / 主创 / 季集组件与仅 TMDB 的作品详情页共用(components/media/media-detail)。
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
@@ -251,7 +260,52 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
|
||||
|
||||
// ── 元数据字段来源与锁 ──
|
||||
|
||||
const ENRICH_ONLY_FIELDS = new Set<string>(["platform", "episodeCount", "genres", "posterUrl"]);
|
||||
/** 只在有值时显示的字段:补全专有(平台 / 集数 / 类型 / 网页海报)与 MD-1 TMDB 详情字段 */
|
||||
const ENRICH_ONLY_FIELDS = new Set<string>([
|
||||
"platform",
|
||||
"episodeCount",
|
||||
"genres",
|
||||
"posterUrl",
|
||||
"titleZh",
|
||||
"tagline",
|
||||
"lastAirDate",
|
||||
"airStatus",
|
||||
"voteAverage",
|
||||
"voteCount",
|
||||
"runtime",
|
||||
"seasonCount",
|
||||
"cast",
|
||||
"crew",
|
||||
"externalIds",
|
||||
]);
|
||||
/** 结构化值(列表 / 对象)不走文本覆盖对话框 */
|
||||
const NOT_EDITABLE = new Set<string>(["cast", "crew", "externalIds"]);
|
||||
|
||||
/** 字段值 → 一行文本 */
|
||||
function fieldText(field: string, v: unknown): string {
|
||||
if (v === null || v === undefined || v === "") return "—";
|
||||
if (field === "cast" || field === "crew") {
|
||||
const list = (Array.isArray(v) ? v : []) as MediaCredit[];
|
||||
return (
|
||||
list
|
||||
.map((c) => (c.role ? `${c.name}(${c.role})` : c.name))
|
||||
.slice(0, 6)
|
||||
.join("、") || "—"
|
||||
);
|
||||
}
|
||||
if (field === "externalIds" && typeof v === "object") {
|
||||
return (
|
||||
Object.entries(v as Record<string, unknown>)
|
||||
.filter(([, x]) => x)
|
||||
.map(([k, x]) => `${k} ${String(x)}`)
|
||||
.join(" · ") || "—"
|
||||
);
|
||||
}
|
||||
if (field === "airStatus" && typeof v === "string") return AIR_STATUS[v]?.label ?? v;
|
||||
if (Array.isArray(v)) return v.join("、");
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** META-1 补全写入的字段:证据等级 + 出处网页(来源 web = 网页原文,agent = Agent 归纳,都不是 TMDB) */
|
||||
function EvidenceLine({ field }: { field: MediaEntityDetail["metadataFields"][number] | undefined }) {
|
||||
@@ -307,7 +361,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="元数据"
|
||||
title="字段来源与锁"
|
||||
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
|
||||
actions={
|
||||
<Button
|
||||
@@ -315,10 +369,13 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
size="sm"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() =>
|
||||
refresh.mutate(undefined, {
|
||||
onSuccess: () => toast.success("已提交刷新"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
})
|
||||
refresh.mutate(
|
||||
{ full: true },
|
||||
{
|
||||
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
@@ -333,16 +390,8 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
<li key={field} className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
|
||||
<div className="w-24 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENTITY_FIELD_LABEL[field]}</div>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<p className="text-sm break-words text-foreground">
|
||||
{final ? (
|
||||
Array.isArray(final.value) ? (
|
||||
final.value.join("、")
|
||||
) : (
|
||||
String(final.value ?? "—")
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">未提供</span>
|
||||
)}
|
||||
<p className="line-clamp-4 text-sm break-words text-foreground">
|
||||
{final ? fieldText(field, final.value) : <span className="text-muted-foreground">未提供</span>}
|
||||
</p>
|
||||
{final ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
@@ -354,7 +403,9 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
{cands.length ? (
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground">
|
||||
候选:
|
||||
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
|
||||
{cands
|
||||
.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${fieldText(field, c.value)}」`)
|
||||
.join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -386,14 +437,16 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
|
||||
onClick={() => setEditing(field)}
|
||||
>
|
||||
<PencilLine />
|
||||
</Button>
|
||||
{NOT_EDITABLE.has(field) ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
|
||||
onClick={() => setEditing(field)}
|
||||
>
|
||||
<PencilLine />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -540,6 +593,210 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
|
||||
);
|
||||
}
|
||||
|
||||
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按版本目标 ──
|
||||
|
||||
const CELL_TONE: Record<CellKind, "success" | "warning" | "destructive" | "neutral"> = {
|
||||
have: "success",
|
||||
upgradable: "success",
|
||||
inflight: "neutral",
|
||||
deferred: "warning",
|
||||
missing: "destructive",
|
||||
suspected: "warning",
|
||||
not_aired: "neutral",
|
||||
skipped: "neutral",
|
||||
};
|
||||
|
||||
type EpisodeStatus = { label: string; tone: "success" | "warning" | "destructive" | "neutral"; have: boolean };
|
||||
|
||||
/** 覆盖矩阵 → (季, 集) 的格:按内容单元 ID 或集号对列 */
|
||||
function coverageLookup(cov: SubscriptionCoverage | undefined, rowIdx: number) {
|
||||
const map = new Map<string, EpisodeStatus>();
|
||||
if (!cov) return map;
|
||||
const row = cov.rows[rowIdx] ?? cov.rows[0];
|
||||
if (!row) return map;
|
||||
let i = 0;
|
||||
for (const s of cov.seasons) {
|
||||
for (const col of s.columns) {
|
||||
const cell = row.cells[i++];
|
||||
if (!cell || s.seasonNumber === null) continue;
|
||||
const k = cellKind(cell);
|
||||
const st = { label: CELL_LABEL[k], tone: CELL_TONE[k], have: k === "have" || k === "upgradable" };
|
||||
if (col.contentUnitId) map.set(`u:${col.contentUnitId}`, st);
|
||||
if (col.episode !== null) map.set(`e:${s.seasonNumber}:${col.episode}`, st);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
|
||||
const e = detail.entity;
|
||||
const [editing, setEditing] = useState<ContentUnit | null>(null);
|
||||
const [rowIdx, setRowIdx] = useState(0);
|
||||
const refresh = useRefreshMetadata(e.id);
|
||||
const subs = useSubscriptions({ mediaEntityId: e.id });
|
||||
const sub = subs.data?.pages
|
||||
.flatMap((p) => p.items)
|
||||
.find((x) => x.status === "active" || x.status === "pending" || x.status === "paused");
|
||||
const cov = useSubscriptionCoverage(sub?.id);
|
||||
const live = units.filter((u) => u.status !== "retired");
|
||||
const seasonUnits = live.filter((u) => u.kind === "season" && u.seasonNumber !== null);
|
||||
const episodes = live
|
||||
.filter((u) => u.kind === "episode" && u.seasonNumber !== null && u.episodeNumber !== null)
|
||||
.sort((a, b) => a.episodeNumber! - b.episodeNumber! || a.createdAt.localeCompare(b.createdAt));
|
||||
const covered = new Set(detail.assets.filter((a) => a.asset.status === "active").flatMap((a) => a.contentUnitIds));
|
||||
const lookup = useMemo(() => coverageLookup(cov.data, rowIdx), [cov.data, rowIdx]);
|
||||
const seasonNums = [
|
||||
...new Set([...seasonUnits.map((u) => u.seasonNumber!), ...episodes.map((u) => u.seasonNumber!)]),
|
||||
].sort((a, b) => (a || 999) - (b || 999));
|
||||
const seasonUnitOf = (n: number) => seasonUnits.find((u) => u.seasonNumber === n);
|
||||
const trackedSeason = sub?.seasonUnitId ? units.find((u) => u.id === sub.seasonUnitId)?.seasonNumber : undefined;
|
||||
const statusOf = (u: ContentUnit): EpisodeStatus | null => {
|
||||
const hit = lookup.get(`u:${u.id}`) ?? lookup.get(`e:${u.seasonNumber}:${u.episodeNumber}`);
|
||||
if (hit) return hit;
|
||||
const season = seasonUnitOf(u.seasonNumber!);
|
||||
if (covered.has(u.id) || (season && covered.has(season.id)))
|
||||
return { label: "已入库", tone: "success", have: true };
|
||||
const air = finalOf(u, "airDate")?.value ?? u.airDate;
|
||||
if (typeof air === "string" && air > today()) return { label: "未播出", tone: "neutral", have: false };
|
||||
return { label: sub ? "缺失" : "未入库", tone: sub ? "destructive" : "neutral", have: false };
|
||||
};
|
||||
const haveIn = (n: number) => episodes.filter((u) => u.seasonNumber === n && statusOf(u)?.have).length;
|
||||
const initial =
|
||||
trackedSeason ??
|
||||
[...seasonNums].reverse().find((n) => n > 0 && haveIn(n) > 0) ??
|
||||
[...seasonNums].reverse().find((n) => n > 0) ??
|
||||
null;
|
||||
const [open, setOpen] = useState<Set<number> | null>(null);
|
||||
const openSet = open ?? new Set(initial !== null && initial !== undefined ? [initial] : []);
|
||||
const toggle = (n: number) => {
|
||||
const next = new Set(openSet);
|
||||
if (next.has(n)) next.delete(n);
|
||||
else next.add(n);
|
||||
setOpen(next);
|
||||
};
|
||||
const rows = cov.data?.rows ?? [];
|
||||
|
||||
const refreshFull = () =>
|
||||
refresh.mutate(
|
||||
{ full: true },
|
||||
{
|
||||
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
|
||||
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="季与集"
|
||||
description={
|
||||
sub
|
||||
? "库内状态按订阅的覆盖矩阵;标题与简介按语言回退链取值"
|
||||
: "没有订阅时只看是否已入库;标题与简介按语言回退链取值"
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" disabled={refresh.isPending} onClick={refreshFull}>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
更新每集信息
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{rows.length > 1 ? (
|
||||
<fieldset aria-label="按版本目标看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
|
||||
{rows.map((r, i) => (
|
||||
<button
|
||||
key={r.slotId ?? "any"}
|
||||
type="button"
|
||||
aria-pressed={i === rowIdx}
|
||||
onClick={() => setRowIdx(i)}
|
||||
className={
|
||||
i === rowIdx
|
||||
? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground"
|
||||
: "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
{seasonNums.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Tv}
|
||||
title="还没有每集信息"
|
||||
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{seasonNums.map((n) => {
|
||||
const su = seasonUnitOf(n);
|
||||
const eps = episodes.filter((u) => u.seasonNumber === n);
|
||||
const total = su?.episodeTotal ?? (eps.length || null);
|
||||
const nameV = su ? finalOf(su, "name")?.value : null;
|
||||
const posterV = su ? finalOf(su, "posterPath")?.value : null;
|
||||
const ovV = su ? finalOf(su, "overview")?.value : null;
|
||||
const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
|
||||
const have = haveIn(n);
|
||||
return (
|
||||
<SeasonSection
|
||||
key={n}
|
||||
s={{
|
||||
seasonNumber: n,
|
||||
name: typeof nameV === "string" ? nameV : (su?.title ?? null),
|
||||
airDate: typeof airV === "string" ? airV : null,
|
||||
episodeCount: total,
|
||||
posterPath: typeof posterV === "string" ? posterV : null,
|
||||
overview: typeof ovV === "string" ? ovV : null,
|
||||
summary: (
|
||||
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
|
||||
已入库 {have}/{total ?? "?"}
|
||||
</StatusBadge>
|
||||
),
|
||||
}}
|
||||
open={openSet.has(n)}
|
||||
onToggle={() => toggle(n)}
|
||||
>
|
||||
{eps.length === 0 ? (
|
||||
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{eps.map((u) => {
|
||||
const v = (f: string) => finalOf(u, f)?.value;
|
||||
const str = (x: unknown) => (typeof x === "string" && x ? x : null);
|
||||
const num = (x: unknown) => (typeof x === "number" ? x : null);
|
||||
return (
|
||||
<EpisodeRow
|
||||
key={u.id}
|
||||
e={{
|
||||
key: u.id,
|
||||
seasonNumber: n,
|
||||
episodeNumber: u.episodeNumber!,
|
||||
name: str(v("name")) ?? u.title,
|
||||
overview: str(v("overview")),
|
||||
airDate: str(v("airDate")) ?? u.airDate,
|
||||
runtime: num(v("runtime")),
|
||||
stillPath: str(v("stillPath")),
|
||||
voteAverage: num(v("voteAverage")),
|
||||
status: statusOf(u),
|
||||
locked: u.fieldLocks.length > 0,
|
||||
onEdit: () => setEditing(u),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SeasonSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 下载历史与校验 ──
|
||||
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
const columns: DataColumn<LibraryDownloadView>[] = [
|
||||
@@ -628,6 +885,71 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** media_entity.metadata 某字段的最终值 */
|
||||
function metaValue(metadata: unknown, field: string): unknown {
|
||||
const p = FinalFieldValue.safeParse((metadata as Record<string, unknown> | null)?.[field]);
|
||||
return p.success ? p.data.value : undefined;
|
||||
}
|
||||
const strOf = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
const numOf = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
function creditList(v: unknown): MediaCredit[] {
|
||||
return Array.isArray(v)
|
||||
? (v as MediaCredit[]).filter((c) => c && typeof c.name === "string" && typeof c.id === "number")
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 本地作品 → 媒体详情头部数据(字段来自最终元数据;外部链接取已接受的外部身份与 externalIds) */
|
||||
function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): MediaFacts {
|
||||
const e = d.entity;
|
||||
const m = (f: string) => metaValue(e.metadata, f);
|
||||
const accepted = d.identities.filter((i) => i.status === "accepted");
|
||||
const tmdb = accepted.find((i) => i.provider === "tmdb" && (i.entityType === "movie" || i.entityType === "tv"));
|
||||
const ext = (m("externalIds") ?? {}) as { imdb?: string | null; tvdb?: string | null; wikidata?: string | null };
|
||||
const genres = m("genres");
|
||||
const country = m("originCountry");
|
||||
const seasonCount =
|
||||
numOf(m("seasonCount")) ??
|
||||
(new Set(
|
||||
(entity?.contentUnits ?? [])
|
||||
.filter((u) => u.kind === "season" && (u.seasonNumber ?? 0) > 0 && u.status !== "retired")
|
||||
.map((u) => u.seasonNumber),
|
||||
).size ||
|
||||
null);
|
||||
return {
|
||||
title: e.title,
|
||||
originalTitle: e.originalTitle,
|
||||
titleZh: strOf(m("titleZh")),
|
||||
year: e.year,
|
||||
kind: e.kind,
|
||||
kindLabel: MEDIA_KIND[e.kind] ?? e.kind,
|
||||
genres: Array.isArray(genres)
|
||||
? genres.filter((x): x is string => typeof x === "string")
|
||||
: strOf(genres)
|
||||
? [genres as string]
|
||||
: [],
|
||||
voteAverage: numOf(m("voteAverage")),
|
||||
voteCount: numOf(m("voteCount")),
|
||||
airStatus: strOf(m("airStatus")),
|
||||
runtime: numOf(m("runtime")),
|
||||
seasonCount: e.kind === "movie" ? null : seasonCount,
|
||||
episodeCount: e.kind === "movie" ? null : numOf(m("episodeCount")),
|
||||
releaseDate: strOf(m("releaseDate")),
|
||||
lastAirDate: strOf(m("lastAirDate")),
|
||||
platform: strOf(m("platform")),
|
||||
originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [],
|
||||
overview: strOf(m("overview")),
|
||||
tagline: strOf(m("tagline")),
|
||||
posterPath: posterPathOf(e.metadata),
|
||||
backdropPath: strOf(m("backdropPath")),
|
||||
externalLinks: externalLinks({
|
||||
tmdb: tmdb ? { type: tmdb.entityType as "movie" | "tv", id: tmdb.externalId } : null,
|
||||
imdb: ext.imdb ?? accepted.find((i) => i.provider === "imdb")?.externalId ?? null,
|
||||
tvdb: ext.tvdb ?? accepted.find((i) => i.provider === "tvdb")?.externalId ?? null,
|
||||
wikidata: ext.wikidata ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default function LibraryDetailPage() {
|
||||
const { id } = useParams();
|
||||
const detail = useLibraryDetail(id);
|
||||
@@ -671,6 +993,10 @@ export default function LibraryDetailPage() {
|
||||
const d = detail.data;
|
||||
const e = d.entity;
|
||||
const redirected = entity.data?.redirectedFrom;
|
||||
const credits = {
|
||||
cast: creditList(metaValue(e.metadata, "cast")),
|
||||
crew: creditList(metaValue(e.metadata, "crew")),
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
@@ -685,66 +1011,51 @@ export default function LibraryDetailPage() {
|
||||
{redirected ? (
|
||||
<p className="mb-4 text-meta text-muted-foreground">旧 ID {redirected} 已跳转到当前作品。</p>
|
||||
) : null}
|
||||
<header className="mb-6 flex flex-wrap items-start gap-4">
|
||||
<CoverPlaceholder
|
||||
title={e.title}
|
||||
kind={e.kind}
|
||||
posterPath={posterPathOf(e.metadata)}
|
||||
className="h-28 w-20 sm:h-36 sm:w-24"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="min-w-0 text-title-lg font-semibold tracking-tight break-words text-foreground">
|
||||
{e.title}
|
||||
</h1>
|
||||
<MediaHero
|
||||
facts={factsOf(d, entity.data)}
|
||||
badges={
|
||||
<>
|
||||
<Tone v={ENTITY_STATUS[e.status]} />
|
||||
</div>
|
||||
<p className="mt-1 text-body text-muted-foreground">
|
||||
{MEDIA_KIND[e.kind]}
|
||||
{e.year ? ` · ${e.year}` : ""}
|
||||
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
|
||||
{(ms.data?.items.length ?? 0) > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<EmbyBadge refOf={{ mediaEntityId: e.id }} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{e.status !== "merged" ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setSubscribing(true)}>
|
||||
<CalendarPlus />
|
||||
订阅
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
|
||||
>
|
||||
<PackageSearch />
|
||||
搜索资源
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
|
||||
<CalendarClock />
|
||||
查看订阅
|
||||
</Link>
|
||||
</Button>
|
||||
{e.status !== "merged" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
|
||||
<GitMerge />
|
||||
合并作品
|
||||
{(ms.data?.items.length ?? 0) > 0 ? <EmbyBadge refOf={{ mediaEntityId: e.id }} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="mt-4 mb-5 flex flex-wrap items-center gap-2">
|
||||
{e.status !== "merged" ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setSubscribing(true)}>
|
||||
<CalendarPlus />
|
||||
订阅
|
||||
</Button>
|
||||
) : null}
|
||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||
</div>
|
||||
</header>
|
||||
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}>
|
||||
<PackageSearch />
|
||||
搜索资源
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
|
||||
<CalendarClock />
|
||||
查看订阅
|
||||
</Link>
|
||||
</Button>
|
||||
{e.status !== "merged" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
|
||||
<GitMerge />
|
||||
合并作品
|
||||
</Button>
|
||||
) : null}
|
||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||
<span className="font-mono text-meta break-all text-muted-foreground sm:ml-auto">{e.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{credits.cast.length || credits.crew.length ? (
|
||||
<Panel title="主创">
|
||||
<CreditsStrip cast={credits.cast} crew={credits.crew} />
|
||||
</Panel>
|
||||
) : null}
|
||||
{e.kind !== "movie" ? <EpisodesPanel detail={d} units={entity.data?.contentUnits ?? []} /> : null}
|
||||
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<IdentityPanel detail={d} />
|
||||
|
||||
Reference in new issue
Block a user