Merge branch 'md1'

# Conflicts:
#	packages/contracts/src/settings.ts
This commit is contained in:
root committed 2026-09-29 17:42:31 +08:00
commit b633eb5379
30 files changed
+3240 -222

No files matched your search

+34 -2
View File
@@ -10,6 +10,7 @@ import {
MediaEntity,
MediaEntityDetail,
type MediaKind,
type UnitMetadataField,
} from "@umt/contracts";
import type { z } from "zod";
import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts";
@@ -144,11 +145,42 @@ export function useUnlockField(id: string) {
});
}
/** 刷新元数据(字段级;用户锁优先) */
/**
* 刷新元数据(字段级;用户锁优先)。full = 拉全部季的每集信息。刷新是后台任务:
* 提交后隔几秒再取一次详情,拿到任务写回的结果。
*/
export function useRefreshMetadata(id: string) {
const done = useInvalidateEntity(id);
return useMutation({
mutationFn: () => api.post<unknown>(`/media-entities/${id}/refresh`),
mutationFn: (opts?: { full?: boolean }) =>
api.post<unknown>(`/media-entities/${id}/refresh${opts?.full ? "?full=1" : ""}`),
onSuccess: () => {
done();
for (const ms of [3_000, 8_000, 20_000]) setTimeout(done, ms);
},
});
}
/** 季 / 集字段覆盖并锁定(MD-1) */
export function useLockUnitField(entityId: string) {
const done = useInvalidateEntity(entityId);
return useMutation({
mutationFn: (v: { unitId: string; field: UnitMetadataField; value: unknown; reason?: string }) =>
api.put<unknown>(`/content-units/${v.unitId}/fields/${v.field}`, {
value: v.value,
lock: true,
reason: v.reason,
}),
onSuccess: done,
});
}
/** 取消季 / 集字段覆盖与锁定 */
export function useUnlockUnitField(entityId: string) {
const done = useInvalidateEntity(entityId);
return useMutation({
mutationFn: (v: { unitId: string; field: UnitMetadataField }) =>
api.del<unknown>(`/content-units/${v.unitId}/fields/${v.field}`),
onSuccess: done,
});
}
+12
View File
@@ -8,6 +8,7 @@ import {
SearchResponse,
type SearchScope,
TmdbMediaDetail,
TmdbSeasonDetailView,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
@@ -20,6 +21,7 @@ export const searchKeys = {
aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const,
releases: (f: ReleaseSearchFilters) => ["search", "releases", f] as const,
tmdb: (type: string, id: string, ms: boolean) => ["search", "tmdb", type, id, ms] as const,
tmdbSeason: (id: string, season: number) => ["search", "tmdb-season", id, season] as const,
mediaServers: (ref: MediaServerRef) => ["search", "media-servers", ref] as const,
};
@@ -114,6 +116,16 @@ export function useTmdbMedia(type: string | undefined, id: string | undefined) {
});
}
/** 仅 TMDB 剧集的季详情与每集信息(MD-1,季折叠展开时才拉) */
export function useTmdbSeason(id: string | undefined, season: number, enabled: boolean) {
return useQuery({
queryKey: searchKeys.tmdbSeason(id ?? "", season),
enabled: enabled && !!id && /^\d+$/.test(id),
queryFn: ({ signal }) => getParsed(TmdbSeasonDetailView, `/media/tmdb/tv/${id}/season/${season}`, {}, signal),
staleTime: 5 * 60_000,
});
}
export type MediaServerRef = { mediaEntityId: string } | { tmdbType: "movie" | "tv"; tmdbId: string };
/** Emby 库存对比(按本地作品或 TMDB 条目);实例失败 / 超时为 unknown,不等于缺失 */
+23
View File
@@ -246,6 +246,29 @@ export const ENTITY_FIELD_LABEL: Record<string, string> = {
episodeCount: "集数",
genres: "类型",
posterUrl: "网页海报",
titleZh: "中文名",
tagline: "标语",
lastAirDate: "最近播出",
airStatus: "播出状态",
voteAverage: "评分",
voteCount: "评分人数",
runtime: "时长(分钟)",
seasonCount: "季数",
cast: "演员",
crew: "导演 / 主创",
externalIds: "外部 ID",
};
/** 季 / 集字段(contracts UnitMetadataField) */
export const UNIT_FIELD_LABEL: Record<string, string> = {
name: "标题",
overview: "简介",
airDate: "播出日期",
runtime: "时长(分钟)",
stillPath: "剧照",
posterPath: "海报",
voteAverage: "评分",
voteCount: "评分人数",
};
/** 补全字段(contracts EnrichFieldName) */
@@ -0,0 +1,179 @@
import { type ContentUnit, FinalFieldValue, type UnitMetadataField } from "@umt/contracts";
import { LoaderCircle, Lock, LockOpen } from "lucide-react";
import { type FormEvent, useId, useState } from "react";
import { toast } from "sonner";
import { useLockUnitField, useUnlockUnitField } from "~/api/library.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Label } from "~/components/ui/label.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { METADATA_SOURCE, UNIT_FIELD_LABEL } from "./labels.ts";
// 季 / 集字段覆盖与锁定(MD-1):改标题 / 简介 / 播出日期即锁定,元数据刷新不再改写;解锁回到来源值。
const EDITABLE: UnitMetadataField[] = ["name", "airDate", "overview"];
const pad = (n: number | null) => String(n ?? 0).padStart(2, "0");
export function finalOf(u: ContentUnit, field: string): FinalFieldValue | null {
const p = FinalFieldValue.safeParse((u.metadata as Record<string, unknown>)[field]);
return p.success ? p.data : null;
}
export function UnitFieldDialog({
entityId,
unit,
onOpenChange,
}: {
entityId: string;
/** null 表示关闭 */
unit: ContentUnit | null;
onOpenChange: (o: boolean) => void;
}) {
const id = useId();
const lock = useLockUnitField(entityId);
const unlock = useUnlockUnitField(entityId);
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [lastId, setLastId] = useState<string | null>(null);
if ((unit?.id ?? null) !== lastId) {
setLastId(unit?.id ?? null);
setError(null);
setValues(
Object.fromEntries(
EDITABLE.map((f) => {
const v = unit ? finalOf(unit, f)?.value : null;
return [f, typeof v === "string" ? v : ""];
}),
),
);
}
if (!unit) return <Dialog open={false} />;
const busy = lock.isPending || unlock.isPending;
const code = `S${pad(unit.seasonNumber)}E${pad(unit.episodeNumber)}`;
const submit = async (e: FormEvent) => {
e.preventDefault();
const changed = EDITABLE.filter((f) => {
const cur = finalOf(unit, f)?.value;
return (values[f] ?? "").trim() !== (typeof cur === "string" ? cur : "");
});
if (!changed.length) {
onOpenChange(false);
return;
}
const date = values.airDate?.trim();
if (changed.includes("airDate") && date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
setError("播出日期格式是 YYYY-MM-DD");
return;
}
if (changed.includes("name") && !values.name?.trim()) {
setError("标题不能为空");
return;
}
try {
for (const f of changed) {
const v = (values[f] ?? "").trim();
await lock.mutateAsync({ unitId: unit.id, field: f, value: v || null });
}
toast.success(`已锁定 ${code} 的 ${changed.map((f) => UNIT_FIELD_LABEL[f]).join("、")}`);
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const locked = EDITABLE.filter((f) => unit.fieldLocks.includes(f));
return (
<Dialog open onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>修改 {code}</DialogTitle>
<DialogDescription>改过的字段会锁定,刷新元数据不再改写。编号和文件位置不变。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
{EDITABLE.map((f) => {
const fin = finalOf(unit, f);
const isLocked = unit.fieldLocks.includes(f);
return (
<div key={f} className="grid gap-1.5">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`${id}-${f}`}>{UNIT_FIELD_LABEL[f]}</Label>
<div className="flex items-center gap-1.5">
{fin ? (
<span className="text-meta text-muted-foreground">
来源 {METADATA_SOURCE[fin.source] ?? fin.source}
{fin.language ? ` · ${fin.language}` : ""}
</span>
) : null}
{isLocked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
已锁定
</StatusBadge>
) : null}
</div>
</div>
{f === "overview" ? (
<Textarea
id={`${id}-${f}`}
rows={4}
value={values[f] ?? ""}
onChange={(e) => setValues((v) => ({ ...v, [f]: e.target.value }))}
/>
) : (
<Input
id={`${id}-${f}`}
value={values[f] ?? ""}
placeholder={f === "airDate" ? "YYYY-MM-DD" : undefined}
onChange={(e) => setValues((v) => ({ ...v, [f]: e.target.value }))}
/>
)}
</div>
);
})}
{error ? <p className="text-meta text-destructive">{error}</p> : null}
<DialogFooter className="flex-wrap gap-2">
{locked.length ? (
<Button
type="button"
variant="ghost"
disabled={busy}
className="sm:mr-auto"
onClick={async () => {
try {
for (const f of locked) await unlock.mutateAsync({ unitId: unit.id, field: f });
toast.success(`已解锁 ${code}`);
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err, "解锁失败"));
}
}}
>
<LockOpen />
全部解锁
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
取消
</Button>
<Button type="submit" disabled={busy}>
{lock.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存并锁定
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,23 @@
// 外部身份 → 外站链接(媒体详情头部):TMDB / IMDb / TVDB / Wikidata。只拼公开地址,不带任何凭证。
export function externalLinks(ids: {
tmdb?: { type: "movie" | "tv"; id: string } | null;
imdb?: string | null;
tvdb?: string | null;
wikidata?: string | null;
tvdbKind?: "movie" | "series";
}): { label: string; href: string }[] {
const out: { label: string; href: string }[] = [];
if (ids.tmdb && /^\d+$/.test(ids.tmdb.id))
out.push({ label: "TMDB", href: `https://www.themoviedb.org/${ids.tmdb.type}/${ids.tmdb.id}` });
if (ids.imdb && /^tt\d+$/.test(ids.imdb))
out.push({ label: "IMDb", href: `https://www.imdb.com/title/${ids.imdb}/` });
if (ids.tvdb && /^\d+$/.test(ids.tvdb))
out.push({
label: "TVDB",
href: `https://thetvdb.com/dereferrer/${ids.tvdbKind ?? (ids.tmdb?.type === "movie" ? "movie" : "series")}/${ids.tvdb}`,
});
if (ids.wikidata && /^Q\d+$/.test(ids.wikidata))
out.push({ label: "Wikidata", href: `https://www.wikidata.org/wiki/${ids.wikidata}` });
return out;
}
@@ -0,0 +1,403 @@
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
import { type ReactNode, useState } from "react";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。
/** TMDB status 原文 → 界面用词 */
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
"Returning Series": { label: "连载中", tone: "success" },
"In Production": { label: "制作中", tone: "neutral" },
Planned: { label: "计划中", tone: "neutral" },
Pilot: { label: "试播", tone: "neutral" },
Ended: { label: "已完结", tone: "neutral" },
Canceled: { label: "已取消", tone: "warning" },
Released: { label: "已上映", tone: "neutral" },
"Post Production": { label: "后期制作", tone: "neutral" },
Rumored: { label: "传闻", tone: "neutral" },
};
export interface MediaFacts {
title: string;
originalTitle: string | null;
/** 中文名与标题不同时显示在原名一行 */
titleZh?: string | null;
year: number | null;
/** movie / series / variety … */
kind: string;
kindLabel: string;
genres: string[];
voteAverage: number | null;
voteCount: number | null;
airStatus: string | null;
runtime: number | null;
seasonCount: number | null;
episodeCount: number | null;
releaseDate: string | null;
lastAirDate: string | null;
platform: string | null;
originCountry: string[];
overview: string | null;
tagline: string | null;
posterPath: string | null;
backdropPath: string | null;
externalLinks: { label: string; href: string }[];
}
const pad = (n: number) => String(n).padStart(2, "0");
export const today = () => new Date().toISOString().slice(0, 10);
function runtimeText(min: number | null, kind: string): string | null {
if (!min) return null;
const s = min >= 60 ? `${Math.floor(min / 60)} 小时${min % 60 ? ` ${min % 60} 分钟` : ""}` : `${min} 分钟`;
return kind === "movie" ? s : `单集 ${s}`;
}
/** 可折叠的长文本:默认最多 lines 行,超出给「展开」 */
export function ClampText({
text,
lines = 3,
className,
onMedia = false,
}: {
text: string;
lines?: 2 | 3 | 4;
className?: string;
onMedia?: boolean;
}) {
const [open, setOpen] = useState(false);
const long = text.length > lines * 38;
const clamp = lines === 2 ? "line-clamp-2" : lines === 3 ? "line-clamp-3" : "line-clamp-4";
return (
<div className={className}>
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
{long ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"mt-0.5 cursor-pointer text-meta underline-offset-4 hover:underline",
onMedia
? "text-on-media-tertiary hover:text-on-media-strong"
: "text-muted-foreground hover:text-foreground",
)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
/** 头部:背景图(压暗)+ 海报 + 标题与事实行 + 简介 + 外部链接;badges 放标题下方,操作按钮由页面放在头部之后 */
export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: ReactNode }) {
const [bgFailed, setBgFailed] = useState(false);
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
const sub = [
facts.titleZh && facts.titleZh !== facts.title ? facts.titleZh : null,
facts.originalTitle && facts.originalTitle !== facts.title && facts.originalTitle !== facts.titleZh
? facts.originalTitle
: null,
].filter(Boolean);
const line = [
facts.kindLabel,
facts.year ? String(facts.year) : null,
facts.kind !== "movie" && facts.seasonCount ? `${facts.seasonCount} 季` : null,
facts.kind !== "movie" && facts.episodeCount ? `${facts.episodeCount} 集` : null,
runtimeText(facts.runtime, facts.kind),
facts.platform,
facts.originCountry.join(" / ") || null,
].filter(Boolean);
const dates = [
facts.releaseDate ? `${facts.kind === "movie" ? "上映" : "首播"} ${facts.releaseDate}` : null,
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
].filter(Boolean);
return (
<section className="relative isolate overflow-hidden rounded-2xl border border-border bg-scrim text-on-media">
{bg ? (
<img
src={bg}
alt=""
aria-hidden
onError={() => setBgFailed(true)}
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
/>
) : null}
<div aria-hidden className="absolute inset-0 -z-10 bg-gradient-to-r from-scrim/90 via-scrim/70 to-scrim/35" />
<div
aria-hidden
className="absolute inset-0 -z-10 bg-gradient-to-t from-scrim/80 via-transparent to-transparent"
/>
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:gap-6 sm:p-6">
<CoverPlaceholder
title={facts.title}
kind={facts.kind}
posterPath={facts.posterPath}
className="h-40 w-28 self-start border-on-media-wash/20 shadow-lg sm:h-60 sm:w-40"
/>
<div className="min-w-0 flex-1">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-on-media-strong sm:text-headline-sm">
{facts.title}
{facts.year ? <span className="font-normal text-on-media-tertiary"> ({facts.year})</span> : null}
</h1>
{sub.length ? <p className="mt-1 text-body break-words text-on-media-secondary">{sub.join(" · ")}</p> : null}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-on-media-secondary">
{facts.voteAverage ? (
<span className="inline-flex items-center gap-1 font-medium text-on-media-strong">
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
{facts.voteAverage.toFixed(1)}
{facts.voteCount ? (
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
) : null}
</span>
) : null}
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{line.length ? <span>{line.join(" · ")}</span> : null}
</div>
{facts.genres.length ? (
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
{facts.genres.map((g) => (
<li
key={g}
className="rounded-full border border-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
>
{g}
</li>
))}
</ul>
) : null}
{badges ? <div className="mt-2 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
{facts.tagline ? <p className="mt-3 text-caption text-on-media-tertiary italic">{facts.tagline}</p> : null}
{facts.overview ? (
<ClampText
text={facts.overview}
lines={4}
onMedia
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
/>
) : (
<p className="mt-3 text-caption text-on-media-tertiary">没有简介。</p>
)}
{dates.length || facts.externalLinks.length ? (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-on-media-tertiary">
{dates.map((d) => (
<span key={d}>{d}</span>
))}
{facts.externalLinks.map((l) => (
<a
key={l.href}
href={l.href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 underline-offset-4 hover:text-on-media-strong hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
))}
</div>
) : null}
</div>
</div>
</section>
);
}
function Avatar({ path, name }: { path: string | null; name: string }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(path, "w185");
return src ? (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="h-16 w-16 shrink-0 rounded-full border border-border bg-foreground/[0.05] object-cover"
/>
) : (
<span
aria-hidden
title={name}
className="grid h-16 w-16 shrink-0 place-items-center rounded-full border border-border bg-foreground/[0.05] text-muted-foreground"
>
<UserRound className="h-6 w-6" />
</span>
);
}
const CREW_ROLE: Record<string, string> = { Director: "导演", Creator: "主创" };
/** 主创:导演 / 主创在前,演员前 10;头像经图片代理,点姓名去 TMDB 人物页 */
export function CreditsStrip({ cast, crew }: { cast: MediaCredit[]; crew: MediaCredit[] }) {
const people = [
...crew.map((c) => ({ ...c, role: c.role ? (CREW_ROLE[c.role] ?? c.role) : null, key: `c${c.id}` })),
...cast.slice(0, 10).map((c) => ({ ...c, key: `a${c.id}` })),
];
if (!people.length) return null;
return (
<ul className="scroll-bleed -mx-1 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap sm:overflow-visible">
{people.map((p) => (
<li key={p.key} className="flex w-20 shrink-0 flex-col items-center text-center">
<Avatar path={p.profilePath} name={p.name} />
<a
href={`https://www.themoviedb.org/person/${p.id}`}
target="_blank"
rel="noreferrer noopener"
className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground hover:underline"
>
{p.name}
</a>
{p.role ? <span className="line-clamp-2 text-micro break-words text-muted-foreground">{p.role}</span> : null}
</li>
))}
</ul>
);
}
export interface EpisodeRowData {
key: string;
seasonNumber: number;
episodeNumber: number;
name: string | null;
overview: string | null;
airDate: string | null;
runtime: number | null;
stillPath: string | null;
voteAverage: number | null;
/** 库内状态;null = 不显示(仅 TMDB 作品未收录) */
status?: { label: string; tone: StatusTone } | null;
/** 有锁定的字段 */
locked?: boolean;
onEdit?: () => void;
}
function Still({ path }: { path: string | null }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(path, "w300");
return (
<div className="aspect-video w-28 shrink-0 overflow-hidden rounded-lg border border-border bg-foreground/[0.05] sm:w-40">
{src ? (
<img src={src} alt="" loading="lazy" onError={() => setFailed(true)} className="h-full w-full object-cover" />
) : (
<div aria-hidden className="grid h-full w-full place-items-center text-muted-foreground">
<ImageOff className="h-4 w-4 opacity-60" />
</div>
)}
</div>
);
}
/** 每集一行:剧照 · 集号与标题 · 播出日期 · 时长 · 简介(折叠)· 库内状态 */
export function EpisodeRow({ e }: { e: EpisodeRowData }) {
const unaired = !!e.airDate && e.airDate > today();
const meta = [
e.airDate ?? "播出日期未知",
e.runtime ? `${e.runtime} 分钟` : null,
e.voteAverage ? `★ ${e.voteAverage.toFixed(1)}` : null,
].filter(Boolean);
const status = e.status === undefined ? (unaired ? { label: "未播出", tone: "neutral" as const } : null) : e.status;
return (
<li className="flex gap-3 py-3 first:pt-0 last:pb-0">
<Still path={e.stillPath} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
<p className="min-w-0 text-sm font-medium break-words text-foreground">
<span className="mr-1.5 font-mono text-caption text-muted-foreground">
S{pad(e.seasonNumber)}E{pad(e.episodeNumber)}
</span>
{e.name ?? `第 ${e.episodeNumber} 集`}
{e.locked ? <Lock className="ml-1 inline h-3 w-3 text-muted-foreground" aria-label="有锁定字段" /> : null}
</p>
<div className="flex shrink-0 items-center gap-1">
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{e.onEdit ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改 S${pad(e.seasonNumber)}E${pad(e.episodeNumber)}`}
onClick={e.onEdit}
>
<PencilLine />
</Button>
) : null}
</div>
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">{meta.join(" · ")}</p>
{e.overview ? <ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" /> : null}
</div>
</li>
);
}
export interface SeasonHeaderData {
seasonNumber: number;
name: string | null;
airDate: string | null;
episodeCount: number | null;
posterPath: string | null;
overview: string | null;
/** 季头右侧的摘要,如「已入库 5/9」 */
summary?: ReactNode;
}
/** 季折叠:季头(海报缩略、季名、首播年、集数、摘要)+ 展开后的内容(集列表由页面传入) */
export function SeasonSection({
s,
open,
onToggle,
children,
}: {
s: SeasonHeaderData;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
const title = s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
return (
<section className="rounded-xl border border-border/70">
<button
type="button"
aria-expanded={open}
onClick={onToggle}
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-base hover:bg-wash/[0.04]"
>
<CoverPlaceholder title={title} kind="series" small posterPath={s.posterPath} className="h-14 w-10" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium break-words text-foreground">
{title}
<span className="ml-2 font-mono text-meta font-normal text-muted-foreground">S{pad(s.seasonNumber)}</span>
</p>
<p className="text-meta text-muted-foreground">
{[s.airDate ? s.airDate.slice(0, 4) : null, s.episodeCount !== null ? `${s.episodeCount} 集` : null]
.filter(Boolean)
.join(" · ") || "—"}
</p>
</div>
{s.summary ? <div className="hidden shrink-0 sm:block">{s.summary}</div> : null}
<ChevronDown
aria-hidden
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-180",
)}
/>
</button>
{open ? (
<div className="border-t border-border/60 px-3 py-3">
{s.summary ? <div className="mb-2 sm:hidden">{s.summary}</div> : null}
{s.overview ? (
<ClampText text={s.overview} lines={2} className="mb-3 text-caption text-muted-foreground" />
) : null}
{children}
</div>
) : null}
</section>
);
}
+32 -2
View File
@@ -1,3 +1,4 @@
import { DEFAULT_SETTINGS } from "@umt/contracts";
import { toast } from "sonner";
import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
import { useUpdateSettings } from "~/api/settings.ts";
@@ -7,7 +8,23 @@ import { errorMessage } from "~/lib/api.ts";
import { str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 元数据:TMDB API Key(只写,存 secret)与 Base URL
// 元数据:TMDB API Key(只写,存 secret)、Base URL、语言回退链
const DEFAULT_LANGUAGES = DEFAULT_SETTINGS.metadata.languages;
/** "zh-CN, zh_sg en" → ["zh-CN","zh-SG","en"];不合法的项丢弃,全空回缺省 */
function parseLanguages(v: string): string[] {
const out = v
.split(/[,,、\s]+/)
.map((x) => x.trim().replace("_", "-"))
.filter(Boolean)
.map((x) => {
const [l, r] = x.split("-");
return r ? `${l!.toLowerCase()}-${r.toUpperCase()}` : l!.toLowerCase();
})
.filter((x) => /^[a-z]{2,3}(-[A-Z]{2})?$/.test(x));
return out.length ? [...new Set(out)].slice(0, 8) : [...DEFAULT_LANGUAGES];
}
const TMDB_CARD: CardSpec = {
anchor: "tmdb",
@@ -29,11 +46,24 @@ const TMDB_CARD: CardSpec = {
hint: "自建反代时填写;留空用官方地址",
wide: true,
},
{
name: "metadata.languages",
label: "语言回退链",
kind: "text",
placeholder: DEFAULT_LANGUAGES.join(", "),
hint: "标题、简介和每集信息按顺序取第一个有值的语言;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG",
wide: true,
},
],
fromSettings: (s) => ({ "metadata.tmdbApiKey": "", "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "" }),
fromSettings: (s) => ({
"metadata.tmdbApiKey": "",
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
}),
toPatch: (v) => ({
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
languages: parseLanguages(str(v["metadata.languages"])),
...(str(v["metadata.tmdbApiKey"]) ? { tmdbApiKey: str(v["metadata.tmdbApiKey"]) } : {}),
},
}),
+403 -92
View File
@@ -5,7 +5,9 @@ import type {
LibraryAssetView,
LibraryDetail,
LibraryDownloadView,
MediaCredit,
MediaEntityDetail,
SubscriptionCoverage,
} from "@umt/contracts";
import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts";
import {
@@ -22,8 +24,9 @@ import {
PencilLine,
RefreshCw,
SearchX,
Tv,
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { toast } from "sonner";
import { mediaServers } from "~/api/instances.ts";
@@ -34,6 +37,7 @@ import {
useRefreshMetadata,
useUnlockField,
} from "~/api/library.ts";
import { useSubscriptionCoverage, useSubscriptions } from "~/api/subscriptions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
@@ -55,20 +59,23 @@ import {
UNIT_KIND,
VERIFY_STATUS,
} from "~/components/library/labels.ts";
import {
Chip,
ChipList,
CoverPlaceholder,
Panel,
ProgressLine,
posterPathOf,
relTime,
Tone,
} from "~/components/library/parts.tsx";
import { Chip, ChipList, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.tsx";
import { externalLinks } from "~/components/media/external-links.ts";
import {
AIR_STATUS,
CreditsStrip,
EpisodeRow,
type MediaFacts,
MediaHero,
SeasonSection,
today,
} from "~/components/media/media-detail.tsx";
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
import { EmbyBadge } from "~/components/search/work-card.tsx";
import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -76,6 +83,8 @@ import { formatBytes, formatDateTime } from "~/lib/utils.ts";
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
// 内容单元、下载历史与校验结果,同一页同时展示。
// MD-1(对照 MP 媒体详情):头部背景图 + 海报 + 评分 / 类型 / 状态 + 简介、主创、季折叠与每集行(库内状态按版本目标),
// 头部 / 主创 / 季集组件与仅 TMDB 的作品详情页共用(components/media/media-detail)。
const pad = (n: number) => String(n).padStart(2, "0");
@@ -251,7 +260,52 @@ function IdentityRow({ i }: { i: ExternalIdentity }) {
// ── 元数据字段来源与锁 ──
const ENRICH_ONLY_FIELDS = new Set<string>(["platform", "episodeCount", "genres", "posterUrl"]);
/** 只在有值时显示的字段:补全专有(平台 / 集数 / 类型 / 网页海报)与 MD-1 TMDB 详情字段 */
const ENRICH_ONLY_FIELDS = new Set<string>([
"platform",
"episodeCount",
"genres",
"posterUrl",
"titleZh",
"tagline",
"lastAirDate",
"airStatus",
"voteAverage",
"voteCount",
"runtime",
"seasonCount",
"cast",
"crew",
"externalIds",
]);
/** 结构化值(列表 / 对象)不走文本覆盖对话框 */
const NOT_EDITABLE = new Set<string>(["cast", "crew", "externalIds"]);
/** 字段值 → 一行文本 */
function fieldText(field: string, v: unknown): string {
if (v === null || v === undefined || v === "") return "—";
if (field === "cast" || field === "crew") {
const list = (Array.isArray(v) ? v : []) as MediaCredit[];
return (
list
.map((c) => (c.role ? `${c.name}(${c.role})` : c.name))
.slice(0, 6)
.join("、") || "—"
);
}
if (field === "externalIds" && typeof v === "object") {
return (
Object.entries(v as Record<string, unknown>)
.filter(([, x]) => x)
.map(([k, x]) => `${k} ${String(x)}`)
.join(" · ") || "—"
);
}
if (field === "airStatus" && typeof v === "string") return AIR_STATUS[v]?.label ?? v;
if (Array.isArray(v)) return v.join("、");
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
/** META-1 补全写入的字段:证据等级 + 出处网页(来源 web = 网页原文,agent = Agent 归纳,都不是 TMDB) */
function EvidenceLine({ field }: { field: MediaEntityDetail["metadataFields"][number] | undefined }) {
@@ -307,7 +361,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
return (
<Panel
title="元数据"
title="字段来源与锁"
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
actions={
<Button
@@ -315,10 +369,13 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
size="sm"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(undefined, {
onSuccess: () => toast.success("已提交刷新"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
})
refresh.mutate(
{ full: true },
{
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
},
)
}
>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
@@ -333,16 +390,8 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
<li key={field} className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
<div className="w-24 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENTITY_FIELD_LABEL[field]}</div>
<div className="min-w-0 flex-1 basis-48">
<p className="text-sm break-words text-foreground">
{final ? (
Array.isArray(final.value) ? (
final.value.join("、")
) : (
String(final.value ?? "—")
)
) : (
<span className="text-muted-foreground">未提供</span>
)}
<p className="line-clamp-4 text-sm break-words text-foreground">
{final ? fieldText(field, final.value) : <span className="text-muted-foreground">未提供</span>}
</p>
{final ? (
<p className="text-meta text-muted-foreground">
@@ -354,7 +403,9 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
{cands.length ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground">
候选:
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
{cands
.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${fieldText(field, c.value)}」`)
.join(" / ")}
</p>
) : null}
</div>
@@ -386,14 +437,16 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
/>
</>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
onClick={() => setEditing(field)}
>
<PencilLine />
</Button>
{NOT_EDITABLE.has(field) ? null : (
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
onClick={() => setEditing(field)}
>
<PencilLine />
</Button>
)}
</div>
</li>
);
@@ -540,6 +593,210 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
);
}
// ── 季与集(MD-1):季折叠 + 每集行,库内状态按版本目标 ──
const CELL_TONE: Record<CellKind, "success" | "warning" | "destructive" | "neutral"> = {
have: "success",
upgradable: "success",
inflight: "neutral",
deferred: "warning",
missing: "destructive",
suspected: "warning",
not_aired: "neutral",
skipped: "neutral",
};
type EpisodeStatus = { label: string; tone: "success" | "warning" | "destructive" | "neutral"; have: boolean };
/** 覆盖矩阵 → (季, 集) 的格:按内容单元 ID 或集号对列 */
function coverageLookup(cov: SubscriptionCoverage | undefined, rowIdx: number) {
const map = new Map<string, EpisodeStatus>();
if (!cov) return map;
const row = cov.rows[rowIdx] ?? cov.rows[0];
if (!row) return map;
let i = 0;
for (const s of cov.seasons) {
for (const col of s.columns) {
const cell = row.cells[i++];
if (!cell || s.seasonNumber === null) continue;
const k = cellKind(cell);
const st = { label: CELL_LABEL[k], tone: CELL_TONE[k], have: k === "have" || k === "upgradable" };
if (col.contentUnitId) map.set(`u:${col.contentUnitId}`, st);
if (col.episode !== null) map.set(`e:${s.seasonNumber}:${col.episode}`, st);
}
}
return map;
}
function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
const e = detail.entity;
const [editing, setEditing] = useState<ContentUnit | null>(null);
const [rowIdx, setRowIdx] = useState(0);
const refresh = useRefreshMetadata(e.id);
const subs = useSubscriptions({ mediaEntityId: e.id });
const sub = subs.data?.pages
.flatMap((p) => p.items)
.find((x) => x.status === "active" || x.status === "pending" || x.status === "paused");
const cov = useSubscriptionCoverage(sub?.id);
const live = units.filter((u) => u.status !== "retired");
const seasonUnits = live.filter((u) => u.kind === "season" && u.seasonNumber !== null);
const episodes = live
.filter((u) => u.kind === "episode" && u.seasonNumber !== null && u.episodeNumber !== null)
.sort((a, b) => a.episodeNumber! - b.episodeNumber! || a.createdAt.localeCompare(b.createdAt));
const covered = new Set(detail.assets.filter((a) => a.asset.status === "active").flatMap((a) => a.contentUnitIds));
const lookup = useMemo(() => coverageLookup(cov.data, rowIdx), [cov.data, rowIdx]);
const seasonNums = [
...new Set([...seasonUnits.map((u) => u.seasonNumber!), ...episodes.map((u) => u.seasonNumber!)]),
].sort((a, b) => (a || 999) - (b || 999));
const seasonUnitOf = (n: number) => seasonUnits.find((u) => u.seasonNumber === n);
const trackedSeason = sub?.seasonUnitId ? units.find((u) => u.id === sub.seasonUnitId)?.seasonNumber : undefined;
const statusOf = (u: ContentUnit): EpisodeStatus | null => {
const hit = lookup.get(`u:${u.id}`) ?? lookup.get(`e:${u.seasonNumber}:${u.episodeNumber}`);
if (hit) return hit;
const season = seasonUnitOf(u.seasonNumber!);
if (covered.has(u.id) || (season && covered.has(season.id)))
return { label: "已入库", tone: "success", have: true };
const air = finalOf(u, "airDate")?.value ?? u.airDate;
if (typeof air === "string" && air > today()) return { label: "未播出", tone: "neutral", have: false };
return { label: sub ? "缺失" : "未入库", tone: sub ? "destructive" : "neutral", have: false };
};
const haveIn = (n: number) => episodes.filter((u) => u.seasonNumber === n && statusOf(u)?.have).length;
const initial =
trackedSeason ??
[...seasonNums].reverse().find((n) => n > 0 && haveIn(n) > 0) ??
[...seasonNums].reverse().find((n) => n > 0) ??
null;
const [open, setOpen] = useState<Set<number> | null>(null);
const openSet = open ?? new Set(initial !== null && initial !== undefined ? [initial] : []);
const toggle = (n: number) => {
const next = new Set(openSet);
if (next.has(n)) next.delete(n);
else next.add(n);
setOpen(next);
};
const rows = cov.data?.rows ?? [];
const refreshFull = () =>
refresh.mutate(
{ full: true },
{
onSuccess: () => toast.success("已提交刷新,含全部季的每集信息"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
},
);
return (
<Panel
title="季与集"
description={
sub
? "库内状态按订阅的覆盖矩阵;标题与简介按语言回退链取值"
: "没有订阅时只看是否已入库;标题与简介按语言回退链取值"
}
actions={
<Button variant="outline" size="sm" disabled={refresh.isPending} onClick={refreshFull}>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
更新每集信息
</Button>
}
>
{rows.length > 1 ? (
<fieldset aria-label="按版本目标看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
{rows.map((r, i) => (
<button
key={r.slotId ?? "any"}
type="button"
aria-pressed={i === rowIdx}
onClick={() => setRowIdx(i)}
className={
i === rowIdx
? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground"
: "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
}
>
{r.name}
</button>
))}
</fieldset>
) : null}
{seasonNums.length === 0 ? (
<EmptyState
plain
icon={Tv}
title="还没有每集信息"
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
className="py-8"
/>
) : (
<div className="space-y-2">
{seasonNums.map((n) => {
const su = seasonUnitOf(n);
const eps = episodes.filter((u) => u.seasonNumber === n);
const total = su?.episodeTotal ?? (eps.length || null);
const nameV = su ? finalOf(su, "name")?.value : null;
const posterV = su ? finalOf(su, "posterPath")?.value : null;
const ovV = su ? finalOf(su, "overview")?.value : null;
const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
const have = haveIn(n);
return (
<SeasonSection
key={n}
s={{
seasonNumber: n,
name: typeof nameV === "string" ? nameV : (su?.title ?? null),
airDate: typeof airV === "string" ? airV : null,
episodeCount: total,
posterPath: typeof posterV === "string" ? posterV : null,
overview: typeof ovV === "string" ? ovV : null,
summary: (
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
已入库 {have}/{total ?? "?"}
</StatusBadge>
),
}}
open={openSet.has(n)}
onToggle={() => toggle(n)}
>
{eps.length === 0 ? (
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
) : (
<ul className="divide-y divide-border/60">
{eps.map((u) => {
const v = (f: string) => finalOf(u, f)?.value;
const str = (x: unknown) => (typeof x === "string" && x ? x : null);
const num = (x: unknown) => (typeof x === "number" ? x : null);
return (
<EpisodeRow
key={u.id}
e={{
key: u.id,
seasonNumber: n,
episodeNumber: u.episodeNumber!,
name: str(v("name")) ?? u.title,
overview: str(v("overview")),
airDate: str(v("airDate")) ?? u.airDate,
runtime: num(v("runtime")),
stillPath: str(v("stillPath")),
voteAverage: num(v("voteAverage")),
status: statusOf(u),
locked: u.fieldLocks.length > 0,
onEdit: () => setEditing(u),
}}
/>
);
})}
</ul>
)}
</SeasonSection>
);
})}
</div>
)}
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
</Panel>
);
}
// ── 下载历史与校验 ──
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
const columns: DataColumn<LibraryDownloadView>[] = [
@@ -628,6 +885,71 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
);
}
/** media_entity.metadata 某字段的最终值 */
function metaValue(metadata: unknown, field: string): unknown {
const p = FinalFieldValue.safeParse((metadata as Record<string, unknown> | null)?.[field]);
return p.success ? p.data.value : undefined;
}
const strOf = (v: unknown) => (typeof v === "string" && v ? v : null);
const numOf = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
function creditList(v: unknown): MediaCredit[] {
return Array.isArray(v)
? (v as MediaCredit[]).filter((c) => c && typeof c.name === "string" && typeof c.id === "number")
: [];
}
/** 本地作品 → 媒体详情头部数据(字段来自最终元数据;外部链接取已接受的外部身份与 externalIds) */
function factsOf(d: LibraryDetail, entity: MediaEntityDetail | undefined): MediaFacts {
const e = d.entity;
const m = (f: string) => metaValue(e.metadata, f);
const accepted = d.identities.filter((i) => i.status === "accepted");
const tmdb = accepted.find((i) => i.provider === "tmdb" && (i.entityType === "movie" || i.entityType === "tv"));
const ext = (m("externalIds") ?? {}) as { imdb?: string | null; tvdb?: string | null; wikidata?: string | null };
const genres = m("genres");
const country = m("originCountry");
const seasonCount =
numOf(m("seasonCount")) ??
(new Set(
(entity?.contentUnits ?? [])
.filter((u) => u.kind === "season" && (u.seasonNumber ?? 0) > 0 && u.status !== "retired")
.map((u) => u.seasonNumber),
).size ||
null);
return {
title: e.title,
originalTitle: e.originalTitle,
titleZh: strOf(m("titleZh")),
year: e.year,
kind: e.kind,
kindLabel: MEDIA_KIND[e.kind] ?? e.kind,
genres: Array.isArray(genres)
? genres.filter((x): x is string => typeof x === "string")
: strOf(genres)
? [genres as string]
: [],
voteAverage: numOf(m("voteAverage")),
voteCount: numOf(m("voteCount")),
airStatus: strOf(m("airStatus")),
runtime: numOf(m("runtime")),
seasonCount: e.kind === "movie" ? null : seasonCount,
episodeCount: e.kind === "movie" ? null : numOf(m("episodeCount")),
releaseDate: strOf(m("releaseDate")),
lastAirDate: strOf(m("lastAirDate")),
platform: strOf(m("platform")),
originCountry: Array.isArray(country) ? country.filter((x): x is string => typeof x === "string") : [],
overview: strOf(m("overview")),
tagline: strOf(m("tagline")),
posterPath: posterPathOf(e.metadata),
backdropPath: strOf(m("backdropPath")),
externalLinks: externalLinks({
tmdb: tmdb ? { type: tmdb.entityType as "movie" | "tv", id: tmdb.externalId } : null,
imdb: ext.imdb ?? accepted.find((i) => i.provider === "imdb")?.externalId ?? null,
tvdb: ext.tvdb ?? accepted.find((i) => i.provider === "tvdb")?.externalId ?? null,
wikidata: ext.wikidata ?? null,
}),
};
}
export default function LibraryDetailPage() {
const { id } = useParams();
const detail = useLibraryDetail(id);
@@ -671,6 +993,10 @@ export default function LibraryDetailPage() {
const d = detail.data;
const e = d.entity;
const redirected = entity.data?.redirectedFrom;
const credits = {
cast: creditList(metaValue(e.metadata, "cast")),
crew: creditList(metaValue(e.metadata, "crew")),
};
return (
<>
{back}
@@ -685,66 +1011,51 @@ export default function LibraryDetailPage() {
{redirected ? (
<p className="mb-4 text-meta text-muted-foreground">旧 ID {redirected} 已跳转到当前作品。</p>
) : null}
<header className="mb-6 flex flex-wrap items-start gap-4">
<CoverPlaceholder
title={e.title}
kind={e.kind}
posterPath={posterPathOf(e.metadata)}
className="h-28 w-20 sm:h-36 sm:w-24"
/>
<div className="min-w-0 flex-1 basis-56">
<div className="flex flex-wrap items-center gap-2">
<h1 className="min-w-0 text-title-lg font-semibold tracking-tight break-words text-foreground">
{e.title}
</h1>
<MediaHero
facts={factsOf(d, entity.data)}
badges={
<>
<Tone v={ENTITY_STATUS[e.status]} />
</div>
<p className="mt-1 text-body text-muted-foreground">
{MEDIA_KIND[e.kind]}
{e.year ? ` · ${e.year}` : ""}
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
</p>
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
{(ms.data?.items.length ?? 0) > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
<EmbyBadge refOf={{ mediaEntityId: e.id }} />
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{e.status !== "merged" ? (
<>
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button
variant="outline"
size="sm"
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
>
<PackageSearch />
搜索资源
</Button>
</>
) : null}
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
查看订阅
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并作品
{(ms.data?.items.length ?? 0) > 0 ? <EmbyBadge refOf={{ mediaEntityId: e.id }} /> : null}
</>
}
/>
<div className="mt-4 mb-5 flex flex-wrap items-center gap-2">
{e.status !== "merged" ? (
<>
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
</div>
</header>
<Button variant="outline" size="sm" onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}>
<PackageSearch />
搜索资源
</Button>
</>
) : null}
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
查看订阅
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并作品
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
<span className="font-mono text-meta break-all text-muted-foreground sm:ml-auto">{e.id}</span>
</div>
<div className="space-y-5">
{credits.cast.length || credits.crew.length ? (
<Panel title="主创">
<CreditsStrip cast={credits.cast} crew={credits.crew} />
</Panel>
) : null}
{e.kind !== "movie" ? <EpisodesPanel detail={d} units={entity.data?.contentUnits ?? []} /> : null}
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
<div className="grid gap-5 xl:grid-cols-2">
<IdentityPanel detail={d} />
@@ -5,12 +5,20 @@ import { Link, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { useEntityFromCandidate } from "~/api/files.ts";
import { mediaServers } from "~/api/instances.ts";
import { useMediaServerInventory, useTmdbMedia } from "~/api/search.ts";
import { useMediaServerInventory, useTmdbMedia, useTmdbSeason } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts";
import { CoverPlaceholder, Panel } from "~/components/library/parts.tsx";
import { Panel } from "~/components/library/parts.tsx";
import { externalLinks } from "~/components/media/external-links.ts";
import {
CreditsStrip,
EpisodeRow,
type MediaFacts,
MediaHero,
SeasonSection,
} from "~/components/media/media-detail.tsx";
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
import { InventoryBadges, mediaServerSummary, slotLine } from "~/components/search/work-card.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
@@ -18,12 +26,11 @@ import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
// 作品详情(仅 TMDB,SEARCH-2):TMDB 信息与季列表 + 本地账本 / Emby 对比;在这里「订阅」「搜索资源」「建档不订阅」。
// 已建档的作品同时给媒体库详情入口。
// 已建档的作品同时给媒体库详情入口。MD-1:头部 / 主创 / 季折叠与每集行复用 components/media/media-detail(与媒体库详情同一组件),
// 每季的集信息在展开时才拉 GET /media/tmdb/tv/{id}/season/{n}。
export const meta = () => [{ title: "作品详情 · UHD 媒体自动化控制台" }];
const pad = (n: number) => String(n).padStart(2, "0");
export default function TmdbMediaPage() {
const { type, id } = useParams();
const q = useTmdbMedia(type, id);
@@ -89,72 +96,73 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
},
);
const facts: MediaFacts = {
title: d.title,
originalTitle: d.originalTitle,
titleZh: d.titleZh ?? null,
year: d.year,
kind,
kindLabel: MEDIA_KIND[kind] ?? kind,
genres: d.genres ?? [],
voteAverage: d.voteAverage ?? null,
voteCount: d.voteCount ?? null,
airStatus: d.airStatus ?? null,
runtime: d.runtime ?? null,
seasonCount: d.entityType === "tv" ? d.seasons.filter((x) => x.seasonNumber > 0).length || null : null,
episodeCount: d.episodeCount ?? null,
releaseDate: d.releaseDate,
lastAirDate: d.lastAirDate ?? null,
platform: d.platform ?? null,
originCountry: d.originCountry,
overview: d.overview,
tagline: d.tagline ?? null,
posterPath: d.posterPath,
backdropPath: d.backdropPath,
externalLinks: externalLinks({
tmdb: { type: d.entityType, id: d.externalId },
imdb: d.externalIds.imdb,
tvdb: d.externalIds.tvdb,
}),
};
return (
<>
<header className="mb-6 flex flex-wrap items-start gap-4">
<CoverPlaceholder title={d.title} kind={kind} posterPath={d.posterPath} className="h-40 w-28 sm:h-48 sm:w-32" />
<div className="min-w-0 flex-1 basis-64">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground">
{d.title}
{d.year ? <span className="font-normal text-muted-foreground"> ({d.year})</span> : null}
</h1>
<p className="mt-1 text-body break-words text-muted-foreground">
{[
MEDIA_KIND[kind],
d.originalTitle && d.originalTitle !== d.title ? d.originalTitle : null,
d.releaseDate,
d.originCountry.join(" / ") || null,
`TMDB ${d.externalId}`,
]
.filter(Boolean)
.join(" · ")}
</p>
<div className="mt-2 flex flex-wrap gap-1">
<InventoryBadges inv={d.inventory} />
</div>
{d.overview ? <p className="mt-3 max-w-3xl text-caption text-foreground">{d.overview}</p> : null}
<div className="mt-4 flex flex-wrap gap-2">
<Button onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button variant="outline" onClick={() => launcher.open({ title: d.title, ref })}>
<PackageSearch />
搜索资源
</Button>
{d.localEntityId ? (
<Button asChild variant="outline">
<Link to={consolePath("library", d.localEntityId)}>
<BookmarkPlus />
媒体库详情
</Link>
</Button>
) : (
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
<FolderPlus />
{entity.isPending ? "建档中…" : "建档不订阅"}
</Button>
)}
</div>
</div>
</header>
<MediaHero facts={facts} badges={<InventoryBadges inv={d.inventory} />} />
<div className="mt-4 mb-5 flex flex-wrap gap-2">
<Button onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button variant="outline" onClick={() => launcher.open({ title: d.title, ref })}>
<PackageSearch />
搜索资源
</Button>
{d.localEntityId ? (
<Button asChild variant="outline">
<Link to={consolePath("library", d.localEntityId)}>
<BookmarkPlus />
媒体库详情
</Link>
</Button>
) : (
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
<FolderPlus />
{entity.isPending ? "建档中…" : "建档不订阅"}
</Button>
)}
</div>
<div className="space-y-5">
<InventoryPanel d={d} />
{d.cast?.length || d.crew?.length ? (
<Panel title="主创">
<CreditsStrip cast={d.cast ?? []} crew={d.crew ?? []} />
</Panel>
) : null}
{d.entityType === "tv" ? <SeasonsPanel d={d} /> : null}
{d.aliases.length || d.externalIds.imdb || d.externalIds.tvdb ? (
<Panel title="别名与外部身份">
<p className="text-caption break-words text-muted-foreground">
{[
d.externalIds.imdb ? `IMDb ${d.externalIds.imdb}` : null,
d.externalIds.tvdb ? `TVDB ${d.externalIds.tvdb}` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
{d.aliases.length ? (
<p className="mt-1 text-caption break-words text-foreground">{d.aliases.join("、")}</p>
) : null}
<InventoryPanel d={d} />
{d.aliases.length ? (
<Panel title="别名">
<p className="text-caption break-words text-foreground">{d.aliases.join("、")}</p>
</Panel>
) : null}
</div>
@@ -254,41 +262,80 @@ function EmbyInstances({ ms }: { ms: MediaServerInventory }) {
}
function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
const regular = d.seasons.filter((x) => x.seasonNumber > 0 && x.episodeCount > 0);
// 默认展开:本地已有的最新一季;未收录时展开最新一季
const initial =
[...regular].reverse().find((x) => (x.localHave ?? 0) > 0)?.seasonNumber ?? regular.at(-1)?.seasonNumber ?? null;
const [open, setOpen] = useState<Set<number>>(() => new Set(initial !== null ? [initial] : []));
if (d.seasons.length === 0) {
return (
<Panel title="季">
<Panel title="季与集">
<EmptyState plain title="TMDB 还没有季信息" className="py-6" />
</Panel>
);
}
const toggle = (n: number) =>
setOpen((cur) => {
const next = new Set(cur);
if (next.has(n)) next.delete(n);
else next.add(n);
return next;
});
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
return (
<Panel title="季" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,本地一列不显示"}>
<table className="w-full text-caption">
<thead>
<tr className="text-left text-meta text-muted-foreground">
<th className="py-1.5 pr-3 font-normal">季</th>
<th className="py-1.5 pr-3 font-normal">名称</th>
<th className="py-1.5 pr-3 font-normal">首播</th>
<th className="py-1.5 pr-3 text-right font-normal">集数</th>
{d.localEntityId ? <th className="py-1.5 text-right font-normal">本地</th> : null}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{d.seasons.map((s) => (
<tr key={s.seasonNumber}>
<td className="py-1.5 pr-3 font-mono text-foreground">S{pad(s.seasonNumber)}</td>
<td className="py-1.5 pr-3 break-words text-foreground">{s.name ?? "—"}</td>
<td className="py-1.5 pr-3 text-muted-foreground tabular-nums">{s.airDate ?? "—"}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{s.episodeCount}</td>
{d.localEntityId ? (
<td className="py-1.5 text-right tabular-nums">
{s.localHave === null ? "—" : `${s.localHave}/${s.episodeCount}`}
</td>
) : null}
</tr>
))}
</tbody>
</table>
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,不显示库内状态"}>
<div className="space-y-2">
{seasons.map((s) => (
<SeasonSection
key={s.seasonNumber}
s={{
seasonNumber: s.seasonNumber,
name: s.name,
airDate: s.airDate,
episodeCount: s.episodeCount,
posterPath: s.posterPath ?? null,
overview: s.overview ?? null,
summary:
d.localEntityId && s.localHave !== null ? (
<StatusBadge tone={s.localHave >= s.episodeCount && s.episodeCount > 0 ? "success" : "neutral"}>
已入库 {s.localHave}/{s.episodeCount}
</StatusBadge>
) : undefined,
}}
open={open.has(s.seasonNumber)}
onToggle={() => toggle(s.seasonNumber)}
>
<SeasonEpisodes tvId={d.externalId} season={s.seasonNumber} />
</SeasonSection>
))}
</div>
</Panel>
);
}
function SeasonEpisodes({ tvId, season }: { tvId: string; season: number }) {
const q = useTmdbSeason(tvId, season, true);
if (q.isPending) return <LoadingState rows={3} />;
if (q.isError) return <ErrorState title="季详情加载失败" error={q.error} onRetry={() => q.refetch()} />;
if (!q.data.episodes.length) return <EmptyState plain title="TMDB 还没有这一季的集信息" className="py-6" />;
return (
<ul className="divide-y divide-border/60">
{q.data.episodes.map((e) => (
<EpisodeRow
key={e.episodeNumber}
e={{
key: String(e.episodeNumber),
seasonNumber: e.seasonNumber,
episodeNumber: e.episodeNumber,
name: e.name,
overview: e.overview,
airDate: e.airDate,
runtime: e.runtime,
stillPath: e.stillPath,
voteAverage: e.voteAverage,
}}
/>
))}
</ul>
);
}