Merge branch 'md1'
# Conflicts: # packages/contracts/src/settings.ts
This commit is contained in:
commit
b633eb5379
30 files changed
+3240
-222
No files matched your search
@@ -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<string, Final>;
|
||||
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<string, Final>;
|
||||
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<string, Final>).posterPath?.value).toBe("/sev-s1.jpg");
|
||||
expect((s1.metadata as Record<string, Final>).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<string, Final>).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<string, Final>).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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user