Merge branch 'md1'
# Conflicts: # packages/contracts/src/settings.ts
This commit is contained in:
commit
b633eb5379
30 files changed
+3240
-222
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,不等于缺失 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]) } : {}),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user