import { ManualAcquisitionRequest } from "@umt/contracts"; import { createMockQb, createTempTree, makeTorrent } from "@umt/testkit"; import { describe, expect, it } from "vitest"; import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; import { submitDownload } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { planManualAcquisition } from "../../src/modules/acquisition-organize/services/manual-acquisition.ts"; import { createHarness, fakeDisk, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot } from "./harness.ts"; const hasPg = await pgAvailable(); describe.skipIf(!hasPg)("独立手动下载", () => { it("无订阅下载,未知资源确认,同hash沿用原路径", async () => { const qb = await createMockQb({ addVisibleAfterPolls: 0 }); const cache = await createTempTree({}); const lib = await createTempTree({}, { sentinel: true }); const torrents = new Map(); const disk = fakeDisk(10_000_000); disk.setPct(20); const h = await createHarness("manual_acquisition", { diskUsage: disk.fn, torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }), }); try { const base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); const entityId = await seedEntity(h.db, { kind: "movie", title: "Manual Movie", year: 2026 }); const torrent = makeTorrent({ name: "Manual.Movie.2026.1080p.mkv", length: 300_000 }); const relId = await seedRelease(h.db, { siteId: base.siteId, entityId, title: torrent.name, size: 300_000, hash: torrent.hash, }); torrents.set(relId, torrent.bytes); const request = (id: string, opts = {}) => ManualAcquisitionRequest.parse({ sourceReleaseId: id, ...opts }); const first = await planManualAcquisition(h.ctx, request(relId)); expect(first).toMatchObject({ submitted: true, slotId: null }); expect(await h.db.selectFrom("subscription").selectAll().execute()).toHaveLength(0); expect(await h.db.selectFrom("acquisition_slot").selectAll().execute()).toHaveLength(0); const sub = await submitDownload(h.d, first.plan!.id); expect(sub.status).toBe("submitted"); const rel2 = await seedRelease(h.db, { siteId: base.siteId, entityId, title: "Other.Release.2160p.mkv", size: 300_000, hash: torrent.hash, }); torrents.set(rel2, torrent.bytes); const second = await planManualAcquisition(h.ctx, request(rel2, { savePath: `${cache.root}/other` })); const reused = await submitDownload(h.d, second.plan!.id); expect(reused).toMatchObject({ reason: "already_exists", downloadTaskId: sub.downloadTaskId }); const dt = await h.db .selectFrom("download_task") .selectAll() .where("id", "=", sub.downloadTaskId!) .executeTakeFirstOrThrow(); expect(dt.save_path).toBe(cache.root); expect(dt.media_entity_id).toBe(entityId); const unknown = makeTorrent({ name: "Unknown.1080p.mkv", length: 300_000, pieceSeed: 42 }); const uid = await seedRelease(h.db, { siteId: base.siteId, entityId, title: unknown.name, size: 300_000, hash: unknown.hash, }); await h.db.updateTable("source_release").set({ media_entity_id: null }).where("id", "=", uid).execute(); torrents.set(uid, unknown.bytes); expect(await planManualAcquisition(h.ctx, request(uid))).toMatchObject({ submitted: false, requiresConfirmation: true, }); const confirmed = await planManualAcquisition(h.ctx, request(uid, { allowUnrecognized: true })); const submitted = await submitDownload(h.d, confirmed.plan!.id); expect(submitted.status).toBe("submitted"); expect( ( await h.db .selectFrom("download_task") .select("media_entity_id") .where("id", "=", submitted.downloadTaskId!) .executeTakeFirstOrThrow() ).media_entity_id, ).toBeNull(); const autoEntity = await seedEntity(h.db, { kind: "movie", title: "Auto Movie", year: 2026 }); const slotId = await seedSlot(h.db, { entityId: autoEntity }); const autoTorrent = makeTorrent({ name: "Auto.Movie.2026.1080p.BluRay.x264-GRP.mkv", length: 300_000, pieceSeed: 100, }); const autoRelease = await seedRelease(h.db, { siteId: base.siteId, entityId: autoEntity, title: autoTorrent.name, size: 300_000, hash: autoTorrent.hash, }); torrents.set(autoRelease, autoTorrent.bytes); const auto = await evaluateAcquisition(h.d, { sourceReleaseId: autoRelease }); expect(auto.plans).toHaveLength(1); const autoTask = await submitDownload(h.d, auto.plans[0]!); const oldAsset = await h.db .insertInto("media_asset") .values({ media_entity_id: autoEntity, kind: "video", size_bytes: 300_000, status: "active" }) .returning("id") .executeTakeFirstOrThrow(); await h.db .updateTable("download_task") .set({ status: "imported" }) .where("id", "=", autoTask.downloadTaskId!) .execute(); const unchanged = await planManualAcquisition(h.ctx, request(autoRelease)); expect(unchanged.plan!.id).toBe(auto.plans[0]); const wash = await planManualAcquisition( h.ctx, request(autoRelease, { importMode: "replace", replacementAssetIds: [oldAsset.id] }), ); const washed = await submitDownload(h.d, wash.plan!.id); expect(washed.downloadTaskId).toBe(autoTask.downloadTaskId); const linked = await h.db .selectFrom("download_task") .selectAll() .where("id", "=", autoTask.downloadTaskId!) .executeTakeFirstOrThrow(); expect(linked).toMatchObject({ acquisition_plan_id: auto.plans[0], import_plan_id: wash.plan!.id, status: "completed", }); expect( ( await h.db .selectFrom("acquisition_plan") .select("slot_id") .where("id", "=", auto.plans[0]!) .executeTakeFirstOrThrow() ).slot_id, ).toBe(slotId); expect(h.engine.enqueued).toContainEqual( expect.objectContaining({ task: "verifyAndImport", input: expect.objectContaining({ downloadTaskId: autoTask.downloadTaskId }), }), ); } finally { await h.close(); await qb.close(); await cache.cleanup(); await lib.cleanup(); } }); });