缓存卷 → 下载盘;站点快照 → 站点列表抓取;Feed → RSS;去掉猜测原因与「请…」;同步断言。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
84 lines
4.6 KiB
TypeScript
84 lines
4.6 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
}
|