fix(identity): persist verified auxiliary season mappings and reuse source ids
This commit is contained in:
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",
|
||||
|
||||
Reference in new issue
Block a user