import { existsSync, readFileSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { sql } from "@umt/db"; import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; import { cleanupDownloadCache, submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; import { reconcileLibrary } from "../../src/modules/acquisition-organize/services/reconcile.ts"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot, } from "./harness.ts"; // 纵切:release.resolved → 计划 → 提交 mock qB → 完成(落盘)→ Copy 入库 → 整理记录 API(含 changes 游标); // 以及硬阈值暂停/恢复、already_exists 接管、缓存清理安全边界、资产对账。 const hasPg = await pgAvailable(); const SIZE = 300_000; describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临时目录)", () => { let h: Harness; let qb: MockQb; let lib: TempTree; let cache: TempTree; const disk = fakeDisk(10_000_000); const torrents = new Map(); let base: Awaited>; beforeAll(async () => { qb = await createMockQb({ addVisibleAfterPolls: 0 }); lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" }); cache = await createTempTree({}, { prefix: "umt-cache-" }); disk.state.watch = [cache.root]; disk.setPct(40); h = await createHarness("vertical", { diskUsage: disk.fn, torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }), }); base = 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(); }); async function release(entityId: string, title: string, opts: { hr?: boolean } = {}) { const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) }); const id = await seedRelease(h.db, { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash, ...(opts.hr !== undefined ? { hr: opts.hr } : {}), }); torrents.set(id, t.bytes); return { id, hash: t.hash, name: t.name }; } async function runToCompleted(entityId: string, title: string, opts: { hr?: boolean } = {}) { const rel = await release(entityId, title, opts); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id }); expect(ev.plans).toHaveLength(1); const sub = await submitDownload(h.d, ev.plans[0]!); expect(sub.status).toBe("submitted"); await qb.complete(rel.hash, { writeFiles: true }); const sync = await syncDownloads(h.d); expect(sync.completed).toContain(sub.downloadTaskId); return { rel, planId: ev.plans[0]!, dtId: sub.downloadTaskId! }; } let movieId: string; let slotId: string; let firstRecord: string; it("release → 计划(预留 Slot)→ 提交 qB(管理标签)→ 完成 → Copy 入库 → 登记资产与绑定", async () => { movieId = await seedEntity(h.db, { kind: "movie", title: "沙丘2", year: 2024 }); slotId = await seedSlot(h.db, { entityId: movieId, hard: { resolutions: ["1080p"] } }); // 不满足硬条件的候选:写拒绝理由,不建计划 const bad = await release(movieId, "Dune.Part.Two.2024.720p.WEB-DL.x264-AAA"); const r0 = await evaluateAcquisition(h.d, { sourceReleaseId: bad.id }); expect(r0.plans).toHaveLength(0); const rel = await release(movieId, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP"); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id }); expect(ev.plans).toHaveLength(1); const slot1 = await h.db .selectFrom("acquisition_slot") .selectAll() .where("id", "=", slotId) .executeTakeFirstOrThrow(); expect(slot1.status).toBe("reserved"); expect(h.engine.enqueued.map((e) => e.task)).toContain("submitDownload"); const sub = await submitDownload(h.d, ev.plans[0]!); expect(sub).toMatchObject({ status: "submitted", reason: "added" }); const t = qb.get(rel.hash)!; expect(t.tags).toContain("umt"); expect(t.save_path).toBe(cache.root); // 重放:账本已有结果,不重复提交 expect((await submitDownload(h.d, ev.plans[0]!)).status).toBe("skipped"); expect(qb.callsTo("torrents/add")).toHaveLength(1); expect((await h.outbox("download.submitted")).length).toBe(1); expect( (await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow()) .status, ).toBe("downloading"); // 未完成:同步只更新进度 qb.setProgress(rel.hash, 0.5); const s1 = await syncDownloads(h.d); expect(s1.completed).toHaveLength(0); await qb.complete(rel.hash, { writeFiles: true }); const s2 = await syncDownloads(h.d); expect(s2.completed).toEqual([sub.downloadTaskId]); const done = await h.outbox("download.status_changed"); expect(done.filter((e) => e.payload.to === "completed")).toHaveLength(1); expect( (await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow()) .status, ).toBe("verifying"); const plan = await planImport(h.d, { downloadTaskId: sub.downloadTaskId, trigger: "auto", actorType: "system" }); expect(plan.status).toBe("running"); firstRecord = plan.recordId!; const ex = await executeRecord(h.d, firstRecord); expect(ex).toMatchObject({ status: "succeeded", succeeded: 1, failed: 0 }); const target = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"); expect(existsSync(target)).toBe(true); // Copy:源继续做种 expect(existsSync(join(cache.root, `${rel.name}`))).toBe(true); const slot = await h.db .selectFrom("acquisition_slot") .selectAll() .where("id", "=", slotId) .executeTakeFirstOrThrow(); expect(slot.status).toBe("satisfied"); expect(slot.current_asset_id).not.toBeNull(); const binding = await h.db.selectFrom("path_binding").selectAll().where("media_entity_id", "=", movieId).execute(); expect(binding.map((b) => b.relative_path)).toEqual(["沙丘2 (2024)"]); const loc = await h.db .selectFrom("asset_location") .selectAll() .where("media_asset_id", "=", slot.current_asset_id!) .executeTakeFirstOrThrow(); expect(loc.relative_path).toBe("沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv"); const dt = await h.db .selectFrom("download_task") .selectAll() .where("id", "=", sub.downloadTaskId!) .executeTakeFirstOrThrow(); expect(dt.status).toBe("imported"); expect( ( await h.db .selectFrom("acquisition_plan") .select("status") .where("id", "=", ev.plans[0]!) .executeTakeFirstOrThrow() ).status, ).toBe("completed"); // 重跑执行:幂等,不重复登记 await executeRecord(h.d, firstRecord); expect( await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", movieId).execute(), ).toHaveLength(1); }, 60_000); it("整理记录 API:详情含文件明细;changes 游标增量(含既有记录状态更新);changes 先于 {id}", async () => { const det = await h.api("GET", `/organization-records/${firstRecord}`); expect(det.status).toBe(200); expect(det.body.data).toMatchObject({ id: firstRecord, status: "succeeded", action: "copy", origin: "auto", counts: { total: 1, succeeded: 1 }, }); expect(det.body.data.files[0]).toMatchObject({ role: "main", status: "succeeded", target: { path: "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv", view: "library" }, }); expect(det.body.data.media).toMatchObject({ entityId: movieId, title: "沙丘2" }); const poll = async (after: string) => { for (let i = 0; i < 50; i++) { const r = await h.api("GET", `/organization-records/changes?after=${after}&limit=100`); if (r.body.data.items.length > 0) return r; await new Promise((res) => setTimeout(res, 100)); } return h.api("GET", `/organization-records/changes?after=${after}&limit=100`); }; const first = await poll("0"); expect(first.status).toBe(200); expect(first.body.data.items.map((i: { id: string }) => i.id)).toContain(firstRecord); const cursor = first.body.data.nextCursor as string; const again = await h.api("GET", `/organization-records/changes?after=${cursor}`); expect(again.body.data).toMatchObject({ items: [], nextCursor: cursor, hasMore: false }); // 既有记录状态更新 → 以更大的 changeSeq 再次出现 await sql`update organization_record set reason = 'touched' where id = ${firstRecord}`.execute(h.db); const upd = await poll(cursor); expect(upd.body.data.items.map((i: { id: string }) => i.id)).toEqual([firstRecord]); expect(BigInt(upd.body.data.nextCursor)).toBeGreaterThan(BigInt(cursor)); const list = await h.api("GET", "/organization-records?limit=10"); expect(list.body.data.items[0].files).toBeNull(); expect((await h.api("GET", "/organization-records/changes?after=abc")).status).toBe(422); }); it("硬阈值:达到时暂停纳管下载中任务并阻止新计划;低于恢复水位只恢复阈值暂停项", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "阈值片", year: 2024 }); await seedSlot(h.db, { entityId: e, hard: {} }); // 一个下载中的纳管任务 + 一个用户手动暂停的纳管任务(贴标签接管) const a = makeTorrent({ name: "busy.mkv", length: SIZE, pieceSeed: 11 }); const b = makeTorrent({ name: "user-paused.mkv", length: SIZE, pieceSeed: 12 }); qb.seed({ hash: a.hash, name: "busy.mkv", tags: ["umt"], progress: 0.2, save_path: cache.root, size: SIZE }); qb.seed({ hash: b.hash, name: "user-paused.mkv", tags: ["umt"], progress: 0.2, save_path: cache.root, size: SIZE, state: "pausedDL", }); const s0 = await syncDownloads(h.d); expect(s0.adopted).toBe(2); const rowB = await h.db .selectFrom("download_task") .selectAll() .where("torrent_hash", "=", b.hash) .executeTakeFirstOrThrow(); expect(rowB).toMatchObject({ status: "paused", pause_reason: "user", origin: "adopted" }); disk.setPct(95); const s1 = await syncDownloads(h.d); expect(s1.thresholdPaused).toBe(1); expect(qb.get(a.hash)!.state).toMatch(/^paused|^stopped/i); expect( (await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", a.hash).executeTakeFirstOrThrow()) .pause_reason, ).toBe("threshold"); const tripped = await h.outbox("download.threshold"); expect(tripped.at(-1)?.payload.state).toBe("tripped"); const rel = await release(e, "Threshold.Movie.2024.1080p.WEB-DL-X"); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id }); expect(ev.plans).toHaveLength(0); expect(ev.skipped[0]?.reason).toMatch(/^cache:/); // 手动恢复阈值暂停项被拒绝(不绕过阈值) const rowA = await h.db .selectFrom("download_task") .select("id") .where("torrent_hash", "=", a.hash) .executeTakeFirstOrThrow(); expect((await h.api("POST", `/download-tasks/${rowA.id}/resume`)).status).toBe(412); // 下一轮观察:暂停中不计入无进展,原因保持 threshold await syncDownloads(h.d); expect( (await h.db.selectFrom("download_task").selectAll().where("id", "=", rowA.id).executeTakeFirstOrThrow()) .pause_reason, ).toBe("threshold"); disk.setPct(88); // 高于恢复水位:保持暂停 expect((await syncDownloads(h.d)).thresholdResumed).toBe(0); disk.setPct(70); const s3 = await syncDownloads(h.d); expect(s3.thresholdResumed).toBe(1); expect(qb.get(a.hash)!.state).not.toMatch(/^paused|^stopped/i); expect(qb.get(b.hash)!.state).toMatch(/^paused|^stopped/i); expect( (await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", b.hash).executeTakeFirstOrThrow()) .pause_reason, ).toBe("user"); // 读不到磁盘:保守阻止 disk.state.fail = true; const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id }); expect(ev2.plans).toHaveLength(0); disk.state.fail = false; disk.setPct(40); qb.setProgress(a.hash, 0); }, 60_000); it("already_exists:种子原本在 qB 里 → 接管,只补管理标签,不改保存路径和其它标签", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "旧种", year: 2023 }); await seedSlot(h.db, { entityId: e }); const rel = await release(e, "Old.Seed.2023.1080p.BluRay-OLD"); qb.seed({ hash: rel.hash, name: rel.name, tags: ["keep"], save_path: "/elsewhere", progress: 0.1, size: SIZE }); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id }); const sub = await submitDownload(h.d, ev.plans[0]!); expect(sub).toMatchObject({ status: "submitted", reason: "already_exists" }); const t = qb.get(rel.hash)!; expect(t.save_path).toBe("/elsewhere"); expect(t.tags.sort()).toEqual(["keep", "umt"]); const row = await h.db .selectFrom("download_task") .selectAll() .where("id", "=", sub.downloadTaskId!) .executeTakeFirstOrThrow(); expect(row).toMatchObject({ origin: "adopted", managed: true, acquisition_plan_id: ev.plans[0], media_entity_id: e, }); expect(qb.callsTo("torrents/add").length).toBe(1); }, 30_000); it("H&R 未满足时 Move 变冲突:记录失败 + 待处理,不搬源、不静默改 Copy", async () => { await h.setSettings("organize", { defaultMode: "move" }); const e = await seedEntity(h.db, { kind: "movie", title: "做种片", year: 2022 }); const slot = await seedSlot(h.db, { entityId: e }); const { dtId, rel } = await runToCompleted(e, "Seeding.Movie.2022.1080p.BluRay-HR", { hr: true }); const plan = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" }); expect(plan.status).toBe("failed"); expect(plan.reason).toContain("hnr_unsatisfied"); const rec = await h.db .selectFrom("organization_record") .selectAll() .where("id", "=", plan.recordId!) .executeTakeFirstOrThrow(); expect(rec.action).toBe("move"); expect(existsSync(join(cache.root, rel.name))).toBe(true); expect(existsSync(join(lib.root, "做种片 (2022)"))).toBe(false); const dec = await h.db .selectFrom("decision") .selectAll() .where("subject_id", "=", plan.recordId!) .executeTakeFirstOrThrow(); expect(dec).toMatchObject({ kind: "approval_required", status: "open" }); expect( (await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slot).executeTakeFirstOrThrow()) .status, ).toBe("attention"); await h.setSettings("organize", { defaultMode: "copy" }); }, 30_000); it("缓存清理:低于阈值不动;超阈值只删已验证入库且满足保留/H&R 的任务,删数据必经安全检查", async () => { // 已有:第一部片(已入库、hr=false、保留 0 天)→ 可清理;做种片(hr 未满足,未入库)→ 锁定 disk.setPct(50); const r0 = await cleanupDownloadCache(h.d); expect(r0.volumes[0]!.action).toBe("none"); const firstDt = await h.db.selectFrom("download_task").selectAll().where("status", "=", "imported").execute(); expect(firstDt.length).toBeGreaterThanOrEqual(1); // 另造一个"已入库"但 content_path 落在媒体库内的任务:安全检查必须拒绝删数据 const trap = await h.db .insertInto("download_task") .values({ instance_id: base.instanceId, torrent_hash: "f".repeat(40), name: "trap", source_release_id: firstDt[0]!.source_release_id, save_path: lib.root, content_path: join(lib.root, "沙丘2 (2024)"), status: "imported", managed: true, completed_at: new Date(0), size_bytes: 1, }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("organization_record") .values({ trigger: "auto", actor_type: "system", action: "copy", status: "succeeded", download_task_id: trap.id }) .execute(); qb.seed({ hash: "f".repeat(40), name: "trap", tags: ["umt"], progress: 1, save_path: lib.root, size: 1 }); disk.setPct(95); const r = await cleanupDownloadCache(h.d); const v = r.volumes[0]!; expect(v.removed).toContain(firstDt[0]!.id); expect(v.removed).not.toContain(trap.id); expect(v.skipped.find((s) => s.downloadTaskId === trap.id)?.reason).toMatch(/拒绝删除|媒体库/); expect(qb.get(firstDt[0]!.torrent_hash)).toBeUndefined(); // 库内副本不受影响 expect(existsSync(join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"))).toBe(true); const locked = v.skipped.map((s) => s.reason); expect(locked.some((x) => x === "hnr_unsatisfied" || x === "import_not_verified")).toBe(false); // 做种片未入库,不在候选里 const op = await h.db .selectFrom("asset_operation") .selectAll() .where("operation_id", "=", `cleanup:${firstDt[0]!.id}`) .executeTakeFirstOrThrow(); expect(op).toMatchObject({ kind: "qb_remove", status: "succeeded" }); disk.setPct(40); }, 30_000); it("资产对账:库根离线只标 unavailable;缺失按 suspected → confirmed 推进,不删记录不补下载", async () => { const file = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"); const content = readFileSync(file); const loc = await h.db .selectFrom("asset_location") .selectAll() .where("relative_path", "=", "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv") .executeTakeFirstOrThrow(); const { rm } = await import("node:fs/promises"); await rm(join(lib.root, ".uhd-root")); const off = await reconcileLibrary(h.d); expect(off.stats.rootsOffline).toHaveLength(1); expect( (await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow()) .status, ).toBe("unavailable"); await writeFile(join(lib.root, ".uhd-root"), "x"); await rm(file); for (const want of ["suspected_missing", "suspected_missing", "confirmed_missing"]) { await reconcileLibrary(h.d); expect( (await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow()) .status, ).toBe(want); } expect( (await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow()) .status, ).toBe("attention"); expect( await h.db.selectFrom("media_asset").select("id").where("id", "=", loc.media_asset_id).executeTakeFirst(), ).toBeDefined(); await writeFile(file, content); await reconcileLibrary(h.d); expect( (await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow()) .status, ).toBe("available"); const runs = await h.db.selectFrom("reconcile_run").select(["kind", "status"]).execute(); expect(runs.every((r) => r.kind === "asset")).toBe(true); }, 30_000); it("首页、媒体库、库存叠加 API", async () => { const dash = await h.api("GET", "/dashboard"); expect(dash.status).toBe(200); expect(dash.body.data.recentOutput.length).toBeGreaterThan(0); expect(dash.body.data.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 }); expect(dash.body.data.needsAttention.total).toBeGreaterThan(0); const auto = dash.body.data.automation; expect(auto.library.entities).toBeGreaterThanOrEqual(1); expect(auto.library.movies).toBeGreaterThanOrEqual(1); expect(auto.library.assets).toBeGreaterThanOrEqual(1); const slotTotal = Object.values(auto.slotCounts as Record).reduce((a, b) => a + b, 0); expect(auto.slotSatisfiedRate).toBe(slotTotal ? auto.slotCounts.satisfied / slotTotal : null); const libList = await h.api("GET", "/library?q=沙丘"); expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 }); const det = await h.api("GET", `/library/${movieId}`); expect(det.status).toBe(200); expect(det.body.data.assets[0].locations[0].absolutePath).toBe( join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"), ); expect(det.body.data.candidates.some((c: { decision: string }) => c.decision === "reject")).toBe(true); expect(det.body.data.downloads[0].organizationRecordIds.length).toBeGreaterThan(0); // QB-1:下载任务直接记作品;整理记录按作品列出 const dune = det.body.data.downloads.find((x: { downloadTask: { name: string } }) => x.downloadTask.name.startsWith("Dune.Part.Two"), ); expect(dune.downloadTask.mediaEntityId).toBe(movieId); expect(det.body.data.organizationRecords.map((r: { id: string }) => r.id)).toEqual( expect.arrayContaining(dune.organizationRecordIds), ); const inv = await h.api("GET", `/inventory?entityIds=${movieId}`); expect(inv.body.data.items[0].versions[0]).toMatchObject({ resolution: "1080p", available: true }); const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`); expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [], partial: false }); }); it("自动整理被拦(attention)后手动整理成功:Slot 经 verifying 落 satisfied,整理事项自动关闭(QA U1)", async () => { await h.setSettings("organize", { defaultMode: "move" }); const e = await seedEntity(h.db, { kind: "movie", title: "被拦片", year: 2021 }); const slot = await seedSlot(h.db, { entityId: e }); const { dtId, planId } = await runToCompleted(e, "Blocked.Movie.2021.1080p.BluRay-HR", { hr: true }); // 新作品首次下载:计划用途是补缺,不是升级(QA U7) expect( (await h.db.selectFrom("acquisition_plan").select("purpose").where("id", "=", planId).executeTakeFirstOrThrow()) .purpose, ).toBe("fill"); const auto = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" }); expect(auto.status).toBe("failed"); const slotRow = () => h.db.selectFrom("acquisition_slot").selectAll().where("id", "=", slot).executeTakeFirstOrThrow(); expect((await slotRow()).status).toBe("attention"); const dec = await h.db .selectFrom("decision") .selectAll() .where("dedupe_key", "=", `organize_blocked:${dtId}`) .executeTakeFirstOrThrow(); expect(dec.status).toBe("open"); await h.setSettings("organize", { defaultMode: "copy" }); const manual = await planImport(h.d, { downloadTaskId: dtId, trigger: "manual", actorType: "user", actorId: null }); expect(manual.status).toBe("running"); const done = await executeRecord(h.d, manual.recordId!); expect(done.status).toBe("succeeded"); const after = await slotRow(); expect(after.status).toBe("satisfied"); expect(after.current_asset_id).not.toBeNull(); const asset = await h.db .selectFrom("media_asset") .select("media_entity_id") .where("id", "=", after.current_asset_id!) .executeTakeFirstOrThrow(); expect(asset.media_entity_id).toBe(e); const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); expect(closed.status).toBe("resolved"); expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: manual.recordId }); expect(closed.resolved_by).toBe("user"); }, 30_000); });