diff --git a/CHANGELOG.md b/CHANGELOG.md index e92e291b..b64d8b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 约三分之一。 diff --git a/docs/porting/moviepilot-identity-match.md b/docs/porting/moviepilot-identity-match.md new file mode 100644 index 00000000..97eb7e42 --- /dev/null +++ b/docs/porting/moviepilot-identity-match.md @@ -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` 的长度 / 空白 / 句读守卫挡掉,但守卫是启发式。 diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index 495bfc2a..f6888dce 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -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 = '' 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'; ``` diff --git a/scripts/identity-reevaluate.ts b/scripts/identity-reevaluate.ts new file mode 100644 index 00000000..d9673f69 --- /dev/null +++ b/scripts/identity-reevaluate.ts @@ -0,0 +1,236 @@ +// 身份识别重评(ID-2):匹配规则更新后,处理存量的待识别事项与错误映射。默认只读(dry-run),加 --apply 才改库。 +// +// 用法(DATABASE_URL 必须显式给出,脚本不带默认库): +// DATABASE_URL=postgres://umt:***@: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 (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( + `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( + `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( + `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 }>( + `select id, title, subtitle, parsed from source_release where media_entity_id = $1 and not resolution_locked`, + [entityId], + ); + const wrongNames = new Set(); + 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();