Files
UHD_Media_Tool/apps/server/test/sources-identity/organize-episode-samples.test.ts
T

346 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { EpisodeMappingInspectOutput, EpisodeMappingSimulateOutput } from "@umt/contracts";
import { parseRelease } from "@umt/domain";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveEpisodeDecision } from "../../src/modules/sources-identity/service/episode-mapping-admin.ts";
import {
applyAgentEpisodeRule,
inspectEpisodeMapping,
simulateForAgent,
} from "../../src/modules/sources-identity/service/episode-mapping-agent.ts";
import {
clearReleaseEpisodes,
type EpisodeFileSample,
episodeFileSamples,
reresolveEntityEpisodes,
resolveReleaseEpisodes,
settleEpisodeDecision,
settleOrganizeEpisodeDecisions,
} from "../../src/modules/sources-identity/service/episode-resolution.ts";
import { isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts";
import { setWordsText } from "../support/identify-words.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
const pg = await pgAvailable();
const ACTOR = { type: "user" as const, id: "admin" };
describe.skipIf(!pg)("整理真实文件的季集映射闭环", () => {
let h: Harness;
let siteId: string;
beforeAll(async () => {
h = await createHarness("organize_episode_samples");
siteId = (await seedSite(h, { baseUrl: "https://example.test" })).site.id;
}, 120_000);
afterAll(async () => {
await h?.close();
});
const series = async () => {
const entity = await h.db
.insertInto("media_entity")
.values({ title: "整理分季测试", kind: "series", metadata: JSON.stringify({ airStatus: "Ended" }) })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("content_unit")
.values([
{
media_entity_id: entity.id,
kind: "season",
season_number: 1,
episode_total: 12,
source: "tmdb",
status: "confirmed",
},
{
media_entity_id: entity.id,
kind: "season",
season_number: 2,
episode_total: 13,
source: "tmdb",
status: "confirmed",
},
])
.execute();
return entity.id;
};
const file = async (
entityId: string,
episode: number,
group = "ANi",
publishedAt: string | null = null,
): Promise<Omit<EpisodeFileSample, "recordId">> => {
const path = `/downloads/[${group}] Test S01E${episode}.mkv`;
const parsed = parseRelease(path.split("/").at(-1)!);
const mapped = await resolveReleaseEpisodes(h.db, { entityId, kind: "series", parsed, publishedAt });
return { path, parsed, resolution: mapped.resolution, publishedAt };
};
const pending = (entityId: string) =>
h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("subject_id", "=", entityId)
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
const release = async (entityId: string, episode: number, group = "ANi") => {
const title = `[${group}] Test S01E${episode} [1080p]`;
const parsed = parseRelease(title);
const mapped = await resolveReleaseEpisodes(h.db, { entityId, kind: "series", parsed });
const row = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: randomUUID(),
title,
media_entity_id: entityId,
resolution_status: "resolved",
episode_resolution: JSON.stringify(mapped.resolution),
parsed: JSON.stringify(parsed),
})
.returningAll()
.executeTakeFirstOrThrow();
return { row, parsed, resolution: mapped.resolution };
};
it("无订阅、无资源站资源也按真实文件归集,重复记录样本不累加,inspect保留未知发布时间", async () => {
const entityId = await series();
expect(await isEntityWatched(h.db, entityId)).toBe(false);
const recordId = randomUUID();
const files = [await file(entityId, 40), await file(entityId, 41), await file(entityId, 50, "Other")];
await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId, entityId, files });
await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId, entityId, files });
const decisions = await pending(entityId);
expect(decisions).toHaveLength(2);
const ani = decisions.find((d) => d.dedupe_key.endsWith(":ani"))!;
expect(episodeFileSamples(ani.evidence)).toHaveLength(2);
expect(ani.evidence).toMatchObject({ sourceReleaseIds: [], suggestion: null, count: 2 });
const inspect = EpisodeMappingInspectOutput.parse(await inspectEpisodeMapping(h.db, { decisionId: ani.id }));
expect(inspect.releases).toEqual([]);
expect(inspect.files).toHaveLength(2);
expect(inspect.files[0]).toMatchObject({
organizationRecordId: recordId,
sourcePath: files[0]!.path,
publishedAt: null,
source: { season: 1, episodes: { start: 40, end: 40 } },
});
expect(inspect.suggestion).toBeNull();
});
it("模拟同时验证每个文件,无法只靠一个正确资源标题放行整包", async () => {
const entityId = await series();
await h.db
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: "series", status: "active" })
.execute();
const r = await release(entityId, 40);
await settleEpisodeDecision(h.ctx, h.db, {
sourceReleaseId: r.row.id,
entityId,
parsed: r.parsed,
resolution: r.resolution,
});
const files = [await file(entityId, 41), await file(entityId, 60)];
await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: randomUUID(), entityId, files });
const d = (await pending(entityId))[0]!;
const simulation = EpisodeMappingSimulateOutput.parse(
await simulateForAgent(h.db, { decisionId: d.id, line: "S01 @ANi >> S02 EP-30" }, null),
);
expect(simulation.summary).toMatchObject({ samples: 3, resolved: 2, ok: false });
expect(simulation.failures.join(";")).toContain(files[1]!.path);
expect(simulation.items.filter((i) => i.sourceReleaseId === null)).toHaveLength(2);
await expect(
applyAgentEpisodeRule(h.ctx, { decisionId: d.id, line: simulation.line, reasons: ["核对真实文件"] }, ACTOR, null),
).rejects.toThrow("模拟没通过");
expect(
await h.db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId).execute(),
).toEqual([]);
});
it("纯文件规则模拟通过后自动重算并关闭,后续同范围文件沿用作品规则", async () => {
const entityId = await series();
const recordId = randomUUID();
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId,
entityId,
files: [await file(entityId, 40), await file(entityId, 41)],
});
const d = (await pending(entityId))[0]!;
const sim = await simulateForAgent(
h.db,
{ decisionId: d.id, line: "S01 @ANi >> S02 EP-30", reasons: ["资源站与文件对应 S02E10-11"] },
{ runId: null },
);
expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, passed: 2, ok: true });
expect(sim.suggestionRecorded).toBe(true);
const out = await applyAgentEpisodeRule(
h.ctx,
{ decisionId: d.id, line: sim.line, reasons: ["文件编号已核对"] },
ACTOR,
null,
);
expect(out.decisionStatus).toBe("resolved");
expect(await pending(entityId)).toEqual([]);
const after = await h.db.selectFrom("decision").select("evidence").where("id", "=", d.id).executeTakeFirstOrThrow();
expect(episodeFileSamples(after.evidence).map((f) => f.resolution.canonical)).toEqual([
{ season: 2, episodes: { start: 10, end: 10 } },
{ season: 2, episodes: { start: 11, end: 11 } },
]);
expect((await file(entityId, 42)).resolution.canonical).toEqual({ season: 2, episodes: { start: 12, end: 12 } });
});
it("普通保存作品识别词也重算纯文件事项,无资源不能提前返回", async () => {
const entityId = await series();
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 40)],
});
await setWordsText(h.db, entityId, "S01 @ANi >> S02 EP-30");
expect((await reresolveEntityEpisodes(h.ctx, h.db, entityId)).releases).toBe(0);
expect(await pending(entityId)).toEqual([]);
});
it("摘除资源站资源不会关闭仍有未解决文件的同范围事项", async () => {
const entityId = await series();
await h.db
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: "series", status: "active" })
.execute();
const r = await release(entityId, 40);
await settleEpisodeDecision(h.ctx, h.db, {
sourceReleaseId: r.row.id,
entityId,
resolution: r.resolution,
parsed: r.parsed,
});
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 41)],
});
await clearReleaseEpisodes(h.ctx, h.db, r.row.id);
const d = (await pending(entityId))[0]!;
expect(d).toBeDefined();
expect(d.evidence).toMatchObject({ sourceReleaseIds: [], count: 1 });
expect(episodeFileSamples(d.evidence)).toHaveLength(1);
});
it("确认智能体建议前重新核对当前文件,成功时保存文件重算结果", async () => {
const entityId = await series();
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 40)],
});
const d = (await pending(entityId))[0]!;
await simulateForAgent(
h.db,
{ decisionId: d.id, line: "S01 @ANi >> S02 EP-30", reasons: ["文件证据"] },
{ runId: null },
);
const cur = await h.db.selectFrom("decision").selectAll().where("id", "=", d.id).executeTakeFirstOrThrow();
const result = await resolveEpisodeDecision(h.ctx, cur, "confirm_agent_suggestion", null, ACTOR);
expect(result.status).toBe("resolved");
expect(episodeFileSamples(result.evidence)[0]!.resolution.canonical).toEqual({
season: 2,
episodes: { start: 10, end: 10 },
});
});
it("已有同发布组资源被规则弄成待确认时拒绝写入", async () => {
const entityId = await series();
await release(entityId, 1);
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 40)],
});
const d = (await pending(entityId))[0]!;
const simulation = await simulateForAgent(h.db, { decisionId: d.id, line: "S01 @ANi >> S02 EP-30" }, null);
expect(simulation.summary).toMatchObject({ samples: 1, resolved: 1, regressions: 1, ok: false });
await expect(
applyAgentEpisodeRule(h.ctx, { decisionId: d.id, line: simulation.line, reasons: ["只核对了文件"] }, ACTOR, null),
).rejects.toThrow("已对上的资源");
});
it("首次混合整理保留已对上的文件,纯文件偏移也要通过回归保护", async () => {
const entityId = await series();
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 1), await file(entityId, 40)],
});
const d = (await pending(entityId))[0]!;
expect(episodeFileSamples(d.evidence)).toHaveLength(2);
const simulation = await simulateForAgent(h.db, { decisionId: d.id, line: "S01 @ANi >> S02 EP-30" }, null);
expect(simulation.summary).toMatchObject({ samples: 2, resolved: 1, regressions: 1, ok: false });
await expect(
applyAgentEpisodeRule(h.ctx, { decisionId: d.id, line: simulation.line, reasons: ["同批文件"] }, ACTOR, null),
).rejects.toThrow("已对上的文件");
expect(
await h.db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId).execute(),
).toHaveLength(0);
});
it("保存部分有效的映射保留未决文件与已解决样本,不误关闭事项", async () => {
const entityId = await series();
const files = [await file(entityId, 40), await file(entityId, 60)];
await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: randomUUID(), entityId, files });
await setWordsText(h.db, entityId, "S01 @ANi >> S02 EP-30");
await reresolveEntityEpisodes(h.ctx, h.db, entityId);
const d = (await pending(entityId))[0]!;
expect(episodeFileSamples(d.evidence).map((f) => f.resolution.method)).toEqual(["rule", "unresolved"]);
expect(d.evidence).toMatchObject({ count: 1 });
expect((await inspectEpisodeMapping(h.db, { decisionId: d.id })).files).toHaveLength(2);
});
it("旧建议确认前必须按后来进入的文件再次模拟,失败不关闭也不写规则", async () => {
const entityId = await series();
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 40)],
});
const d = (await pending(entityId))[0]!;
await simulateForAgent(
h.db,
{ decisionId: d.id, line: "S01 @ANi >> S02 EP-30", reasons: ["旧样本"] },
{ runId: null },
);
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: randomUUID(),
entityId,
files: [await file(entityId, 60)],
});
const cur = (await pending(entityId))[0]!;
expect(cur.evidence).toHaveProperty("agentSuggestion");
await expect(resolveEpisodeDecision(h.ctx, cur, "confirm_agent_suggestion", null, ACTOR)).rejects.toThrow("文件");
expect((await pending(entityId))[0]!.status).toBe("open");
expect(
await h.db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId).execute(),
).toEqual([]);
});
it("覆盖同一记录样本时移除旧范围,保留其他整理记录的未决文件", async () => {
const entityId = await series();
const recordA = randomUUID();
const recordB = randomUUID();
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: recordA,
entityId,
files: [await file(entityId, 40)],
});
await settleOrganizeEpisodeDecisions(h.ctx, h.db, {
recordId: recordB,
entityId,
files: [await file(entityId, 41)],
});
await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: recordA, entityId, files: [] });
const d = (await pending(entityId))[0]!;
expect(episodeFileSamples(d.evidence).map((f) => f.recordId)).toEqual([recordB]);
await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: recordB, entityId, files: [] });
expect(await pending(entityId)).toEqual([]);
});
});