diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index a13f98ea..5e85777b 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -718,6 +718,9 @@ export interface SyncStats { thresholdResumed: number; } +/** 连续多少轮同步(列表 + 按 hash 查)都查不到才判 removed;与资产对账的 missingConfirmAfter 同口径 */ +export const MISSING_CONFIRM_ROUNDS = 3; + export async function syncDownloads(d: AoDeps): Promise { const stats: SyncStats = { instances: 0, @@ -797,13 +800,20 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats) // 管理标签被移除:停止后续自动动作,保留历史与资产 await trx .updateTable("download_task") - .set({ managed: false, tags: still.tags, last_synced_at: now }) + .set({ managed: false, tags: still.tags, last_synced_at: now, missing_since: null, missing_count: 0 }) + .where("id", "=", row.id) + .execute(); + } else if (row.missing_count + 1 < MISSING_CONFIRM_ROUNDS) { + // 列表与按 hash 都查不到:先记缺席(qB 刚重启、任务还没加载完时会短暂返回空 / 部分列表) + await trx + .updateTable("download_task") + .set({ missing_since: row.missing_since ?? now, missing_count: row.missing_count + 1, last_synced_at: now }) .where("id", "=", row.id) .execute(); } else { await trx .updateTable("download_task") - .set({ status: "removed", removed_at: now, last_synced_at: now }) + .set({ status: "removed", removed_at: now, last_synced_at: now, missing_count: row.missing_count + 1 }) .where("id", "=", row.id) .execute(); await emitStatus(d, trx, row.id, row.status, "removed", null); @@ -892,6 +902,8 @@ async function observeLocked( last_progress_at: lastProgressAt, no_progress_since: noProgressSince, last_synced_at: now, + missing_since: null, + missing_count: 0, ...(becameComplete ? { completed_at: now } : {}), }) .where("id", "=", row.id) diff --git a/apps/server/test/acquisition-organize/subscription.test.ts b/apps/server/test/acquisition-organize/subscription.test.ts index a779fc9e..776fe353 100644 --- a/apps/server/test/acquisition-organize/subscription.test.ts +++ b/apps/server/test/acquisition-organize/subscription.test.ts @@ -2,7 +2,11 @@ import { createQbClient } from "@umt/adapters"; 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 { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { + MISSING_CONFIRM_ROUNDS, + submitDownload, + syncDownloads, +} from "../../src/modules/acquisition-organize/services/downloads.ts"; import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; import { applyEpisodeTotal, @@ -159,7 +163,7 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () = // 用户在 qB 里删掉 E02:确认移除 → 计划失败释放单元 → Slot 到期重评 → 延后的 E02 另一版本得到计划 const qc = createQbClient({ instanceId: "t", baseUrl: qb.url, username: "admin", password: "adminadmin" }); await qc.delete([e2.hash], { deleteFiles: false }); - await syncDownloads(h.d); + for (let i = 0; i < MISSING_CONFIRM_ROUNDS; i++) await syncDownloads(h.d); expect(await planRow(p2)).toMatchObject({ status: "failed", status_reason: "download_removed" }); expect((await slotRow(slotId)).status).toBe("attention"); h.engine.enqueued.length = 0; diff --git a/apps/server/test/e2e/agent-quality.test.ts b/apps/server/test/e2e/agent-quality.test.ts index 7485fb35..c2c00ffd 100644 --- a/apps/server/test/e2e/agent-quality.test.ts +++ b/apps/server/test/e2e/agent-quality.test.ts @@ -41,6 +41,8 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => { name: "Dune.Part.Two.2024.1080p.BluRay.x264-GRP", status: "failed", last_error: JSON.stringify({ message: "qB 状态 missingFiles" }), + // 已连续缺席两轮:下一轮同步即确认移除(多轮缺席才判移除) + missing_count: 2, }) .returning("id") .executeTakeFirstOrThrow() diff --git a/apps/server/test/e2e/tag-removal.test.ts b/apps/server/test/e2e/tag-removal.test.ts index 2be080c8..8aab6f51 100644 --- a/apps/server/test/e2e/tag-removal.test.ts +++ b/apps/server/test/e2e/tag-removal.test.ts @@ -2,7 +2,7 @@ import { createQbClient } from "@umt/adapters"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; -import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { MISSING_CONFIRM_ROUNDS, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { torrentFor } from "./fixtures/modules.ts"; import { createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts"; @@ -206,7 +206,8 @@ describe.skipIf(!hasPg)("E8 AT24 标签移除 / 任务消失", () => { }); await ext.delete([ids.D.hash], { deleteFiles: false }); const externalDeletes = e.qb.callsTo("torrents/delete").length; - await syncDownloads(deps()); + // 连续多轮查不到才确认移除 + for (let i = 0; i < MISSING_CONFIRM_ROUNDS; i++) await syncDownloads(deps()); const row = await dt("D"); expect(row.status).toBe("removed"); expect(row.removed_at).not.toBeNull();