feat(scripts): identity-reevaluate with --rollback-wrong; docs for ID-2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:41:22 +08:00
1 parent 03de030371
commit 42e08b30dc
4 files changed
+378 -1

No files matched your search

+7
View File
@@ -4,6 +4,13 @@
## [Unreleased]
### 修复(ID-2 身份匹配对照 MoviePilot)
- 憨憨 RSS 标题的分类前缀「[电视剧]」「[动漫]」「[音乐]」被当成中文名,经别名召回把多部剧的资源并入同一部作品;分类词、单字、停用词不再作为片名或别名参与比对,自动绑定也不再登记它们。存量用 `scripts/identity-reevaluate.ts --rollback-wrong` 撤销(缺省只读)。
- 副标题与 RSS 内嵌描述里的中文名、别名参与匹配;中英名任一精确 + 年份精确即高证据,续作 / 章节序号与季号参与区分,同级多候选按年份精确、命中的名称文字种数取舍;评分权重成表可覆盖。
- `Travis - 12 Memories` 不再被当成第 12 集。
- 站点资源标题更新后按新标题重新解析并重新识别,不能确认时撤销原自动绑定。
- 憨憨真实标题差分集(113 条)对照 MP:修前一致 88.5%,修后 100%(含 1 条 MP 误识别),见 `docs/porting/moviepilot-identity-match.md`。
### 已知限制
需求逐条覆盖见 `docs/plan/11-requirements-coverage.md`:当前相当于 REQ 的 M0 大部分 + M1(仅 mock 环境)+ M2 约三分之一。
+107
View File
@@ -0,0 +1,107 @@
# MoviePilot 身份匹配(TMDB 识别)对照与差分(ID-2)
> 只记录上游行为与本项目的取舍,不复制上游源码。上游快照与许可见 `moviepilot-metadata-recognition.md` §0(`e6820a8`,GPLv3)。
> 标题解析(MetaInfo ↔ parseRelease)的字段级差分见 `moviepilot-metadata-recognition.md` 与 `packages/domain/test/differential/meta-*`;本文只管"解析结果 → 哪个 TMDB 条目"。
## 1. 结论
| 项 | 值 |
| --- | --- |
| 差分集 | 憨憨真实标题 113 条(50 部作品):主标题 + 副标题 77 条,RSS「`[分类]英文名 … -GRP[中文名 / … \| 第N集 \| …][体积]`」21 条,短剧 15 条;同一标题只留一条 |
| MP 侧 | 99 条命中 TMDB,14 条无结果(短剧) |
| 修前(`c3aaf90`) | 100 / 113 与 MP 一致(88.5%);13 条不一致全是我们漏:RSS 分类前缀被当成中文名(武神主宰 / 三角洲 / 掌门低调点 6 条)、副标题中文名不参与(档案、猪猪侠 5 条)、中英名各命中一部(A Prophet)、英文名只在 TMDB 别名里而旧链路不取详情(China Now) |
| 修后 | 112 / 113 一致;剩 1 条是 MP 误识别(短剧《人中之龙第一季》被 MP 按译名配到《如龙》2024),我们不自动绑定。非故意偏离项一致率 100%(MP 缺陷且我们正确的计为一致) |
| 真机误识别回归 | 用户栈里被「电视剧」别名并入《东北有个周东北》的资源(32 条标题 / 副标题组合,含只有英文名的变体)全部识别回各自作品或不并入 |
| TMDB 请求 | 上行 191 次(预算 200),全部经本地缓存代理;测试离线回放 `tmdb-fixtures.json` |
## 2. MP 的识别链(按实际调用顺序)
1. `MetaInfo(title, subtitle)`:副标题**只取季集**(`init_subtitle`),不取片名。RSS 标题里内嵌的描述方括号整体丢弃;开头的分类方括号 `[动漫]` 被 `_prepare_meta_input` 去掉。所以憨憨标题在 MP 里只有英文名。
2. `TheMovieDbModule._prepare_search_names`:`[cn_name, 简体 cn_name, en_name]` 去重,逐个名称识别,首个命中即停。
3. `_build_match_plan`(`app/modules/themoviedb/__init__.py`):
- 剧集:`match(name, year, tv, season_year=year, season_number)`,再退到无年份;
- 其他:电影 + 年份 → 剧集 + 年份 → `match_multi`(无类型无年份时直接 multi)。
4. `TmdbApi.match`(`tmdbapi.py`):季年份步骤优先(`__search_tv_by_season`:首播年 = 季年份,或详情里该季 air_date 年份 = 季年份),再按年份 `[y, y+1, y-1]` 逐个搜;每次搜索结果按日期降序、按年份过滤后,**标题 / 原标题规范化后完全相等**即命中;否则**逐个拉详情**用别名 + 各语言译名(`__get_names`)比对,首个相等即命中。
5. `match_multi`:电影在前、日期降序,同样先标题后别名。
特点:没有"证据强弱",第一个名称相等的候选就是答案;中英名只按顺序尝试,不会互相佐证;通用词(`Archives`)与续作序号没有特别处理,靠年份过滤和"第一个"兜底。
## 3. 我们的链路(修后)
`parseRelease` → `recallTmdbCandidates`(`packages/adapters/src/tmdb/recall.ts`)→ `tmdbToCandidate`(`sources-identity/service/tmdb-candidate.ts`)→ `resolveIdentity`(`packages/domain/src/identity/match.ts`)。DEC-1 的触发(何时查 TMDB)与池 / 合并事项逻辑没改。
| 环节 | MP | 我们 | 性质 |
| --- | --- | --- | --- |
| 分类前缀 `[电视剧]` `[音乐]` … | 去掉 | 去掉,记为 `parsed.category`(词表:电影 / 电视剧 / 动漫 / 综艺 / 纪录片 / 音乐 / 短剧 / 体育 / 游戏 / 软件 / 学习 / 其他) | 修复(修前当成中文名) |
| RSS 内嵌描述 `-GRP[中文名 / … \| 第N集 \| …][体积]` | 丢弃 | 拆出来当副标题用(补中文名、别名、季集) | 增强 |
| 副标题片名 | 不取 | 第一段按 `/` 拆:主标题无中文名时第一个中文名作 `titleCn`(去季号后缀),其余进 `altTitles`;字幕 / 音轨 / 简介字段不算 | 增强(元数据差分登记为 intentional) |
| `Travis - 12 Memories` | — | `- 12` 后紧跟普通单词时不是集号 | 修复 |
| 搜索名称 | cn → 简体 cn → en,逐个短路 | cn → en,两个都搜,候选合成一个池;通用词(分类词 / 单字 / 停用词)不搜 | 故意偏离:收集后打分,同名多候选进池而不是取第一个 |
| 搜索语言 | `TMDB_LOCALE` 单语言 | 首选语言单语言(`searchRaw`) | 对齐(修前是 zh-CN + en-US 各搜一遍) |
| 类型 | 剧集线索只搜 tv;否则 movie → tv → multi | 剧集线索只搜 tv;否则 movie + tv | 对齐 |
| 别名回退 | 标题对不上时对**全部**候选逐个拉详情 | 年份相容的候选按年份相近度取前 3 个拉详情,直接命中的前 2 个也拉(看另一种文字的名称能否佐证);详情失败保留搜索结果 | 故意偏离:限次 |
| 标题 vs 别名 | 同等对待 | 主标题 / 原标题 / 其他语言本地化标题 = exact;alternative_titles + translations + 副标题别名 = alias | 故意偏离:别名证据弱一级 |
| 年份 | 年份过滤 `[y, y+1, y-1]`,季年份优先 | 精确 / 季首播年 / ±1 / 未知 / 不符(不符直接拒绝) | 对齐 |
| 续作 / 章节 | 无 | `Chapter III`、`Part Two`、`第3章`、`Ⅲ` 解析成序号,候选名里有序号且不一致 → 拒绝,一致 → 加分 | 增强 |
| 季号 | 只用于季年份 | 明写的季号不在候选(取了详情)的季列表里 → 扣分 | 增强 |
| 通用词 | 无 | 单个英文词只靠别名命中、且池里 ≥3 个候选名称含该词(`Archives`)→ 封顶待确认;中文名命中则不受影响 | 增强 |
| 别名佐证 | 无 | 只靠别名命中时需要年份相容或第二种文字的名称也命中,否则封顶待确认 | 增强(`人中之龙` → 《如龙》即此类) |
| 多候选 | 取第一个 | 同等级时:年份精确优先 → 命中的名称文字种数(中文 / 拉丁)→ 分值;严格占优才自动确认,完全对称进池 | 故意偏离 |
### 3.1 评分权重表(`DEFAULT_MATCH_WEIGHTS`,可由 `MatchOptions.weights` 覆盖)
| 证据 | 分值 |
| --- | --- |
| 标题 exact / alias / partial / none | 4 / 3 / 1 / 0 |
| 年份 exact / season_year / ±1 / 未知 | 3 / 3 / 2 / 0(不符直接拒绝) |
| 年份未知但明写季号且类型一致 | +2 |
| 第二种文字的名称也命中 | +1 |
| 续作序号一致 | +1(冲突直接拒绝) |
| 明写季号不在候选季列表 | −3 |
| 定级阈值 | ≥7 rule_confirmed,≥5 multi_evidence,≥1 needs_confirm |
硬规则优先于分值:年份不符 / 续作冲突 / 标题无关 → 拒绝;部分包含、通用词只靠别名、别名缺佐证、类型冲突 → 封顶 needs_confirm;显式 ID 提升一级。默认值下 v0.1 的判定不变(`identity.test.ts` 原样通过)。分值不是概率,只用于定级和同级排序。
## 4. 差分方法
- 样本:`packages/domain/test/differential/identity/samples.json`,2026-09-29 从用户栈 `source_release`(憨憨)只读导出,同标题去重;非短剧全收(98 条 / 35 部),短剧取前 15 部。
- MP 侧:`gen-identity-mp.py`(MP 仓库 `.venv`,`MetaInfo` 未注入 Rust 加速器,走 Python 参考实现;名称识别直接调 `TheMovieDbModule._prepare_search_names` + `_search_by_name`,不含识别缓存、插件辅助识别与详情补全),结果 `mp.json`。
- 我们侧:`apps/server/test/sources-identity/identity-differential.lib.ts`(与生产同一组函数),`identity-differential.run.ts` 录制,结果 `ours.json`。
- TMDB:两侧都经本地缓存代理(会话临时目录,不入库):同一请求只上行一次;搜索结果集与 `language` 无关,所以 zh-CN 为空时其他语言直接判空;带年份的搜索先取无年份结果,总数 ≤20 时按日期年份本地过滤;上行计数封顶。上行 191 次后改为离线,离线未命中的 13 个详情请求按"取不到详情"处理(录进 fixture 为 404,回放同样走这条分支)。
- 修前基线:在 `c3aaf90` 的工作树里用旧链路(`searchAll` 双语言、旧打分)跑同一批样本。基线需要的 en-US 搜索由代理从 zh-CN 结果 + 已缓存详情的英文译名合成,48 个未缓存请求按空结果近似——基线数字是近似值,修后数字是实测回放。
- 归因:`deviations.json`(`mp_defect` / `intentional` / `known_gap`);测试要求未登记的样本全部与 MP 一致。
- 回归测试:`identity-differential.test.ts`(unit 组,不连 PG、不连 TMDB),含 brief 三类案例与真机误识别场景(`poisoned-local.json`)。
重新录制:
```sh
# 1. 起缓存代理(TMDB key 只从 .local/admin-credentials 读),见会话记录;然后
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. TMDB_PROXY=http://127.0.0.1:18731 \
.venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/identity/gen-identity-mp.py
TMDB_PROXY=http://127.0.0.1:18731 deno run -A apps/server/test/sources-identity/identity-differential.run.ts
```
## 5. brief 三类案例
| 案例 | 修前 | 修后 |
| --- | --- | --- |
| `[动漫]Martial Master S01 2020 …[武神主宰 / … ]`(3 条) | 分类词「动漫」当中文名,英文名在 zh-CN 结果里对不上 → 无匹配 | 中文名 武神主宰 精确 + 2020 精确 → rule_confirmed,2023 的同名剧年份不符被拒 → 自动确认 tv:110181 |
| `Mononoke The Movie Chapter III … 2026` | 生产里判歧义 | 候选 1322562 的英文译名精确 + 年份精确 + 第 3 章序号一致 → 自动确认;第 1 / 2 章若在池里按序号冲突拒绝 |
| `Archives S01 2009`(副标题 `档案 / 档案解密`) | 只有英文名,部分包含 → 待确认 | 中文名 档案 精确 + 2009 → 自动确认 tv:201401;没有副标题时 `Archives` 只靠别名且池里撞名 → 待确认 |
## 6. 真机误识别(2026-09-29,用户栈)
憨憨 RSS 标题带分类前缀,修前解析器把「电视剧」当中文名;第一条这样的资源经本地别名命中《东北有个周东北》后,`bindAuto` 把「电视剧」和各资源的英文名(`Irreplaceable`、`Spark of the Long March`、`A Prophet`、`Spring of the Blade`)都登记成站点别名,后续资源再经这些别名以"别名 + 年份精确"自动并入,链式扩散。修复:
1. 解析:分类前缀识别为 `category`,不进片名。
2. 比对:通用词(分类词 / 单字 / ≤2 个拉丁字符 / 纯数字 / 停用词)不作为查询名,也不作为候选别名参与比对;只靠别名命中需要年份相容或第二种文字的名称佐证。
3. 登记:自动绑定只在标题精确命中(或别名命中且有第二种文字佐证)时登记站点别名,通用词不登记。
4. 标题更新:快照 upsert 在标题 / 副标题变化时重算 `parsed` 并发 `release.observed(isNew=false)`;Feed 标题修正同样;路由对 `isNew=false` 带 `force` 重新识别(触发来源仍是 observed,DEC-1 不变),重新识别后不能确认的撤销原自动绑定。
5. 存量:`scripts/identity-reevaluate.ts --rollback-wrong`(见 `docs/runbook/identity.md`)。在用户库副本上演练:解除 23 条误并入、删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名),资源退回池并排入立即复查。
## 7. 已知差距
- 别名回退限次(前 3 + 直接命中前 2):MP 会把全部候选的详情拉一遍。候选多且目标排得靠后时,我们可能拿不到别名证据而进池(不会误绑)。
- 同名多候选我们进池,MP 取日期最新的一个:MP 在这类场景"答对"时算我们漏。本批样本未出现。
- 剧集组(episode group)季年份未迁移(同 `match.ts` 头注释)。
- 副标题片名只取第一段;第一段是简介句子的站点(非憨憨格式)会被 `cnTitleFromSubtitle` 的长度 / 空白 / 句读守卫挡掉,但守卫是启发式。
+28 -1
View File
@@ -22,7 +22,13 @@
| `new_title_rule` | 有已发布、开启 `newTitle` 且有有效持续授权的获取策略,资源类型在规则的 `mediaKinds` 内,标题年份(若有)不早于窗口能覆盖的年份 |
都不满足:只进池,`reason = unmatched`,不查 TMDB,不开待处理。
3. 查了 TMDB 的,走原来的 `domain.resolveIdentity`:auto 建档 / 关联并发 `release.resolved`;否则进池。
3. 查了 TMDB 的,召回 TMDB 候选(`recallTmdbCandidates`:中文名 → 英文名,按类型单语言搜索,标题对不上的按年份相近度拉前几个详情比对别名 / 译名),
交给 `domain.resolveIdentity` 打分:auto 建档 / 关联并发 `release.resolved`;否则进池。打分规则与 MP 的对照见
`docs/porting/moviepilot-identity-match.md`(ID-2)。
4. 自动绑定时把站点标题登记为作品别名,只在标题精确命中(或别名命中且另一种文字的名称也命中)时登记,分类词 / 单字 / 停用词不登记。
标题更新(RSS 标题与列表标题不同、站点改标题):按新标题重新解析,发 `release.observed(isNew=false)`,以 `force` 重新识别
(已识别的也不短路,触发来源仍是 observed);重新识别不能确认时撤销原自动绑定、进池。人工锁定的不动。
任务输入 `trigger` 缺省:`force` 为 true 视为 `review`(池复查),否则 `observed`。
搜索模块想让搜索结果直接识别,入队 `resolveObservation` 时传 `trigger: "search"`(目前搜索结果经 `release.observed` 进来,
@@ -75,6 +81,24 @@
需要重建镜像 / 重启 api + worker 才生效(迁移随启动执行)。
## 规则更新后的存量重评(ID-2,`scripts/identity-reevaluate.ts`)
匹配规则更新(新镜像已部署、api + worker 已重启)后,用这个脚本处理存量。缺省只读,加 `--apply` 才改库;DATABASE_URL 必须显式给出。
```sh
# 用户栈的 PG 不对宿主暴露端口:取容器 IP,密码取栈的 .env
PGIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' umt-postgres-1)
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-reevaluate.ts --rollback-wrong # 先看清单
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-reevaluate.ts --rollback-wrong --apply # 确认后执行
```
- 待识别事项:列出 open 的 `identity_ambiguous` 成员,按新解析规则给出预估(只用池里存的候选,不查 TMDB);`--apply` 把这些池条目排入立即复查,
由运行中的服务按新规则重新识别——能自动确认的绑定作品、建映射,并从合并事项摘除(组空即关闭)。
- `--rollback-wrong`:找站点来源的分类词 / 通用词别名(例如「电视剧」),对挂着它的作品用新解析 + 作品自身名称(主标题 / 原标题 / 非站点来源别名)
核对已绑定资源;对不上的解除绑定、退回池(`reason = no_match`,立即复查),删除该别名和由误并入资源登记的站点别名,各记一条
`resolution_attempt(outcome = manual, reason = rollback_wrong_alias)`。内容单元不删,只报告无资源引用的数量;引用了这些资源的其他表只报告。
- 2026-09-29 用户库副本演练:《东北有个周东北》29 条绑定里 23 条误并入,删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名)。
## 排查
```sql
@@ -86,6 +110,9 @@ select resolution_status, resolution_evidence->>'lookup' as lookup, resolution_e
-- 某个合并事项的成员
select p.source_release_id, r.title, p.reason from identity_candidate p join source_release r on r.id = p.source_release_id
where p.decision_id = '<decisionId>' and p.status = 'pending';
-- 站点来源的可疑别名(分类词等)
select e.title, a.alias, a.created_at from title_alias a join media_entity e on e.id = a.media_entity_id
where a.source = 'site' and a.normalized in ('电视剧', '动漫', '综艺', '电影', '纪录片', '音乐', '短剧');
-- 迁移搬走的事项
select count(*) from decision where resolution->>'resolution' = 'moved_to_pool';
```
+236
View File
@@ -0,0 +1,236 @@
// 身份识别重评(ID-2):匹配规则更新后,处理存量的待识别事项与错误映射。默认只读(dry-run),加 --apply 才改库。
//
// 用法(DATABASE_URL 必须显式给出,脚本不带默认库):
// DATABASE_URL=postgres://umt:***@<host>:5432/umt deno run -A scripts/identity-reevaluate.ts
// DATABASE_URL=… deno run -A scripts/identity-reevaluate.ts --rollback-wrong # 另查分类词别名造成的错误映射
// DATABASE_URL=… deno run -A scripts/identity-reevaluate.ts --rollback-wrong --apply # 真正执行
//
// 1. open 的 identity_ambiguous:按新解析规则重新解析每个成员资源并给出预估(只用池里存的候选,不查 TMDB);
// --apply 时把这些池条目的 next_review_at 置为现在、attempts 清零,由运行中的服务(已部署新规则)的池复查
// 重新识别——能自动确认的会绑定作品、建映射,并从合并事项里摘除(组空即关闭)。脚本自己不查 TMDB、不建作品。
// 2. --rollback-wrong:找出站点来源(source=site)且是分类词 / 停用词 / 单字的别名(例如 RSS 前缀「[电视剧]」被当成
// 中文名登记的「电视剧」),对挂着这类别名的作品,用新解析 + 作品自身名称(主标题 / 原标题 / 非站点来源别名)
// 重新核对已绑定的资源:对不上的视为经错误别名误并入 → --apply 时解除绑定、退回待识别池(立即复查),
// 删除该错误别名以及由误并入资源登记的站点别名(链式扩散的那些),各记一条 resolution_attempt(manual)。
// 内容单元(季 / 集)不删,只列出已无资源引用的单元。
import {
type IdentityCandidate,
isGenericAliasName,
normalizeTitle,
parseRelease,
queryTitles,
resolveIdentity,
scoreCandidate,
} from "@umt/domain";
import pg from "pg";
const args = new Set(Deno.args);
const APPLY = args.has("--apply");
const ROLLBACK = args.has("--rollback-wrong");
const url = Deno.env.get("DATABASE_URL");
if (!url) {
console.error("需要 DATABASE_URL(脚本不带默认库)");
Deno.exit(2);
}
const db = new pg.Client({ connectionString: url });
await db.connect();
const q = async <T>(text: string, values: unknown[] = []) => (await db.query(text, values)).rows as T[];
const mode = APPLY ? "APPLY" : "DRY-RUN";
console.log(`# identity-reevaluate (${mode})${ROLLBACK ? " + rollback-wrong" : ""}\n`);
interface PoolRow {
pool_id: string;
decision_id: string;
decision_title: string;
source_release_id: string;
title: string;
subtitle: string | null;
candidates: {
provider: string;
entityType: "movie" | "tv";
externalId: string;
title: string;
year: number | null;
}[];
}
// ---------------------------------------------------------------- 1. open identity_ambiguous
const pool = await q<PoolRow>(
`select c.id as pool_id, d.id as decision_id, d.title as decision_title, r.id as source_release_id, r.title, r.subtitle,
c.candidates
from decision d
join identity_candidate c on c.decision_id = d.id and c.status = 'pending'
join source_release r on r.id = c.source_release_id
where d.kind = 'identity_ambiguous' and d.status in ('open', 'investigating', 'awaiting_user')
order by d.id, r.title`,
);
console.log(
`## open identity_ambiguous:${new Set(pool.map((p) => p.decision_id)).size} 条事项,${pool.length} 个成员资源`,
);
let predictedAuto = 0;
for (const p of pool) {
const parsed = parseRelease(p.title, p.subtitle ? { subtitle: p.subtitle } : {});
const cands: IdentityCandidate[] = (p.candidates ?? []).map((c) => ({
provider: c.provider,
entityType: c.entityType,
externalId: c.externalId,
titles: [c.title],
aliases: [],
...(c.year != null ? { year: c.year } : {}),
}));
const res = resolveIdentity(parsed, cands);
if (res.status === "auto") predictedAuto++;
const best = res.best?.candidate;
console.log(
`- [${res.status}${res.status === "auto" ? ` → ${best?.provider}:${best?.entityType}/${best?.externalId} ${best?.titles[0]}` : ""}] ` +
`${p.title} (中文名 ${parsed.titleCn ?? "-"},英文名 ${parsed.titleEn ?? "-"},年份 ${parsed.year ?? "-"})`,
);
}
console.log(
`\n预估(只用池里存的候选,服务复查时会按新规则重新查 TMDB 并比对别名,结果以复查为准):${predictedAuto}/${pool.length} 可自动确认`,
);
if (APPLY && pool.length) {
await q(`update identity_candidate set next_review_at = now(), attempts = 0 where id = any($1::uuid[])`, [
pool.map((p) => p.pool_id),
]);
console.log(`已把 ${pool.length} 个池条目排入立即复查(由运行中的服务处理)`);
}
// ---------------------------------------------------------------- 2. rollback-wrong
if (ROLLBACK) {
interface AliasRow {
id: string;
media_entity_id: string;
alias: string;
normalized: string;
source: string;
}
const siteAliases = await q<AliasRow>(
`select id, media_entity_id, alias, normalized, source from title_alias where source = 'site' and content_unit_id is null`,
);
const poison = siteAliases.filter((a) => isGenericAliasName(a.normalized));
const entities = [...new Set(poison.map((a) => a.media_entity_id))];
console.log(`\n## rollback-wrong:${poison.length} 个分类词 / 通用词站点别名,涉及 ${entities.length} 部作品`);
const toUnbind: { id: string; title: string; subtitle: string | null; entityId: string }[] = [];
const aliasIdsToDelete = new Set(poison.map((a) => a.id));
for (const entityId of entities) {
const [e] = await q<{
id: string;
kind: string;
title: string;
original_title: string | null;
year: number | null;
}>(`select id, kind, title, original_title, year from media_entity where id = $1`, [entityId]);
if (!e) continue;
const all = await q<AliasRow>(
`select id, media_entity_id, alias, normalized, source from title_alias where media_entity_id = $1`,
[entityId],
);
// 作品自身名称:主标题 / 原标题 / 非站点来源的别名(TMDB 译名等)
const legit: IdentityCandidate = {
provider: "local",
entityType: e.kind === "movie" ? "movie" : "tv",
externalId: e.id,
titles: [e.title, ...(e.original_title ? [e.original_title] : [])],
aliases: all.filter((a) => a.source !== "site").map((a) => a.alias),
...(e.year != null ? { year: e.year } : {}),
};
const legitNorm = new Set([...legit.titles, ...legit.aliases].map(normalizeTitle));
const releases = await q<{ id: string; title: string; subtitle: string | null; parsed: Record<string, unknown> }>(
`select id, title, subtitle, parsed from source_release where media_entity_id = $1 and not resolution_locked`,
[entityId],
);
const wrongNames = new Set<string>();
let kept = 0;
for (const r of releases) {
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
const s = scoreCandidate(parsed, legit);
if (s.titleMatch === "exact" || s.titleMatch === "alias") {
kept++;
continue;
}
toUnbind.push({ id: r.id, title: r.title, subtitle: r.subtitle, entityId });
for (const n of queryTitles(parsed)) wrongNames.add(n);
for (const k of ["titleCn", "titleEn", "title"]) {
const v = r.parsed?.[k];
if (typeof v === "string" && v) wrongNames.add(normalizeTitle(v));
}
}
const chain = all.filter(
(a) => a.source === "site" && !legitNorm.has(a.normalized) && wrongNames.has(a.normalized),
);
for (const a of chain) aliasIdsToDelete.add(a.id);
console.log(
`\n### ${e.title}(${e.year ?? "-"}):已绑定 ${releases.length} 条,核对通过 ${kept} 条,误并入 ${releases.length - kept} 条`,
);
console.log(
` 删除别名:${[...new Set([...poison.filter((a) => a.media_entity_id === entityId), ...chain].map((a) => `「${a.alias}」`))].join(" ") || "-"}`,
);
for (const r of toUnbind.filter((x) => x.entityId === entityId)) console.log(` - 解除:${r.title}`);
}
// 引用了这些资源的其他记录(只报告,不改)
if (toUnbind.length) {
const refs = await q<{ table_name: string }>(
`select table_name from information_schema.columns
where table_schema = 'public' and column_name = 'source_release_id'
and table_name not in ('identity_candidate', 'resolution_attempt', 'observation')`,
);
for (const { table_name } of refs) {
const [c] = await q<{ n: number }>(
`select count(*)::int as n from "${table_name}" where source_release_id = any($1::uuid[])`,
[toUnbind.map((r) => r.id)],
);
if (c && c.n > 0) console.log(` 注意:${table_name} 里有 ${c.n} 行引用了误并入的资源(不自动处理,请人工核对)`);
}
}
console.log(`\n合计:解除 ${toUnbind.length} 条资源绑定,删除 ${aliasIdsToDelete.size} 个站点别名`);
if (APPLY && (toUnbind.length || aliasIdsToDelete.size)) {
await db.query("begin");
try {
await q(`delete from title_alias where id = any($1::uuid[])`, [[...aliasIdsToDelete]]);
for (const r of toUnbind) {
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
await q(
`update source_release set media_entity_id = null, content_unit_id = null, resolution_status = 'unresolved',
resolution_evidence = $2::jsonb, updated_at = now()
where id = $1 and not resolution_locked`,
[r.id, JSON.stringify({ reason: "rollback_wrong_alias", previousEntityId: r.entityId })],
);
const normalized = parsed.title ? normalizeTitle(parsed.title) || null : null;
await q(
`insert into identity_candidate (source_release_id, status, reason, query, normalized_title, candidates,
evidence_version, attempts, last_attempt_at, next_review_at)
values ($1, 'pending', 'no_match', $2::jsonb, $3, '[]'::jsonb, 'rollback', 0, now(), now())
on conflict (source_release_id) do update set status = 'pending', reason = 'no_match', attempts = 0,
next_review_at = now(), resolved_media_entity_id = null, resolved_by = null, resolved_at = null`,
[r.id, JSON.stringify({ title: parsed.title ?? null, rollbackFrom: r.entityId }), normalized],
);
await q(
`insert into resolution_attempt (source_release_id, outcome, reason, evidence_version, candidate_count,
media_entity_id, actor_type, actor_id)
values ($1, 'manual', 'rollback_wrong_alias', 'rollback', 0, null, 'system', 'identity-reevaluate')`,
[r.id],
);
}
await db.query("commit");
console.log("已执行:资源退回待识别池并排入立即复查(由运行中的服务按新规则重新识别)");
} catch (e) {
await db.query("rollback");
throw e;
}
}
const orphan = await q<{ n: number }>(
`select count(*)::int as n from content_unit u
where u.media_entity_id = any($1::uuid[])
and not exists (select 1 from source_release r where r.content_unit_id = u.id)`,
[entities],
).catch(() => [{ n: -1 }]);
if (orphan[0] && orphan[0].n > 0)
console.log(
`提示:这些作品下${APPLY ? "现在" : "当前"}有 ${orphan[0].n} 个内容单元没有资源引用(脚本不删除,需要时人工清理)`,
);
}
if (!APPLY) console.log("\n(dry-run:未改库;确认后加 --apply 执行)");
await db.end();