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:
1 parent
081db4651c
commit
f9901b514b
22 files changed
+887
-38
No files matched your search
@@ -4,6 +4,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 变更(MD-2 简介不回退英文)
|
||||
- 作品、季、集的简介(以及作品标语)只回退到中文变体(缺省链 zh-CN → zh-SG → zh-TW → zh-HK),不再回退到 en-US;都没有就留空。标题、别名仍按完整回退链(可到 en-US)。排在第一个中文之前的语言照常用,链里没有中文时按原链。对照 MP:每集信息只按配置语言取,简介为空就为空。
|
||||
- 设置 → 元数据 → TMDB 新增「简介回退到非中文」(`metadata.overviewNonChineseFallback`,缺省关),打开后恢复旧行为。
|
||||
- 媒体详情:作品和每集没有简介时显示一行小字「暂无简介」。已有的英文简介在下次刷新时按新规则清掉。
|
||||
- 存量重算脚本 `scripts/overview-recompute.ts`:找出语言不在简介链里的作品 / 季 / 集简介,不请求 TMDB,只用 `kv_cache` 里缓存的 TMDB 响应按新规则重算(缓存已淘汰的直接清空);缺省 dry-run,`--apply` 写库。见 `docs/runbook/metadata.md`「存量简介重算(MD-2)」。
|
||||
|
||||
## [0.3.2] - 2026-09-30
|
||||
|
||||
### 新增(BR-4 浏览器 UA 与指纹统一)
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
createEntityFromTmdbCandidate,
|
||||
enqueueNewEntityRefresh,
|
||||
} from "../sources-identity/service/entity-from-candidate.ts";
|
||||
import { metadataLanguages } from "../sources-identity/service/metadata-rich.ts";
|
||||
import { metadataLanguages, metadataTextOptions } from "../sources-identity/service/metadata-rich.ts";
|
||||
import { identifyParsed, type NameIdentifyResult } from "../sources-identity/service/resolve.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
@@ -133,7 +133,7 @@ export function createPorts(
|
||||
() => rt().tmdb(),
|
||||
{ kind: ref.type, externalId: ref.id },
|
||||
{ type, id: actor.id },
|
||||
{ languages: await metadataLanguages(ctx) },
|
||||
{ languages: await metadataLanguages(ctx), ...(await metadataTextOptions(ctx)) },
|
||||
);
|
||||
if (r.created) await enqueueNewEntityRefresh(ctx, r.mediaEntityId);
|
||||
return r.mediaEntityId;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createEntityFromTmdbCandidate,
|
||||
enqueueNewEntityRefresh,
|
||||
} from "../sources-identity/service/entity-from-candidate.ts";
|
||||
import { metadataLanguages } from "../sources-identity/service/metadata-rich.ts";
|
||||
import { metadataLanguages, metadataTextOptions } from "../sources-identity/service/metadata-rich.ts";
|
||||
import { actorOfP, FilesError, type FmDeps } from "./common.ts";
|
||||
import { audit } from "./ledger.ts";
|
||||
|
||||
@@ -24,7 +24,10 @@ export async function entityFromCandidate(
|
||||
): Promise<FilesEntityFromCandidateResult> {
|
||||
try {
|
||||
const languages = await metadataLanguages(d.ctx);
|
||||
const r = await createEntityFromTmdbCandidate(d.ctx.db, d.tmdb, ref, actorOf(principal), { languages });
|
||||
const r = await createEntityFromTmdbCandidate(d.ctx.db, d.tmdb, ref, actorOf(principal), {
|
||||
languages,
|
||||
...(await metadataTextOptions(d.ctx)),
|
||||
});
|
||||
if (r.created) {
|
||||
await audit(d, principal, "entity_from_candidate", { ...ref, mediaEntityId: r.mediaEntityId });
|
||||
// 订阅建档 / 搜索建档 / 整理向导都走这里:建档即入队 full 刷新(每集信息、富字段)
|
||||
|
||||
@@ -28,7 +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 { metadataLanguages, metadataTextOptions } 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";
|
||||
@@ -169,7 +169,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
try {
|
||||
const langs = await metadataLanguages(ctx);
|
||||
return await raceAbort(
|
||||
fetchRichDetail(client, type, id, langs),
|
||||
fetchRichDetail(client, type, id, langs, await metadataTextOptions(ctx)),
|
||||
AbortSignal.timeout(d.siteTimeoutMs),
|
||||
"TMDB 查询超时",
|
||||
);
|
||||
@@ -413,7 +413,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
try {
|
||||
const langs = await metadataLanguages(ctx);
|
||||
const sd = await raceAbort(
|
||||
fetchRichSeason(client, p.id, p.season, langs),
|
||||
fetchRichSeason(client, p.id, p.season, langs, await metadataTextOptions(ctx)),
|
||||
AbortSignal.timeout(d.siteTimeoutMs * 2),
|
||||
"TMDB 查询超时",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { fetchRichDetail, type MetadataCandidate, type TmdbClient, TmdbError } from "@umt/adapters";
|
||||
import {
|
||||
fetchRichDetail,
|
||||
type MetadataCandidate,
|
||||
type RichTextOptions,
|
||||
type TmdbClient,
|
||||
TmdbError,
|
||||
} from "@umt/adapters";
|
||||
import type { MediaKind } from "@umt/contracts";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
@@ -26,11 +32,12 @@ export async function withLanguageChain(
|
||||
client: TmdbClient,
|
||||
detail: MetadataCandidate,
|
||||
languages: readonly string[] | undefined,
|
||||
textOpts: RichTextOptions = {},
|
||||
): Promise<MetadataCandidate> {
|
||||
if (!languages?.length || typeof client.getJson !== "function") return detail;
|
||||
if (detail.entityType !== "movie" && detail.entityType !== "tv") return detail;
|
||||
try {
|
||||
const rich = await fetchRichDetail(client, detail.entityType, detail.externalId, languages);
|
||||
const rich = await fetchRichDetail(client, detail.entityType, detail.externalId, languages, textOpts);
|
||||
return { ...detail, title: rich.title || detail.title, overview: rich.overview ?? detail.overview };
|
||||
} catch (e) {
|
||||
if (e instanceof TmdbError) return detail;
|
||||
@@ -74,7 +81,7 @@ export async function createEntityFromTmdbCandidate(
|
||||
tmdb: () => Promise<TmdbClient | null>,
|
||||
ref: { externalId: string; kind: "movie" | "tv" },
|
||||
actor: Actor,
|
||||
opts: { languages?: readonly string[] } = {},
|
||||
opts: { languages?: readonly string[] } & RichTextOptions = {},
|
||||
): Promise<EntityFromCandidate> {
|
||||
const extRef = { provider: "tmdb", entityType: ref.kind, externalId: ref.externalId };
|
||||
const existing = await entityByExternal(db, extRef);
|
||||
@@ -91,7 +98,9 @@ export async function createEntityFromTmdbCandidate(
|
||||
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
|
||||
throw e;
|
||||
}
|
||||
detail = await withLanguageChain(client, detail, opts.languages);
|
||||
detail = await withLanguageChain(client, detail, opts.languages, {
|
||||
overviewNonChineseFallback: opts.overviewNonChineseFallback === true,
|
||||
});
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "./entities.ts";
|
||||
import { enqueueNewEntityRefresh, withLanguageChain } from "./entity-from-candidate.ts";
|
||||
import { detachFromIdentityDecisions } from "./identity-decisions.ts";
|
||||
import { metadataLanguages } from "./metadata-rich.ts";
|
||||
import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
|
||||
import { recordAttempt } from "./resolve.ts";
|
||||
import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||
|
||||
@@ -59,7 +59,7 @@ async function entityForExternal(
|
||||
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
|
||||
throw e;
|
||||
}
|
||||
detail = await withLanguageChain(tmdb, detail, await metadataLanguages(rt.ctx));
|
||||
detail = await withLanguageChain(tmdb, detail, await metadataLanguages(rt.ctx), await metadataTextOptions(rt.ctx));
|
||||
const r = await createEntityFromTmdb(exec, detail, {
|
||||
level: "manual",
|
||||
evidence: { by: actorName(actor) },
|
||||
|
||||
@@ -22,7 +22,7 @@ import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import { ensureSeason, promoteUnits } from "./metadata-totals.ts";
|
||||
|
||||
// MD-1 语言回退链 + 每集元数据(REQ §7.3 字段级):
|
||||
// - 作品:TMDB 富详情(fetchRichDetail)按设置 metadata.languages 取标题 / 简介 / 标语,另存中文名 titleZh、类型、评分、
|
||||
// - 作品:TMDB 富详情(fetchRichDetail)按设置 metadata.languages 取标题 / 简介 / 标语(MD-2:简介 / 标语缺省只回退到中文变体),另存中文名 titleZh、类型、评分、
|
||||
// 状态、时长、季数、主创、外部 ID;来源值写 metadata_field(language = 实际取到的语言),同字段换语言时删旧行;
|
||||
// - 别名:translations / alternative_titles 写 title_alias(来源 tmdb,标语言);已有同文本别名只补空的语言;
|
||||
// - 季 / 集:fetchRichSeason 的每集 name / overview / airDate / runtime / stillPath / vote 写 metadata_field(content_unit_id),
|
||||
@@ -40,6 +40,15 @@ export async function metadataLanguages(ctx: ServerContext): Promise<string[]> {
|
||||
return langs?.length ? langs : [...DEFAULT_METADATA_LANGUAGES];
|
||||
}
|
||||
|
||||
/** 设置 metadata.overviewNonChineseFallback(MD-2):简介 / 标语是否允许回退到非中文;缺省 false */
|
||||
export async function metadataTextOptions(ctx: ServerContext): Promise<{ overviewNonChineseFallback: boolean }> {
|
||||
const m = await ctx.settings.get("metadata").catch(() => null);
|
||||
return {
|
||||
overviewNonChineseFallback:
|
||||
(m as { overviewNonChineseFallback?: boolean } | null)?.overviewNonChineseFallback === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置 metadata.includeSpecials:full 刷新 / 回刷是否包含季 0(特别篇);缺省 true */
|
||||
export async function metadataIncludeSpecials(ctx: ServerContext): Promise<boolean> {
|
||||
const m = await ctx.settings.get("metadata").catch(() => null);
|
||||
@@ -82,7 +91,7 @@ const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stri
|
||||
* 写一组 tmdb 来源字段(作品或内容单元):值和语言都没变的跳过;语言变了删掉该字段其它语言的 tmdb 行再写。
|
||||
* 返回写入(新增 / 变化)的字段名。
|
||||
*/
|
||||
async function writeSourceFields(
|
||||
export async function writeSourceFields(
|
||||
exec: Exec,
|
||||
target: { mediaEntityId: string } | { contentUnitId: string },
|
||||
fields: Record<string, FieldValue>,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
applySeasonMetadata,
|
||||
metadataIncludeSpecials,
|
||||
metadataLanguages,
|
||||
metadataTextOptions,
|
||||
type SeasonMetadataResult,
|
||||
seasonsInLibrary,
|
||||
seasonsToRefresh,
|
||||
@@ -189,13 +190,14 @@ export async function refreshMetadata(
|
||||
// MD-1 富详情(语言回退链、类型、评分、主创…)与每集信息;注入的简化客户端没有 getJson 时走旧路径
|
||||
const rich = typeof tmdb.getJson === "function";
|
||||
const langs = rich ? await metadataLanguages(ctx) : [];
|
||||
const textOpts = await metadataTextOptions(ctx);
|
||||
let richDetail: TmdbRichDetail | null = null;
|
||||
const richSeasons: TmdbRichSeasonDetail[] = [];
|
||||
// 可以新建集单元的季:full 刷新的全部季、追踪中订阅的季;其余季只更新已有集单元
|
||||
let creatable = new Set<number>();
|
||||
if (rich) {
|
||||
try {
|
||||
richDetail = await fetchRichDetail(tmdb, ident.entity_type, ident.external_id, langs);
|
||||
richDetail = await fetchRichDetail(tmdb, ident.entity_type, ident.external_id, langs, textOpts);
|
||||
} catch (e) {
|
||||
if (!(e instanceof TmdbError)) throw e;
|
||||
ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新");
|
||||
@@ -213,7 +215,7 @@ export async function refreshMetadata(
|
||||
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() }));
|
||||
richSeasons.push(await fetchRichSeason(tmdb, ident.external_id, n, langs, { now: rt.now(), ...textOpts }));
|
||||
} catch (e) {
|
||||
ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
overviewLanguages,
|
||||
type TmdbClient,
|
||||
TmdbError,
|
||||
tmdbRequestKey,
|
||||
} from "@umt/adapters";
|
||||
import { sql } from "@umt/db";
|
||||
import { CACHE_REGIONS } from "../../../shared-cache.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import { project } from "./metadata.ts";
|
||||
import { projectUnit, writeSourceFields } from "./metadata-rich.ts";
|
||||
|
||||
// MD-2 存量简介重算:MD-1 的回退链会把 en-US 等非中文简介填进作品 / 季 / 集的简介;新规则下简介只回退到中文变体
|
||||
// (见 adapters overviewLanguages)。这里找出来源 tmdb、语言不在简介链里的简介,**不请求 TMDB**,只读 kv_cache
|
||||
// (region tmdb.request)里当初取值用过的响应,按新规则重算:
|
||||
// - 缓存里有响应:用同一套 fetchRichDetail / fetchRichSeason 重算(缓存读取不续期、不改最近使用时间,过期未清理的行也用);
|
||||
// - 缓存里没有(已淘汰):当初能落到非中文,说明当时链里的中文变体都没有简介,按新规则直接清空(basis = no_cache);
|
||||
// - 作品另按缓存重算标语(标语不记语言,只能在缓存命中时重算)。
|
||||
// 写入走字段来源表 + 投影(与刷新同一套 writeSourceFields / project / projectUnit),用户锁定 / 覆盖的字段最终值不变。
|
||||
// 入口:scripts/overview-recompute.ts(缺省 dry-run,--apply 写库)。
|
||||
|
||||
export interface OverviewValue {
|
||||
value: string | null;
|
||||
language: string | null;
|
||||
}
|
||||
|
||||
export interface OverviewChange {
|
||||
target: "entity" | "season" | "episode";
|
||||
mediaEntityId: string;
|
||||
entityTitle: string;
|
||||
contentUnitId: string | null;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
field: "overview" | "tagline";
|
||||
from: OverviewValue;
|
||||
to: OverviewValue;
|
||||
/** cache = 按缓存的 TMDB 响应重算;no_cache = 缓存里没有,按"当时中文为空"清空 */
|
||||
basis: "cache" | "no_cache";
|
||||
/** tmdb 来源修订(写 metadata_field.source_revision) */
|
||||
revision: string;
|
||||
}
|
||||
|
||||
export interface OverviewRecomputePlan {
|
||||
languages: string[];
|
||||
overviewLanguages: string[];
|
||||
/** 语言不在简介链里的简介行数(作品 / 季与集) */
|
||||
scanned: { entities: number; units: number };
|
||||
changes: OverviewChange[];
|
||||
/** 读缓存次数 / 未命中次数 */
|
||||
cache: { reads: number; misses: number };
|
||||
}
|
||||
|
||||
type CacheClient = Pick<TmdbClient, "getJson"> & { reads: number; misses: number };
|
||||
|
||||
/** 只读 kv_cache 的 TMDB 客户端:命中返回缓存值,未命中抛 notfound(fetchRichSeason 的补拉遇错即停) */
|
||||
export function cacheOnlyTmdb(exec: Exec): CacheClient {
|
||||
const c: CacheClient = {
|
||||
reads: 0,
|
||||
misses: 0,
|
||||
async getJson<T>(path: string, params: Record<string, string | undefined> = {}): Promise<T> {
|
||||
const key = tmdbRequestKey(path, params);
|
||||
c.reads++;
|
||||
const r = await sql<{ value: unknown }>`select value from kv_cache
|
||||
where region = ${CACHE_REGIONS.tmdbRequest} and key = ${key}`.execute(exec);
|
||||
const v = r.rows[0]?.value;
|
||||
if (v === undefined || v === null) {
|
||||
c.misses++;
|
||||
throw new TmdbError("notfound", `未缓存 ${key}`, { path });
|
||||
}
|
||||
return v as T;
|
||||
},
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
const same = (a: OverviewValue, b: OverviewValue) =>
|
||||
(a.value ?? null) === (b.value ?? null) && a.language === b.language;
|
||||
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
||||
|
||||
/** 生成重算清单(只读;不请求 TMDB) */
|
||||
export async function planOverviewRecompute(
|
||||
exec: Exec,
|
||||
opts: { languages: readonly string[]; overviewNonChineseFallback?: boolean; now?: Date; entityId?: string | null },
|
||||
): Promise<OverviewRecomputePlan> {
|
||||
const languages = [...opts.languages];
|
||||
const textLangs = overviewLanguages(languages, opts.overviewNonChineseFallback === true);
|
||||
const only = opts.entityId ?? null;
|
||||
const tmdb = cacheOnlyTmdb(exec);
|
||||
const changes: OverviewChange[] = [];
|
||||
|
||||
// ── 作品 ──
|
||||
const ents = await sql<{
|
||||
id: string;
|
||||
title: string;
|
||||
value: unknown;
|
||||
language: string;
|
||||
entity_type: "movie" | "tv";
|
||||
external_id: string;
|
||||
}>`
|
||||
select distinct on (f.media_entity_id) f.media_entity_id as id, e.title, f.value, f.language, x.entity_type, x.external_id
|
||||
from metadata_field f
|
||||
join media_entity e on e.id = f.media_entity_id
|
||||
join external_identity x on x.media_entity_id = e.id and x.provider = 'tmdb' and x.status = 'accepted'
|
||||
and x.entity_type in ('movie', 'tv')
|
||||
where f.content_unit_id is null and f.source = 'tmdb' and f.field = 'overview'
|
||||
and f.language is not null and not (f.language = any(${textLangs}::text[]))
|
||||
and (${only}::uuid is null or f.media_entity_id = ${only}::uuid)
|
||||
order by f.media_entity_id, x.created_at`.execute(exec);
|
||||
for (const e of ents.rows) {
|
||||
const revision = `tmdb:${e.entity_type}/${e.external_id}`;
|
||||
const base = {
|
||||
target: "entity" as const,
|
||||
mediaEntityId: e.id,
|
||||
entityTitle: e.title,
|
||||
contentUnitId: null,
|
||||
season: null,
|
||||
episode: null,
|
||||
revision,
|
||||
};
|
||||
const from = { value: str(e.value), language: e.language };
|
||||
try {
|
||||
const rich = await fetchRichDetail(tmdb, e.entity_type, e.external_id, languages, {
|
||||
overviewNonChineseFallback: opts.overviewNonChineseFallback === true,
|
||||
});
|
||||
const to = { value: rich.overview, language: rich.overviewLanguage };
|
||||
if (!same(from, to)) changes.push({ ...base, field: "overview", from, to, basis: "cache" });
|
||||
const tag = await exec
|
||||
.selectFrom("metadata_field")
|
||||
.select(["value", "language"])
|
||||
.where("media_entity_id", "=", e.id)
|
||||
.where("content_unit_id", "is", null)
|
||||
.where("source", "=", "tmdb")
|
||||
.where("field", "=", "tagline")
|
||||
.executeTakeFirst();
|
||||
const tagFrom = { value: str(tag?.value), language: tag?.language ?? null };
|
||||
const tagTo = { value: rich.tagline, language: null };
|
||||
if (tag && !same(tagFrom, tagTo))
|
||||
changes.push({ ...base, field: "tagline", from: tagFrom, to: tagTo, basis: "cache" });
|
||||
} catch (err) {
|
||||
if (!(err instanceof TmdbError)) throw err;
|
||||
changes.push({ ...base, field: "overview", from, to: { value: null, language: null }, basis: "no_cache" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 季 / 集 ──
|
||||
const units = await sql<{
|
||||
unit_id: string;
|
||||
kind: string;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
media_entity_id: string;
|
||||
title: string;
|
||||
value: unknown;
|
||||
language: string;
|
||||
external_id: string;
|
||||
}>`
|
||||
select distinct on (f.content_unit_id) f.content_unit_id as unit_id, u.kind, u.season_number, u.episode_number,
|
||||
u.media_entity_id, e.title, f.value, f.language, x.external_id
|
||||
from metadata_field f
|
||||
join content_unit u on u.id = f.content_unit_id
|
||||
join media_entity e on e.id = u.media_entity_id
|
||||
join external_identity x on x.media_entity_id = e.id and x.provider = 'tmdb' and x.status = 'accepted'
|
||||
and x.entity_type = 'tv'
|
||||
where f.media_entity_id is null and f.source = 'tmdb' and f.field = 'overview'
|
||||
and f.language is not null and not (f.language = any(${textLangs}::text[]))
|
||||
and u.status <> 'retired' and u.kind in ('season', 'episode') and u.season_number is not null
|
||||
and (${only}::uuid is null or u.media_entity_id = ${only}::uuid)
|
||||
order by f.content_unit_id, x.created_at`.execute(exec);
|
||||
const groups = new Map<string, typeof units.rows>();
|
||||
for (const u of units.rows) {
|
||||
const k = `${u.media_entity_id}|${u.external_id}|${u.season_number}`;
|
||||
groups.set(k, [...(groups.get(k) ?? []), u]);
|
||||
}
|
||||
for (const rows of groups.values()) {
|
||||
const first = rows[0]!;
|
||||
const season = first.season_number!;
|
||||
let sd: Awaited<ReturnType<typeof fetchRichSeason>> | null = null;
|
||||
try {
|
||||
sd = await fetchRichSeason(tmdb, first.external_id, season, languages, {
|
||||
now: opts.now ?? new Date(),
|
||||
overviewNonChineseFallback: opts.overviewNonChineseFallback === true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof TmdbError)) throw err;
|
||||
}
|
||||
const sorted = [...rows].sort((a, b) => (a.episode_number ?? -1) - (b.episode_number ?? -1));
|
||||
for (const u of sorted) {
|
||||
const from = { value: str(u.value), language: u.language };
|
||||
let to: OverviewValue = { value: null, language: null };
|
||||
let basis: OverviewChange["basis"] = "no_cache";
|
||||
if (sd && u.kind === "season") {
|
||||
to = { value: sd.overview, language: sd.overviewLanguage };
|
||||
basis = "cache";
|
||||
} else if (sd) {
|
||||
const ep = sd.episodes.find((x) => x.episodeNumber === u.episode_number);
|
||||
if (ep) {
|
||||
to = { value: ep.overview, language: ep.overviewLanguage };
|
||||
basis = "cache";
|
||||
}
|
||||
}
|
||||
if (same(from, to)) continue;
|
||||
changes.push({
|
||||
target: u.kind === "season" ? "season" : "episode",
|
||||
mediaEntityId: u.media_entity_id,
|
||||
entityTitle: u.title,
|
||||
contentUnitId: u.unit_id,
|
||||
season,
|
||||
episode: u.kind === "season" ? null : u.episode_number,
|
||||
field: "overview",
|
||||
from,
|
||||
to,
|
||||
basis,
|
||||
revision: `tmdb:tv/${u.external_id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
languages,
|
||||
overviewLanguages: textLangs,
|
||||
scanned: { entities: ents.rows.length, units: units.rows.length },
|
||||
changes,
|
||||
cache: { reads: tmdb.reads, misses: tmdb.misses },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按清单写库:同刷新一样写字段来源(语言变了删旧语言行)并投影;锁定 / 覆盖的字段最终值不变。
|
||||
* 调用方负责事务(脚本在一个事务里执行)。返回投影后最终值有变化的作品 / 单元数。
|
||||
*/
|
||||
export async function applyOverviewRecompute(
|
||||
exec: Exec,
|
||||
plan: OverviewRecomputePlan,
|
||||
opts: { now: Date; actor?: Actor },
|
||||
): Promise<{ entities: number; units: number }> {
|
||||
const byTarget = new Map<string, OverviewChange[]>();
|
||||
for (const c of plan.changes) {
|
||||
const k = c.contentUnitId ? `u:${c.contentUnitId}` : `e:${c.mediaEntityId}`;
|
||||
byTarget.set(k, [...(byTarget.get(k) ?? []), c]);
|
||||
}
|
||||
let entities = 0;
|
||||
let units = 0;
|
||||
for (const list of byTarget.values()) {
|
||||
const c0 = list[0]!;
|
||||
const fields = Object.fromEntries(list.map((c) => [c.field, c.to]));
|
||||
const names = list.map((c) => c.field);
|
||||
if (c0.contentUnitId) {
|
||||
const existing = await exec
|
||||
.selectFrom("metadata_field")
|
||||
.select(["field", "value", "language"])
|
||||
.where("content_unit_id", "=", c0.contentUnitId)
|
||||
.where("media_entity_id", "is", null)
|
||||
.where("source", "=", "tmdb")
|
||||
.where("field", "in", names)
|
||||
.execute();
|
||||
await writeSourceFields(exec, { contentUnitId: c0.contentUnitId }, fields, existing, c0.revision, opts.now);
|
||||
const r = await projectUnit(exec, c0.contentUnitId, opts.now);
|
||||
if (r.changedFields.length) units++;
|
||||
} else {
|
||||
const existing = await exec
|
||||
.selectFrom("metadata_field")
|
||||
.select(["field", "value", "language"])
|
||||
.where("media_entity_id", "=", c0.mediaEntityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.where("source", "=", "tmdb")
|
||||
.where("field", "in", names)
|
||||
.execute();
|
||||
await writeSourceFields(exec, { mediaEntityId: c0.mediaEntityId }, fields, existing, c0.revision, opts.now);
|
||||
const r = await project(exec, c0.mediaEntityId, opts.now);
|
||||
if (r.changedFields.length) entities++;
|
||||
}
|
||||
}
|
||||
if (plan.changes.length) {
|
||||
await audit(
|
||||
exec,
|
||||
opts.actor ?? { type: "system", id: "overview-recompute" },
|
||||
"metadata.overview_recompute",
|
||||
{ type: "metadata", id: null },
|
||||
{
|
||||
overviewLanguages: plan.overviewLanguages,
|
||||
changes: plan.changes.length,
|
||||
noCache: plan.changes.filter((c) => c.basis === "no_cache").length,
|
||||
},
|
||||
);
|
||||
}
|
||||
return { entities, units };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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?");
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,7 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
|
||||
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-3 text-caption text-on-media-tertiary">没有简介。</p>
|
||||
<p className="mt-2 text-meta text-on-media-tertiary">暂无简介</p>
|
||||
)}
|
||||
{dates.length || facts.externalLinks.length ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-on-media-tertiary">
|
||||
@@ -332,7 +332,11 @@ export function EpisodeRow({ e }: { e: EpisodeRowData }) {
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">{meta.join(" · ")}</p>
|
||||
{e.overview ? <ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" /> : null}
|
||||
{e.overview ? (
|
||||
<ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" />
|
||||
) : (
|
||||
<p className="mt-0.5 text-meta text-muted-foreground">暂无简介</p>
|
||||
)}
|
||||
{e.extra}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -51,7 +51,14 @@ const TMDB_CARD: CardSpec = {
|
||||
label: "语言回退链",
|
||||
kind: "text",
|
||||
placeholder: DEFAULT_LANGUAGES.join(", "),
|
||||
hint: "标题、简介和每集信息按顺序取第一个有值的语言;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG",
|
||||
hint: "标题和每集信息按顺序取第一个有值的语言,简介默认只取链里的中文;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "metadata.overviewNonChineseFallback",
|
||||
label: "简介回退到非中文",
|
||||
kind: "switch",
|
||||
hint: "没有中文简介时,按回退链取英文等语言。关闭时留空,标题不受影响",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
@@ -68,6 +75,7 @@ const TMDB_CARD: CardSpec = {
|
||||
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
|
||||
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
|
||||
"metadata.includeSpecials": s.metadata.includeSpecials !== false,
|
||||
"metadata.overviewNonChineseFallback": s.metadata.overviewNonChineseFallback === true,
|
||||
}),
|
||||
toPatch: (v) => {
|
||||
const key = secretPatch(v["metadata.tmdbApiKey"]);
|
||||
@@ -76,6 +84,7 @@ const TMDB_CARD: CardSpec = {
|
||||
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
|
||||
languages: parseLanguages(str(v["metadata.languages"])),
|
||||
includeSpecials: v["metadata.includeSpecials"] === true,
|
||||
overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === true,
|
||||
...(key !== undefined ? { tmdbApiKey: key } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -204,5 +204,5 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
|
||||
- **文件预览不加 ffmpeg(用户 2026-09-30)**:视频缩略图 / 截图条不做,镜像不引入 ffmpeg(+141MB);`docs/design/file-preview-research.md` 对应项标为不做。
|
||||
- 用户 2026-09-30:BR-4 直接做完上线;《征途》重复单元不再处理(刷新时自动合并即可)。
|
||||
- **用户 2026-09-30 决定**:短剧补全(META-1 后续)继续做;文件预览的音频 / 压缩包 / 原盘目录不做;修完零碎问题后接其他站点真机测试。
|
||||
- **每集简介英文(MD-2,用户以《心动的信号》为例)**:协调者核对——TMDB 匹配正确(103147),每期标题为 zh-CN;但 TMDB 上这些集没有中文简介,只有英文"简介"(实为标题英译),MD-1 的语言回退链把 en-US 填进了简介。改法:简介的回退链只到中文变体(zh-CN→zh-SG→zh-TW→zh-HK),不回退到英文,没有就留空并显示"暂无简介";标题仍可回退英文;存量数据按新规则重算(只读 dry-run 后应用)。
|
||||
- **每集简介英文(MD-2,用户以《心动的信号》为例)**:协调者核对——TMDB 匹配正确(103147),每期标题为 zh-CN;但 TMDB 上这些集没有中文简介,只有英文"简介"(实为标题英译),MD-1 的语言回退链把 en-US 填进了简介。改法:简介的回退链只到中文变体(zh-CN→zh-SG→zh-TW→zh-HK),不回退到英文,没有就留空并显示"暂无简介";标题仍可回退英文;存量数据按新规则重算(只读 dry-run 后应用)。**状态:已实现(分支 md2,待合并)**——adapters `overviewLanguages`(简介链)+ 设置 `metadata.overviewNonChineseFallback`(缺省关)+ 详情页「暂无简介」+ 存量重算 `scripts/overview-recompute.ts`(只读 kv_cache,缺省 dry-run);见 runbook metadata.md「存量简介重算(MD-2)」。
|
||||
- **零碎问题(2026-09-30 讨论)**:跨进程限流已在迁移 0041 做完,CHANGELOG「已知限制」过期条目删除;子会话思考英文 → 子会话系统提示补中文要求 + 测试;`agent.spawn` 重复调用 → 同目标去重(返回已有子会话);camofox 空闲 5 分钟回收 → 桌面接管 / 智能体会话打开期间由 app 定期保活,关闭后照常回收;憨憨 UA"待切换"→ 站点页「刷新登录」即可切换,不另做。
|
||||
@@ -5,8 +5,8 @@
|
||||
- 设置 → 元数据 → TMDB →「语言回退链」,即 `settings.metadata.languages`,缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US`。
|
||||
- 作品标题:按链逐个语言查 TMDB `translations`(语言 + 地区精确匹配);该语言译名为空时查同地区、文字相符的 `alternative_titles`(类型为空的优先);原始语言等于链中语言时原名也算。全部落空用请求语言下的 name / 原名。对照 MP `__update_tmdbinfo_cn_title`(中文名 → CN 别名 → SG 译名)。
|
||||
- 中文名 `titleZh`:只在链里的中文语言中按顺序取(zh-CN 优先),值必须含汉字。链首改成 en-US 时标题变英文,`titleZh` 不变。
|
||||
- 简介 / 标语:按链取 `translations` 的第一个非空值。
|
||||
- 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停;链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。
|
||||
- 简介 / 标语(MD-2):只按「简介链」取 `translations` 的第一个非空值,取不到留空(界面显示「暂无简介」)。简介链 = 回退链里第一个中文之前的语言 + 链里全部中文,之后的非中文去掉;缺省链得到 `zh-CN, zh-SG, zh-TW, zh-HK`。链里没有中文时简介链就是原链。设置 → 元数据 → TMDB「简介回退到非中文」(`metadata.overviewNonChineseFallback`,缺省关)打开后简介链 = 原链(旧行为)。原因:TMDB 上很多剧集(如《心动的信号》103147)只有英文"简介",而且常是标题英译;MP 每集信息只按配置语言取,简介为空就为空。请求语言下的原值若等于链外某语言的译文(TMDB / 反代把缺失字段回退了),不当作链内的值。标题 / 别名不受影响。
|
||||
- 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停(简介只从简介链里的语言取;只缺简介且剩下的语言都不在简介链里时不再补拉);链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。
|
||||
- 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。
|
||||
- `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。
|
||||
|
||||
@@ -44,6 +44,25 @@ curl -s -H "Authorization: Bearer $KEY" "$BASE/api/v1/media-entities?limit=200"
|
||||
while read -r id; do curl -s -X POST -H "Authorization: Bearer $KEY" "$BASE/api/v1/media-entities/$id/refresh?full=1"; done
|
||||
```
|
||||
|
||||
## 存量简介重算(MD-2)
|
||||
|
||||
MD-1 的回退链把 en-US 等非中文简介写进过作品 / 季 / 集。新规则上线后,刷新会按新规则清掉;不想等刷新、或想先看范围时用脚本:
|
||||
|
||||
```sh
|
||||
# 看清单(只读):按作品列出每项「旧语言 旧值 → 新语言 新值(依据)」和合计
|
||||
DATABASE_URL=postgres://umt:***@<host>:5432/umt deno run -A scripts/overview-recompute.ts
|
||||
DATABASE_URL=… deno run -A scripts/overview-recompute.ts --entity <作品 id> # 只看一部
|
||||
DATABASE_URL=… deno run -A scripts/overview-recompute.ts --json # 清单 JSON
|
||||
DATABASE_URL=… deno run -A scripts/overview-recompute.ts --apply # 写库(单个事务 + 审计 metadata.overview_recompute)
|
||||
```
|
||||
|
||||
- 对象:来源 tmdb、`language` 不为空且不在简介链里的 `overview`(作品 / 季单元 / 集单元,未退役,有已接受的 TMDB 身份)。语言链与开关取库里的设置。
|
||||
- **不请求 TMDB**:用只读 `kv_cache`(region `tmdb.request`)的客户端跑同一套 `fetchRichDetail` / `fetchRichSeason`,读缓存不续期、不改最近使用时间,过期但还没被清理的行也用。依据写在清单里:
|
||||
- `缓存`:按缓存的响应重算,可能改成某个中文变体的简介,也可能为空;作品的标语一起按缓存重算;
|
||||
- `无缓存→清空`:缓存已淘汰。当初能落到非中文,说明那时简介链里的语言都没有简介,按新规则直接清空。
|
||||
- 写库与刷新同一套:写字段来源(语言变了删旧语言行)再投影;用户锁定 / 覆盖的字段最终值不变。重跑幂等(第二次清单为空)。
|
||||
- 按 lesson 22:对真实库先跑 dry-run,由协调者过目清单再 `--apply`。代码:`apps/server/src/modules/sources-identity/service/overview-recompute.ts`。
|
||||
|
||||
## 集字段(字段级,可锁)
|
||||
|
||||
- 集单元字段:`name`(投影到 `content_unit.title`)、`overview`、`airDate`(投影到 `air_date`)、`runtime`、`stillPath`、`voteAverage`、`voteCount`;季单元另有 `posterPath`。最终值在 `content_unit.metadata`,每个字段带来源 / 语言 / 时间 / 锁。
|
||||
@@ -65,6 +84,7 @@ curl -s -H "Authorization: Bearer $KEY" "$BASE/api/v1/media-entities?limit=200"
|
||||
| 现象 | 看哪里 |
|
||||
|---|---|
|
||||
| 标题仍是英文原名 | 作品详情「字段来源与锁」里 `标题` 的来源与语言;是否被锁;设置里的语言链;对作品跑 `full=1` |
|
||||
| 简介显示「暂无简介」 | TMDB 该作品 / 集在简介链的语言里都没有简介,属正常(MD-2 不再用英文顶替);要英文时打开「简介回退到非中文」再刷新;也可在每集行手动填写并锁定 |
|
||||
| 某集没有中文标题 | 该集在 TMDB 各语言都没有译名时保留占位名 / 英文名,属正常;可在每集行手动改并锁定 |
|
||||
| 季里没有集 | 默认刷新不为未追踪的季建集单元,点「更新每集信息」(full) |
|
||||
| 没有特别篇 | TMDB 该剧有没有季 0;设置 `metadata.includeSpecials` 是否关了;对作品跑 `full=1` |
|
||||
|
||||
@@ -123,6 +123,17 @@ export interface TmdbSharedCache {
|
||||
set(key: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
}
|
||||
|
||||
function requestQuery(params: Record<string, string | undefined>): URLSearchParams {
|
||||
const qs = new URLSearchParams();
|
||||
for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!);
|
||||
return qs;
|
||||
}
|
||||
|
||||
/** 请求缓存键 `path?query`(参数按名排序、不含凭证);只读缓存的离线重算(MD-2)据此找已缓存的响应 */
|
||||
export function tmdbRequestKey(path: string, params: Record<string, string | undefined> = {}): string {
|
||||
return `${path}?${requestQuery(params)}`;
|
||||
}
|
||||
|
||||
/** 空结果(搜索 0 条)用短 TTL(对照 MP EMPTY_RESULT_CACHE_TTL) */
|
||||
const isEmptyResult = (v: unknown) =>
|
||||
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0;
|
||||
@@ -163,8 +174,7 @@ export class TmdbClient {
|
||||
}
|
||||
|
||||
private async get<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!);
|
||||
const qs = requestQuery(params);
|
||||
const key = `${path}?${qs}`;
|
||||
const emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000;
|
||||
const baseTtl = this.cfg.cache?.ttlMs ?? 24 * 3600_000;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { createMockTmdb, HEART_SIGNAL_FIXTURE, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createTmdbClient,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
isPlaceholderEpisodeName,
|
||||
overviewLanguages,
|
||||
parseAltTitles,
|
||||
parseTranslations,
|
||||
pickChineseTitle,
|
||||
@@ -19,7 +20,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
mock = await createMockTmdb();
|
||||
mock = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, HEART_SIGNAL_FIXTURE] });
|
||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
|
||||
return { mock, tmdb };
|
||||
}
|
||||
@@ -64,6 +65,36 @@ describe("语言回退链(纯函数)", () => {
|
||||
expect(pickText(["ja-JP"], translations, "overview", { value: null, language: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("MD-2 简介链:缺省只到中文变体;排在中文前面的语言保留;链里没有中文或允许回退时原链", () => {
|
||||
expect(overviewLanguages(CHAIN)).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]);
|
||||
expect(overviewLanguages(CHAIN, true)).toEqual(CHAIN);
|
||||
expect(overviewLanguages(["en-US", "zh-CN", "ja-JP"])).toEqual(["en-US", "zh-CN"]);
|
||||
expect(overviewLanguages(["ja-JP", "en-US"])).toEqual(["ja-JP", "en-US"]);
|
||||
expect(overviewLanguages(["zh", "en-US"])).toEqual(["zh"]);
|
||||
});
|
||||
|
||||
it("MD-2 简介只有英文:按中文链取不到就为空;标题仍回退到英文", () => {
|
||||
const enOnly = parseTranslations(
|
||||
{
|
||||
translations: [
|
||||
{ iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "" } },
|
||||
{ iso_639_1: "en", iso_3166_1: "US", data: { name: "Heart Signal", overview: "English only" } },
|
||||
],
|
||||
},
|
||||
"name",
|
||||
);
|
||||
expect(pickText(overviewLanguages(CHAIN), enOnly, "overview")).toBeNull();
|
||||
expect(pickText(CHAIN, enOnly, "overview")).toMatchObject({ value: "English only", language: "en-US" });
|
||||
// 请求语言下的原值其实是英文译文(TMDB 回退):不当作 zh-CN
|
||||
expect(
|
||||
pickText(overviewLanguages(CHAIN), enOnly, "overview", { value: "English only", language: "zh-CN" }),
|
||||
).toBeNull();
|
||||
expect(pickTitle(CHAIN, { translations: enOnly, primary: null })).toMatchObject({
|
||||
value: "Heart Signal",
|
||||
language: "en-US",
|
||||
});
|
||||
});
|
||||
|
||||
it("占位集名识别", () => {
|
||||
expect(isPlaceholderEpisodeName("第 3 集", 3)).toBe(true);
|
||||
expect(isPlaceholderEpisodeName("Episode 3", 3)).toBe(true);
|
||||
@@ -137,6 +168,53 @@ describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => {
|
||||
expect(s2.fetchedLanguages).toEqual(["zh-CN"]);
|
||||
});
|
||||
|
||||
it("MD-2《心动的信号》:作品与每集简介只有英文 → 留空;标题照常回退;允许非中文时恢复英文", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
const d = await fetchRichDetail(tmdb, "tv", 103147, CHAIN);
|
||||
expect(d.title).toBe("心动的信号");
|
||||
expect(d.overview).toBeNull();
|
||||
expect(d.overviewLanguage).toBeNull();
|
||||
const s = await fetchRichSeason(tmdb, 103147, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
const [e1, e2, e3] = s.episodes;
|
||||
expect(e1).toMatchObject({ name: "心动不如行动,大胆亮出底牌!", nameLanguage: "zh-CN", overview: null });
|
||||
expect(e1!.overviewLanguage).toBeNull();
|
||||
// 标题:zh-CN 是占位名,回退到 en-US;简介仍不回退
|
||||
expect(e2).toMatchObject({ name: "Who Is the One?", nameLanguage: "en-US", overview: null });
|
||||
expect(e3).toMatchObject({ name: "约会之夜", overview: "約會之夜。", overviewLanguage: "zh-TW" });
|
||||
// 补拉 en-US 只为了标题
|
||||
expect(s.fetchedLanguages).toEqual(CHAIN);
|
||||
|
||||
const any = await fetchRichSeason(tmdb, 103147, 1, CHAIN, {
|
||||
now: new Date("2026-01-01"),
|
||||
overviewNonChineseFallback: true,
|
||||
});
|
||||
expect(any.episodes[0]).toMatchObject({
|
||||
overview: "If Your Heart Skips a Beat, Play Your Cards Openly!",
|
||||
overviewLanguage: "en-US",
|
||||
});
|
||||
const anyD = await fetchRichDetail(tmdb, "tv", 103147, CHAIN, { overviewNonChineseFallback: true });
|
||||
expect(anyD).toMatchObject({ overview: "A Chinese dating reality show.", overviewLanguage: "en-US" });
|
||||
expect(mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("MD-2 标题都齐、只缺简介时,中文变体走完就停,不为简介补拉英文", async () => {
|
||||
const fx = {
|
||||
...HEART_SIGNAL_FIXTURE,
|
||||
seasons: [
|
||||
{
|
||||
...HEART_SIGNAL_FIXTURE.seasons![0]!,
|
||||
overviews: { "zh-CN": "第一季。" },
|
||||
episodes: [HEART_SIGNAL_FIXTURE.seasons![0]!.episodes[0]!],
|
||||
},
|
||||
],
|
||||
};
|
||||
mock = await createMockTmdb({ fixtures: [fx] });
|
||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 });
|
||||
const s = await fetchRichSeason(tmdb, 103147, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
expect(s.episodes[0]!.overview).toBeNull();
|
||||
expect(s.fetchedLanguages).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]);
|
||||
});
|
||||
|
||||
it("补拉走请求缓存:同一季第二次不再请求", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* 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),头像只存路径(前端经图片代理)。
|
||||
* 链走完或全部补齐即停。MD-2:简介 / 标语只按 overviewLanguages(缺省只到中文变体,不回退英文)取,取不到留空。主创只留前 10 位演员 + 导演 / 主创(created_by),头像只存路径(前端经图片代理)。
|
||||
*/
|
||||
import type { MediaEntityType, TmdbClient } from "./client.ts";
|
||||
import {
|
||||
DEFAULT_METADATA_LANGUAGES,
|
||||
isPlaceholderEpisodeName,
|
||||
isPlaceholderSeasonName,
|
||||
overviewLanguages,
|
||||
parseAltTitles,
|
||||
parseTranslations,
|
||||
pickChineseTitle,
|
||||
@@ -116,6 +117,12 @@ export interface TmdbRichSeasonDetail {
|
||||
fetchedLanguages: string[];
|
||||
}
|
||||
|
||||
/** MD-2 取值选项 */
|
||||
export interface RichTextOptions {
|
||||
/** 简介 / 标语是否允许回退到非中文(设置 metadata.overviewNonChineseFallback,缺省否) */
|
||||
overviewNonChineseFallback?: boolean;
|
||||
}
|
||||
|
||||
const chainOf = (languages?: readonly string[]) =>
|
||||
languages?.length ? [...languages] : [...DEFAULT_METADATA_LANGUAGES];
|
||||
|
||||
@@ -156,8 +163,10 @@ export async function fetchRichDetail(
|
||||
type: MediaEntityType,
|
||||
id: string | number,
|
||||
languages?: readonly string[],
|
||||
opts: RichTextOptions = {},
|
||||
): Promise<TmdbRichDetail> {
|
||||
const chain = chainOf(languages);
|
||||
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
|
||||
const j = await client.getJson<Json>(`/${type}/${id}`, {
|
||||
language: chain[0],
|
||||
append_to_response: "alternative_titles,credits,external_ids,translations",
|
||||
@@ -171,8 +180,9 @@ export async function fetchRichDetail(
|
||||
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 });
|
||||
// 请求语言 = 链首,链首总在 textChain 里(见 overviewLanguages),所以 primary 可以兜底
|
||||
const overview = pickText(textChain, translations, "overview", { value: s(j.overview), language: chain[0] ?? null });
|
||||
const tagline = pickText(textChain, 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[]) : [];
|
||||
@@ -258,10 +268,12 @@ export async function fetchRichSeason(
|
||||
tvId: string | number,
|
||||
seasonNumber: number,
|
||||
languages?: readonly string[],
|
||||
opts: { now?: Date } = {},
|
||||
opts: { now?: Date } & RichTextOptions = {},
|
||||
): Promise<TmdbRichSeasonDetail> {
|
||||
const chain = chainOf(languages);
|
||||
const first = chain[0]!;
|
||||
// MD-2:简介只从这些语言取(缺省只到中文变体);标题仍按完整链
|
||||
const textChain = overviewLanguages(chain, opts.overviewNonChineseFallback);
|
||||
const j = await client.getJson<Json>(`/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));
|
||||
@@ -274,9 +286,15 @@ export async function fetchRichSeason(
|
||||
// 只为已播出的集补拉(未播出的集各语言通常都空,补拉只会白白耗请求);季名"第 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;
|
||||
// 缺简介只在剩下的语言里还有可取简介的语言时才值得补拉
|
||||
const missing = (rest: readonly string[]) => {
|
||||
const ov = rest.some((l) => textChain.includes(l));
|
||||
return eps.some((e) => aired(e) && (!e.name || (ov && !e.overview))) || (ov && !seasonOverview && eps.some(aired));
|
||||
};
|
||||
for (const [i, lang] of chain.entries()) {
|
||||
if (i === 0) continue;
|
||||
if (!eps.length || !missing(chain.slice(i))) break;
|
||||
const textOk = textChain.includes(lang);
|
||||
let k: Json;
|
||||
try {
|
||||
k = await client.getJson<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: lang });
|
||||
@@ -296,12 +314,12 @@ export async function fetchRichSeason(
|
||||
e.name = other.name;
|
||||
e.nameLanguage = lang;
|
||||
}
|
||||
if (!e.overview && other.overview) {
|
||||
if (textOk && !e.overview && other.overview) {
|
||||
e.overview = other.overview;
|
||||
e.overviewLanguage = lang;
|
||||
}
|
||||
}
|
||||
if (!seasonOverview && s(k.overview)) {
|
||||
if (textOk && !seasonOverview && s(k.overview)) {
|
||||
seasonOverview = s(k.overview);
|
||||
seasonOverviewLang = lang;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,26 @@ export function pickChineseTitle(chain: readonly string[], src: TitleSources): P
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值) */
|
||||
/** 语言标签是否中文(zh / zh-CN / zh-TW …) */
|
||||
export const isChineseTag = (tag: string): boolean => splitTag(tag).lang === "zh";
|
||||
|
||||
/**
|
||||
* MD-2 简介 / 标语用的语言链:缺省只回退到中文变体,不回退到非中文(TMDB 上不少剧集没有中文简介,
|
||||
* 英文"简介"常是标题英译,中文界面里显得不对)。规则:
|
||||
* - allowNonChinese = true(设置 metadata.overviewNonChineseFallback):原链不变;
|
||||
* - 链里没有中文:原链不变(用户本来就选了非中文);
|
||||
* - 否则:第一个中文之前的语言(用户排在中文前面、是首选而不是回退)+ 链中的全部中文,之后的非中文去掉。
|
||||
* 缺省链 zh-CN → zh-SG → zh-TW → zh-HK → en-US 得到 zh-CN → zh-SG → zh-TW → zh-HK。
|
||||
* 标题 / 别名不受影响,仍按完整链回退。MP 对照:tmdbapi 每集信息只按配置语言取,简介为空就为空。
|
||||
*/
|
||||
export function overviewLanguages(chain: readonly string[], allowNonChinese = false): string[] {
|
||||
if (allowNonChinese) return [...chain];
|
||||
const firstZh = chain.findIndex(isChineseTag);
|
||||
if (firstZh < 0) return [...chain];
|
||||
return chain.filter((t, i) => i < firstZh || isChineseTag(t));
|
||||
}
|
||||
|
||||
/** 简介 / 标语:按链取 translations 的非空值;全部落空时用 primary(请求语言下的原值,等于链外语言译文时不用) */
|
||||
export function pickText(
|
||||
chain: readonly string[],
|
||||
translations: readonly TmdbTranslation[],
|
||||
@@ -158,7 +177,9 @@ export function pickText(
|
||||
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" };
|
||||
// 请求语言下的原值若正是链外某语言的译文(TMDB / 反代把缺译名的字段回退成了别的语言),不当作链内的值
|
||||
const foreign = translations.some((t) => t[key] === primary?.value && !chain.some((tag) => tagMatches(tag, t)));
|
||||
if (primary?.value && !foreign) return { value: primary.value, language: primary.language, via: "primary" };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 每集简介差分(MD-2):MP `TmdbApi.get_tv_season_detail`(e6820a8,app/modules/themoviedb/tmdbapi.py)只按配置语言
|
||||
* (TMDB_LOCALE = zh)请求季详情,episodes[].overview 原样使用,为空就为空;本项目 `fetchRichSeason` 按语言链补拉。
|
||||
* 响应按《心动的信号》(TMDB tv/103147)各语言季详情的真实形态裁剪(每期标题有 zh-CN,简介只有 en-US)。
|
||||
* 期望:简介与 MP 一致(中文变体都没有 → 空);标题允许按链回退(登记偏离 intentional:MD-1 占位集名按链补标题)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fetchRichSeason } from "../../src/tmdb/index.ts";
|
||||
|
||||
type Ep = { episode_number: number; air_date: string; name: string; overview: string };
|
||||
const season = (episodes: Ep[]) => ({
|
||||
season_number: 1,
|
||||
name: "第 1 季",
|
||||
overview: "",
|
||||
air_date: "2018-08-01",
|
||||
episodes,
|
||||
});
|
||||
|
||||
/** 各语言的 /tv/103147/season/1 响应(裁剪) */
|
||||
const RECORDED: Record<string, ReturnType<typeof season>> = {
|
||||
"zh-CN": season([
|
||||
{ episode_number: 1, air_date: "2018-08-01", name: "心动不如行动,大胆亮出底牌!", overview: "" },
|
||||
{ episode_number: 2, air_date: "2018-08-08", name: "第 2 集", overview: "" },
|
||||
]),
|
||||
"zh-SG": season([
|
||||
{ episode_number: 1, air_date: "2018-08-01", name: "第 1 集", overview: "" },
|
||||
{ episode_number: 2, air_date: "2018-08-08", name: "第 2 集", overview: "" },
|
||||
]),
|
||||
"zh-TW": season([
|
||||
{ episode_number: 1, air_date: "2018-08-01", name: "第 1 集", overview: "" },
|
||||
{ episode_number: 2, air_date: "2018-08-08", name: "第 2 集", overview: "" },
|
||||
]),
|
||||
"zh-HK": season([
|
||||
{ episode_number: 1, air_date: "2018-08-01", name: "第 1 集", overview: "" },
|
||||
{ episode_number: 2, air_date: "2018-08-08", name: "第 2 集", overview: "" },
|
||||
]),
|
||||
"en-US": season([
|
||||
{
|
||||
episode_number: 1,
|
||||
air_date: "2018-08-01",
|
||||
name: "Episode 1",
|
||||
overview: "If Your Heart Skips a Beat, Play Your Cards Openly!",
|
||||
},
|
||||
{ episode_number: 2, air_date: "2018-08-08", name: "Who Is the One?", overview: "Who Is the One?" },
|
||||
]),
|
||||
};
|
||||
|
||||
const client = {
|
||||
getJson: <T>(_path: string, params: Record<string, string | undefined> = {}) =>
|
||||
Promise.resolve(RECORDED[params.language ?? "en-US"] as T),
|
||||
};
|
||||
|
||||
/** MP 侧:只取配置语言(zh-CN)的季详情,集名 / 简介原样(空串 = 没有) */
|
||||
function mpEpisodes(lang = "zh-CN") {
|
||||
return RECORDED[lang]!.episodes.map((e) => ({ ep: e.episode_number, name: e.name, overview: e.overview || null }));
|
||||
}
|
||||
|
||||
const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
|
||||
const DEVIATIONS: Record<string, { kind: "intentional"; note: string }> = {
|
||||
"E2.name": { kind: "intentional", note: "MD-1:zh-CN 是占位名「第 2 集」时按链补标题(可到 en-US)" },
|
||||
};
|
||||
|
||||
describe("每集简介差分(MP get_tv_season_detail ↔ fetchRichSeason)", () => {
|
||||
it("简介与 MP 一致:中文变体都没有时为空,不回退英文;标题偏离已登记", async () => {
|
||||
const ours = await fetchRichSeason(client, 103147, 1, CHAIN, { now: new Date("2026-01-01") });
|
||||
const mismatches: string[] = [];
|
||||
for (const mp of mpEpisodes()) {
|
||||
const e = ours.episodes.find((x) => x.episodeNumber === mp.ep)!;
|
||||
if (e.overview !== mp.overview) mismatches.push(`E${mp.ep}.overview`);
|
||||
if (e.name !== mp.name) mismatches.push(`E${mp.ep}.name`);
|
||||
}
|
||||
expect(mismatches.filter((m) => !DEVIATIONS[m])).toEqual([]);
|
||||
expect(Object.keys(DEVIATIONS).every((k) => mismatches.includes(k))).toBe(true);
|
||||
expect(ours.episodes.map((e) => e.overview)).toEqual([null, null]);
|
||||
});
|
||||
});
|
||||
@@ -246,6 +246,11 @@ export const MetadataSettings = z.object({
|
||||
.min(1)
|
||||
.max(8)
|
||||
.default(["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]),
|
||||
/**
|
||||
* MD-2:简介 / 标语是否允许回退到非中文。缺省 false = 只按链里的中文变体(及排在第一个中文之前的语言)取,
|
||||
* 取不到留空;标题不受影响。链里没有中文时不起作用
|
||||
*/
|
||||
overviewNonChineseFallback: z.boolean().default(false),
|
||||
/**
|
||||
* DH-1 季 0(特别篇):true = full 刷新与元数据回刷在 TMDB 有季 0 时一并拉取每集信息并建集单元(对照 MP);
|
||||
* 季 0 不计入正片季总集数与订阅完成判定
|
||||
@@ -649,6 +654,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
tmdbApiKeySecretRef: null,
|
||||
tmdbBaseUrl: null,
|
||||
languages: ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"],
|
||||
overviewNonChineseFallback: false,
|
||||
includeSpecials: true,
|
||||
},
|
||||
sites: {
|
||||
@@ -954,6 +960,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
"metadata.tmdbApiKeySecretRef",
|
||||
"metadata.tmdbBaseUrl",
|
||||
"metadata.languages",
|
||||
"metadata.overviewNonChineseFallback",
|
||||
"metadata.includeSpecials",
|
||||
"cache.tmdbMaxSize",
|
||||
"identity.eagerTmdbLookup",
|
||||
|
||||
@@ -328,3 +328,49 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* MD-2:《心动的信号》(TMDB tv/103147 的真实形态):作品与每期标题有 zh-CN,简介只有 en-US(实为标题英译)。
|
||||
* 不在 TMDB_FIXTURES 里,用到的测试自行拼进 fixtures。
|
||||
*/
|
||||
export const HEART_SIGNAL_FIXTURE: TmdbFixture = {
|
||||
type: "tv",
|
||||
id: 103147,
|
||||
original_title: "心动的信号",
|
||||
original_language: "zh",
|
||||
origin_country: ["CN"],
|
||||
date: "2018-08-01",
|
||||
titles: { "zh-CN": "心动的信号", "en-US": "Heart Signal" },
|
||||
overviews: { "en-US": "A Chinese dating reality show." },
|
||||
popularity: 6.2,
|
||||
alternative_titles: [],
|
||||
external_ids: { imdb_id: null, tvdb_id: null },
|
||||
rich: { genres: ["真人秀"], status: "Returning Series" },
|
||||
seasons: [
|
||||
{
|
||||
season_number: 1,
|
||||
air_date: "2018-08-01",
|
||||
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
|
||||
episodes: [
|
||||
{
|
||||
episode_number: 1,
|
||||
air_date: "2018-08-01",
|
||||
names: { "zh-CN": "心动不如行动,大胆亮出底牌!", "en-US": "Episode 1" },
|
||||
overviews: { "en-US": "If Your Heart Skips a Beat, Play Your Cards Openly!" },
|
||||
},
|
||||
{
|
||||
episode_number: 2,
|
||||
air_date: "2018-08-08",
|
||||
names: { "zh-CN": "第 2 集", "en-US": "Who Is the One?" },
|
||||
overviews: { "en-US": "Who Is the One?" },
|
||||
},
|
||||
{
|
||||
episode_number: 3,
|
||||
air_date: "2018-08-15",
|
||||
names: { "zh-CN": "约会之夜", "en-US": "Date Night" },
|
||||
overviews: { "zh-TW": "約會之夜。", "en-US": "Date Night." },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user