346 lines
14 KiB
TypeScript
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([]);
|
|
});
|
|
});
|