- 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
378 lines
13 KiB
TypeScript
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>;
|