兰香缘等 26 部:季单元是资源懒建的临时单元,TMDB 集已挂在下面成了正式单元, 季单元没转正,季表丢掉整季,资源全成「元数据未覆盖」。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
520 lines
23 KiB
TypeScript
520 lines
23 KiB
TypeScript
/**
|
|
* 识别准确度与失败可见性(T4):bgm 搜索结果要与资源名称核对;人工指定回填的识别缓存不扩散给年份 / 类型不相容的同名资源;
|
|
* 合并事项按年份 / 类型分组,组内不相容的资源不随组锁定;临时季不进季表;快照连续失败 / TMDB 复查耗尽开待处理;
|
|
* 召回拉详情失败不写负缓存;改 TMDB 映射后旧 tmdb 别名不再召回;合并作品时内容单元迁到目标作品。全部走 mock,零真实请求。
|
|
*/
|
|
import { SiteError } from "@umt/adapters";
|
|
import { sql } from "@umt/db";
|
|
import { parseRelease } from "@umt/domain";
|
|
import { createMockTmdb, type MockTmdb, type TmdbFixture } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { releaseFitsEntity } from "../../src/modules/sources-identity/routes/decisions.ts";
|
|
import type { DoubanSubject } from "../../src/modules/sources-identity/service/douban-subject.ts";
|
|
import { loadSeasonTable } from "../../src/modules/sources-identity/service/episode-resolution.ts";
|
|
import {
|
|
assignReleaseIdentity,
|
|
mergeEntities,
|
|
setEntityIdentity,
|
|
} from "../../src/modules/sources-identity/service/identity-admin.ts";
|
|
import { identityGroupKey } from "../../src/modules/sources-identity/service/identity-decisions.ts";
|
|
import { pickBangumiSubject } from "../../src/modules/sources-identity/service/meta-subjects.ts";
|
|
import { decisionWanted, resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
|
import { SNAPSHOT_FAIL_DECISION_AT, snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts";
|
|
import { entryCompatible } from "../../src/modules/sources-identity/service/tmdb-recognize-cache.ts";
|
|
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
|
|
|
|
const pg = await pgAvailable();
|
|
|
|
const subject = (id: string, title: string, aliases: string[] = [], year: number | null = 2023): DoubanSubject => ({
|
|
id,
|
|
title,
|
|
aliases,
|
|
year,
|
|
source: "bangumi_api",
|
|
ref: `https://bgm.tv/subject/${id}`,
|
|
at: "2026-10-01T00:00:00.000Z",
|
|
});
|
|
|
|
describe("纯函数:bgm 核对 / 缓存相容 / 分组键 / 组内相容 / 事项开关", () => {
|
|
it("bgm 搜索结果:第一条名称对不上不采用;唯一同名才采用;多个同名靠年份区分,区分不了不采用", () => {
|
|
const wrong = subject("1", "迷宫芙莉莲", ["迷宮のフリーレン"]);
|
|
const right = subject("2", "葬送的芙莉莲", ["葬送のフリーレン", "Sousou no Frieren"]);
|
|
expect(pickBangumiSubject(["Sousou no Frieren"], undefined, [wrong])).toBeNull();
|
|
expect(pickBangumiSubject(["Sousou no Frieren"], undefined, [wrong, right])?.subject.id).toBe("2");
|
|
// 第二季条目去掉季名后也同名,但第一季条目是完全同名:取完全同名的
|
|
const s2 = subject("3", "葬送的芙莉莲 第二季", [], 2026);
|
|
expect(pickBangumiSubject(["葬送的芙莉莲"], undefined, [s2, right])?.subject.id).toBe("2");
|
|
// 两个完全同名(重制版):资源年份能区分才采用
|
|
const remake = subject("4", "葬送的芙莉莲", [], 2030);
|
|
expect(pickBangumiSubject(["葬送的芙莉莲"], undefined, [right, remake])).toBeNull();
|
|
expect(pickBangumiSubject(["葬送的芙莉莲"], 2030, [right, remake])?.subject.id).toBe("4");
|
|
});
|
|
|
|
it("识别缓存条目与资源相容:年份 ±1(剧集允许更晚),剧集线索不配电影,负缓存总相容", () => {
|
|
const p = (t: string) => parseRelease(t);
|
|
expect(entryCompatible(p("Echo Valley 2025 1080p"), { id: 1, type: "movie", year: "2025" })).toBe(true);
|
|
expect(entryCompatible(p("Echo Valley 2010 1080p"), { id: 1, type: "movie", year: "2025" })).toBe(false);
|
|
expect(entryCompatible(p("Echo Valley S01E01 2010"), { id: 1, type: "tv", year: "2025" })).toBe(false);
|
|
expect(entryCompatible(p("Echo Valley S03E01 2027"), { id: 1, type: "tv", year: "2025" })).toBe(true);
|
|
expect(entryCompatible(p("Echo Valley S01E01"), { id: 1, type: "movie", year: "2025" })).toBe(false);
|
|
expect(entryCompatible(p("Echo Valley 2010"), { id: 0 })).toBe(true);
|
|
});
|
|
|
|
it("合并事项分组键带年份与剧集线索;组内资源与所选作品的相容判定", () => {
|
|
expect(identityGroupKey("twins", parseRelease("Twins.1988.1080p.BluRay"))).toBe("twins:1988");
|
|
expect(identityGroupKey("show", parseRelease("Show.S01E02.1080p"))).toBe("show:tv");
|
|
expect(identityGroupKey("show", parseRelease("Show 1080p WEB-DL"))).toBe("show");
|
|
expect(releaseFitsEntity(parseRelease("Twins.1988.1080p"), { kind: "movie", year: 1988 })).toBe(true);
|
|
expect(releaseFitsEntity(parseRelease("Twins.2016.1080p"), { kind: "movie", year: 1988 })).toBe(false);
|
|
expect(releaseFitsEntity(parseRelease("Twins.S01E01.1080p"), { kind: "movie", year: 1988 })).toBe(false);
|
|
expect(releaseFitsEntity(parseRelease("Twins.S03E01.2020"), { kind: "series", year: 2016 })).toBe(true);
|
|
expect(releaseFitsEntity(parseRelease("Twins.1080p"), { kind: "movie", year: 1988 })).toBe(true);
|
|
});
|
|
|
|
it("进池开事项:查询失败复查耗尽不论是否命中本地作品;无结果命中本地作品就开;升级推迟中不开", () => {
|
|
expect(decisionWanted("lookup_failed", 0, false, null, false)).toBe(true);
|
|
expect(decisionWanted("lookup_failed", 0, true, 60_000, false)).toBe(false);
|
|
expect(decisionWanted("no_match", 0, true, 60_000, false)).toBe(true);
|
|
expect(decisionWanted("no_match", 0, false, null, false)).toBe(false);
|
|
expect(decisionWanted("no_match", 0, true, null, true)).toBe(false);
|
|
expect(decisionWanted("ambiguous", 2, false, 60_000, false)).toBe(true);
|
|
});
|
|
});
|
|
|
|
const fx = (
|
|
type: "movie" | "tv",
|
|
id: number,
|
|
en: string,
|
|
zh: string,
|
|
date: string,
|
|
alt: string[] = [],
|
|
): TmdbFixture => ({
|
|
type,
|
|
id,
|
|
original_title: en,
|
|
original_language: "en",
|
|
origin_country: ["US"],
|
|
date,
|
|
titles: { "zh-CN": zh, "en-US": en },
|
|
popularity: 10,
|
|
alternative_titles: alt.map((title) => ({ iso_3166_1: "US", title, type: "" })),
|
|
external_ids: {},
|
|
...(type === "tv"
|
|
? {
|
|
seasons: [
|
|
{ season_number: 1, air_date: date, names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, episodes: [] },
|
|
],
|
|
}
|
|
: {}),
|
|
});
|
|
|
|
describe.skipIf(!pg)("识别准确度与失败可见性", () => {
|
|
let h: Harness;
|
|
let tmdb: MockTmdb;
|
|
let siteId: string;
|
|
let n = 0;
|
|
const USER = { type: "user" as const, id: "tester" };
|
|
|
|
beforeAll(async () => {
|
|
tmdb = await createMockTmdb({
|
|
fixtures: [
|
|
// 同名两部剧集:2025 与 2010
|
|
fx("tv", 880001, "Echo Valley", "回声谷", "2025-03-01"),
|
|
fx("tv", 880002, "Echo Valley", "回声谷", "2010-03-01"),
|
|
// 同名电影各两部:1995 与 2016(各自同年同名 → 歧义)
|
|
fx("movie", 880011, "Harbor Lights", "港湾灯火", "1995-05-01"),
|
|
fx("movie", 880012, "Harbor Lights", "港口之光", "1995-08-01"),
|
|
fx("movie", 880013, "Harbor Lights", "海港之灯", "2016-05-01"),
|
|
fx("movie", 880014, "Harbor Lights", "港灯", "2016-08-01"),
|
|
// 英文名只在 TMDB 别名里:不取详情就对不上
|
|
fx("movie", 880021, "Glass Field", "玻璃原野", "2024-04-01", ["Quartz Meadow"]),
|
|
// 改映射:旧作品带别名 Copper Harbour,新作品没有
|
|
fx("movie", 880031, "Copper Bay", "铜湾", "2020-06-01", ["Copper Harbour"]),
|
|
fx("movie", 880032, "Iron Bay", "铁湾", "2020-06-01"),
|
|
],
|
|
});
|
|
h = await createHarness("identity_accuracy", { tmdbUrl: tmdb.url });
|
|
siteId = (await seedSite(h, { key: "accuracy", baseUrl: "https://accuracy.example/" })).site.id;
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await tmdb?.close();
|
|
});
|
|
|
|
const release = (title: string) =>
|
|
h.db
|
|
.insertInto("source_release")
|
|
.values({ site_id: siteId, site_release_id: `acc-${++n}`, title, raw: JSON.stringify({}) } as never)
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
const row = (id: string) =>
|
|
h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
|
const tmdbIdOf = async (entityId: string) =>
|
|
(
|
|
await h.db
|
|
.selectFrom("external_identity")
|
|
.select("external_id")
|
|
.where("media_entity_id", "=", entityId)
|
|
.where("provider", "=", "tmdb")
|
|
.where("status", "=", "accepted")
|
|
.executeTakeFirstOrThrow()
|
|
).external_id;
|
|
const recognizeKeys = async () =>
|
|
(await sql<{ key: string }>`select key from kv_cache where region = 'tmdb.recognize'`.execute(h.db)).rows.map(
|
|
(r) => r.key,
|
|
);
|
|
|
|
it("H2:人工指定不扩散——无年份的资源不回填缓存,同名剧集照常召回出两个候选而不是只剩被指定的那部", async () => {
|
|
const a = await release("Echo Valley S01E01 1080p WEB-DL-GRP");
|
|
expect((await resolveRelease(h.rt, { sourceReleaseId: a.id, trigger: "search" })).outcome).toBe("ambiguous");
|
|
await assignReleaseIdentity(
|
|
h.rt,
|
|
a.id,
|
|
{ external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true },
|
|
USER,
|
|
);
|
|
expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-None-1");
|
|
const b = await release("Echo Valley S01E02 1080p WEB-DL-GRP");
|
|
const rb = await resolveRelease(h.rt, { sourceReleaseId: b.id, trigger: "search" });
|
|
expect(rb.outcome).toBe("ambiguous");
|
|
expect((await row(b.id)).media_entity_id).toBeNull();
|
|
});
|
|
|
|
it("H2:指定到年份不相容的作品不回填;同名不同年份的资源按自己的年份认到另一部", async () => {
|
|
const a = await release("Echo Valley 2010 S01E01 1080p HDTV-OLD");
|
|
await assignReleaseIdentity(
|
|
h.rt,
|
|
a.id,
|
|
{ external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true },
|
|
USER,
|
|
);
|
|
expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-2010-1");
|
|
const b = await release("Echo Valley 2010 S01E05 1080p HDTV-OLD");
|
|
const rb = await resolveRelease(h.rt, { sourceReleaseId: b.id, trigger: "search" });
|
|
expect(rb.outcome).toBe("auto");
|
|
expect(await tmdbIdOf(rb.mediaEntityId!)).toBe("880002");
|
|
});
|
|
|
|
it("H2:用户指定且年份相容的照常回填(同年同名命中);Agent 的指定不回填", async () => {
|
|
const a = await release("Echo Valley 2025 S01E03 2160p WEB-DL-NEW");
|
|
await assignReleaseIdentity(
|
|
h.rt,
|
|
a.id,
|
|
{ external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true },
|
|
USER,
|
|
);
|
|
expect(await recognizeKeys()).toContain("[电视剧][zh-CN]Echo Valley-2025-1");
|
|
const c = await release("Echo Valley 2025 S02E01 2160p WEB-DL-NEW");
|
|
await assignReleaseIdentity(
|
|
h.rt,
|
|
c.id,
|
|
{ external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true },
|
|
{ type: "agent", id: "agent-1" },
|
|
);
|
|
expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-2025-2");
|
|
});
|
|
|
|
it("H3:同名不同年份分成两个合并事项;旧的宽分组里年份不相容的资源不随组锁定,留在事项里", async () => {
|
|
const ids: string[] = [];
|
|
for (const t of [
|
|
"Harbor.Lights.1995.1080p.BluRay-AAA",
|
|
"Harbor.Lights.1995.720p.BluRay-BBB",
|
|
"Harbor.Lights.2016.1080p.WEB-DL-CCC",
|
|
]) {
|
|
const r = await release(t);
|
|
ids.push(r.id);
|
|
expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous");
|
|
}
|
|
const decisions = await h.db
|
|
.selectFrom("decision")
|
|
.select(["id", "dedupe_key", "subject_id"])
|
|
.where("dedupe_key", "like", `identity-group:${siteId}:harborlights%`)
|
|
.where("status", "=", "open")
|
|
.execute();
|
|
expect(decisions.map((d) => d.dedupe_key).sort()).toEqual([
|
|
`identity-group:${siteId}:harborlights:1995`,
|
|
`identity-group:${siteId}:harborlights:2016`,
|
|
]);
|
|
// 模拟升级前按「站点 + 标题」建的宽分组:2016 的资源也挂在 1995 的事项上
|
|
const d1995 = decisions.find((d) => d.dedupe_key.endsWith(":1995"))!;
|
|
await h.db
|
|
.updateTable("identity_candidate")
|
|
.set({ decision_id: d1995.id })
|
|
.where("source_release_id", "=", ids[2]!)
|
|
.execute();
|
|
const res = await h.req(`/decisions/${d1995.id}/resolve`, { body: { optionKey: "c0" } });
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: { status: string; subject_id?: string; subjectId?: string } };
|
|
expect(body.data.status).toBe("open");
|
|
const r1995 = await Promise.all([row(ids[0]!), row(ids[1]!)]);
|
|
expect(r1995.every((r) => r.media_entity_id && r.resolution_locked)).toBe(true);
|
|
expect(r1995[0]!.media_entity_id).toBe(r1995[1]!.media_entity_id);
|
|
const r2016 = await row(ids[2]!);
|
|
expect(r2016.media_entity_id).toBeNull();
|
|
expect(r2016.resolution_locked).toBe(false);
|
|
const after = await h.db.selectFrom("decision").selectAll().where("id", "=", d1995.id).executeTakeFirstOrThrow();
|
|
expect(after.status).toBe("open");
|
|
expect(after.subject_id).toBe(ids[2]);
|
|
});
|
|
|
|
it("季表只计 TMDB / 人工 / 已确认的季:资源懒建的临时季不进季表、不抬高最新季", async () => {
|
|
const e = await h.db
|
|
.insertInto("media_entity")
|
|
.values({ kind: "series", title: "季表测试" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values([
|
|
{
|
|
media_entity_id: e.id,
|
|
kind: "season",
|
|
season_number: 1,
|
|
episode_total: 10,
|
|
status: "confirmed",
|
|
source: "tmdb",
|
|
},
|
|
{ media_entity_id: e.id, kind: "season", season_number: 2, status: "provisional", source: "user" },
|
|
{ media_entity_id: e.id, kind: "season", season_number: 5, status: "provisional", source: "release" },
|
|
{ media_entity_id: e.id, kind: "season", season_number: 6, status: "provisional", source: "file" },
|
|
])
|
|
.execute();
|
|
const table = await loadSeasonTable(h.db, e.id);
|
|
expect(table.seasons.map((s) => s.season)).toEqual([1, 2]);
|
|
expect(table.latestSeason).toBe(2);
|
|
});
|
|
|
|
it("季表:临时季下已有 TMDB 正式集(DH-1 前的半转正数据)也进季表", async () => {
|
|
const e = await h.db
|
|
.insertInto("media_entity")
|
|
.values({ kind: "series", title: "半转正季表" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
const s1 = await h.db
|
|
.insertInto("content_unit")
|
|
.values({ media_entity_id: e.id, kind: "season", season_number: 1, episode_total: 47, status: "provisional" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values([
|
|
{
|
|
media_entity_id: e.id,
|
|
parent_unit_id: s1.id,
|
|
kind: "episode",
|
|
season_number: 1,
|
|
episode_number: 1,
|
|
status: "confirmed",
|
|
source: "tmdb",
|
|
air_date: "2026-09-11",
|
|
},
|
|
// 只有资源懒建的临时集:不算覆盖
|
|
{ media_entity_id: e.id, kind: "season", season_number: 3, status: "provisional", source: "release" },
|
|
{
|
|
media_entity_id: e.id,
|
|
kind: "episode",
|
|
season_number: 3,
|
|
episode_number: 1,
|
|
status: "provisional",
|
|
source: "release",
|
|
},
|
|
])
|
|
.execute();
|
|
const table = await loadSeasonTable(h.db, e.id);
|
|
expect(table.seasons.map((s) => [s.season, s.total])).toEqual([[1, 47]]);
|
|
expect(table.latestSeason).toBe(1);
|
|
});
|
|
|
|
it("快照连续失败 N 次开站点不可达事项(按站点去重),恢复后自动关闭", async () => {
|
|
// 用有站点定义(支持列表抓取)的 key,适配器换成会失败的假实现
|
|
const { site } = await seedSite(h, { key: "hhanclub", baseUrl: "https://hhanclub.example/" });
|
|
let fail = true;
|
|
const orig = h.rt.siteAdapter.bind(h.rt);
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试替换适配器
|
|
(h.rt as any).siteAdapter = (s: { id: string }) =>
|
|
s.id === site.id
|
|
? {
|
|
definitionVersion: "test",
|
|
list: async () => {
|
|
if (fail) throw new SiteError(site.id, "challenge", "Cloudflare 验证页");
|
|
return { items: [], totalPages: 1 };
|
|
},
|
|
}
|
|
: orig(s as never);
|
|
const open = () =>
|
|
h.db
|
|
.selectFrom("decision")
|
|
.select(["id", "kind", "status"])
|
|
.where("dedupe_key", "=", `site-snapshot:${site.id}`)
|
|
.where("status", "=", "open")
|
|
.execute();
|
|
for (let i = 1; i < SNAPSHOT_FAIL_DECISION_AT; i++) {
|
|
expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("failed");
|
|
expect(await open()).toHaveLength(0);
|
|
}
|
|
expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("failed");
|
|
expect(await open()).toEqual([expect.objectContaining({ kind: "source_unreachable" })]);
|
|
// 再失败一次不重复开
|
|
await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" });
|
|
expect(await open()).toHaveLength(1);
|
|
fail = false;
|
|
expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("ok");
|
|
expect(await open()).toHaveLength(0);
|
|
const closed = await h.db
|
|
.selectFrom("decision")
|
|
.select(["status", "resolution"])
|
|
.where("dedupe_key", "=", `site-snapshot:${site.id}`)
|
|
.executeTakeFirstOrThrow();
|
|
expect(closed).toMatchObject({ status: "resolved", resolution: { by: "snapshot_recovered" } });
|
|
// biome-ignore lint/suspicious/noExplicitAny: 还原
|
|
(h.rt as any).siteAdapter = orig;
|
|
});
|
|
|
|
it("TMDB 查询失败复查耗尽:不命中本地作品的资源也开待处理", async () => {
|
|
const r = await release("Zephyr Quill 2026 1080p WEB-DL-ZZ");
|
|
tmdb.fault(/\/3\/search\//, { status: 401, times: 100 });
|
|
try {
|
|
const first = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
|
|
expect(first.outcome).toBe("lookup_failed");
|
|
expect(first.decisionId).toBeNull();
|
|
// 已复查 7 次(同一证据):这次是第 8 次,停止自动复查
|
|
await h.db.updateTable("identity_candidate").set({ attempts: 7 }).where("source_release_id", "=", r.id).execute();
|
|
const last = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "review", force: true });
|
|
expect(last.outcome).toBe("lookup_failed");
|
|
expect(last.decisionId).not.toBeNull();
|
|
const d = await h.db
|
|
.selectFrom("decision")
|
|
.select(["kind", "status", "summary"])
|
|
.where("id", "=", last.decisionId!)
|
|
.executeTakeFirstOrThrow();
|
|
expect(d).toMatchObject({ kind: "identity_unresolved", status: "open" });
|
|
expect(d.summary).toMatch(/元数据查询多次失败/);
|
|
const pool = await h.db
|
|
.selectFrom("identity_candidate")
|
|
.select(["next_review_at"])
|
|
.where("source_release_id", "=", r.id)
|
|
.executeTakeFirstOrThrow();
|
|
expect(pool.next_review_at).toBeNull();
|
|
} finally {
|
|
tmdb.clearFaults();
|
|
}
|
|
});
|
|
|
|
it("召回时候选拉详情失败:这一轮无匹配不写负缓存;详情恢复后照常认出", async () => {
|
|
const r = await release("Quartz Meadow 2024 1080p WEB-DL-QM");
|
|
tmdb.fault(/\/3\/movie\/880021/, { status: 404, times: 100 });
|
|
try {
|
|
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
|
|
expect(res.outcome).toBe("no_match");
|
|
expect(await recognizeKeys()).not.toContain("[未知][zh-CN]Quartz Meadow-2024-None");
|
|
} finally {
|
|
tmdb.clearFaults();
|
|
}
|
|
const again = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
|
|
expect(again.outcome).toBe("auto");
|
|
expect(await tmdbIdOf(again.mediaEntityId!)).toBe("880021");
|
|
});
|
|
|
|
it("改 TMDB 映射:旧作品的 tmdb 别名删除、人工别名保留;旧别名的同名资源不再本地召回到这部作品", async () => {
|
|
const r = await release("Copper Bay 2020 1080p BluRay-CB");
|
|
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
|
|
expect(res.outcome).toBe("auto");
|
|
const id = res.mediaEntityId!;
|
|
await h.db
|
|
.insertInto("title_alias")
|
|
.values({ media_entity_id: id, alias: "铜湾人工名", normalized: "铜湾人工名", source: "user" })
|
|
.execute();
|
|
const aliases = async () =>
|
|
(
|
|
await h.db.selectFrom("title_alias").select(["alias", "source"]).where("media_entity_id", "=", id).execute()
|
|
).map((a) => `${a.source}:${a.alias}`);
|
|
expect(await aliases()).toContain("tmdb:Copper Harbour");
|
|
await setEntityIdentity(
|
|
h.rt,
|
|
id,
|
|
{ provider: "tmdb", entityType: "movie", externalId: "880032", lock: true },
|
|
USER,
|
|
);
|
|
const after = await aliases();
|
|
expect(after.filter((a) => a.startsWith("tmdb:"))).toEqual([]);
|
|
expect(after).toContain("user:铜湾人工名");
|
|
// 旧作品的别名 Copper Harbour:不再命中本地作品(未订阅、不查 TMDB,只进池)
|
|
const old = await release("Copper Harbour 2020 1080p WEB-DL-CH");
|
|
const ro = await resolveRelease(h.rt, { sourceReleaseId: old.id });
|
|
expect(ro.outcome).toBe("unmatched");
|
|
expect((await row(old.id)).media_entity_id).toBeNull();
|
|
});
|
|
|
|
it("合并作品:被合并作品的内容单元迁到目标,同季同集的并入目标已有单元,资源引用跟着走", async () => {
|
|
const mk = async (title: string) =>
|
|
(
|
|
await h.db
|
|
.insertInto("media_entity")
|
|
.values({ kind: "series", title })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
const from = await mk("合并源");
|
|
const into = await mk("合并目标");
|
|
const unit = async (entity: string, kind: "season" | "episode", season: number, episode: number | null) =>
|
|
(
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: entity,
|
|
kind,
|
|
season_number: season,
|
|
episode_number: episode,
|
|
status: "provisional",
|
|
source: "release",
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
await unit(from, "season", 1, null);
|
|
const fromE1 = await unit(from, "episode", 1, 1);
|
|
const fromE3 = await unit(from, "episode", 1, 3);
|
|
await unit(into, "season", 1, null);
|
|
const intoE1 = await unit(into, "episode", 1, 1);
|
|
const r1 = await release("合并源 S01E01");
|
|
const r3 = await release("合并源 S01E03");
|
|
await h.db
|
|
.updateTable("source_release")
|
|
.set({ media_entity_id: from, content_unit_id: fromE1 })
|
|
.where("id", "=", r1.id)
|
|
.execute();
|
|
await h.db
|
|
.updateTable("source_release")
|
|
.set({ media_entity_id: from, content_unit_id: fromE3 })
|
|
.where("id", "=", r3.id)
|
|
.execute();
|
|
|
|
await mergeEntities(h.rt, from, into, "重复作品", USER);
|
|
const left = await h.db
|
|
.selectFrom("content_unit")
|
|
.select("id")
|
|
.where("media_entity_id", "=", from)
|
|
.where("status", "<>", "retired")
|
|
.execute();
|
|
expect(left).toHaveLength(0);
|
|
const units = await h.db
|
|
.selectFrom("content_unit")
|
|
.select(["id", "kind", "season_number", "episode_number"])
|
|
.where("media_entity_id", "=", into)
|
|
.orderBy("kind", "desc")
|
|
.orderBy("episode_number")
|
|
.execute();
|
|
expect(units.map((u) => `${u.kind}:${u.season_number}:${u.episode_number}`)).toEqual([
|
|
"season:1:null",
|
|
"episode:1:1",
|
|
"episode:1:3",
|
|
]);
|
|
const a1 = await row(r1.id);
|
|
const a3 = await row(r3.id);
|
|
expect(a1).toMatchObject({ media_entity_id: into });
|
|
expect([intoE1, fromE1]).toContain(a1.content_unit_id);
|
|
expect(units.map((u) => u.id)).toContain(a1.content_unit_id);
|
|
expect(a3).toMatchObject({ media_entity_id: into, content_unit_id: fromE3 });
|
|
});
|
|
});
|