feat(identity): 改 TMDB 映射后清旧字段、full 刷新并自动更新重刮

- setEntityIdentity:TMDB 映射变化时同事务清掉作品及季 / 集单元的 tmdb 来源字段(人工覆盖 / 锁定保留,
  海报背景图不再保留旧作品的),退役没锁的 IMDb 映射,提交后排 full 刷新
- refreshMetadata:NFO 相关字段(作品或季 / 集)有变化时调 scheduleEntityRescrape,评分票数变化不触发

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 21:04:57 +08:00
1 parent dbbf39eda4
commit 37cf0a398e
4 files changed
+340 -3

No files matched your search

@@ -29,6 +29,7 @@ import {
settleEpisodeDecision,
} from "./episode-resolution.ts";
import { detachFromIdentityDecisions } from "./identity-decisions.ts";
import { clearTmdbMetadata } from "./metadata.ts";
import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
import { recordAttempt } from "./resolve.ts";
import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts";
@@ -271,10 +272,12 @@ async function retireIdentity(exec: Exec, row: ExternalIdentityRow, actor: Actor
/**
* 改作品的外部映射:同 provider+类型的旧 accepted 映射退役(留修订并锁定,防止自动匹配改回),
* 新映射 accepted + 人工锁。目标身份已被其它作品接受时 409(应走合并)。
* TMDB 映射变了:同一事务里清掉该作品(及季 / 集单元)tmdb 来源的元数据(人工覆盖 / 锁定保留),提交后排一次
* full 刷新;刷新写回新身份的字段后,刮削过的作品自动排「更新」模式重刮(refreshMetadata → scheduleEntityRescrape)。
*/
export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, input: EntityIdentitySet, actor: Actor) {
const { ctx } = rt;
return ctx.db.transaction().execute(async (trx) => {
const r = await ctx.db.transaction().execute(async (trx) => {
const id = await resolveEntityId(trx, entityId);
if (!id) throw new ApiException("not_found", "作品不存在");
const other = await entityByExternal(trx, input);
@@ -297,6 +300,21 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
reason: input.reason ?? null,
});
if (!newId) throw new ApiException("conflict", "外部身份唯一约束冲突");
if (input.provider === "tmdb") {
// 旧 TMDB 带出来的 IMDb 映射(没人工锁的)一并退役:NFO 里 IMDb 是 default uniqueid,留着会被认成旧作品;
// 刷新后 NFO 取新详情里的 IMDb
const derived = await trx
.selectFrom("external_identity")
.selectAll()
.where("media_entity_id", "=", id)
.where("content_unit_id", "is", null)
.where("provider", "=", "imdb")
.where("status", "=", "accepted")
.where("locked", "=", false)
.execute();
for (const o of derived) await retireIdentity(trx, o, actor, "tmdb_mapping_changed");
await clearTmdbMetadata(trx, id, rt.now());
}
await audit(
trx,
actor,
@@ -310,6 +328,20 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
);
return { mediaEntityId: id, changed: true, identityId: newId, retired: olds.map((o) => o.id) };
});
if (r.changed && input.provider === "tmdb" && r.identityId)
await ctx.engine
.enqueue(
"refreshMetadata",
{ mediaEntityId: r.mediaEntityId, full: true },
{ idempotencyKey: `refreshMetadata:identity:${r.identityId}`, traceId: r.mediaEntityId },
)
.catch((err: unknown) =>
ctx.logger.warn(
{ entityId: r.mediaEntityId, err: { message: (err as Error).message } },
"改映射后元数据刷新没排上",
),
);
return r;
}
/** 外部身份字段修订(乐观并发 expectedRevision);每次变更写 identity_revision */
@@ -315,8 +315,20 @@ export interface SeasonMetadataResult {
changedUnits: number;
/** DH-1:临时转正式的集单元数(季单元转正不计) */
promoted: number;
/** 写进 NFO 的字段(名称、简介、日期、时长、剧照、季海报)有变化的单元数;刷新后据此决定要不要自动重刮 */
nfoChangedUnits?: number;
}
/** 内容单元里写进 NFO 的字段(评分 / 票数变化不触发自动重刮) */
export const UNIT_NFO_FIELDS: ReadonlySet<string> = new Set([
"name",
"overview",
"airDate",
"runtime",
"stillPath",
"posterPath",
]);
/**
* 季详情 → 季单元与集单元的字段级元数据。集单元按(作品, 季, 集号)找最早建的未退役单元,没有时:
* create = true(full 刷新 / 追踪中的季)建 confirmed 单元;否则只更新已有单元(集单元仍由发布 / 文件懒建)。
@@ -331,7 +343,14 @@ export async function applySeasonMetadata(
opts: { create?: boolean } = {},
): Promise<SeasonMetadataResult> {
const create = opts.create ?? true;
const out: SeasonMetadataResult = { season: sd.seasonNumber, episodes: 0, created: 0, changedUnits: 0, promoted: 0 };
const out: SeasonMetadataResult = {
season: sd.seasonNumber,
episodes: 0,
created: 0,
changedUnits: 0,
promoted: 0,
nfoChangedUnits: 0,
};
if (sd.seasonNumber < 0) return out;
const seasonUnit = await ensureSeason(exec, entityId, sd.seasonNumber, sd.name);
const units = await exec
@@ -399,6 +418,7 @@ export async function applySeasonMetadata(
if (written.length || t.fresh) {
const r = await projectUnit(exec, t.unitId, now);
if (r.changedFields.length) out.changedUnits++;
if (r.changedFields.some((f) => UNIT_NFO_FIELDS.has(f))) out.nfoChangedUnits = (out.nfoChangedUnits ?? 0) + 1;
}
}
out.episodes = eps.length;
@@ -17,6 +17,7 @@ import type {
} from "@umt/contracts";
import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
import { scheduleEntityRescrape } from "../../acquisition-organize/services/scrape.ts";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, type Exec } from "./common.ts";
@@ -118,6 +119,102 @@ export async function writeEnrichmentFields(
}
}
/** 作品里写进 NFO 的字段:刷新后这些字段有变化才自动重刮(评分、票数、播出状态等不算) */
const ENTITY_NFO_FIELDS: ReadonlySet<string> = new Set([
"title",
"originalTitle",
"year",
"overview",
"tagline",
"releaseDate",
"genres",
"originCountry",
"platform",
"runtime",
"cast",
"crew",
"externalIds",
"posterPath",
"backdropPath",
]);
/**
* 改作品的 TMDB 映射后:清掉该作品及其季 / 集单元的 tmdb 来源字段(metadata_field 与投影出的最终值),
* 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空。之后由 full 刷新按新身份重新填,
* 避免新身份的 NFO 混进旧作品的标题、简介、IMDb、海报(海报 / 背景图「TMDB 没有时保留旧值」不能保留旧作品的)。
*/
export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date): Promise<void> {
const keep = (meta: Record<string, FinalFieldValue>, locks: Set<string>, overridden: Set<string>) => {
const out: Record<string, FinalFieldValue> = {};
for (const [k, v] of Object.entries(meta))
if (v?.source !== "tmdb" || locks.has(k) || overridden.has(k) || v.locked) out[k] = v;
return out;
};
const e = await trx
.selectFrom("media_entity")
.select(["metadata", "field_locks"])
.where("id", "=", entityId)
.forUpdate()
.executeTakeFirstOrThrow();
const entityOverrides = await trx
.selectFrom("field_override")
.select("field")
.where("media_entity_id", "=", entityId)
.execute();
await trx
.deleteFrom("metadata_field")
.where("media_entity_id", "=", entityId)
.where("content_unit_id", "is", null)
.where("source", "=", "tmdb")
.execute();
await trx
.updateTable("media_entity")
.set({
metadata: jsonb(
keep(
(e.metadata ?? {}) as Record<string, FinalFieldValue>,
new Set(e.field_locks),
new Set(entityOverrides.map((o) => o.field)),
),
),
updated_at: now,
})
.where("id", "=", entityId)
.execute();
// 其它来源(web / agent 补全)按原规则重新投影
await project(trx, entityId, now);
const units = await trx
.selectFrom("content_unit")
.select(["id", "metadata", "field_locks"])
.where("media_entity_id", "=", entityId)
.execute();
if (!units.length) return;
const ids = units.map((u) => u.id);
const unitOverrides = await trx
.selectFrom("field_override")
.select(["content_unit_id", "field"])
.where("content_unit_id", "in", ids)
.execute();
await trx.deleteFrom("metadata_field").where("content_unit_id", "in", ids).where("source", "=", "tmdb").execute();
for (const u of units) {
const meta = (u.metadata ?? {}) as Record<string, FinalFieldValue>;
const overridden = new Set(unitOverrides.filter((o) => o.content_unit_id === u.id).map((o) => o.field));
const next = keep(meta, new Set(u.field_locks), overridden);
if (Object.keys(next).length === Object.keys(meta).length) continue;
await trx
.updateTable("content_unit")
.set({
metadata: jsonb(next),
...(next.name ? {} : { title: null }),
...(next.airDate ? {} : { air_date: null }),
updated_at: now,
})
.where("id", "=", u.id)
.execute();
}
}
export interface RefreshResult {
status: "refreshed" | "skipped" | "failed";
reason?: string;
@@ -240,7 +337,7 @@ export async function refreshMetadata(
}
const now = rt.now();
const rev = `tmdb:${ident.entity_type}/${ident.external_id}`;
return ctx.db.transaction().execute(async (trx) => {
const result = await ctx.db.transaction().execute(async (trx) => {
if (richDetail) await writeRichEntityFields(trx, id, richDetail, rev, now);
else await writeTmdbFields(trx, id, detail, rev, now);
await trx
@@ -282,6 +379,15 @@ export async function refreshMetadata(
: {}),
};
});
// 写进 NFO 的字段有变化:刮削过的作品延后排一次「更新」模式重刮(期间再刷新合进同一条);失败只记日志
const nfoChanged =
result.changedFields.some((f) => ENTITY_NFO_FIELDS.has(f)) ||
(result.episodes ?? []).some((x) => (x.nfoChangedUnits ?? 0) > 0);
if (nfoChanged)
await scheduleEntityRescrape({ ctx }, id).catch((err: unknown) =>
ctx.logger.warn({ entityId: id, err: { message: (err as Error).message } }, "元数据刷新后重刮没排上"),
);
return result;
}
/**