From 9cd5a289431edc089367ede084d4f05f450f8c77 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 17:28:16 +0800 Subject: [PATCH] feat(metadata): field-level episode metadata, titleZh and refresh?full=1 (MD-1) Co-Authored-By: Claude Opus 5.5 --- apps/server/src/modules/search/routes.ts | 81 ++- .../sources-identity/routes/identity.ts | 49 +- .../sources-identity/service/metadata-rich.ts | 498 ++++++++++++++++++ .../service/metadata-totals.ts | 3 +- .../sources-identity/service/metadata.ts | 86 ++- .../src/modules/sources-identity/tasks.ts | 5 +- apps/server/test/search/search2.test.ts | 27 + .../sources-identity/metadata-md1.test.ts | 212 ++++++++ packages/contracts/src/api.ts | 57 +- packages/contracts/src/content.ts | 47 ++ packages/contracts/src/search.ts | 54 +- packages/contracts/src/settings.ts | 12 +- 12 files changed, 1104 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/modules/sources-identity/service/metadata-rich.ts create mode 100644 apps/server/test/sources-identity/metadata-md1.test.ts diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index ce9f0927..096dd74f 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -1,5 +1,5 @@ import { createRoute, type RouteConfig } from "@hono/zod-openapi"; -import { TmdbError } from "@umt/adapters"; +import { fetchRichDetail, fetchRichSeason, TmdbError, type TmdbRichDetail } from "@umt/adapters"; import { Api, type ApiScope, @@ -19,6 +19,8 @@ import { type TmdbMediaDetail, type TmdbMediaParams, type TmdbMediaQuery, + type TmdbSeasonDetailView, + type TmdbSeasonParams, } from "@umt/contracts"; import type { SiteRow } from "@umt/db"; import type { Context } from "hono"; @@ -26,6 +28,7 @@ import type { z } from "zod"; import { type AuthEnv, requireScope } from "../../http/auth.ts"; import { ApiException } from "../../http/errors.ts"; import type { SourcesRuntime } from "../sources-identity/runtime.ts"; +import { metadataLanguages } from "../sources-identity/service/metadata-rich.ts"; import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { matchActions } from "./actions.ts"; @@ -159,6 +162,23 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear } }; + /** MD-1 富详情(语言回退链、类型、评分、主创);失败返回 null,详情页退回基础字段 */ + const tmdbRich = async (type: "movie" | "tv", id: string): Promise => { + const client = await d.runtime().tmdb(); + if (!client || typeof client.getJson !== "function") return null; + try { + const langs = await metadataLanguages(ctx); + return await raceAbort( + fetchRichDetail(client, type, id, langs), + AbortSignal.timeout(d.siteTimeoutMs), + "TMDB 查询超时", + ); + } catch (e) { + ctx.logger.debug({ type, id, err: { message: (e as Error).message } }, "TMDB 富详情获取失败"); + return null; + } + }; + /** 查询词:给了 q 就只用 q;按作品搜时由作品名 / 原名 / 年份生成 */ const releaseTerms = async (q: ReleaseSearchQuery): Promise => { if (q.q) return [q.q]; @@ -314,7 +334,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear route(app, Api.search.tmdbDetail, async ({ params, query, c }) => { const p = params as TmdbMediaParams; const inc = (query as TmdbMediaQuery).include; - const m = await tmdbDetails(p.type, p.id); + const [m, rich] = await Promise.all([tmdbDetails(p.type, p.id), tmdbRich(p.type, p.id)]); const localId = (await localEntities(ctx, [{ entityType: p.type, externalId: p.id }])).get(`${p.type}:${p.id}`) ?? null; const [inv, counts] = localId @@ -356,8 +376,65 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear mediaServers, queries: searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: p.type === "movie" }), }; + if (rich) { + // 标题 / 简介按语言回退链;季名 / 简介 / 海报取富详情 + out.title = rich.title || out.title; + out.overview = rich.overview ?? out.overview; + out.titleZh = rich.titleZh; + out.tagline = rich.tagline; + out.genres = rich.genres; + out.voteAverage = rich.voteAverage; + out.voteCount = rich.voteCount; + out.airStatus = rich.status; + out.runtime = rich.runtime; + out.episodeCount = rich.episodeCount; + out.lastAirDate = rich.lastAirDate; + out.platform = rich.networks.join(" / ") || null; + out.cast = rich.cast; + out.crew = rich.crew; + out.backdropPath = out.backdropPath ?? rich.backdropPath; + out.aliases = [ + ...new Set([rich.titleZh, ...out.aliases].filter((x): x is string => !!x && x !== rich.title)), + ].slice(0, 20); + out.seasons = out.seasons.map((s) => { + const r = rich.seasons.find((x) => x.seasonNumber === s.seasonNumber); + return r ? { ...s, overview: r.overview, posterPath: r.posterPath } : s; + }); + } return out; }); + + route(app, Api.search.tmdbSeason, async ({ params }) => { + const p = params as TmdbSeasonParams; + const client = await d.runtime().tmdb(); + if (!client || typeof client.getJson !== "function") + throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key"); + try { + const langs = await metadataLanguages(ctx); + const sd = await raceAbort( + fetchRichSeason(client, p.id, p.season, langs), + AbortSignal.timeout(d.siteTimeoutMs * 2), + "TMDB 查询超时", + ); + const out: TmdbSeasonDetailView = { + tvId: sd.tvId, + seasonNumber: sd.seasonNumber, + name: sd.name, + overview: sd.overview, + airDate: sd.airDate, + posterPath: sd.posterPath, + episodes: sd.episodes, + }; + return out; + } catch (e) { + if (e instanceof TmdbError && e.kind === "notfound") + throw new ApiException("not_found", `TMDB 没有 tv/${p.id} 第 ${p.season} 季`); + throw new ApiException( + "upstream_unavailable", + `TMDB 查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`, + ); + } + }); } const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url"); diff --git a/apps/server/src/modules/sources-identity/routes/identity.ts b/apps/server/src/modules/sources-identity/routes/identity.ts index 02589aee..7fca1f93 100644 --- a/apps/server/src/modules/sources-identity/routes/identity.ts +++ b/apps/server/src/modules/sources-identity/routes/identity.ts @@ -16,6 +16,7 @@ import { OkResult, ReleaseIdentityAssign, type TitleAlias, + UnitMetadataField, } from "@umt/contracts"; import { type DB, type Kysely, sql } from "@umt/db"; import { normalizeTitle } from "@umt/domain"; @@ -36,6 +37,7 @@ import { updateExternalIdentity, } from "../service/identity-admin.ts"; import { clearFieldOverride, setFieldOverride } from "../service/metadata.ts"; +import { clearUnitFieldOverride, setUnitFieldOverride } from "../service/metadata-rich.ts"; // 身份与元数据 API:作品(列表/详情/纠错/合并/字段覆盖)、内容单元、别名、外部身份、待识别池。 @@ -43,6 +45,12 @@ const T_ENT = ["media-entities"]; const T_ID = ["identity"]; const Accepted = z.object({ taskId: Id }); const FieldParam = z.object({ id: Id, field: EntityMetadataField }); +const UnitFieldParam = z.object({ id: Id, field: UnitMetadataField }); +const UnitFieldResult = z.object({ + contentUnitId: Id, + changedFields: z.array(z.string()), + lockedFields: z.array(z.string()), +}); /** 列表带作品标题:按 media_entity_id 批量取;只挂在内容单元上的行以 `cu:` 为键取所属作品标题 */ async function entityTitles( @@ -221,18 +229,24 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) { method: "post", path: "/media-entities/{id}/refresh", scope: "organize.execute", - summary: "刷新元数据(字段级;用户锁优先,不动路径)", + summary: + "刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息", tags: T_ENT, params: IdParam, + query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }), response: Accepted, }, - async ({ params }) => { + async ({ params, query }) => { const id = await resolveEntityId(db(), params.id); if (!id) throw new ApiException("not_found", "作品不存在"); + const full = query.full === "1" || query.full === "true"; const r = await rt().ctx.engine.enqueue( "refreshMetadata", - { mediaEntityId: id }, - { idempotencyKey: `refreshMetadata:${id}:${Math.floor(Date.now() / 60_000)}`, traceId: randomUUID() }, + { mediaEntityId: id, ...(full ? { full: true } : {}) }, + { + idempotencyKey: `refreshMetadata:${id}:${full ? "full:" : ""}${Math.floor(Date.now() / 60_000)}`, + traceId: randomUUID(), + }, ); return { taskId: r.runId }; }, @@ -336,6 +350,33 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) { ); return toApi(row); }); + route( + app, + { + method: "put", + path: "/content-units/{id}/fields/{field}", + scope: "organize.execute", + summary: "覆盖季 / 集字段并锁定(元数据刷新不再改写)", + tags: T_ENT, + params: UnitFieldParam, + body: FieldOverrideInput, + response: UnitFieldResult, + }, + async ({ params, body, actor }) => setUnitFieldOverride(rt(), params.id, params.field, body, actor), + ); + route( + app, + { + method: "delete", + path: "/content-units/{id}/fields/{field}", + scope: "organize.execute", + summary: "取消季 / 集字段覆盖与锁定", + tags: T_ENT, + params: UnitFieldParam, + response: UnitFieldResult, + }, + async ({ params, actor }) => clearUnitFieldOverride(rt(), params.id, params.field, actor), + ); // ── 别名 ── route(app, D.titleAliases.list, async ({ query }) => { diff --git a/apps/server/src/modules/sources-identity/service/metadata-rich.ts b/apps/server/src/modules/sources-identity/service/metadata-rich.ts new file mode 100644 index 00000000..c7dc4286 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/metadata-rich.ts @@ -0,0 +1,498 @@ +import { + DEFAULT_METADATA_LANGUAGES, + scriptMatches, + type TmdbRichDetail, + type TmdbRichEpisode, + type TmdbRichSeasonDetail, +} from "@umt/adapters"; +import type { + EntityMetadataField, + FieldOverrideInput, + FinalFieldValue, + MetadataSource, + UnitMetadataField, +} from "@umt/contracts"; +import { sql } from "@umt/db"; +import { normalizeTitle } from "@umt/domain"; +import { ApiException } from "../../../http/errors.ts"; +import type { ServerContext } from "../../types.ts"; +import { jsonb } from "../rows.ts"; +import type { SourcesRuntime } from "../runtime.ts"; +import { type Actor, audit, type Exec } from "./common.ts"; +import { ensureSeason } from "./metadata-totals.ts"; + +// MD-1 语言回退链 + 每集元数据(REQ §7.3 字段级): +// - 作品:TMDB 富详情(fetchRichDetail)按设置 metadata.languages 取标题 / 简介 / 标语,另存中文名 titleZh、类型、评分、 +// 状态、时长、季数、主创、外部 ID;来源值写 metadata_field(language = 实际取到的语言),同字段换语言时删旧行; +// - 别名:translations / alternative_titles 写 title_alias(来源 tmdb,标语言);已有同文本别名只补空的语言; +// - 季 / 集:fetchRichSeason 的每集 name / overview / airDate / runtime / stillPath / vote 写 metadata_field(content_unit_id), +// 投影到 content_unit.metadata(字段级,可锁),name → title 列、airDate → air_date 列;只写变化的字段, +// 不改编号、不碰路径绑定与资产覆盖。集单元不存在时按 TMDB 建(confirmed,挂季单元)。 + +type FieldValue = { value: unknown; language: string | null }; + +/** 设置 metadata.languages(缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US) */ +export async function metadataLanguages(ctx: ServerContext): Promise { + const m = await ctx.settings.get("metadata").catch(() => null); + const langs = (m as { languages?: string[] } | null)?.languages; + return langs?.length ? langs : [...DEFAULT_METADATA_LANGUAGES]; +} + +/** 富详情 → 作品字段(值 + 语言);海报 / 背景图没有时不写(保留旧值) */ +export function richEntityFields(r: TmdbRichDetail): Partial> { + const v = (value: unknown, language: string | null = null): FieldValue => ({ value, language }); + return { + title: v(r.title, r.titleLanguage), + ...(r.titleZh ? { titleZh: v(r.titleZh, r.titleZhLanguage) } : {}), + originalTitle: v(r.originalTitle, r.originalLanguage), + year: v(r.year), + overview: v(r.overview, r.overviewLanguage), + tagline: v(r.tagline), + releaseDate: v(r.releaseDate), + ...(r.entityType === "tv" ? { lastAirDate: v(r.lastAirDate) } : {}), + originalLanguage: v(r.originalLanguage), + originCountry: v(r.originCountry), + ...(r.posterPath ? { posterPath: v(r.posterPath) } : {}), + ...(r.backdropPath ? { backdropPath: v(r.backdropPath) } : {}), + ...(r.genres.length ? { genres: v(r.genres) } : {}), + ...(r.networks.length ? { platform: v(r.networks.join(" / ")) } : {}), + ...(r.entityType === "tv" && r.episodeCount !== null ? { episodeCount: v(r.episodeCount) } : {}), + ...(r.entityType === "tv" ? { seasonCount: v(r.seasonCount) } : {}), + voteAverage: v(r.voteAverage), + voteCount: v(r.voteCount), + airStatus: v(r.status), + runtime: v(r.runtime), + cast: v(r.cast), + crew: v(r.crew), + externalIds: v(r.externalIds), + }; +} + +const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + +/** + * 写一组 tmdb 来源字段(作品或内容单元):值和语言都没变的跳过;语言变了删掉该字段其它语言的 tmdb 行再写。 + * 返回写入(新增 / 变化)的字段名。 + */ +async function writeSourceFields( + exec: Exec, + target: { mediaEntityId: string } | { contentUnitId: string }, + fields: Record, + existing: readonly { field: string; value: unknown; language: string | null }[], + sourceRevision: string, + now: Date, +): Promise { + const entityId = "mediaEntityId" in target ? target.mediaEntityId : null; + const unitId = "contentUnitId" in target ? target.contentUnitId : null; + const written: string[] = []; + for (const [field, fv] of Object.entries(fields)) { + const rows = existing.filter((x) => x.field === field); + if (rows.length === 1 && rows[0]!.language === fv.language && same(rows[0]!.value, fv.value)) continue; + if (rows.some((x) => x.language !== fv.language)) { + let q = exec.deleteFrom("metadata_field").where("field", "=", field).where("source", "=", "tmdb"); + q = entityId + ? q.where("media_entity_id", "=", entityId).where("content_unit_id", "is", null) + : q.where("content_unit_id", "=", unitId!).where("media_entity_id", "is", null); + q = + fv.language === null + ? q.where("language", "is not", null) + : q.where(sql`language is distinct from ${fv.language}`); + await q.execute(); + } + await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at) + values (${entityId}, ${unitId}, ${field}, ${jsonb(fv.value)}::jsonb, ${fv.language}, 'tmdb', ${sourceRevision}, ${now}) + on conflict (media_entity_id, content_unit_id, field, source, language) do update set + value = excluded.value, source_revision = excluded.source_revision, fetched_at = excluded.fetched_at`.execute( + exec, + ); + written.push(field); + } + return written; +} + +/** 富详情写作品的字段来源表;调用方随后 project() 投影 */ +export async function writeRichEntityFields( + exec: Exec, + entityId: string, + rich: TmdbRichDetail, + sourceRevision: string, + now: Date, +): Promise { + const existing = await exec + .selectFrom("metadata_field") + .select(["field", "value", "language"]) + .where("media_entity_id", "=", entityId) + .where("content_unit_id", "is", null) + .where("source", "=", "tmdb") + .execute(); + return writeSourceFields( + exec, + { mediaEntityId: entityId }, + richEntityFields(rich) as Record, + existing, + sourceRevision, + now, + ); +} + +/** 别名的地区 → 语言:只在文字相符时标(CN 地区的英文别名不标 zh-CN) */ +function altLanguage(region: string | null, text: string): string | null { + if (!region) return null; + if (["CN", "SG", "TW", "HK", "MO"].includes(region)) return scriptMatches("zh", text) ? `zh-${region}` : null; + if (region === "JP") return scriptMatches("ja", text) ? "ja-JP" : null; + if (region === "KR") return scriptMatches("ko", text) ? "ko-KR" : null; + if (["US", "GB", "CA", "AU", "NZ", "IE"].includes(region)) return scriptMatches("en", text) ? `en-${region}` : null; + return null; +} + +/** + * translations(译名,kind translated)与 alternative_titles(别名,kind other)写 title_alias,来源 tmdb、标语言。 + * 同作品已有同文本别名时不再新增,只给来源 tmdb 且语言为空的那行补语言。最多 80 条。 + */ +export async function tagTitleAliases(exec: Exec, entityId: string, rich: TmdbRichDetail): Promise { + const want: { text: string; kind: "translated" | "other"; language: string | null }[] = []; + if (rich.titleZh) want.push({ text: rich.titleZh, kind: "translated", language: rich.titleZhLanguage }); + for (const t of rich.translations) if (t.title) want.push({ text: t.title, kind: "translated", language: t.lang }); + for (const a of rich.alternativeTitles) + want.push({ text: a.title, kind: "other", language: altLanguage(a.region, a.title) }); + const rows = await exec + .selectFrom("title_alias") + .select(["id", "normalized", "language", "source"]) + .where("media_entity_id", "=", entityId) + .where("content_unit_id", "is", null) + .execute(); + const byNorm = new Map(); + for (const r of rows) byNorm.set(r.normalized, [...(byNorm.get(r.normalized) ?? []), r]); + let n = 0; + const seen = new Set(); + for (const w of want.slice(0, 120)) { + const norm = normalizeTitle(w.text); + if (!norm || seen.has(norm)) continue; + seen.add(norm); + const hit = byNorm.get(norm); + if (hit?.length) { + const blank = hit.find((r) => r.source === "tmdb" && r.language === null); + if (w.language && blank && !hit.some((r) => r.language)) { + await exec.updateTable("title_alias").set({ language: w.language }).where("id", "=", blank.id).execute(); + n++; + } + continue; + } + if (n >= 80) break; + await sql`insert into title_alias (media_entity_id, content_unit_id, alias, normalized, language, kind, source, locked) + values (${entityId}, null, ${w.text}, ${norm}, ${w.language}, ${w.kind}, 'tmdb', false) + on conflict (media_entity_id, content_unit_id, normalized, kind) do nothing`.execute(exec); + n++; + } + return n; +} + +// ── 内容单元字段 ── + +const UNIT_COLUMN: Partial> = { name: "title", airDate: "air_date" }; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * 重新投影内容单元的最终字段:覆盖(锁)> field_locks 已锁字段保持 > tmdb 来源值。 + * name → title 列、airDate → air_date 列(来源没给日期时不清空列);编号不动。只在有变化时写。 + */ +export async function projectUnit( + exec: Exec, + unitId: string, + now: Date, +): Promise<{ changedFields: string[]; lockedFields: string[] }> { + const u = await exec + .selectFrom("content_unit") + .select(["id", "metadata", "field_locks", "title", sql`to_char(air_date, 'YYYY-MM-DD')`.as("air")]) + .where("id", "=", unitId) + .forUpdate() + .executeTakeFirstOrThrow(); + const [overrides, sources] = await Promise.all([ + exec.selectFrom("field_override").selectAll().where("content_unit_id", "=", unitId).execute(), + exec + .selectFrom("metadata_field") + .selectAll() + .where("content_unit_id", "=", unitId) + .orderBy("fetched_at", "desc") + .execute(), + ]); + const meta = { ...((u.metadata ?? {}) as Record) }; + const locks = new Set(u.field_locks); + const changed: string[] = []; + const lockedFields: string[] = []; + const colPatch: { title?: string; air_date?: string } = {}; + const names = new Set([...sources.map((s) => s.field), ...overrides.map((o) => o.field)]); + for (const field of names) { + const ov = overrides.find((o) => o.field === field); + let next: FinalFieldValue; + if (ov) { + next = { + value: ov.value, + source: ov.source as MetadataSource, + language: null, + at: ov.updated_at.toISOString(), + locked: ov.locked, + }; + if (ov.locked) lockedFields.push(field); + } else if (locks.has(field)) { + lockedFields.push(field); + continue; + } else { + const src = + sources.find((s) => s.field === field && s.source === "tmdb") ?? sources.find((s) => s.field === field); + if (!src) continue; + next = { + value: src.value, + source: src.source as MetadataSource, + language: src.language, + at: src.fetched_at.toISOString(), + locked: false, + }; + } + const prev = meta[field]; + if ( + !prev || + !same(prev.value, next.value) || + prev.source !== next.source || + prev.language !== next.language || + prev.locked !== next.locked + ) { + changed.push(field); + meta[field] = next; + } + const col = UNIT_COLUMN[field as UnitMetadataField]; + if (col === "title" && typeof next.value === "string" && next.value && next.value !== u.title) + colPatch.title = next.value; + if (col === "air_date" && typeof next.value === "string" && DATE_RE.test(next.value) && next.value !== u.air) + colPatch.air_date = next.value; + } + if (changed.length || Object.keys(colPatch).length) { + await exec + .updateTable("content_unit") + .set({ ...colPatch, metadata: jsonb(meta), updated_at: now }) + .where("id", "=", unitId) + .execute(); + } + return { changedFields: changed, lockedFields }; +} + +function episodeFields(e: TmdbRichEpisode): Record { + const air = e.airDate && DATE_RE.test(e.airDate.slice(0, 10)) ? e.airDate.slice(0, 10) : null; + return { + name: { value: e.name, language: e.nameLanguage }, + overview: { value: e.overview, language: e.overviewLanguage }, + airDate: { value: air, language: null }, + runtime: { value: e.runtime, language: null }, + stillPath: { value: e.stillPath, language: null }, + voteAverage: { value: e.voteAverage, language: null }, + voteCount: { value: e.voteCount, language: null }, + }; +} + +export interface SeasonMetadataResult { + season: number; + episodes: number; + /** 新建的集单元数 */ + created: number; + /** 字段有变化(投影改写)的内容单元数(含季单元) */ + changedUnits: number; +} + +/** + * 季详情 → 季单元与集单元的字段级元数据。集单元按(作品, 季, 集号)找最早建的未退役单元,没有时: + * create = true(full 刷新 / 追踪中的季)建 confirmed 单元;否则只更新已有单元(集单元仍由发布 / 文件懒建)。 + * 只写变化的来源字段,只投影有变化的单元。 + */ +export async function applySeasonMetadata( + exec: Exec, + entityId: string, + sd: TmdbRichSeasonDetail, + sourceRevision: string, + now: Date, + opts: { create?: boolean } = {}, +): Promise { + const create = opts.create ?? true; + const out: SeasonMetadataResult = { season: sd.seasonNumber, episodes: 0, created: 0, changedUnits: 0 }; + if (sd.seasonNumber < 1) return out; + const seasonUnit = await ensureSeason(exec, entityId, sd.seasonNumber, sd.name); + const units = await exec + .selectFrom("content_unit") + .select(["id", "episode_number", "metadata"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "episode") + .where("season_number", "=", sd.seasonNumber) + .where("status", "!=", "retired") + .orderBy("created_at") + .execute(); + const unitOf = new Map(); + for (const u of units) + if (u.episode_number !== null && !unitOf.has(u.episode_number)) unitOf.set(u.episode_number, u); + const eps = sd.episodes.filter((e) => e.episodeNumber >= 1).slice(0, 500); + const targets: { unitId: string; fields: Record; fresh: boolean }[] = []; + for (const e of eps) { + let hit = unitOf.get(e.episodeNumber); + let fresh = false; + if (!hit && !create) continue; + if (!hit) { + const row = await exec + .insertInto("content_unit") + .values({ + media_entity_id: entityId, + parent_unit_id: seasonUnit, + kind: "episode", + season_number: sd.seasonNumber, + episode_number: e.episodeNumber, + status: "confirmed", + display_order: e.episodeNumber, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + hit = { id: row.id, metadata: {} }; + unitOf.set(e.episodeNumber, hit); + out.created++; + fresh = true; + } + targets.push({ unitId: hit.id, fields: episodeFields(e), fresh }); + } + const seasonFields: Record = { + name: { value: sd.name, language: sd.nameLanguage }, + overview: { value: sd.overview, language: sd.overviewLanguage }, + airDate: { value: sd.airDate && DATE_RE.test(sd.airDate) ? sd.airDate : null, language: null }, + ...(sd.posterPath ? { posterPath: { value: sd.posterPath, language: null } } : {}), + }; + targets.push({ unitId: seasonUnit, fields: seasonFields, fresh: false }); + const ids = targets.map((t) => t.unitId); + const existing = ids.length + ? await exec + .selectFrom("metadata_field") + .select(["content_unit_id", "field", "value", "language"]) + .where("content_unit_id", "in", ids) + .where("source", "=", "tmdb") + .execute() + : []; + for (const t of targets) { + const mine = existing.filter((x) => x.content_unit_id === t.unitId); + const written = await writeSourceFields(exec, { contentUnitId: t.unitId }, t.fields, mine, sourceRevision, now); + if (written.length || t.fresh) { + const r = await projectUnit(exec, t.unitId, now); + if (r.changedFields.length) out.changedUnits++; + } + } + out.episodes = eps.length; + return out; +} + +/** 该作品已有集单元(或整季资产覆盖)的季号:默认刷新时这些季也拉每集信息 */ +export async function seasonsInLibrary(exec: Exec, entityId: string): Promise { + const r = await sql<{ season_number: number }>` + select distinct season_number from content_unit + where media_entity_id = ${entityId} and kind = 'episode' and status <> 'retired' and season_number >= 1 + order by season_number`.execute(exec); + return r.rows.map((x) => x.season_number); +} + +/** + * 本次刷新要拉每集信息的季:full = 全部正片季(最多 60);否则 = 追踪中的季 ∪ 库里已有集单元的季,取最近 8 季 + * (没有追踪订阅、库里也没有集单元时不拉季详情,定时刷新不额外请求 TMDB)。 + */ +export function seasonsToRefresh( + known: readonly number[], + opts: { full: boolean; tracked: readonly number[]; inLibrary: readonly number[] }, +): number[] { + const all = [...new Set(known.filter((n) => n >= 1))].sort((a, b) => a - b); + if (opts.full) return all.slice(0, 60); + const want = new Set([...opts.tracked, ...opts.inLibrary].filter((n) => all.includes(n))); + return [...want].sort((a, b) => a - b).slice(-8); +} + +// ── 内容单元字段覆盖 / 锁 ── + +function validateUnitValue(field: UnitMetadataField, v: unknown): void { + const bad = (m: string) => { + throw new ApiException("bad_request", m); + }; + if (v === null) { + if (field === "name") bad("name 必须是非空字符串"); + return; + } + if ( + (field === "name" || field === "overview" || field === "stillPath" || field === "posterPath") && + typeof v !== "string" + ) + bad(`${field} 必须是字符串`); + if (field === "name" && !(v as string).trim()) bad("name 必须是非空字符串"); + if (field === "airDate" && (typeof v !== "string" || !DATE_RE.test(v))) bad("airDate 必须是 YYYY-MM-DD"); + if ((field === "runtime" || field === "voteCount") && (typeof v !== "number" || !Number.isInteger(v) || v < 0)) + bad(`${field} 必须是非负整数`); + if (field === "voteAverage" && (typeof v !== "number" || v < 0 || v > 10)) bad("voteAverage 必须在 0–10 之间"); +} + +export async function setUnitFieldOverride( + rt: SourcesRuntime, + unitId: string, + field: UnitMetadataField, + input: FieldOverrideInput, + actor: Actor, +) { + validateUnitValue(field, input.value); + return rt.ctx.db.transaction().execute(async (trx) => { + const u = await trx + .selectFrom("content_unit") + .select(["id", "field_locks", "media_entity_id"]) + .where("id", "=", unitId) + .forUpdate() + .executeTakeFirst(); + if (!u) throw new ApiException("not_found", "内容单元不存在"); + await sql`insert into field_override (media_entity_id, content_unit_id, field, value, locked, source, reason, actor_type, actor_id) + values (null, ${u.id}, ${field}, ${jsonb(input.value)}::jsonb, ${input.lock}, 'user', ${input.reason ?? null}, ${actor.type}, ${actor.id}) + on conflict (media_entity_id, content_unit_id, field) do update set + value = excluded.value, locked = excluded.locked, reason = excluded.reason, + actor_type = excluded.actor_type, actor_id = excluded.actor_id`.execute(trx); + const locks = new Set(u.field_locks); + if (input.lock) locks.add(field); + else locks.delete(field); + await trx + .updateTable("content_unit") + .set({ field_locks: [...locks].sort() }) + .where("id", "=", u.id) + .execute(); + const r = await projectUnit(trx, u.id, rt.now()); + await audit( + trx, + actor, + "metadata.unit_field_override", + { type: "content_unit", id: u.id }, + { field, lock: input.lock, mediaEntityId: u.media_entity_id }, + ); + return { contentUnitId: u.id, ...r }; + }); +} + +export async function clearUnitFieldOverride( + rt: SourcesRuntime, + unitId: string, + field: UnitMetadataField, + actor: Actor, +) { + return rt.ctx.db.transaction().execute(async (trx) => { + const u = await trx + .selectFrom("content_unit") + .select(["id", "field_locks", "metadata"]) + .where("id", "=", unitId) + .forUpdate() + .executeTakeFirst(); + if (!u) throw new ApiException("not_found", "内容单元不存在"); + await trx.deleteFrom("field_override").where("content_unit_id", "=", u.id).where("field", "=", field).execute(); + // 最终值里去掉覆盖留下的值,重新按来源投影(没有来源值时该字段消失) + const meta = { ...((u.metadata ?? {}) as Record) }; + delete meta[field]; + await trx + .updateTable("content_unit") + .set({ field_locks: u.field_locks.filter((f) => f !== field), metadata: jsonb(meta) }) + .where("id", "=", u.id) + .execute(); + const r = await projectUnit(trx, u.id, rt.now()); + await audit(trx, actor, "metadata.unit_field_override_cleared", { type: "content_unit", id: u.id }, { field }); + return { contentUnitId: u.id, ...r }; + }); +} diff --git a/apps/server/src/modules/sources-identity/service/metadata-totals.ts b/apps/server/src/modules/sources-identity/service/metadata-totals.ts index bd73446d..f76f93f5 100644 --- a/apps/server/src/modules/sources-identity/service/metadata-totals.ts +++ b/apps/server/src/modules/sources-identity/service/metadata-totals.ts @@ -14,7 +14,8 @@ export interface SeasonTotalsResult { activated: number; } -async function ensureSeason(exec: Exec, entityId: string, season: number, name: string | null): Promise { +/** 季单元:按(作品, 季号)找最早建的未退役季单元,没有就建(confirmed) */ +export async function ensureSeason(exec: Exec, entityId: string, season: number, name: string | null): Promise { const hit = await exec .selectFrom("content_unit") .select("id") diff --git a/apps/server/src/modules/sources-identity/service/metadata.ts b/apps/server/src/modules/sources-identity/service/metadata.ts index 5a85305c..f6192976 100644 --- a/apps/server/src/modules/sources-identity/service/metadata.ts +++ b/apps/server/src/modules/sources-identity/service/metadata.ts @@ -1,4 +1,12 @@ -import { type MetadataCandidate, TmdbError, type TmdbSeasonDetail } from "@umt/adapters"; +import { + fetchRichDetail, + fetchRichSeason, + type MetadataCandidate, + TmdbError, + type TmdbRichDetail, + type TmdbRichSeasonDetail, + type TmdbSeasonDetail, +} from "@umt/adapters"; import type { EntityMetadataField, FieldEvidence, @@ -12,6 +20,15 @@ import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, audit, type Exec } from "./common.ts"; import { resolveEntityId, seedAliases } from "./entities.ts"; +import { + applySeasonMetadata, + metadataLanguages, + type SeasonMetadataResult, + seasonsInLibrary, + seasonsToRefresh, + tagTitleAliases, + writeRichEntityFields, +} from "./metadata-rich.ts"; import { applyEpisodeAirDates, applySeasonTotals, @@ -103,9 +120,22 @@ export interface RefreshResult { lockedFields: string[]; /** 剧集:季总集数账本刷新结果(W5-C) */ seasons?: SeasonTotalsResult["seasons"]; + /** MD-1:本次拉了每集信息的季 */ + episodes?: SeasonMetadataResult[]; + /** MD-1:回退链取到的标题 / 中文名(语言) */ + titles?: { title: string; language: string | null; titleZh: string | null }; } -export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string): Promise { +export interface RefreshOptions { + /** true = 拉全部正片季的每集信息(POST /media-entities/{id}/refresh?full=1);默认只拉追踪中 / 库里已有 / 最新的季 */ + full?: boolean; +} + +export async function refreshMetadata( + rt: SourcesRuntime, + mediaEntityId: string, + opts: RefreshOptions = {}, +): Promise { const { ctx } = rt; const id = await resolveEntityId(ctx.db, mediaEntityId); if (!id) return { status: "skipped", reason: "not_found", changedFields: [], lockedFields: [] }; @@ -144,9 +174,41 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string) } throw e; } + // MD-1 富详情(语言回退链、类型、评分、主创…)与每集信息;注入的简化客户端没有 getJson 时走旧路径 + const rich = typeof tmdb.getJson === "function"; + const langs = rich ? await metadataLanguages(ctx) : []; + let richDetail: TmdbRichDetail | null = null; + const richSeasons: TmdbRichSeasonDetail[] = []; + // 可以新建集单元的季:full 刷新的全部季、追踪中订阅的季;其余季只更新已有集单元 + let creatable = new Set(); + if (rich) { + try { + richDetail = await fetchRichDetail(tmdb, ident.entity_type, ident.external_id, langs); + } catch (e) { + if (!(e instanceof TmdbError)) throw e; + ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新"); + } + } + if (rich && ident.entity_type === "tv" && detail.seasons?.length) { + const known = detail.seasons.map((x) => x.seasonNumber); + const tracked = await trackedSeasonsForAirDates(ctx.db, id, known); + const want = seasonsToRefresh(known, { + full: !!opts.full, + tracked, + inLibrary: await seasonsInLibrary(ctx.db, id), + }); + creatable = new Set(opts.full ? want : tracked); + for (const n of want) { + try { + richSeasons.push(await fetchRichSeason(tmdb, ident.external_id, n, langs, { now: rt.now() })); + } catch (e) { + ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败"); + } + } + } // 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新 const airSeasons: TmdbSeasonDetail[] = []; - if (ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") { + if (!rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") { const want = await trackedSeasonsForAirDates( ctx.db, id, @@ -161,8 +223,10 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string) } } const now = rt.now(); + const rev = `tmdb:${ident.entity_type}/${ident.external_id}`; return ctx.db.transaction().execute(async (trx) => { - await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now); + if (richDetail) await writeRichEntityFields(trx, id, richDetail, rev, now); + else await writeTmdbFields(trx, id, detail, rev, now); await trx .updateTable("external_identity") .set({ verify_status: "ok", verified_at: now }) @@ -171,6 +235,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string) .where("status", "=", "accepted") .execute(); await seedAliases(trx, id, detail); + if (richDetail) await tagTitleAliases(trx, id, richDetail); const r = await project(trx, id, now); // 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此) const totals = @@ -178,8 +243,19 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string) ? await applySeasonTotals(trx, id, detail.seasons, now) : null; for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd); + const episodes: SeasonMetadataResult[] = []; + for (const sd of richSeasons) + episodes.push(await applySeasonMetadata(trx, id, sd, rev, now, { create: creatable.has(sd.seasonNumber) })); await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute(); - return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) }; + return { + status: "refreshed" as const, + ...r, + ...(totals ? { seasons: totals.seasons } : {}), + ...(richSeasons.length ? { episodes } : {}), + ...(richDetail + ? { titles: { title: richDetail.title, language: richDetail.titleLanguage, titleZh: richDetail.titleZh } } + : {}), + }; }); } diff --git a/apps/server/src/modules/sources-identity/tasks.ts b/apps/server/src/modules/sources-identity/tasks.ts index dba99c8e..7ae65dd8 100644 --- a/apps/server/src/modules/sources-identity/tasks.ts +++ b/apps/server/src/modules/sources-identity/tasks.ts @@ -67,9 +67,10 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu defineTask({ name: "refreshMetadata", version: 1, - inputSchema: z.object({ mediaEntityId: id }), + inputSchema: z.object({ mediaEntityId: id, full: z.boolean().optional() }), retryPolicy: NETWORK_RETRY, - handler: (t) => t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId)), + handler: (t) => + t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full })), }), defineTask({ name: "searchSubscription", diff --git a/apps/server/test/search/search2.test.ts b/apps/server/test/search/search2.test.ts index 70bc59ae..4540bd54 100644 --- a/apps/server/test/search/search2.test.ts +++ b/apps/server/test/search/search2.test.ts @@ -346,6 +346,33 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => { expect(counts()).toEqual(before); }); + it("MD-1 GET /media/tmdb/tv/{id}(语言回退链)与 /season/{n}(每集信息)", async () => { + const d = await req(`/media/tmdb/tv/95396`); + expect(d.status).toBe(200); + expect(d.body.data).toMatchObject({ + title: "人生切割术", + titleZh: "人生切割术", + overview: "故事发生在一家名为卢蒙的超级大公司内。", + genres: ["剧情", "悬疑", "Sci-Fi & Fantasy"], + voteAverage: 8.4, + airStatus: "Returning Series", + platform: "Apple TV", + backdropPath: "/severance-bg.jpg", + }); + expect(d.body.data.cast[0]).toMatchObject({ name: "亚当·斯科特", role: "Mark Scout", profilePath: "/adam.jpg" }); + expect(d.body.data.seasons[0]).toMatchObject({ seasonNumber: 1, posterPath: "/sev-s1.jpg" }); + + const s1 = await req(`/media/tmdb/tv/95396/season/1`); + expect(s1.status).toBe(200); + expect(s1.body.data.episodes.map((e: Loose) => [e.episodeNumber, e.name, e.nameLanguage])).toEqual([ + [1, "有关地狱的好消息", "zh-CN"], + [2, "半循環", "zh-TW"], + [3, "In Perpetuity", "en-US"], + ]); + expect(s1.body.data.episodes[0]).toMatchObject({ runtime: 59, stillPath: "/sev-101.jpg", voteAverage: 8.2 }); + expect((await req(`/media/tmdb/tv/95396/season/9`)).status).toBe(404); + }); + it("GET /inventory/media-servers 可按 TMDB 条目查(未建档时 mediaEntityId = null)", async () => { const r = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=872585`); expect(r.status).toBe(200); diff --git a/apps/server/test/sources-identity/metadata-md1.test.ts b/apps/server/test/sources-identity/metadata-md1.test.ts new file mode 100644 index 00000000..92536ea0 --- /dev/null +++ b/apps/server/test/sources-identity/metadata-md1.test.ts @@ -0,0 +1,212 @@ +import { createMockTmdb, type MockTmdb } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { seasonsToRefresh } from "../../src/modules/sources-identity/service/metadata-rich.ts"; +import { createHarness, type Harness, json, pgAvailable } from "./harness.ts"; + +const up = await pgAvailable(); +type Final = { value: unknown; source: string; language: string | null; locked: boolean }; + +describe("seasonsToRefresh", () => { + it("full 拉全部正片季;默认 = 追踪 ∪ 库里已有,最多 8 季;都没有时不拉", () => { + expect(seasonsToRefresh([0, 1, 2, 3], { full: true, tracked: [], inLibrary: [] })).toEqual([1, 2, 3]); + expect(seasonsToRefresh([0, 1, 2, 3], { full: false, tracked: [], inLibrary: [] })).toEqual([]); + expect(seasonsToRefresh([1, 2, 3, 4], { full: false, tracked: [2], inLibrary: [1, 9] })).toEqual([1, 2]); + const many = Array.from({ length: 20 }, (_, i) => i + 1); + expect(seasonsToRefresh(many, { full: false, tracked: [], inLibrary: many })).toEqual(many.slice(-8)); + }); +}); + +describe.skipIf(!up)("MD-1 语言回退链 + 每集元数据", () => { + let h: Harness; + let tmdb: MockTmdb; + let id: string; + const actor = { type: "user" as const, id: "tester" }; + + beforeAll(async () => { + tmdb = await createMockTmdb(); + h = await createHarness("md1", { tmdbUrl: tmdb.url, now: () => new Date("2026-01-01T00:00:00Z") }); + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const entity = () => h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + const episodes = () => + h.db + .selectFrom("content_unit") + .selectAll() + .where("media_entity_id", "=", id) + .where("kind", "=", "episode") + .orderBy("season_number") + .orderBy("episode_number") + .execute(); + + it("建档后 full 刷新:标题取 CN 别名「人生切割术」,中文名 / 简介带语言,别名标语言", async () => { + const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "95396", kind: "tv" }, actor); + id = r.mediaEntityId; + // details() 的 zh-CN name 是原名(TMDB 缺 zh-CN 译名时回退) + expect((await entity()).title).toBe("Severance"); + const before = await episodes(); + expect(before.map((u) => [u.season_number, u.episode_number, u.status])).toEqual([ + [1, 1, "provisional"], + [1, 2, "provisional"], + [1, 3, "provisional"], + [2, 1, "provisional"], + ]); + + const acc = await json(await h.req(`/media-entities/${id}/refresh?full=1`, { method: "POST" })); + expect(acc.status).toBe(200); + expect(h.engine.enqueued.at(-1)).toMatchObject({ + task: "refreshMetadata", + input: { mediaEntityId: id, full: true }, + }); + + const res = await refreshMetadata(h.rt, id, { full: true }); + expect(res.status).toBe("refreshed"); + expect(res.titles).toEqual({ title: "人生切割术", language: "zh-CN", titleZh: "人生切割术" }); + const e = await entity(); + expect(e.title).toBe("人生切割术"); + const meta = e.metadata as Record; + expect(meta.title).toMatchObject({ value: "人生切割术", language: "zh-CN", source: "tmdb" }); + expect(meta.titleZh).toMatchObject({ value: "人生切割术", language: "zh-CN" }); + expect(meta.overview).toMatchObject({ value: "故事发生在一家名为卢蒙的超级大公司内。", language: "zh-CN" }); + expect(meta.genres?.value).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]); + expect(meta.voteAverage?.value).toBe(8.4); + expect(meta.airStatus?.value).toBe("Returning Series"); + expect(meta.platform?.value).toBe("Apple TV"); + expect(meta.backdropPath?.value).toBe("/severance-bg.jpg"); + expect((meta.cast?.value as { name: string }[] | undefined)?.[0]?.name).toBe("亚当·斯科特"); + expect(meta.externalIds?.value).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null }); + // 同字段只留一行 tmdb 来源(语言变了删旧行) + const titleRows = await h.db + .selectFrom("metadata_field") + .select(["language"]) + .where("media_entity_id", "=", id) + .where("field", "=", "title") + .execute(); + expect(titleRows).toEqual([{ language: "zh-CN" }]); + + const aliases = await h.db + .selectFrom("title_alias") + .select(["alias", "language", "source", "kind"]) + .where("media_entity_id", "=", id) + .execute(); + const lang = (a: string) => aliases.find((x) => x.alias === a)?.language; + expect(lang("人生切割术")).toBe("zh-CN"); + // 繁简归一化后与「人生切割术」同键,不重复登记 + expect(aliases.filter((a) => a.alias === "人生切割術")).toHaveLength(0); + expect(lang("生活割離術")).toBe("zh-HK"); + expect(aliases.every((a) => a.source === "tmdb")).toBe(true); + }); + + it("每集元数据:字段级写入 content_unit,编号与已有单元不变,名称按链回退", async () => { + const units = await episodes(); + expect(units).toHaveLength(4); + const [e1, e2, e3, s2e1] = units; + const m = (u: (typeof units)[number]) => u.metadata as Record; + expect(e1!.title).toBe("有关地狱的好消息"); + expect(e1!.air_date).toBeTruthy(); + expect(m(e1!).name).toMatchObject({ value: "有关地狱的好消息", language: "zh-CN", source: "tmdb" }); + expect(m(e1!).overview?.value).toBe("马克升职成为部门主管。"); + expect(m(e1!).runtime?.value).toBe(59); + expect(m(e1!).stillPath?.value).toBe("/sev-101.jpg"); + expect(m(e1!).voteAverage?.value).toBe(8.2); + expect(m(e2!).name).toMatchObject({ value: "半循環", language: "zh-TW" }); + expect(m(e3!).name).toMatchObject({ value: "In Perpetuity", language: "en-US" }); + expect(m(e3!).overview?.value).toBeNull(); + // 未播出:占位名,语言未知 + expect(m(s2e1!).name).toMatchObject({ value: "第 1 集", language: null }); + // 编号 / 状态不被元数据刷新改动(provisional 仍是 provisional) + expect(units.map((u) => [u.season_number, u.episode_number, u.status, u.numbering_locked])).toEqual([ + [1, 1, "provisional", false], + [1, 2, "provisional", false], + [1, 3, "provisional", false], + [2, 1, "provisional", false], + ]); + const s1 = await h.db + .selectFrom("content_unit") + .selectAll() + .where("media_entity_id", "=", id) + .where("kind", "=", "season") + .where("season_number", "=", 1) + .executeTakeFirstOrThrow(); + expect((s1.metadata as Record).posterPath?.value).toBe("/sev-s1.jpg"); + expect((s1.metadata as Record).overview?.value).toContain("卢蒙"); + }); + + it("增量:再刷新没有变化的单元不改写;锁定集名后刷新不覆盖,解锁回到来源值", async () => { + const at = async () => + ( + await h.db + .selectFrom("metadata_field") + .select(["fetched_at"]) + .where("content_unit_id", "is not", null) + .execute() + ) + .map((x) => x.fetched_at.getTime()) + .sort(); + const t0 = await at(); + const again = await refreshMetadata(h.rt, id, { full: true }); + expect(again.episodes?.every((s) => s.changedUnits === 0 && s.created === 0)).toBe(true); + expect(await at()).toEqual(t0); + + const e1 = (await episodes())[0]!; + const put = await json( + await h.req(`/content-units/${e1.id}/fields/name`, { method: "PUT", body: { value: "自定义标题" } }), + ); + expect(put.status).toBe(200); + expect(put.body.data).toMatchObject({ contentUnitId: e1.id, lockedFields: ["name"] }); + const bad = await json( + await h.req(`/content-units/${e1.id}/fields/airDate`, { method: "PUT", body: { value: "x" } }), + ); + expect(bad.status).toBe(400); + + await refreshMetadata(h.rt, id, { full: true }); + let u = (await episodes())[0]!; + expect(u.title).toBe("自定义标题"); + expect(u.field_locks).toEqual(["name"]); + expect((u.metadata as Record).name).toMatchObject({ + value: "自定义标题", + source: "user", + locked: true, + }); + + const del = await json(await h.req(`/content-units/${e1.id}/fields/name`, { method: "DELETE" })); + expect(del.status).toBe(200); + u = (await episodes())[0]!; + expect(u.title).toBe("有关地狱的好消息"); + expect(u.field_locks).toEqual([]); + }); + + it("设置 metadata.languages 换链:en-US 优先时标题取英文,中文名仍是 zh-CN 值", async () => { + await h.db + .insertInto("settings") + .values({ key: "metadata", value: JSON.stringify({ languages: ["en-US", "zh-CN"] }) } as never) + .execute(); + const r = await refreshMetadata(h.rt, id); + expect(r.titles).toEqual({ title: "Severance", language: "en-US", titleZh: "人生切割术" }); + const e = await entity(); + expect(e.title).toBe("Severance"); + expect((e.metadata as Record).overview).toMatchObject({ + value: "Mark leads a team of office workers.", + language: "en-US", + }); + await h.db.deleteFrom("settings").where("key", "=", "metadata").execute(); + await refreshMetadata(h.rt, id); + expect((await entity()).title).toBe("人生切割术"); + }); + + it("作品详情 API 带内容单元元数据;作品级字段来源不含集字段", async () => { + const d = await json(await h.req(`/media-entities/${id}/detail`)); + expect(d.status).toBe(200); + const ep = d.body.data.contentUnits.find( + (u: { kind: string; episodeNumber: number | null; seasonNumber: number | null }) => + u.kind === "episode" && u.seasonNumber === 1 && u.episodeNumber === 2, + ); + expect(ep.metadata.name.value).toBe("半循環"); + expect(d.body.data.metadataFields.some((f: { field: string }) => f.field === "stillPath")).toBe(false); + }); +}); diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 6ec7cc5d..3a26ae8d 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -149,6 +149,12 @@ const FieldOverrideResult = z.object({ lockedFields: z.array(z.string()), }); const FieldParam = z.object({ id: Id, field: content.EntityMetadataField }); +const UnitFieldParam = z.object({ id: Id, field: content.UnitMetadataField }); +const UnitFieldOverrideResult = z.object({ + contentUnitId: Id, + changedFields: z.array(z.string()), + lockedFields: z.array(z.string()), +}); /** 引擎 run id 不是 uuid(openworkflow 自有 id) */ const EngineRunIdParam = z.object({ id: z.string().min(1).max(200) }); @@ -219,8 +225,10 @@ export const Api = { method: "post", path: "/media-entities/{id}/refresh", scope: "organize.execute", - summary: "刷新元数据(字段级;用户锁优先,不动路径)", + summary: + "刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息", params: IdParam, + query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }), response: TaskAccepted, }), setField: ep({ @@ -241,16 +249,35 @@ export const Api = { response: FieldOverrideResult, }), }, - contentUnits: crud( - "/content-units", - "内容单元", - { - row: content.ContentUnit, - update: content.ContentUnitUpdate, - filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() }, - }, - R, - ), + contentUnits: { + ...crud( + "/content-units", + "内容单元", + { + row: content.ContentUnit, + update: content.ContentUnitUpdate, + filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() }, + }, + R, + ), + setField: ep({ + method: "put", + path: "/content-units/{id}/fields/{field}", + scope: "organize.execute", + summary: "覆盖季 / 集字段并锁定(元数据刷新不再改写)", + params: UnitFieldParam, + body: content.FieldOverrideInput, + response: UnitFieldOverrideResult, + }), + clearField: ep({ + method: "delete", + path: "/content-units/{id}/fields/{field}", + scope: "organize.execute", + summary: "取消季 / 集字段覆盖与锁定", + params: UnitFieldParam, + response: UnitFieldOverrideResult, + }), + }, titleAliases: { ...crud( "/title-aliases", @@ -2224,6 +2251,14 @@ export const Api = { query: search.TmdbMediaQuery, response: search.TmdbMediaDetail, }), + tmdbSeason: ep({ + method: "get", + path: "/media/tmdb/tv/{id}/season/{season}", + scope: "read", + summary: "仅 TMDB 剧集的季详情与每集信息(标题 / 简介按语言回退链;走 TMDB 请求缓存)", + params: search.TmdbSeasonParams, + response: search.TmdbSeasonDetailView, + }), }, // ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ── diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts index 40c607ec..ff43ac31 100644 --- a/packages/contracts/src/content.ts +++ b/packages/contracts/src/content.ts @@ -198,9 +198,56 @@ export const EntityMetadataField = z.enum([ "episodeCount", "genres", "posterUrl", + // MD-1 TMDB 详情(对照 MP 媒体详情):中文名、标语、最近播出、状态、评分、时长、季数、主创、外部 ID + "titleZh", + "tagline", + "lastAirDate", + "airStatus", + "voteAverage", + "voteCount", + "runtime", + "seasonCount", + "cast", + "crew", + "externalIds", ]); export type EntityMetadataField = z.infer; +/** + * 内容单元(季 / 集)可覆盖 / 锁定的字段(MD-1):来源值写 metadata_field(content_unit_id), + * 最终值投影到 content_unit.metadata;name → title 列、airDate → air_date 列。编号不属于元数据。 + */ +export const UnitMetadataField = z.enum([ + "name", + "overview", + "airDate", + "runtime", + "stillPath", + "posterPath", + "voteAverage", + "voteCount", +]); +export type UnitMetadataField = z.infer; + +/** 主创(cast / crew 字段的元素):TMDB 人物 ID、姓名(按回退链语言)、角色或职务、头像路径(经图片代理显示) */ +export const MediaCredit = z.object({ + id: z.number().int(), + name: z.string(), + originalName: z.string().nullable(), + /** 演员:角色名;幕后:职务(Director / Creator …) */ + role: z.string().nullable(), + profilePath: z.string().nullable(), +}); +export type MediaCredit = z.infer; + +/** externalIds 字段的值 */ +export const MediaExternalIds = z.object({ + imdb: z.string().nullable(), + tvdb: z.string().nullable(), + wikidata: z.string().nullable(), +}); +export type MediaExternalIds = z.infer; + export const FieldOverrideInput = z.object({ value: z.unknown(), lock: z.boolean().default(true), diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index e9319a14..4c5c71d5 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { Id, NonNegInt, Timestamp } from "./common.ts"; -import { MediaEntityStatus, MediaKind } from "./content.ts"; +import { MediaCredit, MediaEntityStatus, MediaKind } from "./content.ts"; import { ManualAcquisitionRequest } from "./execution.ts"; import { InventoryEntry, MediaServerInventory, SlotStatusCounts } from "./organization.ts"; @@ -338,6 +338,8 @@ export const TmdbMediaSeason = z.object({ airDate: z.string().nullable(), /** 本地已入库的集数(整季资产按全季计);未收录为 null */ localHave: NonNegInt.nullable(), + overview: z.string().nullable().optional(), + posterPath: z.string().nullable().optional(), }); export type TmdbMediaSeason = z.infer; @@ -367,5 +369,55 @@ export const TmdbMediaDetail = z.object({ mediaServers: MediaServerInventory.nullable(), /** 搜资源时服务端会用的查询词 */ queries: z.array(z.string()), + // MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示) + /** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */ + titleZh: z.string().nullable().optional(), + tagline: z.string().nullable().optional(), + genres: z.array(z.string()).optional(), + voteAverage: z.number().nullable().optional(), + voteCount: z.number().int().nullable().optional(), + /** TMDB status 原文(Returning Series / Ended / Released …) */ + airStatus: z.string().nullable().optional(), + /** 分钟:电影片长;剧集为单集时长 */ + runtime: z.number().int().nullable().optional(), + episodeCount: z.number().int().nullable().optional(), + lastAirDate: z.string().nullable().optional(), + platform: z.string().nullable().optional(), + cast: z.array(MediaCredit).optional(), + crew: z.array(MediaCredit).optional(), }); export type TmdbMediaDetail = z.infer; + +/** 一集的 TMDB 信息(标题 / 简介按语言回退链,language 记实际取到的语言) */ +export const MediaEpisodeInfo = z.object({ + seasonNumber: z.number().int(), + episodeNumber: z.number().int(), + name: z.string().nullable(), + nameLanguage: z.string().nullable(), + overview: z.string().nullable(), + overviewLanguage: z.string().nullable(), + airDate: z.string().nullable(), + runtime: z.number().int().nullable(), + stillPath: z.string().nullable(), + voteAverage: z.number().nullable(), + voteCount: z.number().int().nullable(), +}); +export type MediaEpisodeInfo = z.infer; + +export const TmdbSeasonParams = z.object({ + id: z.string().regex(/^\d{1,12}$/), + season: z.coerce.number().int().min(0).max(999), +}); +export type TmdbSeasonParams = z.infer; + +/** GET /media/tmdb/tv/{id}/season/{season}:季详情与集列表(仅 TMDB 作品详情页按季展开时拉取) */ +export const TmdbSeasonDetailView = z.object({ + tvId: z.string(), + seasonNumber: z.number().int(), + name: z.string().nullable(), + overview: z.string().nullable(), + airDate: z.string().nullable(), + posterPath: z.string().nullable(), + episodes: z.array(MediaEpisodeInfo), +}); +export type TmdbSeasonDetailView = z.infer; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4b459a7b..c1f2be5e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -214,6 +214,15 @@ export const MetadataSettings = z.object({ tmdbApiKeySecretRef: Id.nullable(), /** 自建反代或测试 mock;null 用官方地址 */ tmdbBaseUrl: z.url().nullable(), + /** + * 元数据语言回退链(MD-1):作品 / 季 / 集的标题与简介按顺序取第一个非空值;中文名(titleZh)取链中第一个中文值。 + * BCP47 语言-地区,如 zh-CN;缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US + */ + languages: z + .array(z.string().regex(/^[a-z]{2,3}(-[A-Z]{2})?$/)) + .min(1) + .max(8) + .default(["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]), }); export type MetadataSettings = z.infer; @@ -506,7 +515,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = { sessionDir: null, webSearch: DEFAULT_WEB_SEARCH, }, - metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null }, + metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null, languages: ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"] }, sites: { defaultRateLimit: { requestsPerMinute: 20, concurrency: 2 }, defaultSnapshotIntervalSec: 3600, @@ -774,6 +783,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ fields: [ "metadata.tmdbApiKeySecretRef", "metadata.tmdbBaseUrl", + "metadata.languages", "cache.tmdbTtlHours", "cache.tmdbMaxSize", "cache.emptyResultTtlSec",