From fc1856c2e34a457de496cd7424b344ea6fbf0c61 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 17:28:16 +0800 Subject: [PATCH 1/4] feat(tmdb): language fallback chain, rich details and per-episode season fetch (MD-1) Co-Authored-By: Claude Opus 5.5 --- packages/adapters/src/tmdb/client.ts | 11 + packages/adapters/src/tmdb/details.test.ts | 147 +++++++++ packages/adapters/src/tmdb/details.ts | 339 +++++++++++++++++++++ packages/adapters/src/tmdb/index.ts | 2 + packages/adapters/src/tmdb/translations.ts | 184 +++++++++++ packages/contracts/test/schemas.test.ts | 1 + packages/testkit/src/tmdb/fixtures.ts | 112 ++++++- packages/testkit/src/tmdb/mock-tmdb.ts | 43 ++- 8 files changed, 834 insertions(+), 5 deletions(-) create mode 100644 packages/adapters/src/tmdb/details.test.ts create mode 100644 packages/adapters/src/tmdb/details.ts create mode 100644 packages/adapters/src/tmdb/translations.ts diff --git a/packages/adapters/src/tmdb/client.ts b/packages/adapters/src/tmdb/client.ts index 78abe02a..15475f70 100644 --- a/packages/adapters/src/tmdb/client.ts +++ b/packages/adapters/src/tmdb/client.ts @@ -252,6 +252,17 @@ export class TmdbClient { } } + /** + * 原始 JSON 请求(走同一套缓存 / 限流 / 重试 / 脱敏):details.ts 的多语言详情、季集详情用。 + * path 以 `/` 开头(不含 /3 前缀),params 不含凭证。 + */ + async getJson>( + path: string, + params: Record = {}, + ): Promise { + return this.get(path, params); + } + /** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */ async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise { const lang = this.langs[0]!; diff --git a/packages/adapters/src/tmdb/details.test.ts b/packages/adapters/src/tmdb/details.test.ts new file mode 100644 index 00000000..9e5d1b0a --- /dev/null +++ b/packages/adapters/src/tmdb/details.test.ts @@ -0,0 +1,147 @@ +import { createMockTmdb, type MockTmdb } from "@umt/testkit"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createTmdbClient, + fetchRichDetail, + fetchRichSeason, + isPlaceholderEpisodeName, + parseAltTitles, + parseTranslations, + pickChineseTitle, + pickText, + pickTitle, +} from "./index.ts"; + +let mock: MockTmdb | null = null; +afterEach(async () => { + await mock?.close(); + mock = null; +}); + +async function setup() { + mock = await createMockTmdb(); + const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 }); + return { mock, tmdb }; +} + +const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; + +describe("语言回退链(纯函数)", () => { + const translations = parseTranslations( + { + translations: [ + { iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "简中简介" } }, + { iso_639_1: "zh", iso_3166_1: "TW", data: { name: "人生切割術", overview: "繁中簡介" } }, + { iso_639_1: "en", iso_3166_1: "US", data: { name: "", overview: "English" } }, + ], + }, + "name", + ); + const altTitles = parseAltTitles({ + results: [ + { iso_3166_1: "CN", title: "Severance CN", type: "" }, + { iso_3166_1: "CN", title: "人生切割术", type: "" }, + { iso_3166_1: "HK", title: "生活割離術", type: "" }, + ], + }); + const src = { translations, altTitles, originalTitle: "Severance", originalLanguage: "en", primary: "Severance" }; + + it("zh-CN 译名为空时取 CN 地区的中文别名(对照 MP __update_tmdbinfo_cn_title)", () => { + expect(pickTitle(CHAIN, src)).toEqual({ value: "人生切割术", language: "zh-CN", via: "alternative_title" }); + expect(pickChineseTitle(CHAIN, src)?.value).toBe("人生切割术"); + }); + + it("没有 CN 别名时按链落到 zh-TW 译名;链只有英文时英文原名", () => { + const noCn = { ...src, altTitles: altTitles.filter((a) => a.region !== "CN") }; + expect(pickTitle(CHAIN, noCn)).toMatchObject({ value: "人生切割術", language: "zh-TW", via: "translation" }); + expect(pickTitle(["en-US"], noCn)).toMatchObject({ value: "Severance", language: "en-US", via: "original" }); + expect(pickTitle(["ja-JP"], { ...noCn, originalLanguage: "ko" })).toMatchObject({ via: "primary" }); + }); + + it("简介按链取第一个非空值", () => { + expect(pickText(CHAIN, translations, "overview")).toMatchObject({ value: "简中简介", language: "zh-CN" }); + expect(pickText(["zh-HK", "en-US"], translations, "overview")).toMatchObject({ value: "English" }); + expect(pickText(["ja-JP"], translations, "overview", { value: null, language: null })).toBeNull(); + }); + + it("占位集名识别", () => { + expect(isPlaceholderEpisodeName("第 3 集", 3)).toBe(true); + expect(isPlaceholderEpisodeName("Episode 3", 3)).toBe(true); + expect(isPlaceholderEpisodeName("第3話")).toBe(true); + expect(isPlaceholderEpisodeName("第 3 集", 4)).toBe(false); + expect(isPlaceholderEpisodeName("有关地狱的好消息", 1)).toBe(false); + expect(isPlaceholderEpisodeName("")).toBe(true); + }); +}); + +describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => { + it("Severance:标题取 CN 别名,中文名 zh-CN,简介 zh-CN,主创与外部 ID", async () => { + const { tmdb, mock } = await setup(); + const d = await fetchRichDetail(tmdb, "tv", 95396, CHAIN); + expect(d.title).toBe("人生切割术"); + expect(d.titleLanguage).toBe("zh-CN"); + expect(d.titleZh).toBe("人生切割术"); + expect(d.overview).toBe("故事发生在一家名为卢蒙的超级大公司内。"); + expect(d.overviewLanguage).toBe("zh-CN"); + expect(d.genres).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]); + expect(d.voteAverage).toBe(8.4); + expect(d.status).toBe("Returning Series"); + expect(d.networks).toEqual(["Apple TV"]); + expect(d.cast[0]).toEqual({ + id: 4495, + name: "亚当·斯科特", + originalName: null, + role: "Mark Scout", + profilePath: "/adam.jpg", + }); + expect(d.crew[0]).toMatchObject({ name: "丹·埃里克森", role: "Creator" }); + expect(d.externalIds).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null }); + expect(d.seasons.map((s) => s.seasonNumber)).toEqual([1, 2]); + // 一次请求(credits 等随 append_to_response) + expect(mock.calls.filter((r) => r.path === "/3/tv/95396")).toHaveLength(1); + }); + + it("链首 en-US:英文原名、英文简介,中文名仍按中文链取", async () => { + const { tmdb } = await setup(); + const d = await fetchRichDetail(tmdb, "tv", 95396, ["en-US"]); + expect(d.title).toBe("Severance"); + expect(d.overview).toBe("Mark leads a team of office workers."); + expect(d.titleZh).toBe("人生切割术"); + }); + + it("季集:首选语言缺标题 / 简介的已播集按链补拉;未播出的集不触发补拉", async () => { + const { tmdb, mock } = await setup(); + const s = await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); + expect(s.name).toBe("第 1 季"); + expect(s.overview).toContain("卢蒙"); + const [e1, e2, e3] = s.episodes; + expect(e1).toMatchObject({ + name: "有关地狱的好消息", + nameLanguage: "zh-CN", + overview: "马克升职成为部门主管。", + runtime: 59, + stillPath: "/sev-101.jpg", + voteAverage: 8.2, + voteCount: 171, + }); + expect(e2).toMatchObject({ name: "半循環", nameLanguage: "zh-TW", overview: "馬克帶領新員工參觀。" }); + // 链走完仍没有中文名:en-US 标题;简介各语言都空 + expect(e3).toMatchObject({ name: "In Perpetuity", nameLanguage: "en-US", overview: null }); + expect(s.fetchedLanguages).toEqual(CHAIN); + const perLang = mock.calls.filter((r) => r.path === "/3/tv/95396/season/1").map((r) => r.query.language); + expect(perLang).toEqual(CHAIN); + + mock.calls.length = 0; + const s2 = await fetchRichSeason(tmdb, 95396, 2, CHAIN, { now: new Date("2026-01-01") }); + expect(s2.episodes[0]).toMatchObject({ name: "第 1 集", nameLanguage: null }); + expect(s2.fetchedLanguages).toEqual(["zh-CN"]); + }); + + it("补拉走请求缓存:同一季第二次不再请求", async () => { + const { tmdb, mock } = await setup(); + await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); + const n = mock.calls.length; + await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); + expect(mock.calls.length).toBe(n); + }); +}); diff --git a/packages/adapters/src/tmdb/details.ts b/packages/adapters/src/tmdb/details.ts new file mode 100644 index 00000000..5349c6ba --- /dev/null +++ b/packages/adapters/src/tmdb/details.ts @@ -0,0 +1,339 @@ +/** + * TMDB 富详情与季集详情(MD-1,媒体详情页 / 字段级元数据用)。 + * 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8) + * __get_movie_detail / __get_tv_detail(append_to_response=credits,alternative_titles,translations,external_ids …)→ fetchRichDetail; + * get_tv_season_detail(季名、简介、海报、episodes[]:name/overview/air_date/runtime/still_path/vote_average)→ fetchRichSeason; + * 请求缓存语义同 client.ts(MP tmdbv3api request 的 @cached:成功才缓存、空结果短 TTL)。 + * 调整:标题 / 简介按语言回退链取(translations.ts);季集在首选语言缺标题(占位名)或缺简介时按链逐个语言补拉, + * 链走完或全部补齐即停。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。 + */ +import type { MediaEntityType, TmdbClient } from "./client.ts"; +import { + DEFAULT_METADATA_LANGUAGES, + isPlaceholderEpisodeName, + isPlaceholderSeasonName, + parseAltTitles, + parseTranslations, + pickChineseTitle, + pickText, + pickTitle, + type TmdbAltTitle, + type TmdbTranslation, +} from "./translations.ts"; + +type Json = Record; +const s = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null); +const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); +const int = (v: unknown): number | null => { + const x = n(v); + return x === null ? null : Math.round(x); +}; +const yearOf = (d: string | null) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null); + +export interface TmdbCredit { + id: number; + name: string; + originalName: string | null; + /** 演员:角色名;幕后:职务 */ + role: string | null; + profilePath: string | null; +} + +export interface TmdbRichSeason { + seasonNumber: number; + episodeCount: number; + name: string | null; + airDate: string | null; + overview: string | null; + posterPath: string | null; +} + +export interface TmdbRichDetail { + entityType: MediaEntityType; + externalId: string; + /** 回退链取到的标题及语言(null = 链全落空后用的请求语言值 / 原名) */ + title: string; + titleLanguage: string | null; + /** 中文名(链中第一个中文值,zh-CN 优先);没有为 null */ + titleZh: string | null; + titleZhLanguage: string | null; + originalTitle: string | null; + originalLanguage: string | null; + overview: string | null; + overviewLanguage: string | null; + tagline: string | null; + year: number | null; + releaseDate: string | null; + lastAirDate: string | null; + genres: string[]; + voteAverage: number | null; + voteCount: number | null; + /** TMDB status 原文 */ + status: string | null; + /** 分钟:电影片长 / 剧集单集时长(episode_run_time 首项) */ + runtime: number | null; + seasonCount: number | null; + episodeCount: number | null; + networks: string[]; + originCountry: string[]; + posterPath: string | null; + backdropPath: string | null; + cast: TmdbCredit[]; + crew: TmdbCredit[]; + externalIds: { imdb: string | null; tvdb: string | null; wikidata: string | null }; + translations: TmdbTranslation[]; + alternativeTitles: TmdbAltTitle[]; + seasons: TmdbRichSeason[]; + /** 本次使用的语言链 */ + languages: string[]; +} + +export interface TmdbRichEpisode { + seasonNumber: number; + episodeNumber: number; + name: string | null; + nameLanguage: string | null; + overview: string | null; + overviewLanguage: string | null; + airDate: string | null; + runtime: number | null; + stillPath: string | null; + voteAverage: number | null; + voteCount: number | null; +} + +export interface TmdbRichSeasonDetail { + tvId: string; + seasonNumber: number; + name: string | null; + nameLanguage: string | null; + overview: string | null; + overviewLanguage: string | null; + airDate: string | null; + posterPath: string | null; + episodes: TmdbRichEpisode[]; + /** 实际请求过的语言(首选 + 补拉) */ + fetchedLanguages: string[]; +} + +const chainOf = (languages?: readonly string[]) => + languages?.length ? [...languages] : [...DEFAULT_METADATA_LANGUAGES]; + +function credits(raw: unknown, type: MediaEntityType, createdBy: unknown): { cast: TmdbCredit[]; crew: TmdbCredit[] } { + const c = (raw ?? {}) as Json; + const cast = ((Array.isArray(c.cast) ? c.cast : []) as Json[]) + .filter((x) => n(x.id) !== null && s(x.name)) + .sort((a, b) => (n(a.order) ?? 999) - (n(b.order) ?? 999)) + .slice(0, 10) + .map((x) => ({ + id: n(x.id)!, + name: s(x.name)!, + originalName: s(x.original_name), + role: s(x.character), + profilePath: s(x.profile_path), + })); + const crew: TmdbCredit[] = []; + const seen = new Set(); + const push = (x: Json, role: string) => { + const id = n(x.id); + if (id === null || seen.has(id) || !s(x.name)) return; + seen.add(id); + crew.push({ id, name: s(x.name)!, originalName: s(x.original_name), role, profilePath: s(x.profile_path) }); + }; + if (type === "tv") for (const x of (Array.isArray(createdBy) ? createdBy : []) as Json[]) push(x, "Creator"); + for (const x of (Array.isArray(c.crew) ? c.crew : []) as Json[]) { + if (x.job === "Director") push(x, "Director"); + } + return { cast, crew: crew.slice(0, 6) }; +} + +/** + * 富详情:一次请求 `/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`, + * 标题 / 简介 / 标语按链从 translations(+ 同地区别名)取。不存在抛 TmdbError(notfound)。 + */ +export async function fetchRichDetail( + client: Pick, + type: MediaEntityType, + id: string | number, + languages?: readonly string[], +): Promise { + const chain = chainOf(languages); + const j = await client.getJson(`/${type}/${id}`, { + language: chain[0], + append_to_response: "alternative_titles,credits,external_ids,translations", + }); + const titleKey = type === "movie" ? "title" : "name"; + const translations = parseTranslations(j.translations, titleKey); + const altTitles = parseAltTitles(j.alternative_titles); + const originalTitle = s(type === "movie" ? j.original_title : j.original_name); + const originalLanguage = s(j.original_language); + const primary = s(j[titleKey]); + const titleSrc = { translations, altTitles, originalTitle, originalLanguage, primary }; + const title = pickTitle(chain, titleSrc); + const zh = pickChineseTitle(chain, titleSrc); + const overview = pickText(chain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null }); + const tagline = pickText(chain, translations, "tagline", { value: s(j.tagline), language: chain[0] ?? null }); + const date = s(type === "movie" ? j.release_date : j.first_air_date); + const ext = (j.external_ids ?? {}) as Json; + const runtimes = Array.isArray(j.episode_run_time) ? (j.episode_run_time as unknown[]) : []; + const { cast, crew } = credits(j.credits, type, j.created_by); + return { + entityType: type, + externalId: String(id), + title: title?.value ?? primary ?? originalTitle ?? "", + titleLanguage: title?.language ?? null, + titleZh: zh?.value ?? null, + titleZhLanguage: zh?.language ?? null, + originalTitle, + originalLanguage, + overview: overview?.value ?? null, + overviewLanguage: overview?.language ?? null, + tagline: tagline?.value ?? null, + year: yearOf(date), + releaseDate: date, + lastAirDate: s(j.last_air_date), + genres: ((Array.isArray(j.genres) ? j.genres : []) as Json[]).map((g) => s(g.name)).filter((x): x is string => !!x), + voteAverage: n(j.vote_average) !== null ? Math.round(n(j.vote_average)! * 10) / 10 : null, + voteCount: int(j.vote_count), + status: s(j.status), + runtime: type === "movie" ? int(j.runtime) : int(runtimes[0]), + seasonCount: type === "tv" ? int(j.number_of_seasons) : null, + episodeCount: type === "tv" ? int(j.number_of_episodes) : null, + networks: ((Array.isArray(j.networks) ? j.networks : []) as Json[]) + .map((x) => s(x.name)) + .filter((x): x is string => !!x), + originCountry: Array.isArray(j.origin_country) + ? (j.origin_country as unknown[]).filter((x): x is string => typeof x === "string") + : [], + posterPath: s(j.poster_path), + backdropPath: s(j.backdrop_path), + cast, + crew, + externalIds: { + imdb: s(ext.imdb_id), + tvdb: ext.tvdb_id != null && ext.tvdb_id !== "" ? String(ext.tvdb_id) : null, + wikidata: s(ext.wikidata_id), + }, + translations, + alternativeTitles: altTitles, + seasons: ((Array.isArray(j.seasons) ? j.seasons : []) as Json[]).map((x) => ({ + seasonNumber: int(x.season_number) ?? 0, + episodeCount: int(x.episode_count) ?? 0, + name: s(x.name), + airDate: s(x.air_date), + overview: s(x.overview), + posterPath: s(x.poster_path), + })), + languages: chain, + }; +} + +function episodeOf(e: Json, seasonNumber: number, lang: string): TmdbRichEpisode { + const num = int(e.episode_number) ?? 0; + const name = s(e.name); + const overview = s(e.overview); + const named = name && !isPlaceholderEpisodeName(name, num); + return { + seasonNumber: int(e.season_number) ?? seasonNumber, + episodeNumber: num, + name: named ? name : null, + nameLanguage: named ? lang : null, + overview, + overviewLanguage: overview ? lang : null, + airDate: s(e.air_date), + runtime: int(e.runtime), + stillPath: s(e.still_path), + voteAverage: + n(e.vote_average) !== null && (int(e.vote_count) ?? 0) > 0 ? Math.round(n(e.vote_average)! * 10) / 10 : null, + voteCount: int(e.vote_count), + }; +} + +/** + * 季详情:先按链首请求 `/tv/{id}/season/{n}`;有集缺标题(TMDB 占位名"第 N 集")或缺简介时, + * 按链逐个语言补拉同一端点(各自走请求缓存),补齐即停。补不到标题的集保留链首的占位名。 + */ +export async function fetchRichSeason( + client: Pick, + tvId: string | number, + seasonNumber: number, + languages?: readonly string[], + opts: { now?: Date } = {}, +): Promise { + const chain = chainOf(languages); + const first = chain[0]!; + const j = await client.getJson(`/tv/${tvId}/season/${seasonNumber}`, { language: first }); + const rawEps = (Array.isArray(j.episodes) ? j.episodes : []) as Json[]; + const eps = rawEps.map((e) => episodeOf(e, seasonNumber, first)); + const placeholders = new Map(rawEps.map((e) => [int(e.episode_number) ?? 0, s(e.name)])); + let seasonName = s(j.name); + let seasonNameLang: string | null = seasonName ? first : null; + let seasonOverview = s(j.overview); + let seasonOverviewLang: string | null = seasonOverview ? first : null; + const fetched = [first]; + // 只为已播出的集补拉(未播出的集各语言通常都空,补拉只会白白耗请求);季名"第 N 季"各语言都是占位,不触发补拉 + const today = (opts.now ?? new Date()).toISOString().slice(0, 10); + const aired = (e: TmdbRichEpisode) => !!e.airDate && e.airDate <= today; + const missing = () => eps.some((e) => aired(e) && (!e.name || !e.overview)) || (!seasonOverview && eps.some(aired)); + for (const lang of chain.slice(1)) { + if (!eps.length || !missing()) break; + let k: Json; + try { + k = await client.getJson(`/tv/${tvId}/season/${seasonNumber}`, { language: lang }); + } catch { + // 补拉失败不影响首选语言结果 + break; + } + fetched.push(lang); + const byNum = new Map( + ((Array.isArray(k.episodes) ? k.episodes : []) as Json[]).map((e) => [int(e.episode_number) ?? 0, e]), + ); + for (const e of eps) { + const alt = byNum.get(e.episodeNumber); + if (!alt) continue; + const other = episodeOf(alt, seasonNumber, lang); + if (!e.name && other.name) { + e.name = other.name; + e.nameLanguage = lang; + } + if (!e.overview && other.overview) { + e.overview = other.overview; + e.overviewLanguage = lang; + } + } + if (!seasonOverview && s(k.overview)) { + seasonOverview = s(k.overview); + seasonOverviewLang = lang; + } + if ( + isPlaceholderSeasonName(seasonName, seasonNumber) && + s(k.name) && + !isPlaceholderSeasonName(s(k.name), seasonNumber) + ) { + seasonName = s(k.name); + seasonNameLang = lang; + } + } + // 链走完仍没有标题:保留首选语言的占位名(界面显示"第 N 集"比空白好) + for (const e of eps) { + if (!e.name) { + const p = placeholders.get(e.episodeNumber); + if (p) { + e.name = p; + e.nameLanguage = null; + } + } + } + return { + tvId: String(tvId), + seasonNumber: int(j.season_number) ?? seasonNumber, + name: seasonName, + nameLanguage: seasonNameLang, + overview: seasonOverview, + overviewLanguage: seasonOverviewLang, + airDate: s(j.air_date), + posterPath: s(j.poster_path), + episodes: eps, + fetchedLanguages: fetched, + }; +} diff --git a/packages/adapters/src/tmdb/index.ts b/packages/adapters/src/tmdb/index.ts index 2f38d109..1de8bc4e 100644 --- a/packages/adapters/src/tmdb/index.ts +++ b/packages/adapters/src/tmdb/index.ts @@ -1,3 +1,5 @@ export { RequestLimiter, TtlLruCache } from "./cache.ts"; export * from "./client.ts"; +export * from "./details.ts"; export * from "./match.ts"; +export * from "./translations.ts"; diff --git a/packages/adapters/src/tmdb/translations.ts b/packages/adapters/src/tmdb/translations.ts new file mode 100644 index 00000000..22da8183 --- /dev/null +++ b/packages/adapters/src/tmdb/translations.ts @@ -0,0 +1,184 @@ +/** + * TMDB 多语言取值(MD-1):按语言回退链从 translations / alternative_titles 取标题、简介。 + * 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8) + * __update_tmdbinfo_cn_title:本地化名不含中文 → alternative_titles 的 CN 地区中文名 → 新加坡译名(sg_title); + * __update_tmdbinfo_extra_title:translations 按地区取 en / hk / tw / sg 译名。 + * 调整:MP 的"中文名 → CN 别名 → SG"写死在代码里,这里泛化成可配置的语言链(缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US), + * 链中每一项先看 translations(语言 + 地区精确匹配),再看同地区、文字相符的 alternative_titles; + * TMDB 在某语言没有译名时会把 name 回退成原名,所以不信任请求语言下的 name,只信 translations。 + * MP 用 zhconv 判断 CN 别名是否简体,这里不引入繁简转换,只要求含汉字。 + */ + +export const DEFAULT_METADATA_LANGUAGES: readonly string[] = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; + +export interface TmdbTranslation { + /** BCP47,如 zh-CN;没有地区时只有语言 */ + lang: string; + iso639: string; + region: string | null; + title: string | null; + overview: string | null; + tagline: string | null; +} + +export interface TmdbAltTitle { + title: string; + region: string | null; + type: string | null; +} + +/** 取到的值与其来源语言;via 说明来自哪里(给元数据来源页和测试看) */ +export interface PickedText { + value: string; + language: string | null; + via: "translation" | "alternative_title" | "original" | "primary"; +} + +type Json = Record; +const str = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null); + +const HAN = /\p{Script=Han}/u; +const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u; +const HANGUL = /\p{Script=Hangul}/u; +const LATIN = /\p{Script=Latin}/u; + +const containsChinese = (s: string | null | undefined): boolean => !!s && HAN.test(s); + +/** 标题的文字与语言是否相符(用于 alternative_titles 只有地区、没有语言的情况) */ +export function scriptMatches(lang: string, text: string): boolean { + const l = lang.split("-")[0]!.toLowerCase(); + if (l === "zh") return HAN.test(text) && !KANA.test(text); + if (l === "ja") return KANA.test(text) || HAN.test(text); + if (l === "ko") return HANGUL.test(text); + if (["en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi", "pl", "cs", "tr"].includes(l)) + return LATIN.test(text) && !HAN.test(text) && !KANA.test(text) && !HANGUL.test(text); + return true; +} + +function splitTag(tag: string): { lang: string; region: string | null } { + const [l, r] = tag.split("-"); + return { lang: (l ?? "").toLowerCase(), region: r ? r.toUpperCase() : null }; +} + +/** translations.translations[] → 归一化;type 决定标题键(movie: title,tv / season / episode: name) */ +export function parseTranslations(raw: unknown, titleKey: "title" | "name"): TmdbTranslation[] { + const list = Array.isArray((raw as Json | null)?.translations) + ? ((raw as Json).translations as Json[]) + : Array.isArray(raw) + ? (raw as Json[]) + : []; + const out: TmdbTranslation[] = []; + for (const t of list) { + const iso639 = str(t?.iso_639_1)?.toLowerCase(); + if (!iso639) continue; + const region = str(t.iso_3166_1)?.toUpperCase() ?? null; + const data = (t.data ?? {}) as Json; + out.push({ + lang: region ? `${iso639}-${region}` : iso639, + iso639, + region, + title: str(data[titleKey]) ?? str(data.title) ?? str(data.name), + overview: str(data.overview), + tagline: str(data.tagline), + }); + } + return out; +} + +/** alternative_titles(movie: titles,tv: results)→ 归一化 */ +export function parseAltTitles(raw: unknown): TmdbAltTitle[] { + const r = raw as Json | null; + const list = (Array.isArray(r?.titles) ? r.titles : Array.isArray(r?.results) ? r.results : []) as Json[]; + const out: TmdbAltTitle[] = []; + for (const a of list) { + const title = str(a?.title); + if (!title) continue; + out.push({ title, region: str(a.iso_3166_1)?.toUpperCase() ?? null, type: str(a.type) }); + } + return out; +} + +/** 语言标签是否匹配某条译名:zh-CN 只匹配 zh/CN;只写语言(zh)时匹配该语言任意地区 */ +function tagMatches(tag: string, t: Pick): boolean { + const { lang, region } = splitTag(tag); + if (t.iso639 !== lang) return false; + return region === null || t.region === region; +} + +export interface TitleSources { + translations: readonly TmdbTranslation[]; + altTitles?: readonly TmdbAltTitle[]; + originalTitle?: string | null; + originalLanguage?: string | null; + /** 请求语言下的 name/title(TMDB 缺译名时是原名);链全部落空时的兜底 */ + primary?: string | null; +} + +/** + * 按语言链取标题:每个语言先查 translations,再查同地区、文字相符的别名(类型为空的优先), + * 原始语言与链中语言相同时原名也算该语言的值。全部落空:primary → 原名。 + */ +export function pickTitle(chain: readonly string[], src: TitleSources): PickedText | null { + const origLang = src.originalLanguage?.toLowerCase() ?? null; + for (const tag of chain) { + const tr = src.translations.find((t) => tagMatches(tag, t) && t.title); + if (tr?.title) return { value: tr.title, language: tag, via: "translation" }; + const { lang, region } = splitTag(tag); + if (region && src.altTitles?.length) { + const alts = src.altTitles + .filter((a) => a.region === region && scriptMatches(lang, a.title)) + .sort((a, b) => (a.type ? 1 : 0) - (b.type ? 1 : 0)); + if (alts[0]) return { value: alts[0].title, language: tag, via: "alternative_title" }; + } + if (origLang === lang && src.originalTitle) return { value: src.originalTitle, language: tag, via: "original" }; + } + if (src.primary) return { value: src.primary, language: null, via: "primary" }; + if (src.originalTitle) return { value: src.originalTitle, language: origLang, via: "original" }; + return null; +} + +/** 中文名:链里的中文语言按链顺序(没有中文语言时用缺省中文链),取到的值必须含汉字 */ +export function pickChineseTitle(chain: readonly string[], src: TitleSources): PickedText | null { + const zh = chain.filter((t) => splitTag(t).lang === "zh"); + const zhChain = zh.length ? zh : DEFAULT_METADATA_LANGUAGES.filter((t) => t.startsWith("zh")); + const p = pickTitle(zhChain, { ...src, primary: null, originalTitle: src.originalTitle }); + if (p && containsChinese(p.value)) return p; + if (containsChinese(src.primary)) return { value: src.primary!, language: null, via: "primary" }; + return null; +} + +/** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值) */ +export function pickText( + chain: readonly string[], + translations: readonly TmdbTranslation[], + key: "overview" | "tagline", + primary?: { value: string | null; language: string | null }, +): PickedText | null { + for (const tag of chain) { + const tr = translations.find((t) => tagMatches(tag, t) && t[key]); + if (tr?.[key]) return { value: tr[key]!, language: tag, via: "translation" }; + } + if (primary?.value) return { value: primary.value, language: primary.language, via: "primary" }; + return null; +} + +/** + * TMDB 缺译名时给集生成的占位名("第 3 集"、"Episode 3"、"第 3 話"…),当作没有标题继续回退。 + */ +export function isPlaceholderEpisodeName(name: string | null | undefined, episodeNumber?: number): boolean { + if (!name) return true; + const s = name.trim(); + if (!s) return true; + const n = episodeNumber !== undefined ? String(episodeNumber) : "\\d+"; + return new RegExp( + `^(第\\s*${n}\\s*[集話话回]|Episode\\s*${n}|Épisode\\s*${n}|Folge\\s*${n}|Episodio\\s*${n}|エピソード\\s*${n}|${n}\\s*화|에피소드\\s*${n})$`, + "i", + ).test(s); +} + +/** 季的占位名("第 1 季"、"Season 1"、"特别篇"不算占位) */ +export function isPlaceholderSeasonName(name: string | null | undefined, seasonNumber?: number): boolean { + if (!name) return true; + const n = seasonNumber !== undefined ? String(seasonNumber) : "\\d+"; + return new RegExp(`^(第\\s*${n}\\s*季|Season\\s*${n}|シーズン\\s*${n}|시즌\\s*${n})$`, "i").test(name.trim()); +} diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 556db29d..4cdc4a79 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -38,6 +38,7 @@ const STRING_CANDIDATES = [ "ALL", "zh-hans", "movie:157336", + "zh-CN", ]; const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000]; diff --git a/packages/testkit/src/tmdb/fixtures.ts b/packages/testkit/src/tmdb/fixtures.ts index 17b7c7cc..da8fc95b 100644 --- a/packages/testkit/src/tmdb/fixtures.ts +++ b/packages/testkit/src/tmdb/fixtures.ts @@ -5,14 +5,39 @@ export interface TmdbFixtureEpisode { episode_number: number; air_date: string | null; + /** 按语言的集名;请求语言没有时 mock 返回 TMDB 式占位名("第 N 集" / "Episode N") */ names: Record; + /** MD-1:按语言的简介;没有时返回空串 */ + overviews?: Record; + runtime?: number | null; + still_path?: string | null; + vote_average?: number; + vote_count?: number; } export interface TmdbFixtureSeason { season_number: number; air_date: string | null; names: Record; + overviews?: Record; + poster_path?: string | null; episodes: TmdbFixtureEpisode[]; } +/** MD-1 富详情字段(credits / 类型 / 评分 / 状态…);缺省不返回 */ +export interface TmdbFixtureRich { + genres?: string[]; + vote_average?: number; + vote_count?: number; + status?: string; + runtime?: number; + episode_run_time?: number[]; + last_air_date?: string; + backdrop_path?: string; + networks?: string[]; + tagline?: Record; + cast?: { id: number; name: string; character: string; profile_path: string | null }[]; + crew?: { id: number; name: string; job: string; profile_path: string | null }[]; + created_by?: { id: number; name: string; profile_path: string | null }[]; +} export interface TmdbFixture { type: "movie" | "tv"; id: number; @@ -25,8 +50,9 @@ export interface TmdbFixture { popularity: number; alternative_titles: { iso_3166_1: string; title: string; type: string }[]; external_ids: { imdb_id?: string | null; tvdb_id?: number | null }; - /** 额外译名(titles 中的语言会自动生成 translations) */ - extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string }[]; + /** 额外译名(titles 中的语言会自动生成 translations);title 为空串表示该语言只有简介没有译名(TMDB 常见) */ + extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string; overview?: string }[]; + rich?: TmdbFixtureRich; seasons?: TmdbFixtureSeason[]; /** 海报路径(拼 image.tmdb.org);缺省 null */ poster_path?: string | null; @@ -219,4 +245,86 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ }, ], }, + // MD-1 语言回退链:zh-CN 译名为空(只有简介)、CN 地区别名是简中名、zh-TW / zh-HK 有译名(对照 TMDB tv/95396 真实形态) + { + type: "tv", + id: 95396, + original_title: "Severance", + original_language: "en", + origin_country: ["US"], + date: "2022-02-17", + titles: { "en-US": "Severance", "zh-TW": "人生切割術", "zh-HK": "生活割離術" }, + overviews: { "en-US": "Mark leads a team of office workers.", "zh-TW": "馬克帶領的辦公團隊成員全都接受手術。" }, + popularity: 95.1, + alternative_titles: [ + { iso_3166_1: "CN", title: "人生切割术", type: "" }, + { iso_3166_1: "HK", title: "生活割離術", type: "" }, + ], + external_ids: { imdb_id: "tt11280740", tvdb_id: 371980 }, + extra_translations: [ + { iso_639_1: "zh", iso_3166_1: "CN", title: "", overview: "故事发生在一家名为卢蒙的超级大公司内。" }, + { iso_639_1: "zh", iso_3166_1: "SG", title: "", overview: "马克领导着一个办公室里的团队。" }, + ], + poster_path: "/severance.jpg", + rich: { + genres: ["剧情", "悬疑", "Sci-Fi & Fantasy"], + vote_average: 8.395, + vote_count: 2400, + status: "Returning Series", + episode_run_time: [], + last_air_date: "2025-03-21", + backdrop_path: "/severance-bg.jpg", + networks: ["Apple TV"], + cast: [ + { id: 4495, name: "亚当·斯科特", character: "Mark Scout", profile_path: "/adam.jpg" }, + { id: 1319469, name: "布丽特·洛薇尔", character: "Helly Riggs", profile_path: null }, + ], + created_by: [{ id: 3442409, name: "丹·埃里克森", profile_path: "/dan.jpg" }], + }, + seasons: [ + { + season_number: 1, + air_date: "2022-02-17", + names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, + overviews: { "zh-CN": "在卢蒙工业公司,员工们接受了一种名为“分离”的手术。" }, + poster_path: "/sev-s1.jpg", + episodes: [ + { + episode_number: 1, + air_date: "2022-02-17", + names: { "zh-CN": "有关地狱的好消息", "en-US": "Good News About Hell" }, + overviews: { "zh-CN": "马克升职成为部门主管。", "en-US": "Mark is promoted." }, + runtime: 59, + still_path: "/sev-101.jpg", + vote_average: 8.2, + vote_count: 171, + }, + { + episode_number: 2, + air_date: "2022-02-17", + names: { "zh-TW": "半循環", "en-US": "Half Loop" }, + overviews: { "zh-TW": "馬克帶領新員工參觀。", "en-US": "Mark gives Helly a tour." }, + runtime: 53, + still_path: "/sev-102.jpg", + vote_average: 8.0, + vote_count: 150, + }, + { + episode_number: 3, + air_date: "2022-02-25", + names: { "en-US": "In Perpetuity" }, + overviews: {}, + runtime: 52, + still_path: null, + }, + ], + }, + { + season_number: 2, + air_date: "2099-01-17", + names: { "zh-CN": "第 2 季", "en-US": "Season 2" }, + episodes: [{ episode_number: 1, air_date: "2099-01-17", names: {} }], + }, + ], + }, ]; diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts index ce1fddd3..83aebc59 100644 --- a/packages/testkit/src/tmdb/mock-tmdb.ts +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -107,17 +107,27 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise String(x.season_number) === m[2]); if (!f || !s) return notFound; + const lang = q.language ?? "en-US"; + const zh = lang.startsWith("zh"); return { json: { id: f.id * 100 + s.season_number, season_number: s.season_number, name: pick(s.names, q.language, `Season ${s.season_number}`), + overview: s.overviews?.[lang] ?? "", + poster_path: s.poster_path ?? null, air_date: s.air_date, episodes: s.episodes.map((e) => ({ episode_number: e.episode_number, season_number: s.season_number, - name: pick(e.names, q.language, ""), + // TMDB 缺该语言译名时给占位名 + name: e.names[lang] ?? (zh ? `第 ${e.episode_number} 集` : `Episode ${e.episode_number}`), + overview: e.overviews?.[lang] ?? "", air_date: e.air_date, + runtime: e.runtime ?? null, + still_path: e.still_path ?? null, + vote_average: e.vote_average ?? 0, + vote_count: e.vote_count ?? 0, })), }, }; @@ -130,6 +140,27 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise = { ...brief(f, q.language), origin_country: f.origin_country }; + const r = f.rich; + if (r) { + out.genres = (r.genres ?? []).map((name, i) => ({ id: i + 1, name })); + out.vote_average = r.vote_average ?? 0; + out.vote_count = r.vote_count ?? 0; + out.status = r.status ?? null; + out.backdrop_path = r.backdrop_path ?? null; + out.tagline = r.tagline?.[q.language ?? "en-US"] ?? ""; + if (f.type === "movie") out.runtime = r.runtime ?? null; + else { + out.episode_run_time = r.episode_run_time ?? []; + out.last_air_date = r.last_air_date ?? null; + out.networks = (r.networks ?? []).map((name, i) => ({ id: i + 1, name })); + out.created_by = r.created_by ?? []; + out.number_of_episodes = (f.seasons ?? []) + .filter((x) => x.season_number > 0) + .reduce((a, x) => a + x.episodes.length, 0); + } + if (append.has("credits")) + out.credits = { cast: (r.cast ?? []).map((c, order) => ({ ...c, order })), crew: r.crew ?? [] }; + } if (f.type === "tv") { out.number_of_seasons = f.seasons?.filter((s) => s.season_number > 0).length ?? 0; out.seasons = (f.seasons ?? []).map((s) => ({ @@ -137,6 +168,8 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { const [l, r] = lang.split("-"); - return { iso_639_1: l, iso_3166_1: r ?? "", data: { [key]: t } }; + return { + iso_639_1: l, + iso_3166_1: r ?? "", + data: { [key]: t, overview: f.overviews?.[lang] ?? "" }, + }; }), ...(f.extra_translations ?? []).map((x) => ({ iso_639_1: x.iso_639_1, iso_3166_1: x.iso_3166_1, - data: { [key]: x.title }, + data: { [key]: x.title, overview: x.overview ?? "" }, })), ], }; From 9cd5a289431edc089367ede084d4f05f450f8c77 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 17:28:16 +0800 Subject: [PATCH 2/4] 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", From 97ac59c7ace8310654d88c8e1223f789e52b91f8 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 17:28:16 +0800 Subject: [PATCH 3/4] feat(web): MP-style media detail with seasons and episodes, shared by library and TMDB preview (MD-1) Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/library.ts | 36 +- apps/web/app/api/search.ts | 12 + apps/web/app/components/library/labels.ts | 23 + .../components/library/unit-field-dialog.tsx | 179 +++++++ .../app/components/media/external-links.ts | 23 + .../web/app/components/media/media-detail.tsx | 403 ++++++++++++++ apps/web/app/components/settings/metadata.tsx | 34 +- apps/web/app/routes/console/library.$id.tsx | 495 ++++++++++++++---- .../routes/console/media.tmdb.$type.$id.tsx | 235 +++++---- 9 files changed, 1250 insertions(+), 190 deletions(-) create mode 100644 apps/web/app/components/library/unit-field-dialog.tsx create mode 100644 apps/web/app/components/media/external-links.ts create mode 100644 apps/web/app/components/media/media-detail.tsx 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" ? ( +