import { mkdir, readFile, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain"; import { toParsedColumns } from "@umt/mapping"; import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts"; import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts"; import { setWordsText } from "../support/identify-words.ts"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; // 文件侧集数映射(docs/plan/episode-resolution.md 批次 3):整理计划与季包选文件对文件名重新解析出的季集, // 按作品季表 + 映射规则换成规范季集。ANi 连续编号 `- 25` 下载时认作 S02E13,整理时不能又按文件名认成 S01E25。 const hasPg = await pgAvailable(); const SIZE = 300_000; // 已完结:S1 = 12、S2 = 13 const TABLE = { seasons: [ { season: 1, total: 12 }, { season: 2, total: 13 }, ], latestSeason: 2, ended: true, }; const ani = (ep: number) => `[ANi] Kusuriya no Hitorigoto - ${ep} [1080P].mkv`; describe.skipIf(!hasPg)("整理与季包选文件按作品映射集数(真实 PG + mock qB + 临时目录)", () => { let h: Harness; let qb: MockQb; let lib: TempTree; let cache: TempTree; let src: TempTree; let base: Awaited>; const disk = fakeDisk(10_000_000_000); const torrents = new Map(); let n = 0; beforeAll(async () => { qb = await createMockQb({ addVisibleAfterPolls: 0 }); lib = await createTempTree({}, { sentinel: true, prefix: "umt-eplib-" }); cache = await createTempTree({}, { prefix: "umt-epcache-" }); const pack: Record = {}; for (let e = 13; e <= 25; e++) pack[`pack/${ani(e)}`] = SIZE; src = await createTempTree( { ...pack, [`single/${ani(25)}`]: SIZE, [`over/${ani(40)}`]: SIZE, "rule/[ANi] Rule Show - 14 [1080P].mkv": SIZE, "plain/Plain.Show.S01E05.1080p.WEB-DL.x264-GRP.mkv": SIZE, }, { prefix: "umt-epsrc-" }, ); disk.state.watch = [cache.root]; disk.setPct(10); h = await createHarness( "episode_files", { diskUsage: disk.fn, torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }), }, { withSources: true }, ); base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); }, 60_000); afterAll(async () => { await h?.close(); await qb?.close(); for (const t of [lib, cache, src]) await t?.cleanup(); }); /** 剧集作品 + 季表(季单元总集数 + 已完结) */ async function series(title: string, table = TABLE) { const id = await seedEntity(h.db, { kind: "series", title, year: 2023 }); await h.db .updateTable("media_entity") .set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) }) .where("id", "=", id) .execute(); const units: Record = {}; for (const s of table.seasons) { units[s.season] = ( await h.db .insertInto("content_unit") .values({ media_entity_id: id, kind: "season", season_number: s.season, episode_total: s.total, status: "confirmed", source: "tmdb", }) .returning("id") .executeTakeFirstOrThrow() ).id; } return { id, units }; } /** 已识别的资源:parsed 为规范季集,episode_resolution 为判定结果(evidence 可追加标记,验证整理沿用的是它) */ async function release(entityId: string, title: string, opts: { files?: string[]; mark?: string } = {}) { const raw = parseRelease(title); const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] }); if (opts.mark) resolution.evidence.push(opts.mark); const cols = toParsedColumns(canonicalParsed(raw, resolution)); const t = opts.files ? makeTorrent({ name: title, files: opts.files.map((path) => ({ path, length: SIZE })), pieceSeed: Math.floor(Math.random() * 1e6), }) : makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) }); const id = ( await h.db .insertInto("source_release") .values({ site_id: base.siteId, site_release_id: `${Math.floor(Math.random() * 1e9)}`, title, size_bytes: SIZE, infohash_v1: t.hash, media_entity_id: entityId, resolution_status: "resolved", parsed: JSON.stringify(cols.parsed), parser_version: cols.parserVersion, episode_resolution: JSON.stringify(resolution), raw: JSON.stringify({ _site: { hr: false } }), }) .returning("id") .executeTakeFirstOrThrow() ).id; torrents.set(id, t.bytes); return { id, hash: t.hash, resolution }; } /** qB 里已完成的下载任务(接管进来),关联到资源 */ async function completedTask(releaseId: string, name: string, file: string) { n++; const hash = n.toString(16).padStart(40, "b"); qb.seed({ hash, name, tags: ["umt"], save_path: cache.root }); await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] }); await syncDownloads(h.d); const dt = await h.db .selectFrom("download_task") .select("id") .where("torrent_hash", "=", hash) .executeTakeFirstOrThrow(); await h.db.updateTable("download_task").set({ source_release_id: releaseId }).where("id", "=", dt.id).execute(); return dt.id; } const dryRun = async (entityId: string, dir: string, extra: Partial[1]> = {}) => { const r = await planImport(h.d, { sourcePaths: [src.path(dir)], mediaEntityId: entityId, action: "copy", trigger: "manual", actorType: "user", dryRun: true, ...extra, }); expect(r.plan, JSON.stringify(r)).toBeDefined(); return r.plan!; }; const mains = (plan: FrozenPlan) => plan.organize.files .filter((f) => f.role === "main") .map((f) => ({ src: basename(f.src), dst: basename(f.dst), season: f.season, episodes: f.episodes })); it("关联资源 `- 25` 已认作 S02E13:下载文件 `- 25` 整理命名与单元为 S02E13,沿用资源的判定", async () => { const { id } = await series("药屋少女的呢喃"); const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]", { mark: "test:from_release", }); expect(rel.resolution.canonical).toEqual({ season: 2, episodes: { start: 13, end: 13 } }); const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 25", ani(25)); const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system", dryRun: true }); expect(r.status, JSON.stringify(r)).toBe("queued"); const plan = r.plan!; const [m] = mains(plan); expect(m).toMatchObject({ season: 2, episodes: { start: 13, end: 13 } }); expect(m!.dst).toMatch(/S02E13/); expect(m!.dst).not.toMatch(/S01E25/); const fact = Object.values(plan.facts)[0]!; expect(fact).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, unitKeys: ["s2e13"] }); expect(fact.episodeResolution).toMatchObject({ method: "absolute_cross_season", source: { season: 1, episodes: { start: 25, end: 25 } }, }); expect(fact.episodeResolution!.evidence).toContain("test:from_release"); }); it("季包 `- 13`…`- 25`(无季号)逐文件映射到 S02E01…S02E13", async () => { const { id } = await series("季包作品"); const plan = await dryRun(id, "pack"); const got = mains(plan).sort((a, b) => a.episodes!.start - b.episodes!.start); expect(got).toHaveLength(13); got.forEach((m, i) => { expect(m.season).toBe(2); expect(m.episodes).toEqual({ start: i + 1, end: i + 1 }); expect(m.dst).toMatch(new RegExp(`S02E${String(i + 1).padStart(2, "0")}`)); }); expect(Object.values(plan.facts).every((f) => f.season === 2)).toBe(true); }); it("用户逐文件指定的季集优先,不被映射覆盖", async () => { const { id } = await series("指定集数作品"); const file = src.path(`single/${ani(25)}`); const plan = await dryRun(id, "single", { fileOverrides: { [file]: { season: 1, episodes: { start: 7, end: 7 } } }, }); const [m] = mains(plan); expect(m).toMatchObject({ season: 1, episodes: { start: 7, end: 7 } }); expect(m!.dst).toMatch(/S01E07/); expect(plan.facts[file]).toMatchObject({ season: 1, unitKeys: ["s1e7"] }); expect(plan.facts[file]!.episodeResolution).toBeUndefined(); }); it("文件集数待确认:不按原号命名,整理失败原因 episode_unresolved,并入关联资源的 episode_mapping 待处理", async () => { const { id } = await series("待确认作品"); // 没有关联资源:只看计划 const dry = await planImport(h.d, { sourcePaths: [src.path("over")], mediaEntityId: id, action: "copy", trigger: "manual", actorType: "user", dryRun: true, }); expect(dry.status).toBe("failed"); expect(dry.reason).toBe("episode_unresolved"); expect(dry.plan!.organize.files).toHaveLength(0); expect(dry.plan!.organize.excluded).toEqual([ expect.objectContaining({ reason: "episode_unresolved", detail: expect.stringContaining("S01E40") }), ]); expect(Object.values(dry.plan!.unresolvedEpisodes ?? {})[0]).toMatchObject({ method: "unresolved", source: { season: 1, episodes: { start: 40, end: 40 } }, }); // 关联资源(同样待确认):记录失败 + 待处理 const rel = await release(id, "[ANi] Kusuriya no Hitorigoto - 40 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); expect(rel.resolution.method).toBe("unresolved"); const dtId = await completedTask(rel.id, "[ANi] Kusuriya no Hitorigoto - 40", ani(40)); const r = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" }); expect(r).toMatchObject({ status: "failed", reason: "episode_unresolved" }); const files = await h.db .selectFrom("organization_file_result") .select(["target_path", "skip_reason"]) .where("organization_record_id", "=", r.recordId!) .execute(); expect(files.every((f) => !f.target_path?.includes("S01E40"))).toBe(true); expect(files.map((f) => f.skip_reason)).toContain("excluded:episode_unresolved"); const decision = await h.db .selectFrom("decision") .select(["evidence", "status"]) .where("kind", "=", "episode_mapping") .where("subject_id", "=", id) .executeTakeFirstOrThrow(); expect(decision.evidence).toMatchObject({ sourceReleaseIds: [rel.id] }); }); it("作品识别词 `S01 @ANi >> S02 EP-12` 时整理按这一行", async () => { const { id } = await series("规则作品"); await setWordsText(h.db, id, "S01 @ANi >> S02 EP-12\n"); const plan = await dryRun(id, "rule"); const [m] = mains(plan); expect(m).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } }); expect(m!.dst).toMatch(/S02E02/); expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "rule", ruleLine: 1, ruleText: "S01 @ANi >> S02 EP-12", }); }); it("全局识别词也套到整理时的文件名上:`Rule Show - <> \\[1080P >> EP-12` 把 - 14 认成第 2 集", async () => { const { id } = await series("全局识别词作品"); await setWordsText(h.db, null, "Rule Show - <> \\[1080P >> EP-12\n"); try { const plan = await dryRun(id, "rule"); const [m] = mains(plan); expect(m).toMatchObject({ season: 1, episodes: { start: 2, end: 2 } }); expect(m!.dst).toMatch(/S01E02/); expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "raw" }); } finally { await setWordsText(h.db, null, ""); } }); it("原号本来就对(S01E05):整理行为不变", async () => { const { id } = await series("原号作品"); const plan = await dryRun(id, "plain"); const [m] = mains(plan); expect(m).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } }); expect(m!.dst).toMatch(/S01E05/); const fact = Object.values(plan.facts)[0]!; expect(fact).toMatchObject({ season: 1, unitKeys: ["s1e5"] }); expect(fact.episodeResolution).toMatchObject({ method: "raw" }); }); it("刮削:未登记的 `- 25` 按作品映射成 S02E13 对集元数据;待确认的 `- 40` 不写分集 NFO", async () => { const { id } = await series("刮削作品"); await h.db .updateTable("media_entity") .set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" }, title: { value: "刮削作品", source: "tmdb", language: "zh-CN", at: new Date().toISOString(), locked: false }, }), }) .where("id", "=", id) .execute(); await h.db .insertInto("content_unit") .values({ media_entity_id: id, kind: "episode", season_number: 2, episode_number: 13, status: "confirmed", source: "tmdb", title: "第二季终集", }) .execute(); const dir = "刮削作品 (2023)"; await mkdir(join(lib.root, dir), { recursive: true }); for (const ep of [25, 40]) await writeFile(join(lib.root, dir, ani(ep)), "v"); const q = await queueScrape(h.d, { rootId: `lib:${base.rootId}`, rootPath: lib.root, libraryRootId: base.rootId, path: dir, match: { mediaEntityId: id, provider: null, externalId: null, kind: "tv", title: "刮削作品", year: 2023, source: "chosen", }, trigger: "manual", }); const r = await runScrapeRecord(h.d, q.recordId); expect(r.files.find((f) => f.path === `${dir}/${ani(40)}`)).toMatchObject({ action: "skipped", reason: "episode_unresolved", }); const nfo = await readFile(join(lib.root, dir, ani(25).replace(/\.mkv$/, ".nfo")), "utf8"); expect(nfo).toContain("第二季终集"); expect(nfo).toMatch(/2<\/season>\n {2}13<\/episode>/); }); it("季包选文件:订阅缺 S02E03,季包 `- 13`…`- 25` 只下 `- 15`", async () => { const { id } = await series("季包选文件作品"); const sub = await h.db .insertInto("subscription") .values({ media_entity_id: id, media_kind: "series", mode: "continuous", status: "active" }) .returning("id") .executeTakeFirstOrThrow(); const slot = await h.db .insertInto("acquisition_slot") .values({ media_entity_id: id, subscription_id: sub.id, name: "1080p", hard_conditions: JSON.stringify({}), upgrade_threshold: JSON.stringify({}), }) .returning("id") .executeTakeFirstOrThrow(); const names: string[] = []; for (let e = 13; e <= 25; e++) names.push(ani(e)); const rel = await release(id, "[ANi] Kusuriya no Hitorigoto [13-25][1080P][Baha][WEB-DL]", { files: names }); const plan = await h.db .insertInto("acquisition_plan") .values({ slot_id: slot.id, source_release_id: rel.id, status: "reserved", evaluation: JSON.stringify({ instanceId: base.instanceId, savePath: cache.root, mediaEntityId: id }), payload_hash: `ph-${rel.id}`, idempotency_key: `ik-${rel.id}`, unit_keys: ["s2e3"], }) .returning("id") .executeTakeFirstOrThrow(); const s = await submitDownload(h.d, plan.id); expect(s.status, JSON.stringify(s)).toBe("submitted"); const row = await h.db .selectFrom("acquisition_plan") .select(["file_selection", "unit_keys"]) .where("id", "=", plan.id) .executeTakeFirstOrThrow(); expect(row.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s2e3"] }); expect(row.unit_keys).toEqual(["s2e3"]); const t = qb.get(rel.hash)!; const on = t.files.filter((f) => f.priority !== 0).map((f) => f.name.split("/").pop()); expect(on).toEqual([ani(15)]); }); });