Files
UHD_Media_Tool/packages/contracts/src/download-board.ts
T
rootandClaude Opus 5.5 a9147e647d feat(downloads): fold download plans into download panel (DL-3)
- Waiting plans (approval / disk space / automation off / queued) are
  active-segment rows on /download-tasks/board, merged by created_at,
  counted, with approve / cancel actions; ?plan= deep links and legacy
  /downloads/plans routes redirect to the panel
- Download detail shows "why this download" (source, purpose, replaced
  version, reasons); raw evaluation in tech info
- Failed / cancelled / expired plans appear in the entity download
  history; rejected / revoked / expired approvals close draft plans
- New GET /acquisition-plans/{id}/why, POST /acquisition-plans/{id}/cancel
- User-facing copy no longer says 下载计划; glossary updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 01:15:25 +08:00

143 lines
6.2 KiB
TypeScript

import { z } from "zod";
import { DecisionKind } from "./agent.ts";
import { CursorQuery, Id, NonNegInt, Timestamp } from "./common.ts";
import { type DownloadStatus, DownloadTask, PlanPurpose, PlanStatus } from "./execution.ts";
import { OrganizationStatus } from "./organization.ts";
// 下载面板(DL-2):下载任务 + 下载器实时数据(速度、剩余时间、连接数)+ 分组计数 + 下载器总速度。
// 实时数据来自 qB torrents/info 与 transfer/info,服务端短 TTL 缓存(轮询不直打 qB)。
/**
* 进行中 = 排队/提交中/下载中/暂停/无进展/出错/缺失 + 还没提交、在等条件的计划(DL-3);
* 已完成 = 下载完成(含做种)/入库中/已入库;已移除不出现
*/
export const DownloadBoardState = z.enum(["active", "completed"]);
export type DownloadBoardState = z.infer<typeof DownloadBoardState>;
export const DOWNLOAD_BOARD_STATUSES: Record<DownloadBoardState, readonly DownloadStatus[]> = {
active: ["queued", "submitting", "downloading", "paused", "stalled", "failed", "missing"],
completed: ["completed", "importing", "imported"],
};
export const DownloadBoardQuery = CursorQuery.extend({
state: DownloadBoardState.default("active"),
instanceId: Id.optional(),
/** 深链(DL-3):只返回这个计划对应的行——等待中的计划,或它的下载任务(不分进行中 / 已完成) */
planId: Id.optional(),
});
export type DownloadBoardQuery = z.infer<typeof DownloadBoardQuery>;
/** 单个种子的实时数据(qB torrents/info);下载器不可达或已不在下载器里时为 null */
export const DownloadLive = z.object({
/** 字节/秒 */
dlSpeed: NonNegInt,
upSpeed: NonNegInt,
/** 剩余秒数;qB 的 8640000(无穷)与已完成记为 null */
etaSec: NonNegInt.nullable(),
progress: z.number().min(0).max(1),
rawState: z.string(),
/** 已连接的做种 / 下载用户 */
seeds: NonNegInt,
leechs: NonNegInt,
/** Tracker 报告的总数(未知为 null) */
seedsTotal: NonNegInt.nullable(),
leechsTotal: NonNegInt.nullable(),
ratio: z.number().nullable(),
/** 下载器记录的完成时间 */
completedAt: Timestamp.nullable(),
});
export type DownloadLive = z.infer<typeof DownloadLive>;
export const DownloadBoardItem = DownloadTask.extend({
kind: z.literal("task"),
live: DownloadLive.nullable(),
/** 已识别的作品 */
media: z.object({ id: Id, title: z.string(), year: z.number().int().nullable() }).nullable(),
/** 最近一次整理结果:状态 + 原因码(人话见 reasons 表);有未关闭的待处理事项时带上,前端链过去 */
organize: z
.object({
recordId: Id,
status: OrganizationStatus,
reason: z.string().nullable(),
decision: z.object({ id: Id, kind: DecisionKind }).nullable(),
})
.nullable(),
});
export type DownloadBoardItem = z.infer<typeof DownloadBoardItem>;
/**
* 计划在等什么(DL-3):approval = 等管理员批准(有待处理的审批);space = 下载盘超过暂停线或用量未知;
* automation_off = 自动化总开关关闭 / 只读对账;queued = 条件已满足、等提交任务执行。
* 每日下载额度在生成计划时核对,超额不生成计划,所以没有"等额度"的计划。
*/
export const PlanWait = z.enum(["approval", "space", "automation_off", "queued"]);
export type PlanWait = z.infer<typeof PlanWait>;
/** 还没提交到下载器的计划,作为「进行中」的一行(id 是计划 id) */
export const DownloadBoardPlanItem = z.object({
kind: z.literal("plan"),
id: Id,
createdAt: Timestamp,
/** 资源标题 */
name: z.string(),
sizeBytes: NonNegInt.nullable(),
purpose: PlanPurpose,
status: PlanStatus,
media: z.object({ id: Id, title: z.string(), year: z.number().int().nullable() }).nullable(),
wait: PlanWait,
/** 原因码(提交任务的延后原因等;人话见 reasons 表,原码进技术信息) */
waitReason: z.string().nullable(),
/** 待批准时的审批(批准 / 拒绝回传 payloadHash) */
approval: z.object({ id: Id, payloadHash: z.string(), decisionId: Id.nullable() }).nullable(),
});
export type DownloadBoardPlanItem = z.infer<typeof DownloadBoardPlanItem>;
export const DownloadBoardRow = z.discriminatedUnion("kind", [DownloadBoardItem, DownloadBoardPlanItem]);
export type DownloadBoardRow = z.infer<typeof DownloadBoardRow>;
/** 下载的来由(DL-3「为什么下这个」):来源、目的、替换的版本、选中理由的原始要点 */
export const DownloadWhy = z.object({
planId: Id,
source: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("subscription"), id: Id, name: z.string() }),
z.object({ kind: z.literal("agent"), runId: Id, name: z.string() }),
z.object({ kind: z.literal("manual") }),
z.object({ kind: z.literal("auto") }),
]),
purpose: PlanPurpose,
/** 收录版本名 */
slotName: z.string().nullable(),
/** 升级 / 换源时被替换的版本(规格短句) */
replaces: z.object({ assetId: Id, label: z.string() }).nullable(),
/** evaluation.reasons 原串(hard_conditions_met、source_tier_improved、tier:xxx…) */
reasons: z.array(z.string()),
/** 资源的促销类型(free / 2xfree / 50% …)与做种数 */
promotion: z.string().nullable(),
seeders: NonNegInt.nullable(),
siteName: z.string().nullable(),
/** 原始评估结果(技术信息里显示) */
evaluation: z.record(z.string(), z.unknown()),
});
export type DownloadWhy = z.infer<typeof DownloadWhy>;
export const DownloaderTransfer = z.object({
instanceId: Id,
name: z.string(),
reachable: z.boolean(),
dlSpeed: NonNegInt,
upSpeed: NonNegInt,
});
export type DownloaderTransfer = z.infer<typeof DownloaderTransfer>;
export const DownloadBoard = z.object({
/** 进行中:下载任务与等待中的计划按创建时间一起倒序;已完成只有下载任务 */
items: z.array(DownloadBoardRow),
nextCursor: z.string().nullable(),
counts: z.object({ active: NonNegInt, completed: NonNegInt }),
/** 下载器总速度(transfer/info,含非本系统管理的任务);不可达的实例速度记 0 */
transfer: z.object({ dlSpeed: NonNegInt, upSpeed: NonNegInt, instances: z.array(DownloaderTransfer) }),
/** 实时数据的取样时间(缓存命中时早于请求时间);没有启用的下载器时为 null */
liveAt: Timestamp.nullable(),
});
export type DownloadBoard = z.infer<typeof DownloadBoard>;