fix(identity): persist verified auxiliary season mappings and reuse source ids

This commit is contained in:
root committed 2026-10-13 20:35:51 +08:00
1 parent c85b139ec4
commit ee0d62ae6f
15 files changed
+397 -38

No files matched your search

@@ -13,6 +13,7 @@ import {
type TmdbFixture,
} from "@umt/testkit";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { matchTmdb } from "../../src/modules/search/discover.ts";
import {
DOUBAN_SUBJECT_REGION,
doubanSubjectFromSnippet,
@@ -23,6 +24,12 @@ import {
ESCALATION_COUNTER_REGION,
escalationCount,
} from "../../src/modules/sources-identity/service/id-escalation.ts";
import {
mergeEntities,
setEntityIdentity,
updateExternalIdentity,
} from "../../src/modules/sources-identity/service/identity-admin.ts";
import { singleIdentityIds } from "../../src/modules/sources-identity/service/identity-ids.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { sharedCache } from "../../src/shared-cache.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
@@ -347,6 +354,49 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
escalation: { via: "douban", douban: { id: "36000099", title: "青灯记", source: "web_snippet" } },
});
const count = tmdb.calls.length;
const second = await release(hhan, "91005-repeat", "??? S01E04 2025 1080p", { _site: { doubanId: "36000099" } });
const same = await resolveRelease(h.rt, { sourceReleaseId: second.id, trigger: "manual" });
expect(same.outcome).toBe("auto");
expect(same.mediaEntityId).toBe(res.mediaEntityId);
expect(tmdb.calls.length).toBe(count);
expect((await row(second.id)).episode_resolution).toMatchObject({
source: { season: 1, episodes: { start: 4, end: 4 } },
});
await saveDoubanSubject(h.ctx, {
id: "36000100",
title: "青灯记 第二季",
aliases: ["Lanterns of Qing Season 2"],
year: 2026,
kind: "tv",
source: "douban_api",
ref: "https://movie.douban.com/subject/36000100/",
at: new Date().toISOString(),
});
const seasonTwo = await release(hhan, "91005-season2", "Unknown Drama S02E01 2026 1080p", {
_detail: { doubanId: "36000100" },
});
await h.db
.updateTable("source_release")
.set({ detail_fetched_at: new Date() })
.where("id", "=", seasonTwo.id)
.execute();
const nextSeason = await resolveRelease(h.rt, { sourceReleaseId: seasonTwo.id, trigger: "manual" });
expect(nextSeason.outcome).toBe("auto");
expect(nextSeason.mediaEntityId).toBe(res.mediaEntityId);
expect((await row(seasonTwo.id)).episode_resolution).toMatchObject({
source: { season: 2, episodes: { start: 1, end: 1 } },
});
const seasonIds = await h.db
.selectFrom("external_identity")
.select("external_id")
.where("media_entity_id", "=", res.mediaEntityId!)
.where("provider", "=", "douban")
.where("status", "=", "accepted")
.execute();
expect(seasonIds.map((s) => s.external_id).sort()).toEqual(["36000099", "36000100"]);
// 缓存里没有这个豆瓣条目、在线也查不到(404):只进池;有 ID 的资源不开「没识别出作品」
await sharedCache(h.ctx).delete(DOUBAN_SUBJECT_REGION, "36000098");
const r2 = await release(hhan, "91006", "Unknown Drama S01E01 2025 1080p WEB-DL", {
@@ -371,7 +421,7 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
} as never)
.returning("id")
.executeTakeFirstOrThrow();
const r = await release(pub.id, "rel_A1", "[Nix-Raws] Sousou no Frieren S2 - 05 [1080p]", {
const r = await release(pub.id, "rel_A1", "[Nix-Raws] Sousou no Frieren S2 - 05 2026 [1080p]", {
_site: { bangumiId: "515759" },
});
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
@@ -382,11 +432,40 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
});
// 条目进缓存:再识别同 ID 不再请求
const n = metaRequests.length;
const r2 = await release(pub.id, "rel_A2", "[Nix-Raws] Sousou no Frieren S2 - 06 [1080p]", {
const tmdbRequests = tmdb.calls.length;
const accepted = await h.db
.selectFrom("external_identity")
.selectAll()
.where("media_entity_id", "=", res.mediaEntityId!)
.where("provider", "=", "bangumi")
.where("status", "=", "accepted")
.executeTakeFirstOrThrow();
expect(accepted).toMatchObject({ external_id: "515759", locked: false, evidence_level: "multi_evidence" });
expect(accepted.evidence).toMatchObject({ via: "tmdb_projection" });
expect((await row(r.id)).episode_resolution).toMatchObject({
source: { season: 2, episodes: { start: 5, end: 5 } },
});
const r2 = await release(pub.id, "rel_A2", "??? S02E06 2026 1080p", {
_site: { bangumiId: "515759" },
});
await resolveRelease(h.rt, { sourceReleaseId: r2.id, trigger: "manual" });
const reused = await resolveRelease(h.rt, { sourceReleaseId: r2.id, trigger: "manual" });
expect(reused.outcome).toBe("auto");
expect(reused.mediaEntityId).toBe(res.mediaEntityId);
expect((await row(r2.id)).episode_resolution).toMatchObject({
source: { season: 2, episodes: { start: 6, end: 6 } },
});
expect(metaRequests.length).toBe(n);
expect(tmdb.calls.length).toBe(tmdbRequests);
const discovery = await matchTmdb(h.rt, {
source: "bangumi",
id: "515759",
title: "未知分季别名",
aliases: [],
year: 2026,
kind: "tv",
});
expect(discovery.match?.id).toBe("990031");
expect(tmdb.calls.length).toBe(tmdbRequests);
// 详情里的 bgm 条目(蜜柑番组页 / 简介链接)同样用上
const rd = await release(pub.id, "rel_A9", "[Nix-Raws] Sousou no Frieren S2 - 07 [1080p]", {
_detail: { bangumiId: "515759" },
@@ -403,6 +482,14 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
expect((await row(r3.id)).resolution_evidence).toMatchObject({
escalation: { via: "bangumi", bangumi: { id: "400602", search: true, score: 1 } },
});
const seasons = await h.db
.selectFrom("external_identity")
.select("external_id")
.where("media_entity_id", "=", res.mediaEntityId!)
.where("provider", "=", "bangumi")
.where("status", "=", "accepted")
.execute();
expect(seasons.map((s) => s.external_id).sort()).toEqual(["400602", "515759"]);
// H1:搜索结果第一条与资源名称对不上(也没有别名能对上)→ 不走 bgm 投影,不自动绑定
const r5 = await release(pub.id, "rel_A5", "[SubsPlease] Labyrinth Frieren - 03 (1080p)");
const res5 = await resolveRelease(h.rt, { sourceReleaseId: r5.id, trigger: "manual" });
@@ -418,6 +505,58 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
expect(metaRequests.length).toBe(m);
});
it("多分季映射可逐条撤销;同来源 ID 不能覆盖另一作品,合并保留成员", async () => {
const actor = { type: "user" as const, id: null };
const entity = async (title: string) =>
(
await h.db
.insertInto("media_entity")
.values({ kind: "series", title })
.returning("id")
.executeTakeFirstOrThrow()
).id;
const one = await entity("分季映射测试一");
const two = await entity("分季映射测试二");
const ref = (id: string) => ({ provider: "bangumi", entityType: "tv" as const, externalId: id, lock: true });
await setEntityIdentity(h.rt, one, ref("test-season-1"), actor);
await setEntityIdentity(h.rt, one, ref("test-season-2"), actor);
await expect(setEntityIdentity(h.rt, two, ref("test-season-1"), actor)).rejects.toMatchObject({ code: "conflict" });
await setEntityIdentity(h.rt, two, ref("test-season-3"), actor);
await mergeEntities(h.rt, two, one, "同作品分季", actor);
const rows = await h.db
.selectFrom("external_identity")
.selectAll()
.where("media_entity_id", "=", one)
.where("status", "=", "accepted")
.execute();
expect(rows).toHaveLength(3);
const first = rows.find((r) => r.external_id === "test-season-1")!;
await updateExternalIdentity(h.rt, first.id, { expectedRevision: first.revision, status: "retired" }, actor);
const rest = await h.db
.selectFrom("external_identity")
.selectAll()
.where("media_entity_id", "=", one)
.where("status", "=", "accepted")
.execute();
expect(rest.map((r) => r.external_id).sort()).toEqual(["test-season-2", "test-season-3"]);
});
it("多豆瓣分季 ID 不随机输出一个作品级 ID;重复 ID 不算冲突", () => {
expect(
singleIdentityIds([
{ provider: "tmdb", external_id: "1" },
{ provider: "douban", external_id: "2" },
{ provider: "douban", external_id: "3" },
]),
).toEqual({ tmdb: "1" });
expect(
singleIdentityIds([
{ provider: "douban", external_id: "2" },
{ provider: "douban", external_id: "2" },
]),
).toEqual({ douban: "2" });
});
it("升级链没认出(豆瓣召回无匹配)时保留原有歧义:候选不丢,照常开有歧义的事项", async () => {
await saveDoubanSubject(h.ctx, {
id: "36000097",