diff --git a/apps/web/app/api/library.ts b/apps/web/app/api/library.ts index 33aeb65a..4a6bdea4 100644 --- a/apps/web/app/api/library.ts +++ b/apps/web/app/api/library.ts @@ -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(`/media-entities/${id}/refresh`), + mutationFn: (opts?: { full?: boolean }) => + api.post(`/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(`/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(`/content-units/${v.unitId}/fields/${v.field}`), onSuccess: done, }); } diff --git a/apps/web/app/api/search.ts b/apps/web/app/api/search.ts index 8dda8bc2..efbdc17a 100644 --- a/apps/web/app/api/search.ts +++ b/apps/web/app/api/search.ts @@ -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,不等于缺失 */ diff --git a/apps/web/app/components/library/labels.ts b/apps/web/app/components/library/labels.ts index 139e7317..1332f271 100644 --- a/apps/web/app/components/library/labels.ts +++ b/apps/web/app/components/library/labels.ts @@ -246,6 +246,29 @@ export const ENTITY_FIELD_LABEL: Record = { episodeCount: "集数", genres: "类型", posterUrl: "网页海报", + titleZh: "中文名", + tagline: "标语", + lastAirDate: "最近播出", + airStatus: "播出状态", + voteAverage: "评分", + voteCount: "评分人数", + runtime: "时长(分钟)", + seasonCount: "季数", + cast: "演员", + crew: "导演 / 主创", + externalIds: "外部 ID", +}; + +/** 季 / 集字段(contracts UnitMetadataField) */ +export const UNIT_FIELD_LABEL: Record = { + name: "标题", + overview: "简介", + airDate: "播出日期", + runtime: "时长(分钟)", + stillPath: "剧照", + posterPath: "海报", + voteAverage: "评分", + voteCount: "评分人数", }; /** 补全字段(contracts EnrichFieldName) */ diff --git a/apps/web/app/components/library/unit-field-dialog.tsx b/apps/web/app/components/library/unit-field-dialog.tsx new file mode 100644 index 00000000..13efe15f --- /dev/null +++ b/apps/web/app/components/library/unit-field-dialog.tsx @@ -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)[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>({}); + const [error, setError] = useState(null); + const [lastId, setLastId] = useState(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 ; + 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 ( + !busy && onOpenChange(o)}> + + + 修改 {code} + 改过的字段会锁定,刷新元数据不再改写。编号和文件位置不变。 + +
+ {EDITABLE.map((f) => { + const fin = finalOf(unit, f); + const isLocked = unit.fieldLocks.includes(f); + return ( +
+
+ +
+ {fin ? ( + + 来源 {METADATA_SOURCE[fin.source] ?? fin.source} + {fin.language ? ` · ${fin.language}` : ""} + + ) : null} + {isLocked ? ( + + + 已锁定 + + ) : null} +
+
+ {f === "overview" ? ( +