Files
UHD_Media_Tool/apps/server/src/modules/agent/capabilities-download.ts
T
rootandClaude Opus 5.5 0019dc1afe copy(server): 下载、整理、站点、浏览器、字幕等模块错误与提示文案重写
缓存卷 → 下载盘;站点快照 → 站点列表抓取;Feed → RSS;去掉猜测原因与「请…」;同步断言。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:52:24 +08:00

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,
};
});
}