import { existsSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; import { organizeDecisionHook, pickMainVideo, takeoverParsed, } from "../../src/modules/acquisition-organize/services/takeover.ts"; import { addAliasIfAbsent } from "../../src/modules/sources-identity/service/entities.ts"; import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; // QB-1:接管任务(用户手动加进 qB、贴管理标签,没有站点资源)完成后按 种子名 + 主视频文件名 识别 → 直接整理; // 识别失败建带选项的事项(候选 / 选择作品 / 临时作品 / 忽略),处理后执行并闭环;目标已存在 → 替换 / 保留两者 / 跳过; // 手动按路径整理也关闭同一下载任务的整理事项;作品详情下载历史列出接管任务与整理记录。 const hasPg = await pgAvailable(); const SIZE = 300_000; function walkFiles(dir: string): string[] { const out: string[] = []; for (const n of readdirSync(dir)) { const p = join(dir, n); if (statSync(p).isDirectory()) out.push(...walkFiles(p)); else out.push(p); } return out; } describe("接管识别:文件名解析", () => { it("主视频取要下载的最大视频,跳过 sample;文件名 + 目录 + 种子名合并", () => { expect( pickMainVideo([ { name: "Movie.2021/Sample/sample.mkv", size: 9e9 }, { name: "Movie.2021/Movie.2021.1080p.mkv", size: 5e9 }, { name: "Movie.2021/Extra.mkv", size: 6e9, priority: 0 }, { name: "Movie.2021/Movie.2021.nfo", size: 1e3 }, ]), ).toBe("Movie.2021/Movie.2021.1080p.mkv"); expect(pickMainVideo([{ name: "a.nfo", size: 1 }])).toBeNull(); // 文件名只有集号:标题 / 季从目录名(=种子根目录)补 const p = takeoverParsed("The.Bear.S02.1080p.WEB-DL", "The.Bear.S02.1080p.WEB-DL/E03.mkv"); expect(p.title?.toLowerCase()).toContain("bear"); expect(p.season).toBe(2); expect(p.episodes).toEqual({ start: 3, end: 3 }); // 单文件种子:种子名即文件名 const m = takeoverParsed("Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer.2023.2160p.UHD.BluRay.mkv"); expect(m.year).toBe(2023); // 文件名解析不出标题:用种子名 expect(takeoverParsed("Heat.1995.1080p.BluRay", "Heat.1995.1080p.BluRay/0001.mkv").year).toBe(1995); }); }); describe.skipIf(!hasPg)("QB-1 接管任务识别与事项闭环(真实 PG + mock qB + 临时目录)", () => { let h: Harness; let qb: MockQb; let lib: TempTree; let cache: TempTree; let n = 0; const actor = { type: "user", id: "tester" }; beforeAll(async () => { qb = await createMockQb({ addVisibleAfterPolls: 0 }); lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" }); cache = await createTempTree({}, { prefix: "umt-cache-" }); h = await createHarness("takeover", {}, { withSources: true }); await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); await h.setSettings("retention", { seedRetentionDays: 0 }); }, 60_000); afterAll(async () => { await h?.close(); await qb?.close(); await lib?.cleanup(); await cache?.cleanup(); }); /** 用户在 qB 里手动加的任务(贴管理标签)→ 同步接管 → 完成落盘 */ async function adoptCompleted(name: string, file: string, size = SIZE) { n++; const hash = n.toString(16).padStart(40, "a"); qb.seed({ hash, name, tags: ["umt"], save_path: cache.root }); await qb.complete(hash, { files: [{ path: `${name}/${file}`, size }] }); const sync = await syncDownloads(h.d); expect(sync.adopted).toBeGreaterThanOrEqual(1); const dt = await h.db .selectFrom("download_task") .selectAll() .where("torrent_hash", "=", hash) .executeTakeFirstOrThrow(); expect(dt).toMatchObject({ origin: "adopted", source_release_id: null, status: "completed" }); return dt; } const decisionOf = (key: string) => h.db .selectFrom("decision") .selectAll() .where("dedupe_key", "=", key) .orderBy("created_at", "desc") .executeTakeFirst(); let movieId: string; let firstDtId: string; it("识别成功(本地别名召回):不建事项,直接整理入库并回填下载任务的作品", async () => { movieId = await seedEntity(h.db, { kind: "movie", title: "Quiet Harbor", year: 2021 }); await addAliasIfAbsent(h.db, movieId, "Quiet Harbor", "original", "user"); const dt = await adoptCompleted( "Quiet.Harbor.2021.1080p.BluRay.x264-GRP", "Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv", ); firstDtId = dt.id; const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); expect(plan.status).toBe("running"); const done = await executeRecord(h.d, plan.recordId!); expect(done.status).toBe("succeeded"); const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow(); expect(row).toMatchObject({ media_entity_id: movieId, status: "imported" }); expect(await decisionOf(`organize_identity:${dt.id}`)).toBeUndefined(); }); let unknownDtId: string; it("识别失败:事项带 选择作品(本地 / TMDB)/ 临时作品 / 忽略;选临时作品 → 建临时作品并整理,事项闭环", async () => { const dt = await adoptCompleted("Zzqx.Vorlanth.2019.1080p.WEB-DL", "Zzqx.Vorlanth.2019.1080p.WEB-DL.mkv"); unknownDtId = dt.id; const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" }); const dec = (await decisionOf(`organize_identity:${dt.id}`))!; expect(dec).toBeTruthy(); const keys = (dec.options as { key: string }[]).map((o) => o.key); expect(keys).toEqual(expect.arrayContaining(["select_entity", "select_tmdb", "provisional", "dismiss"])); expect(dec.evidence).toMatchObject({ downloadTaskId: dt.id, outcome: "no_match" }); expect((dec.evidence as { parsed: { year: number } }).parsed.year).toBe(2019); const hook = organizeDecisionHook(h.d); expect(hook.match(dec)).toBe(true); // 选择作品(本地)但没给作品 → 错误,事项保持打开 await expect(hook.execute(dec, { optionKey: "select_entity" }, actor)).rejects.toThrow(/mediaEntityId/); const out = await hook.execute(dec, { optionKey: "provisional" }, actor); expect(out).toMatchObject({ kind: "organize", status: "running" }); const ent = await h.db .selectFrom("media_entity") .selectAll() .where("id", "=", out.mediaEntityId as string) .executeTakeFirstOrThrow(); expect(ent).toMatchObject({ status: "provisional", year: 2019 }); expect(h.engine.enqueued.some((e) => (e.input as { recordId?: string }).recordId === out.recordId)).toBe(true); const done = await executeRecord(h.d, out.recordId as string); expect(done.status).toBe("succeeded"); const closed = (await decisionOf(`organize_identity:${dt.id}`))!; expect(closed.status).toBe("resolved"); expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: out.recordId }); const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow(); expect(row.media_entity_id).toBe(ent.id); }); it("待处理中心 API:选候选 / 本地作品 → 执行并关闭;缺作品 → 400 且事项保持打开", async () => { const target = await seedEntity(h.db, { kind: "movie", title: "Amber Coast", year: 2017 }); const dt = await adoptCompleted("Qwzz.Nomatch.2017.1080p.WEB", "Qwzz.Nomatch.2017.1080p.WEB.mkv"); await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); const dec = (await decisionOf(`organize_identity:${dt.id}`))!; const bad = await h.api("POST", `/decisions/${dec.id}/resolve`, { optionKey: "select_entity" }); expect(bad.status).toBe(400); expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open"); const ok = await h.api("POST", `/decisions/${dec.id}/resolve`, { optionKey: "select_entity", identity: { mediaEntityId: target }, }); expect(ok.status).toBe(200); expect(ok.body.data.status).toBe("resolved"); expect(ok.body.data.resolution.execution).toMatchObject({ kind: "organize", mediaEntityId: target }); const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow(); expect(row.media_entity_id).toBe(target); const recId = ok.body.data.resolution.execution.recordId as string; expect((await executeRecord(h.d, recId)).status).toBe("succeeded"); }); it("识别失败后在文件管理按路径手动整理:同一下载任务的整理事项关闭,回填作品", async () => { const other = await seedEntity(h.db, { kind: "movie", title: "Glass Orchard", year: 2018 }); const dt = await adoptCompleted("Xqqz.Unmatched.2018.720p.HDTV", "Xqqz.Unmatched.2018.720p.HDTV.mkv"); await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open"); const src = join(cache.root, "Xqqz.Unmatched.2018.720p.HDTV"); const plan = await planImport(h.d, { sourcePaths: [src], mediaEntityId: other, trigger: "manual", actorType: "user", actorId: "tester", }); expect(plan.status).toBe("running"); expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded"); const closed = (await decisionOf(`organize_identity:${dt.id}`))!; expect(closed).toMatchObject({ status: "resolved" }); expect(closed.resolution).toMatchObject({ key: "manual_organize" }); const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow(); expect(row.media_entity_id).toBe(other); }); it("目标已存在:事项给 替换 / 保留两者 / 跳过;保留两者 → 新文件加序号入库,旧文件不动,事项闭环", async () => { // 同一作品的另一份(不同内容、同名)→ 目标文件已存在 const dt = await adoptCompleted( "Quiet.Harbor.2021.1080p.BluRay.x264-GRP", "Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv", SIZE + 1000, ); // 同名种子在 qB 里是新 hash;接管识别仍命中同一作品 const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); expect(plan.status).toBe("failed"); expect(plan.reason).toContain("target_exists"); const dec = (await decisionOf(`organize_blocked:${dt.id}`))!; expect((dec.options as { key: string }[]).map((o) => o.key)).toEqual(["replace", "keep_both", "skip"]); const before = walkFiles(lib.root).filter((p) => p.endsWith(".mkv")); expect(before.filter((p) => p.includes("Quiet"))).toHaveLength(1); const hook = organizeDecisionHook(h.d); const out = await hook.execute(dec, { optionKey: "keep_both" }, actor); expect(out).toMatchObject({ kind: "organize", choice: "keep_both", status: "running" }); expect((await executeRecord(h.d, out.recordId as string)).status).toBe("succeeded"); const after = walkFiles(lib.root).filter((p) => p.endsWith(".mkv")); expect(after).toHaveLength(before.length + 1); const renamed = after.find((p) => p.includes(" (2).")); expect(renamed && statSync(renamed).size).toBe(SIZE + 1000); for (const p of before) expect(existsSync(p)).toBe(true); expect((await decisionOf(`organize_blocked:${dt.id}`))?.status).toBe("resolved"); }); it("目标已存在 → 跳过:其余照常,已存在的不整理;替换:核验后替换", async () => { const dt = await adoptCompleted( "Quiet.Harbor.2021.1080p.BluRay.x264-GRP", "Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv", SIZE + 2000, ); await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" }); const dec = (await decisionOf(`organize_blocked:${dt.id}`))!; const hook = organizeDecisionHook(h.d); const skip = await hook.execute(dec, { optionKey: "skip" }, actor); const rec = await h.db .selectFrom("organization_record") .selectAll() .where("id", "=", skip.recordId as string) .executeTakeFirstOrThrow(); expect(rec.file_skipped).toBeGreaterThanOrEqual(1); const dt2 = await adoptCompleted( "Quiet.Harbor.2021.1080p.BluRay.x264-GRP", "Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv", SIZE + 3000, ); await planImport(h.d, { downloadTaskId: dt2.id, trigger: "auto", actorType: "system" }); const dec2 = (await decisionOf(`organize_blocked:${dt2.id}`))!; const rep = await hook.execute(dec2, { optionKey: "replace" }, actor); expect(rep.status).toBe("running"); expect((await executeRecord(h.d, rep.recordId as string)).status).toBe("succeeded"); // 被替换的旧文件进库根回收站(.umt-trash),不在库内 const inLib = walkFiles(lib.root).filter( (p) => p.endsWith(".mkv") && p.includes("Quiet") && !p.includes("/.umt-trash/"), ); const sizes = inLib.map((p) => statSync(p).size); expect(sizes).toContain(SIZE + 3000); expect(sizes).not.toContain(SIZE); expect(walkFiles(lib.root).some((p) => p.includes("/.umt-trash/") && statSync(p).size === SIZE)).toBe(true); }); it("作品详情下载历史:列出接管的下载任务与整理记录", async () => { const r = await h.api("GET", `/library/${movieId}`); expect(r.status).toBe(200); const ids = r.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id); expect(ids).toContain(firstDtId); const first = r.body.data.downloads.find((d: { downloadTask: { id: string } }) => d.downloadTask.id === firstDtId); expect(first.downloadTask).toMatchObject({ origin: "adopted", mediaEntityId: movieId }); expect(first.lastOrganizationStatus).toBe("succeeded"); expect(r.body.data.organizationRecords.length).toBeGreaterThanOrEqual(3); const prov = await h.db .selectFrom("download_task") .select("media_entity_id") .where("id", "=", unknownDtId) .executeTakeFirstOrThrow(); const r2 = await h.api("GET", `/library/${prov.media_entity_id}`); expect(r2.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id)).toContain( unknownDtId, ); }); });