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 { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; import { cleanupCandidates, DOWNLOADER_OFFLINE_DECISION_MS, PACK_METADATA_TIMEOUT_MS, submitDownload, syncDownloads, } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { loadVolumes } from "../../src/modules/acquisition-organize/services/volumes.ts"; import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot, } from "./harness.ts"; // 下载侧可见性与剩余隐患:下载器离线 / 下载目录读不到容量开待处理并自动关闭、H&R 未知按下载器设置放行、 // 季包暂停添加选完文件再恢复(磁力元数据延后 / 超时整包)、取消与提交并发、已收单未确认的提交补记订阅进度、 // 排队中的任务受阈值暂停、停用实例上的在途任务仍同步。真实 PG + mock qB。 const hasPg = await pgAvailable(); const SIZE = 300_000; const TABLE = { seasons: [{ season: 1, total: 12 }], latestSeason: 1, ended: true }; const ep = (n: number) => `Pack.Show.S01E${String(n).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`; describe.skipIf(!hasPg)("下载侧可见性(真实 PG + mock qB)", () => { let h: Harness; let qb: MockQb; let lib: TempTree; let cache: TempTree; const disk = fakeDisk(10_000_000_000); /** releaseId → 种子来源(.torrent 字节或磁力) */ const sources = new Map(); /** releaseId → 取种子时执行的钩子(模拟提交过程中发生的事) */ const onSource = new Map Promise>(); let base: Awaited>; let n = 0; beforeAll(async () => { qb = await createMockQb({ addVisibleAfterPolls: 0 }); lib = await createTempTree({}, { sentinel: true, prefix: "umt-vislib-" }); cache = await createTempTree({}, { prefix: "umt-viscache-" }); disk.state.watch = [cache.root]; disk.setPct(40); h = await createHarness( "dl_visibility", { diskUsage: disk.fn, torrentSource: async ({ release }) => { await onSource.get(release.id)?.(); return sources.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(); await lib?.cleanup(); await cache?.cleanup(); }); const decision = (key: string) => h.db .selectFrom("decision") .selectAll() .where("dedupe_key", "=", key) .orderBy("created_at", "desc") .executeTakeFirst(); const planRow = (id: string) => h.db.selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); const dtByHash = (hash: string) => h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirst(); async function movieRelease(title: string, hr: boolean | "unknown" = false) { const entityId = await seedEntity(h.db, { kind: "movie", title, year: 2020 + (n++ % 5) }); const slotId = await seedSlot(h.db, { entityId }); 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: `${title}.2021.1080p.BluRay.x264-GRP`, size: SIZE, hash: t.hash, hr, }); sources.set(id, { kind: "file", bytes: t.bytes }); return { entityId, slotId, releaseId: id, hash: t.hash }; } async function planFor(releaseId: string) { const ev = await evaluateAcquisition(h.d, { sourceReleaseId: releaseId }); expect(ev.plans).toHaveLength(1); return ev.plans[0]!; } /** 季包:剧集 + 季表 + 订阅收录版本 + 只缺 S01E03 的计划 */ async function seasonPack(opts: { magnet?: boolean } = {}) { const title = `Pack Show ${n++}`; const entityId = await seedEntity(h.db, { kind: "series", title, year: 2023 }); await h.db .insertInto("content_unit") .values({ media_entity_id: entityId, kind: "season", season_number: 1, episode_total: 12, status: "confirmed", source: "tmdb", }) .execute(); const sub = await h.db .insertInto("subscription") .values({ media_entity_id: entityId, media_kind: "series", mode: "continuous", status: "active" }) .returning("id") .executeTakeFirstOrThrow(); const slot = await h.db .insertInto("acquisition_slot") .values({ media_entity_id: entityId, subscription_id: sub.id, name: "1080p", hard_conditions: JSON.stringify({}), upgrade_threshold: JSON.stringify({}), }) .returning("id") .executeTakeFirstOrThrow(); const relTitle = `Pack.Show.${n}.S01.1080p.WEB-DL.x264-GRP`; const raw = parseRelease(relTitle); const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] }); const cols = toParsedColumns(canonicalParsed(raw, resolution)); const files = [1, 2, 3, 4].map((e) => ({ path: ep(e), length: SIZE })); const t = makeTorrent({ name: relTitle, files, pieceSeed: Math.floor(Math.random() * 1e6) }); const rel = await h.db .insertInto("source_release") .values({ site_id: base.siteId, site_release_id: `${Math.floor(Math.random() * 1e9)}`, title: relTitle, size_bytes: SIZE * 4, 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(); sources.set( rel.id, opts.magnet ? { kind: "magnet", uri: `magnet:?xt=urn:btih:${t.hash}&dn=${relTitle}` } : { kind: "file", bytes: t.bytes }, ); 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: entityId }), payload_hash: `ph-${rel.id}`, idempotency_key: `ik-${rel.id}`, unit_keys: ["s1e3"], }) .returning("id") .executeTakeFirstOrThrow(); return { planId: plan.id, hash: t.hash, relTitle }; } /** 磁力的 mock 种子:元数据到达后换成真实文件清单 */ const giveMetadata = (hash: string) => { const t = qb.get(hash)!; t.files = [1, 2, 3, 4].map((e) => ({ name: ep(e), size: SIZE, progress: 0, priority: 1 })); }; const priorities = (hash: string) => Object.fromEntries(qb.get(hash)!.files.map((f) => [f.name.split("/").pop()!, f.priority])); const resumedHashes = () => [...qb.callsTo("torrents/resume"), ...qb.callsTo("torrents/start")].flatMap((c) => (c.form.hashes ?? "").split("|"), ); it("下载器连不上超过 10 分钟开 downloader_offline 待处理(按实例去重),恢复连接后自动关闭", async () => { const qb2 = await createMockQb(); const inst = await h.db .insertInto("downloader_instance") .values({ name: "离线的 qB", base_url: "http://127.0.0.1:9", username: "admin", managed_tag: "umt-offline", settings: JSON.stringify({ savePath: cache.root }), last_seen_at: new Date(Date.now() - 2 * 60_000), }) .returning("id") .executeTakeFirstOrThrow(); const key = `downloader_offline:${inst.id}`; try { // 刚断 2 分钟:只标离线,不开事项 let st = await syncDownloads(h.d); expect(st.unreachable).toEqual([inst.id]); expect(await decision(key)).toBeUndefined(); await h.db .updateTable("downloader_instance") .set({ last_seen_at: new Date(Date.now() - DOWNLOADER_OFFLINE_DECISION_MS - 60_000) }) .where("id", "=", inst.id) .execute(); await syncDownloads(h.d); await syncDownloads(h.d); const open = await h.db.selectFrom("decision").selectAll().where("dedupe_key", "=", key).execute(); expect(open).toHaveLength(1); expect(open[0]).toMatchObject({ kind: "downloader_offline", severity: "critical", status: "open", subject_type: "downloader_instance", subject_id: inst.id, }); expect(open[0]!.summary).toContain("分钟连不上"); // 恢复:地址改对 await h.db.updateTable("downloader_instance").set({ base_url: qb2.url }).where("id", "=", inst.id).execute(); st = await syncDownloads(h.d); expect(st.unreachable).toEqual([]); expect(await decision(key)).toMatchObject({ status: "resolved", resolved_by: "system:download-sync" }); const health = await h.db .selectFrom("downloader_instance") .select(["health_status", "last_seen_at"]) .where("id", "=", inst.id) .executeTakeFirstOrThrow(); expect(health.health_status).toBe("healthy"); } finally { await h.db.deleteFrom("downloader_instance").where("id", "=", inst.id).execute(); await qb2.close(); } }); it("下载目录读不到容量:提交延后、面板显示「下载目录读不到」而不是等空间,按卷开 critical 事项,恢复后关闭", async () => { const { releaseId } = await movieRelease("读不到容量片"); const planId = await planFor(releaseId); disk.state.fail = true; try { const s = await submitDownload(h.d, planId); expect(s).toMatchObject({ status: "deferred", reason: "cache:volume_state_unknown" }); // 评估时已建好提交任务行(假引擎不执行):按处理器的做法记下延后结果 await h.db .updateTable("task") .set({ status: "succeeded", output: JSON.stringify(s) }) .where("kind", "=", "submitDownload") .where("idempotency_key", "=", `plan:${planId}`) .execute(); await syncDownloads(h.d); const vol = (await loadVolumes(h.d))[0]!; const key = `cache_volume_unknown:${vol.volumeKey}`; const dec = await decision(key); expect(dec).toMatchObject({ kind: "cache_volume_unknown", severity: "critical", status: "open" }); expect(dec!.evidence).toMatchObject({ reason: "statfs_failed", path: cache.root, usage: { code: "EIO" } }); // 再同步一轮不重复开 await syncDownloads(h.d); expect(await h.db.selectFrom("decision").select("id").where("dedupe_key", "=", key).execute()).toHaveLength(1); const board = (await h.api("GET", "/download-tasks/board")).body.data; const row = board.items.find((x: { id: string }) => x.id === planId); expect(row).toMatchObject({ kind: "plan", wait: "volume_unknown", waitReason: "cache:volume_state_unknown" }); disk.state.fail = false; await syncDownloads(h.d); expect(await decision(key)).toMatchObject({ status: "resolved" }); } finally { disk.state.fail = false; await h.db .updateTable("acquisition_plan") .set({ status: "cancelled", status_reason: "test_cleanup" }) .where("id", "=", planId) .execute(); } }); it("H&R 未知:缺省锁定(hnr_unknown);下载器打开「H&R 未知也可清理」后保种到期即可清理", async () => { const { releaseId, hash, entityId } = await movieRelease("H&R 未知片", "unknown"); const dt = await h.db .insertInto("download_task") .values({ instance_id: base.instanceId, torrent_hash: hash, source_release_id: releaseId, media_entity_id: entityId, name: "H&R 未知片", save_path: cache.root, content_path: `${cache.root}/H&R 未知片.mkv`, tags: ["umt"], managed: true, status: "imported", size_bytes: SIZE, completed_at: new Date(Date.now() - 30 * 86_400_000), }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("organization_record") .values({ download_task_id: dt.id, trigger: "auto", actor_type: "system", action: "copy", status: "succeeded", } as never) .execute(); const lockOf = async () => { const vol = (await loadVolumes(h.d))[0]!; return (await cleanupCandidates(h.d, vol)).find((c) => c.row.id === dt.id)!; }; expect(await lockOf()).toMatchObject({ cleanable: false, lockReason: "hnr_unknown" }); await h.db .updateTable("downloader_instance") .set({ settings: JSON.stringify({ savePath: cache.root, policy: { hnrUnknownCleanable: true } }) }) .where("id", "=", base.instanceId) .execute(); try { expect(await lockOf()).toMatchObject({ cleanable: true, lockReason: null }); // H&R 明确未满足的不受这个开关影响 await h.db .updateTable("source_release") .set({ raw: JSON.stringify({ _site: { hr: true } }) }) .where("id", "=", releaseId) .execute(); expect(await lockOf()).toMatchObject({ cleanable: false, lockReason: "hnr_unsatisfied" }); } finally { await h.db .updateTable("downloader_instance") .set({ settings: JSON.stringify({ savePath: cache.root }) }) .where("id", "=", base.instanceId) .execute(); await h.db.updateTable("download_task").set({ managed: false }).where("id", "=", dt.id).execute(); } }); it("季包(.torrent)暂停添加:选完文件再恢复下载", async () => { const p = await seasonPack(); const s = await submitDownload(h.d, p.planId); expect(s, JSON.stringify(s)).toMatchObject({ status: "submitted", reason: "added" }); const add = qb.callsTo("torrents/add").at(-1)!; expect(add.status).toBe(200); const plan = await planRow(p.planId); expect(plan.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3"] }); expect(priorities(p.hash)).toEqual({ [ep(1)]: 0, [ep(2)]: 0, [ep(3)]: 1, [ep(4)]: 0 }); // 先设文件优先级再恢复 const prioAt = qb.callsTo("torrents/filePrio").at(-1)!.at; const resume = qb .callsTo("torrents/resume") .concat(qb.callsTo("torrents/start")) .find((c) => (c.form.hashes ?? "").includes(p.hash)); expect(resume).toBeDefined(); expect(resume!.at).toBeGreaterThanOrEqual(prioAt); expect(qb.get(p.hash)!.state).toBe("downloading"); // 提交时任务行记为策略暂停,恢复后同步回到下载中 await syncDownloads(h.d); expect(await dtByHash(p.hash)).toMatchObject({ status: "downloading", pause_reason: null }); }); it("磁力季包:不暂停添加而是「拿到元数据后停止」;元数据未就绪时延后选文件,同步里拿到后选完再恢复", async () => { const p = await seasonPack({ magnet: true }); // 磁力元数据还没到:文件清单为空 qb.fault("torrents/files", { status: 200, body: "[]", times: 3 }); const s = await submitDownload(h.d, p.planId); expect(s).toMatchObject({ status: "submitted", reason: "added" }); expect(await planRow(p.planId)).toMatchObject({ file_selection: { mode: "pending", attempts: 1 } }); expect(resumedHashes()).not.toContain(p.hash); qb.clearFaults(); giveMetadata(p.hash); await syncDownloads(h.d); const plan = await planRow(p.planId); expect(plan.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3"] }); expect(plan.unit_keys).toEqual(["s1e3"]); expect(priorities(p.hash)[ep(3)]).toBe(1); expect(priorities(p.hash)[ep(1)]).toBe(0); expect(resumedHashes()).toContain(p.hash); }); it("磁力季包元数据一直拿不到:超时后整包下载并记录原因", async () => { const p = await seasonPack({ magnet: true }); qb.fault("torrents/files", { status: 200, body: "[]", times: 1000 }); const realNow = h.d.ports.now; try { await submitDownload(h.d, p.planId); await syncDownloads(h.d); expect(await planRow(p.planId)).toMatchObject({ file_selection: { mode: "pending", attempts: 2 } }); h.d.ports.now = () => new Date(Date.now() + PACK_METADATA_TIMEOUT_MS + 60_000); await syncDownloads(h.d); } finally { h.d.ports.now = realNow; qb.clearFaults(); } const plan = await planRow(p.planId); expect(plan.file_selection).toMatchObject({ mode: "whole", reason: "file_list_timeout" }); expect(resumedHashes()).toContain(p.hash); }); it("取消与提交并发:qB 已收单时计划被取消,commit 复核后不写回 submitted,qB 里的任务暂停并去掉管理标签", async () => { const { releaseId, hash } = await movieRelease("并发取消片"); const planId = await planFor(releaseId); onSource.set(releaseId, async () => { const r = await h.api("POST", `/acquisition-plans/${planId}/cancel`); expect(r.status).toBe(200); }); const s = await submitDownload(h.d, planId); expect(s).toMatchObject({ status: "skipped", reason: "plan_cancelled" }); expect(await planRow(planId)).toMatchObject({ status: "cancelled", status_reason: "cancelled_by_user" }); expect(await dtByHash(hash)).toBeUndefined(); const t = qb.get(hash)!; expect(t.tags).not.toContain("umt"); expect(t.tags).toContain("umt-cancelled"); expect(t.state).toMatch(/^(paused|stopped)/); // 同步不接管它,计划也不被复活 await syncDownloads(h.d); expect(await dtByHash(hash)).toBeUndefined(); expect(await planRow(planId)).toMatchObject({ status: "cancelled" }); }); it("已收单未确认的提交:同步首次在 qB 里看到时补记订阅进度,计划改记已确认", async () => { const { releaseId, hash, slotId } = await movieRelease("未确认片"); const plan = await h.db .insertInto("acquisition_plan") .values({ slot_id: slotId, source_release_id: releaseId, status: "submitted", status_reason: "accepted_unconfirmed", evaluation: JSON.stringify({ instanceId: base.instanceId }), payload_hash: `ph-${releaseId}`, idempotency_key: `ik-${releaseId}`, }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("download_task") .values({ instance_id: base.instanceId, torrent_hash: hash, acquisition_plan_id: plan.id, source_release_id: releaseId, name: "未确认片", tags: ["umt"], managed: true, status: "submitting", }) .execute(); const sub = () => h.db .selectFrom("subscription as s") .innerJoin("acquisition_slot as sl", "sl.subscription_id", "s.id") .select("s.last_progress_at") .where("sl.id", "=", slotId) .executeTakeFirstOrThrow(); expect((await sub()).last_progress_at).toBeNull(); // 还没出现在 qB 里:不补记 await syncDownloads(h.d); expect(await planRow(plan.id)).toMatchObject({ status_reason: "accepted_unconfirmed" }); qb.seed({ hash, name: "未确认片", tags: ["umt"], save_path: cache.root }); const st = await syncDownloads(h.d); expect(st.confirmedPlans).toEqual([plan.id]); expect(await planRow(plan.id)).toMatchObject({ status: "submitted", status_reason: "accepted_confirmed" }); expect((await sub()).last_progress_at).not.toBeNull(); // 只补一次 expect((await syncDownloads(h.d)).confirmedPlans).toEqual([]); await h.db.updateTable("download_task").set({ managed: false }).where("torrent_hash", "=", hash).execute(); }); it("排队中(queuedDL)的纳管任务也受阈值暂停,降到恢复线后恢复", async () => { const { releaseId, hash } = await movieRelease("排队片"); const planId = await planFor(releaseId); expect(await submitDownload(h.d, planId)).toMatchObject({ status: "submitted" }); qb.setState(hash, "queuedDL"); await syncDownloads(h.d); expect(await dtByHash(hash)).toMatchObject({ status: "queued" }); disk.setPct(95); try { const st = await syncDownloads(h.d); expect(st.thresholdPaused).toBeGreaterThan(0); expect(await dtByHash(hash)).toMatchObject({ status: "paused", pause_reason: "threshold" }); expect(qb.get(hash)!.state).toMatch(/^(paused|stopped)/); } finally { disk.setPct(40); } await syncDownloads(h.d); expect(await dtByHash(hash)).toMatchObject({ pause_reason: null }); expect((await dtByHash(hash))!.status).not.toBe("paused"); }); it("停用的实例上还有在途任务:仍同步其状态,但不接管新任务", async () => { const inst = await h.db .insertInto("downloader_instance") .values({ name: "停用的 qB", base_url: qb.url, username: "admin", managed_tag: "umt-old", enabled: false, settings: JSON.stringify({ savePath: cache.root }), }) .returning("id") .executeTakeFirstOrThrow(); const hash = (900 + n++).toString(16).padStart(40, "e"); const other = (901 + n++).toString(16).padStart(40, "e"); qb.seed({ hash, name: "停用实例上的任务", tags: ["umt-old"], save_path: cache.root }); qb.seed({ hash: other, name: "停用实例上的新任务", tags: ["umt-old"], save_path: cache.root }); qb.setProgress(hash, 0.5); const dt = await h.db .insertInto("download_task") .values({ instance_id: inst.id, torrent_hash: hash, name: "停用实例上的任务", tags: ["umt-old"], managed: true, status: "downloading", progress: 0.1, }) .returning("id") .executeTakeFirstOrThrow(); try { const st = await syncDownloads(h.d); expect(st.unreachable).toEqual([]); const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow(); expect(row.progress).toBeCloseTo(0.5); expect(await dtByHash(other)).toBeUndefined(); } finally { await h.db.deleteFrom("download_task").where("instance_id", "=", inst.id).execute(); await h.db.deleteFrom("downloader_instance").where("id", "=", inst.id).execute(); } }); });