diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index 441d8f6a..7b02c6ff 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -33,6 +33,7 @@ import { clearTmdbMetadata } from "./metadata.ts"; import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts"; import { recordAttempt } from "./resolve.ts"; import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts"; +import { mergeDuplicateUnits } from "./unit-merge.ts"; // 人工纠错:指定来源资源身份(加锁)、改作品外部映射(新增修订、旧映射退役)、合并作品(旧 ID 跳转)。 // 所有变更留 identity_revision / resolution_attempt / audit_event,不直接覆盖历史。 @@ -314,6 +315,15 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in .execute(); for (const o of derived) await retireIdentity(trx, o, actor, "tmdb_mapping_changed"); await clearTmdbMetadata(trx, id, rt.now()); + // 旧 TMDB 条目带来的别名(没锁的)删除:留着会让旧作品的同名资源继续本地召回到这里;人工 / 站点别名保留, + // 刷新后按新详情重新登记 tmdb 别名 + await trx + .deleteFrom("title_alias") + .where("media_entity_id", "=", id) + .where("content_unit_id", "is", null) + .where("source", "=", "tmdb") + .where("locked", "=", false) + .execute(); } await audit( trx, @@ -414,7 +424,7 @@ export async function updateExternalIdentity( /** * 合并重复作品:from → into。旧 ID 保留(status=merged + merged_into_id)并写 identity_redirect; - * 本模块的引用(来源资源、别名、待识别池、外部身份)迁到目标;其它模块的表按 merged_into_id 跳转解析。 + * 本模块的引用(来源资源、别名、待识别池、外部身份、内容单元)迁到目标;其它模块的表按 merged_into_id 跳转解析。 */ export async function mergeEntities( rt: SourcesRuntime, @@ -483,6 +493,35 @@ export async function mergeEntities( select ${intoResolved}, null, alias, normalized, language, kind, source, locked from title_alias where media_entity_id = ${fromId} and content_unit_id is null on conflict (media_entity_id, content_unit_id, normalized, kind) do nothing`.execute(trx); + // 内容单元随作品迁到目标(M3):单元改归目标作品;与目标已有单元同季同集的 + // 按重复单元合并(所有引用改指向保留的那条,见 unit-merge.ts)。目标是电影时源作品的单元退役 + const into = await trx + .selectFrom("media_entity") + .select(["kind"]) + .where("id", "=", intoResolved) + .executeTakeFirstOrThrow(); + if (into.kind === "movie") { + await trx.updateTable("content_unit").set({ status: "retired" }).where("media_entity_id", "=", fromId).execute(); + } else { + const moved = ( + await trx + .updateTable("content_unit") + .set({ media_entity_id: intoResolved }) + .where("media_entity_id", "=", fromId) + .returning(["id"]) + .execute() + ).map((u) => u.id); + if (moved.length) { + // 单元级元数据 / 覆盖只挂 content_unit_id,随单元走;单元级别名还带作品 ID,一并改 + await trx + .updateTable("title_alias") + .set({ media_entity_id: intoResolved }) + .where("media_entity_id", "=", fromId) + .where("content_unit_id", "in", moved) + .execute(); + await mergeDuplicateUnits(trx, intoResolved); + } + } await trx .updateTable("source_release") .set({ media_entity_id: intoResolved })