import type { SourceReleaseRow } from "@umt/db"; import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain"; import { toParsedColumns } from "@umt/mapping"; import { createMockQb, type MockQb } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { evaluateAcquisition, parsedOf } from "../../src/modules/acquisition-organize/services/acquisition.ts"; import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; // 订阅评估与集数解析(docs/plan/episode-resolution.md 批次 2):parsed 列是规范季集,S2 追更订阅能评到连续编号的资源; // 集数待确认的资源跳过(episode_unresolved);parsedOf 重解析时保留规范季集。 const hasPg = await pgAvailable(); // 已完结:S1 = 12、S2 = 13 const TABLE = { seasons: [ { season: 1, total: 12 }, { season: 2, total: 13 }, ], latestSeason: 2, ended: true, }; const ANI_25 = "[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"; const LILITH_40 = "[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"; function resolved(title: string): { parsed: ReturnType; resolution: EpisodeResolution } { const raw = parseRelease(title); const resolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] }); return { parsed: canonicalParsed(raw, resolution), resolution }; } describe("parsedOf 与集数解析", () => { it("解析版本过期按原标题重解析时,叠加 episode_resolution 的规范季集", () => { const { resolution } = resolved(ANI_25); expect(resolution.method).toBe("absolute_cross_season"); const row = { title: ANI_25, subtitle: null, parsed: { season: 1, episodes: { start: 25, end: 25 } }, parser_version: "0", size_bytes: null, episode_resolution: resolution, } as unknown as SourceReleaseRow; const p = parsedOf(row); expect(p).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, seasonInferred: false }); expect(p.releaseGroup?.name).toBe("ANi"); // 没有映射结果的照旧 expect(parsedOf({ ...row, episode_resolution: null })).toMatchObject({ season: 1, episodes: { start: 25, end: 25 }, }); }); }); describe.skipIf(!hasPg)("订阅评估跳过集数待确认的资源", () => { let h: Harness; let qb: MockQb; let base: Awaited>; let entityId: string; beforeAll(async () => { qb = await createMockQb({ addVisibleAfterPolls: 0 }); const disk = fakeDisk(10_000_000_000); disk.setPct(10); h = await createHarness("episode_resolution_eval", { diskUsage: disk.fn }); base = await seedBase(h.db, { libraryPath: "/tmp/umt-none-lib", cachePath: "/tmp/umt-none-cache", qbUrl: qb.url }); entityId = await seedEntity(h.db, { kind: "series", title: "药屋少女的呢喃", year: 2023 }); const units: Record = {}; for (const s of TABLE.seasons) { units[s.season] = ( await h.db .insertInto("content_unit") .values({ media_entity_id: entityId, kind: "season", season_number: s.season, episode_total: s.total, status: "confirmed", source: "tmdb", }) .returning("id") .executeTakeFirstOrThrow() ).id; } // S2 追更订阅(仅本季) const sub = await h.db .insertInto("subscription") .values({ media_entity_id: entityId, media_kind: "series", mode: "continuous", status: "active", season_unit_id: units[2]!, season_scope: "current_season", }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("acquisition_slot") .values({ media_entity_id: entityId, subscription_id: sub.id, name: "1080p", hard_conditions: JSON.stringify({ resolutions: ["1080p"] }), upgrade_threshold: JSON.stringify({}), }) .execute(); }); afterAll(async () => { await h?.close(); await qb?.close(); }); const seed = async (title: string) => { const { parsed, resolution } = resolved(title); const cols = toParsedColumns(parsed); return ( await h.db .insertInto("source_release") .values({ site_id: base.siteId, site_release_id: `${Math.floor(Math.random() * 1e9)}`, title, size_bytes: 300_000_000, infohash_v1: Math.random().toString(16).slice(2).padEnd(40, "0").slice(0, 40), media_entity_id: entityId, resolution_status: "resolved", parsed: JSON.stringify(cols.parsed), parser_version: cols.parserVersion, episode_resolution: JSON.stringify(resolution), }) .returning("id") .executeTakeFirstOrThrow() ).id; }; it("ANi `- 25` 规范为 S02E13:S2 追更订阅进入评估并出计划", async () => { const id = await seed(ANI_25); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id }); expect(ev.skipped.map((s) => s.reason)).not.toContain("episode_unresolved"); expect(ev.evaluated, JSON.stringify(ev)).toBe(1); expect(ev.plans).toHaveLength(1); const plan = await h.db .selectFrom("acquisition_plan") .select(["unit_keys"]) .where("id", "=", ev.plans[0]!) .executeTakeFirstOrThrow(); expect(plan.unit_keys).toEqual(["s2e13"]); }); it("集数待确认的资源跳过,原因 episode_unresolved", async () => { const id = await seed(LILITH_40); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id }); expect(ev.evaluated).toBe(0); expect(ev.plans).toHaveLength(0); expect(ev.skipped).toEqual([{ sourceReleaseId: id, reason: "episode_unresolved" }]); }); it("集数待确认在订阅侧可见:写候选记录 episode_unresolved;确认后正常评估并覆盖", async () => { const slot = await h.db .selectFrom("acquisition_slot") .select("id") .where("media_entity_id", "=", entityId) .executeTakeFirstOrThrow(); const candidate = async (releaseId: string) => ( await h.db .selectFrom("artifact") .select("content") .where("title", "=", "candidate_evaluation") .where("subject_id", "=", slot.id) .execute() ) .map( (r) => r.content as { sourceReleaseId: string; decision: string; reasons: string[]; planId: string | null }, ) .find((c) => c.sourceReleaseId === releaseId); const id = await seed(LILITH_40); await evaluateAcquisition(h.d, { sourceReleaseId: id }); expect(await candidate(id)).toMatchObject({ decision: "skipped", reasons: ["episode_unresolved"] }); // 人工确认为 S02E12(与 `- 24` 的换算结果相同),重发评估:正常建计划,记录被覆盖 const fixed = resolved("[Lilith-Raws] Kusuriya no Hitorigoto - 24 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); const cols = toParsedColumns(fixed.parsed); await h.db .updateTable("source_release") .set({ parsed: JSON.stringify(cols.parsed), parser_version: cols.parserVersion, episode_resolution: JSON.stringify(fixed.resolution), }) .where("id", "=", id) .execute(); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id }); expect(ev.plans, JSON.stringify(ev)).toHaveLength(1); const c = await candidate(id); expect(c?.decision).toBe("accept"); expect(c?.planId).toBe(ev.plans[0]); expect(c?.reasons).not.toContain("episode_unresolved"); }); });