import { CapabilityError, type CapabilityRegistry } from "@umt/agent"; import { capabilitySpec, type DownloadStatus } from "@umt/contracts"; import { downloaderClientFor } from "../acquisition-organize/ports.ts"; import type { ServerContext } from "../types.ts"; import { planBinding } from "./authorize.ts"; import type { AgentModuleOptions } from "./runner.ts"; // download.recheck(REQ §8.4.2 / §10.7 授权内恢复):对已纳管的下载任务发起 qB 强制重新校验或恢复暂停。 // 只动下载器里的这一个种子:不换源、不删除、不改保存路径 / 分类 / 标签;下载任务状态仍由后台同步按 qB 实际状态更新, // 是否"已恢复"由 resolveDecision 的核实步骤看 download_task 状态决定,本能力只返回"已发出请求"。 /** recheck 允许的起点:文件缺失 / 出错 / 无进展 / 暂停(非阈值、非人工) */ const RECHECK_FROM: DownloadStatus[] = ["missing", "failed", "stalled", "paused"]; /** resume 允许的起点 */ const RESUME_FROM: DownloadStatus[] = ["paused", "stalled", "failed"]; /** 持续授权的对象范围:按下载任务 → 计划 → Slot → 作品核对(authorize 对非计划类能力拿不到目标作品) */ async function checkGrantScope(ctx: ServerContext, grantId: string, planId: string | null) { const g = await ctx.db.selectFrom("automation_grant").select("scope").where("id", "=", grantId).executeTakeFirst(); const scope = (g?.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[] }; if (!scope.mediaEntityIds?.length && !scope.mediaKinds?.length) return; const binding = planId ? await planBinding(ctx.db, "acquisition_plan", planId) : null; const e = binding?.mediaEntityId && binding.mediaKind ? { id: binding.mediaEntityId, kind: binding.mediaKind } : null; if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`); if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id)) throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`); if (scope.mediaKinds?.length) { const ok = scope.mediaKinds.includes(e.kind) || (e.kind !== "movie" && e.kind !== "variety" && scope.mediaKinds.includes("series")); if (!ok) throw new CapabilityError("unauthorized", `授权 ${grantId} 的媒体类型范围不含 ${e.kind}`); } } export function registerDownloadRecoveryCapabilities( registry: CapabilityRegistry, ctx: ServerContext, opts: AgentModuleOptions, ): void { registry.register(capabilitySpec("download.recheck"), async (input, c) => { const dt = await ctx.db .selectFrom("download_task") .selectAll() .where("id", "=", input.downloadTaskId) .executeTakeFirst(); if (!dt) throw new CapabilityError("not_found", "下载任务不存在"); if (!dt.managed || dt.removed_at || dt.status === "removed") throw new CapabilityError("precondition", "外部或已移除的下载任务不做自动恢复"); const status = dt.status as DownloadStatus; if (input.action === "recheck" && !RECHECK_FROM.includes(status)) throw new CapabilityError("precondition", `当前状态 ${status} 不需要重新校验`); if (input.action === "resume" && !RESUME_FROM.includes(status)) throw new CapabilityError("precondition", `当前状态 ${status} 不需要恢复`); // 阈值暂停不被绕过;人工暂停只由人恢复 if (status === "paused" && (dt.pause_reason === "threshold" || dt.pause_reason === "user")) throw new CapabilityError( "precondition", `${dt.pause_reason === "threshold" ? "因下载盘到暂停线" : "手动"}暂停的任务不自动恢复`, ); if (c.authorization?.grantId) await checkGrantScope(ctx, c.authorization.grantId, dt.acquisition_plan_id); const inst = await ctx.db .selectFrom("downloader_instance") .selectAll() .where("id", "=", dt.instance_id) .executeTakeFirst(); if (!inst) throw new CapabilityError("not_found", "下载器实例不存在"); // 与下载同步共用按实例缓存的客户端(同一登录会话与凭证处理),不另建 const client = opts.downloader ? await opts.downloader(inst) : await downloaderClientFor(ctx, inst); try { if (input.action === "recheck") await client.recheck([dt.torrent_hash]); else await client.resume([dt.torrent_hash]); } catch (err) { throw new CapabilityError("internal", `下载器请求失败:${(err as Error).message}`); } return { downloadTaskId: dt.id, action: input.action, statusBefore: status, rawStateBefore: dt.raw_state, requested: true, }; }); }