- adapters: overviewLanguages() limits overview/tagline chain to Chinese variants (zh-CN→zh-SG→zh-TW→zh-HK); titles keep full chain; season supplement fetch skips languages useless for overview - setting metadata.overviewNonChineseFallback (default off) + UI switch - media detail shows "暂无简介" for empty entity/episode overview - scripts/overview-recompute.ts: recompute stored non-Chinese overviews from kv_cache only (no TMDB requests), dry-run by default, --apply - tests: chain unit tests, Heart Signal (103147) fixture, server recompute test, MP differential for episode overview - runbook + CHANGELOG Co-Authored-By: Claude Opus 5.5 <[email protected]>
149 lines
6.7 KiB
TypeScript
149 lines
6.7 KiB
TypeScript
import { createTmdbClient } from "@umt/adapters";
|
|
import { PgCache } from "@umt/db";
|
|
import { createMockTmdb, HEART_SIGNAL_FIXTURE, type MockTmdb, TMDB_FIXTURES } 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 {
|
|
applyOverviewRecompute,
|
|
planOverviewRecompute,
|
|
} from "../../src/modules/sources-identity/service/overview-recompute.ts";
|
|
import { CACHE_REGIONS } from "../../src/shared-cache.ts";
|
|
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
|
|
|
|
const up = await pgAvailable();
|
|
type Final = { value: unknown; source: string; language: string | null };
|
|
const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
|
|
|
|
describe.skipIf(!up)("MD-2 简介只回退到中文变体 + 存量重算", () => {
|
|
let h: Harness;
|
|
let tmdb: MockTmdb;
|
|
let id: string;
|
|
const actor = { type: "user" as const, id: "tester" };
|
|
const now = () => new Date("2026-01-01T00:00:00Z");
|
|
|
|
beforeAll(async () => {
|
|
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, HEART_SIGNAL_FIXTURE] });
|
|
// 走 PG 请求缓存(kv_cache tmdb.request),与生产一致:存量重算只读这里
|
|
const cache = {
|
|
get: (k: string) => new PgCache(h.db).get(CACHE_REGIONS.tmdbRequest, k),
|
|
set: (k: string, v: unknown, ttlMs: number) =>
|
|
new PgCache(h.db).set(CACHE_REGIONS.tmdbRequest, k, v, { ttl: ttlMs / 1000 }),
|
|
};
|
|
h = await createHarness("md2", {
|
|
now,
|
|
tmdb: () =>
|
|
createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0, sharedCache: cache }),
|
|
});
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await tmdb?.close();
|
|
});
|
|
|
|
const setMeta = async (v: Record<string, unknown> | null) => {
|
|
await h.db.deleteFrom("settings").where("key", "=", "metadata").execute();
|
|
if (v)
|
|
await h.db
|
|
.insertInto("settings")
|
|
.values({ key: "metadata", value: JSON.stringify(v) } as never)
|
|
.execute();
|
|
};
|
|
const entityMeta = async () =>
|
|
(await h.db.selectFrom("media_entity").select("metadata").where("id", "=", id).executeTakeFirstOrThrow())
|
|
.metadata as Record<string, Final>;
|
|
const eps = async () =>
|
|
(
|
|
await h.db
|
|
.selectFrom("content_unit")
|
|
.select(["episode_number", "title", "metadata"])
|
|
.where("media_entity_id", "=", id)
|
|
.where("kind", "=", "episode")
|
|
.orderBy("episode_number")
|
|
.execute()
|
|
).map(
|
|
(u) =>
|
|
({ ...(u.metadata as Record<string, Final | undefined>), ep: u.episode_number, title: u.title }) as {
|
|
ep: number | null;
|
|
title: string | null;
|
|
overview?: Final;
|
|
name?: Final;
|
|
},
|
|
);
|
|
|
|
it("允许回退到非中文时(旧行为):英文简介进库", async () => {
|
|
await setMeta({ languages: CHAIN, overviewNonChineseFallback: true });
|
|
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "103147", kind: "tv" }, actor);
|
|
id = r.mediaEntityId;
|
|
expect((await refreshMetadata(h.rt, id, { full: true })).status).toBe("refreshed");
|
|
expect((await entityMeta()).overview).toMatchObject({ value: "A Chinese dating reality show.", language: "en-US" });
|
|
const [e1, e2] = await eps();
|
|
expect(e1!.overview).toMatchObject({
|
|
value: "If Your Heart Skips a Beat, Play Your Cards Openly!",
|
|
language: "en-US",
|
|
});
|
|
expect(e2!.overview).toMatchObject({ value: "Who Is the One?", language: "en-US" });
|
|
});
|
|
|
|
it("存量重算:dry-run 只列清单,不请求 TMDB、不改库;--apply 按缓存清空,标题不动", async () => {
|
|
await setMeta({ languages: CHAIN });
|
|
const calls = tmdb.calls.length;
|
|
const plan = await planOverviewRecompute(h.db, { languages: CHAIN, now: now() });
|
|
expect(tmdb.calls.length).toBe(calls);
|
|
expect(plan.overviewLanguages).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]);
|
|
expect(plan.scanned).toEqual({ entities: 1, units: 2 });
|
|
expect(plan.cache.misses).toBe(0);
|
|
const brief = plan.changes.map((c) => [c.target, c.episode, c.field, c.from.language, c.to.value, c.basis]);
|
|
expect(brief).toEqual([
|
|
["entity", null, "overview", "en-US", null, "cache"],
|
|
["episode", 1, "overview", "en-US", null, "cache"],
|
|
["episode", 2, "overview", "en-US", null, "cache"],
|
|
]);
|
|
// dry-run 不改库
|
|
expect((await entityMeta()).overview?.language).toBe("en-US");
|
|
|
|
const r = await h.db.transaction().execute((trx) => applyOverviewRecompute(trx, plan, { now: now() }));
|
|
expect(r).toEqual({ entities: 1, units: 2 });
|
|
expect(tmdb.calls.length).toBe(calls);
|
|
expect((await entityMeta()).overview).toMatchObject({ value: null, language: null, source: "tmdb" });
|
|
const [e1, e2, e3] = await eps();
|
|
expect(e1!.overview?.value).toBeNull();
|
|
expect(e1!.title).toBe("心动不如行动,大胆亮出底牌!");
|
|
expect(e2!.overview?.value).toBeNull();
|
|
// 标题仍可回退到英文
|
|
expect(e2!.name).toMatchObject({ value: "Who Is the One?", language: "en-US" });
|
|
expect(e3!.overview).toMatchObject({ value: "約會之夜。", language: "zh-TW" });
|
|
// 旧语言行删掉,只留一行 tmdb 来源
|
|
const rows = await h.db
|
|
.selectFrom("metadata_field")
|
|
.select(["language", "value"])
|
|
.where("media_entity_id", "=", id)
|
|
.where("field", "=", "overview")
|
|
.execute();
|
|
expect(rows).toEqual([{ language: null, value: null }]);
|
|
// 再跑一次没有要改的
|
|
expect((await planOverviewRecompute(h.db, { languages: CHAIN, now: now() })).changes).toEqual([]);
|
|
});
|
|
|
|
it("缓存被淘汰时不请求 TMDB,按「当时中文为空」清空;刷新按新规则也不再写英文", async () => {
|
|
await setMeta({ languages: CHAIN, overviewNonChineseFallback: true });
|
|
await refreshMetadata(h.rt, id, { full: true });
|
|
expect((await eps())[0]!.overview?.language).toBe("en-US");
|
|
await setMeta({ languages: CHAIN });
|
|
await h.db.deleteFrom("kv_cache" as never).execute();
|
|
const calls = tmdb.calls.length;
|
|
const plan = await planOverviewRecompute(h.db, { languages: CHAIN, now: now() });
|
|
expect(tmdb.calls.length).toBe(calls);
|
|
expect(plan.changes.every((c) => c.basis === "no_cache" && c.to.value === null)).toBe(true);
|
|
expect(plan.changes).toHaveLength(3);
|
|
|
|
// 不跑重算,直接刷新:按新规则清掉英文简介
|
|
await refreshMetadata(h.rt, id, { full: true });
|
|
expect((await entityMeta()).overview?.value).toBeNull();
|
|
const [e1, e2] = await eps();
|
|
expect(e1!.overview?.value).toBeNull();
|
|
expect(e2!.overview?.value).toBeNull();
|
|
expect(e2!.name?.value).toBe("Who Is the One?");
|
|
});
|
|
});
|