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 }); // 编号不被元数据刷新改动;与 TMDB 季集结构对上的临时单元转正式(DH-1,id 不变) expect(units.map((u) => [u.season_number, u.episode_number, u.status, u.numbering_locked])).toEqual([ [1, 1, "confirmed", false], [1, 2, "confirmed", false], [1, 3, "confirmed", false], [2, 1, "confirmed", 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); }); });