feat(metadata): overview falls back to Chinese variants only (MD-2)

- 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 18:04:23 +08:00
1 parent 081db4651c
commit f9901b514b
22 files changed
+887 -38

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
// 存量简介重算(MD-2):简介的语言回退只到中文变体后,把之前回退到英文等非中文的作品 / 季 / 集简介按新规则重算。
// 不请求 TMDB,只读 kv_cache(tmdb.request)里已缓存的 TMDB 响应;缓存里没有的按"当时中文为空"清空。
// 默认只读(dry-run),加 --apply 才改库(单个事务,写一条审计 metadata.overview_recompute)。
//
// 用法(DATABASE_URL 必须显式给出,脚本不带默认库):
// DATABASE_URL=postgres://umt:***@<host>:5432/umt deno run -A scripts/overview-recompute.ts # 看清单
// DATABASE_URL=… deno run -A scripts/overview-recompute.ts --apply # 写库
// DATABASE_URL=… deno run -A scripts/overview-recompute.ts --entity <作品 id> # 只看 / 只处理一部作品
// DATABASE_URL=… deno run -A scripts/overview-recompute.ts --json # 清单输出 JSON
//
// 语言链与"简介是否允许回退到非中文"取库里的设置 metadata.languages / metadata.overviewNonChineseFallback。
// 规则与实现见 apps/server/src/modules/sources-identity/service/overview-recompute.ts、docs/runbook/metadata.md。
import { DEFAULT_METADATA_LANGUAGES } from "@umt/adapters";
import type { DB } from "@umt/db";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import {
applyOverviewRecompute,
type OverviewChange,
planOverviewRecompute,
} from "../apps/server/src/modules/sources-identity/service/overview-recompute.ts";
const argv = [...Deno.args];
const APPLY = argv.includes("--apply");
const JSON_OUT = argv.includes("--json");
if (APPLY && argv.includes("--dry-run")) {
console.error("--apply 与 --dry-run 不能同时给出");
Deno.exit(2);
}
const entityArg = argv.indexOf("--entity");
const ONLY_ENTITY = entityArg >= 0 ? (argv[entityArg + 1] ?? null) : null;
const url = Deno.env.get("DATABASE_URL");
if (!url) {
console.error("需要 DATABASE_URL(脚本不带默认库)");
Deno.exit(2);
}
const db = new Kysely<DB>({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 2 }) }) });
const meta = (
await sql<{ value: { languages?: string[]; overviewNonChineseFallback?: boolean } | null }>`
select value from settings where key = 'metadata'`.execute(db)
).rows[0]?.value;
const languages = meta?.languages?.length ? meta.languages : [...DEFAULT_METADATA_LANGUAGES];
const overviewNonChineseFallback = meta?.overviewNonChineseFallback === true;
const now = new Date();
const cut = (s: string | null, n = 40) => (s === null ? "(空)" : s.length > n ? `${s.slice(0, n)}…` : s);
const where = (c: OverviewChange) =>
c.target === "entity"
? "作品"
: c.target === "season"
? `S${String(c.season).padStart(2, "0")}`
: `S${String(c.season).padStart(2, "0")}E${String(c.episode).padStart(2, "0")}`;
try {
const plan = await planOverviewRecompute(db, { languages, overviewNonChineseFallback, now, entityId: ONLY_ENTITY });
if (JSON_OUT && !APPLY) {
console.log(JSON.stringify(plan, null, 2));
} else {
console.log(
`# overview-recompute (${APPLY ? "APPLY" : "DRY-RUN"})${ONLY_ENTITY ? ` --entity ${ONLY_ENTITY}` : ""}`,
);
console.log(`语言链 ${languages.join(" → ")};简介链 ${plan.overviewLanguages.join(" → ")}`);
console.log(
`非中文简介:作品 ${plan.scanned.entities},季 / 集 ${plan.scanned.units};读缓存 ${plan.cache.reads} 次,未命中 ${plan.cache.misses}\n`,
);
const byEntity = new Map<string, OverviewChange[]>();
for (const c of plan.changes) byEntity.set(c.mediaEntityId, [...(byEntity.get(c.mediaEntityId) ?? []), c]);
for (const [id, list] of byEntity) {
console.log(`## ${list[0]!.entityTitle} (${id}) ${list.length} 项`);
for (const c of list) {
const basis = c.basis === "cache" ? "缓存" : "无缓存→清空";
console.log(
` ${where(c).padEnd(8)} ${c.field.padEnd(8)} [${c.from.language ?? "-"}] ${cut(c.from.value)} → [${c.to.language ?? "-"}] ${cut(c.to.value)} (${basis})`,
);
}
}
const count = (t: OverviewChange["target"]) => plan.changes.filter((c) => c.target === t).length;
console.log(
`\n合计 ${plan.changes.length} 项:作品 ${count("entity")},季 ${count("season")},集 ${count("episode")};` +
`其中无缓存清空 ${plan.changes.filter((c) => c.basis === "no_cache").length},` +
`改成中文 ${plan.changes.filter((c) => c.to.value !== null).length}`,
);
}
if (APPLY && plan.changes.length) {
const r = await db.transaction().execute((trx) => applyOverviewRecompute(trx, plan, { now }));
console.log(`已写库:最终值有变化的作品 ${r.entities},季 / 集 ${r.units}(锁定 / 覆盖的字段不变)`);
} else if (!APPLY) {
console.log("dry-run:未改库。确认清单后加 --apply 执行");
}
} finally {
await db.destroy();
}