Files
UHD_Media_Tool/packages/contracts/src/execution.ts
T
rootandClaude Opus 5.5 4bb19433ad feat(手动下载): 保存目录选项接口与下载前指定作品
- GET /download-save-paths:按下载器列出默认保存目录与路径映射的下载器侧目录,并给出「自动」会用的下载器(站点指定 > 默认 > 第一个)
- POST /acquisition-plans 新增 identity(本地作品或 TMDB 外部身份):转调 /source-releases/{id}/identity 建 / 绑作品并加人工锁,再按该作品下载
- 测试 harness 支持装配 agent 模块;pg 测试覆盖保存目录选项与指定身份下载

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 12:10:03 +08:00

378 lines
13 KiB
TypeScript

import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts";
import { ExternalRef } from "./identity.ts";
import { AcquisitionPriority } from "./policy.ts";
// 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002)
/** 持久任务类型(任务书 §3.1-8)+ Agent 有界任务 */
export const TaskKind = z.enum([
"ingestFeed",
"resolveObservation",
"evaluateAcquisition",
"submitDownload",
"verifyAndImport",
"refreshMetadata",
"reconcileLibrary",
"cleanupDownloadCache",
"resolveDecision",
"agentRun",
]);
export type TaskKind = z.infer<typeof TaskKind>;
export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]);
export type TaskStatus = z.infer<typeof TaskStatus>;
export const Task = EntityBase.extend({
kind: TaskKind,
handlerVersion: z.number().int().min(1),
idempotencyKey: z.string().min(1),
status: TaskStatus,
/** 数值越小越先执行 */
priority: z.number().int(),
input: JsonObject,
output: JsonObject.nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
parentTaskId: Id.nullable(),
engineRunId: z.string().nullable(),
attempts: NonNegInt,
maxAttempts: z.number().int().min(1),
nextRunAt: Timestamp,
waitReason: z.string().nullable(),
cancelRequestedAt: Timestamp.nullable(),
cancelReason: z.string().nullable(),
lastError: JsonObject.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
triggeredBy: z.string().nullable(),
});
export type Task = z.infer<typeof Task>;
/** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */
export const TaskCreate = Task.pick({
kind: true,
handlerVersion: true,
idempotencyKey: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
}).partial({
handlerVersion: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
});
export type TaskCreate = z.infer<typeof TaskCreate>;
export const TaskUpdate = Task.pick({
status: true,
output: true,
engineRunId: true,
attempts: true,
nextRunAt: true,
waitReason: true,
lastError: true,
startedAt: true,
finishedAt: true,
}).partial();
export type TaskUpdate = z.infer<typeof TaskUpdate>;
/** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */
export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) });
export type TaskCancelRequest = z.infer<typeof TaskCancelRequest>;
export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]);
export type PlanPurpose = z.infer<typeof PlanPurpose>;
export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]);
export type PlanStatus = z.infer<typeof PlanStatus>;
/**
* 下载计划:与 Slot 预留、预算预留同事务创建。
* 剧集 Slot 可同时有多个单元不相交的 reserved/submitted 计划(Slot×单元预留);电影 Slot 同时只有一个。
*/
export const AcquisitionPlan = EntityBase.extend({
slotId: Id.nullable(),
sourceReleaseId: Id,
taskId: Id.nullable(),
purpose: PlanPurpose,
status: PlanStatus,
priority: AcquisitionPriority,
/** evaluateAcquisition 的确定性评估结果与拒绝/接受理由 */
evaluation: JsonObject,
reservedBytes: NonNegInt,
budgetReservation: JsonObject,
payloadHash: z.string().min(1),
expectedRevision: z.number().int().min(1),
idempotencyKey: z.string().min(1),
expiresAt: Timestamp.nullable(),
statusReason: z.string().nullable(),
/** 计划预留的内容单元(content_unit id 与合成键 s{季}e{集};s{季}* = 整季未核对),在途期间计入缺集的"已预留" */
unitKeys: z.array(z.string()),
/** 季包按缺集选文件的结果(提交后读文件清单;无法判定时整包下载并记理由) */
fileSelection: JsonObject.nullable(),
});
export type AcquisitionPlan = z.infer<typeof AcquisitionPlan>;
export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({
taskId: true,
purpose: true,
priority: true,
evaluation: true,
reservedBytes: true,
budgetReservation: true,
expectedRevision: true,
expiresAt: true,
statusReason: true,
unitKeys: true,
fileSelection: true,
});
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
/** 手动下载为一次性计划,不要求订阅或Slot;未识别先提示一次,确认后可继续下载。
* slotId保留兼容入口,手动计划不预留或创建订阅Slot。
*/
export const ManualAcquisitionRequest = z.object({
sourceReleaseId: Id,
slotId: Id.optional(),
entityId: Id.optional(),
note: z.string().max(2000).optional(),
downloaderInstanceId: Id.optional(),
savePath: z.string().trim().min(1).max(4096).optional(),
allowUnrecognized: z.boolean().optional(),
importMode: z.enum(["normal", "replace"]).default("normal"),
replacementAssetIds: z.array(Id).default([]),
/**
* 下载前指定作品:本地作品或外部身份(未登记的按 TMDB 详情加入媒体库),
* 与 POST /source-releases/{id}/identity 同一实现,绑定到资源并加人工锁;给了就不再用 entityId。
*/
identity: z
.union([z.object({ mediaEntityId: Id }).strict(), z.object({ external: ExternalRef }).strict()])
.optional(),
});
export type ManualAcquisitionRequest = z.infer<typeof ManualAcquisitionRequest>;
export const ManualAcquisitionResult = z.object({
/** true = 计划已预留并入队 submitDownload */
submitted: z.boolean(),
requiresConfirmation: z.boolean().optional(),
plan: AcquisitionPlan.nullable(),
slotId: Id.nullable(),
/** 业务任务 ID(submitDownload) */
taskId: Id.nullable(),
/** 未生成计划的原因;未识别时附requiresConfirmation */
rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })),
});
export type ManualAcquisitionResult = z.infer<typeof ManualAcquisitionResult>;
/** 手动下载可选的保存目录:按下载器列出默认保存目录与路径映射的下载器侧目录 */
export const DownloadSavePathQuery = z.object({
/** 资源所在站点;站点指定了下载器时「自动」按它解析 */
siteId: Id.optional(),
});
export type DownloadSavePathQuery = z.infer<typeof DownloadSavePathQuery>;
export const DownloadSaveDir = z.object({
/** 下载器视角的路径(提交给下载器的保存路径) */
path: z.string(),
/** 本机视角的路径;配置了映射但对不上时为 null */
hostPath: z.string().nullable(),
/** 下载器设置里的默认保存目录(「自动」用它) */
isDefault: z.boolean(),
});
export type DownloadSaveDir = z.infer<typeof DownloadSaveDir>;
export const DownloadSaveTarget = z.object({
instanceId: Id,
name: z.string(),
isDefault: z.boolean(),
/** 第一个是默认保存目录 */
dirs: z.array(DownloadSaveDir),
});
export type DownloadSaveTarget = z.infer<typeof DownloadSaveTarget>;
export const DownloadSavePaths = z.object({
/** 不指定下载器时会用的那个(站点指定 > 默认下载器 > 第一个有保存目录的);没有可用下载器为 null */
autoInstanceId: Id.nullable(),
instances: z.array(DownloadSaveTarget),
});
export type DownloadSavePaths = z.infer<typeof DownloadSavePaths>;
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;
export const DownloadOrigin = z.enum(["submitted", "adopted"]);
export type DownloadOrigin = z.infer<typeof DownloadOrigin>;
export const DownloadStatus = z.enum([
"queued",
"submitting",
"downloading",
"paused",
"stalled",
"completed",
"importing",
"imported",
"failed",
"removed",
"missing",
]);
export type DownloadStatus = z.infer<typeof DownloadStatus>;
/** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */
export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]);
export type PauseReason = z.infer<typeof PauseReason>;
export const TorrentHash = z.string().regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/);
export const DownloadTask = EntityBase.extend({
instanceId: Id,
/** 该下载器实例内的 torrent 标识(v1 hash 或 v2 hash,小写十六进制) */
torrentHash: TorrentHash,
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
acquisitionPlanId: Id.nullable(),
/** 本次手动整理计划;保留原下载计划关联用于订阅推进 */
importPlanId: Id.nullable().optional(),
sourceReleaseId: Id.nullable(),
/** 关联作品:计划 / 站点资源 / 接管任务按文件名识别 / 手动指定 / 手动整理后回填(QB-1) */
mediaEntityId: Id.nullable().optional(),
name: z.string(),
savePath: z.string().nullable(),
contentPath: z.string().nullable(),
category: z.string().nullable(),
tags: z.array(z.string()),
/** 是否在管理标签范围内;标签移除后暂停后续自动动作 */
managed: z.boolean(),
origin: DownloadOrigin,
status: DownloadStatus,
pauseReason: PauseReason.nullable(),
rawState: z.string().nullable(),
progress: z.number().min(0).max(1),
sizeBytes: NonNegInt.nullable(),
downloadedBytes: NonNegInt,
remainingBytes: NonNegInt.nullable(),
ratio: z.number().nullable(),
seedingTimeSec: NonNegInt.nullable(),
completedAt: Timestamp.nullable(),
lastProgressAt: Timestamp.nullable(),
/** 无进展起点:只记录并汇入待处理,不自动换源(REQ §8.4.2) */
noProgressSince: Timestamp.nullable(),
lastSyncedAt: Timestamp.nullable(),
nextCheckAt: Timestamp.nullable(),
removedAt: Timestamp.nullable(),
fileList: z.array(z.object({ path: z.string(), size: NonNegInt, progress: z.number().optional() })).nullable(),
lastError: JsonObject.nullable(),
/** 下载器里连续查不到的起点与轮数:达到阈值才判 removed,中途出现即清零 */
missingSince: Timestamp.nullable().optional(),
missingCount: NonNegInt.optional(),
/** removed 行被再次提交 / 接管复活的次数(完成事件幂等键随之换代) */
generation: NonNegInt.optional(),
});
export type DownloadTask = z.infer<typeof DownloadTask>;
export const DownloadTaskCreate = DownloadTask.pick({
instanceId: true,
torrentHash: true,
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
name: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
}).partial({
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
});
export type DownloadTaskCreate = z.infer<typeof DownloadTaskCreate>;
export const DownloadTaskUpdate = DownloadTask.omit({
...SERVER_MANAGED,
instanceId: true,
torrentHash: true,
}).partial();
export type DownloadTaskUpdate = z.infer<typeof DownloadTaskUpdate>;
export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]);
export type OperationPhase = z.infer<typeof OperationPhase>;
export const OperationKind = z.enum([
"copy",
"move",
"rename",
"mkdir",
"hardlink",
"delete_source",
"delete_target",
"verify",
"register",
"qb_pause",
"qb_resume",
"qb_remove",
"qb_set_tags",
"other",
]);
export type OperationKind = z.infer<typeof OperationKind>;
export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]);
export type OperationStatus = z.infer<typeof OperationStatus>;
/** 副作用账本:plan → attempt(started, attemptToken) → result;提交时以 attemptToken 做 fencing */
export const AssetOperation = EntityBase.extend({
operationId: z.string().min(1),
taskId: Id.nullable(),
organizationRecordId: Id.nullable(),
planFingerprint: z.string().nullable(),
ordinal: z.number().int().nonnegative(),
phase: OperationPhase,
kind: OperationKind,
status: OperationStatus,
intent: JsonObject,
attemptToken: Id.nullable(),
attempts: NonNegInt,
result: JsonObject.nullable(),
error: JsonObject.nullable(),
mediaAssetId: Id.nullable(),
downloadTaskId: Id.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type AssetOperation = z.infer<typeof AssetOperation>;
export const AssetOperationCreate = AssetOperation.pick({
operationId: true,
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
phase: true,
kind: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
}).partial({
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
});
export type AssetOperationCreate = z.infer<typeof AssetOperationCreate>;
export const AssetOperationUpdate = AssetOperation.pick({
status: true,
attemptToken: true,
attempts: true,
result: true,
error: true,
mediaAssetId: true,
startedAt: true,
finishedAt: true,
}).partial();
export type AssetOperationUpdate = z.infer<typeof AssetOperationUpdate>;