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> => { 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([]); }); });