import { z } from "zod"; import { AgentRunStatus, Decision, DecisionKind } from "./agent.ts"; import { AssetLocation, MediaAsset, PathBinding } from "./assets.ts"; import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts"; import { MediaEntity, MediaKind } from "./content.ts"; import { AcquisitionPlan, DownloadStatus, DownloadTask } from "./execution.ts"; import { ExternalIdentity } from "./identity.ts"; import { AcquisitionSlot, CandidateEvaluation, SlotStatus } from "./policy.ts"; import { LibraryScrapeState } from "./scrape.ts"; // 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4) // 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。 export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]); export type OrganizationTrigger = z.infer; export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]); export type OrganizationStatus = z.infer; /** * Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原 / 原地入库 * (后三者只由文件管理的显式删除/还原产生,记录已结束,不经 planImport) */ export const OrganizationAction = z.enum([ "copy", "move", "rename", "relocate", "hardlink", "trash", "delete", "restore", "in_place", ]); export type OrganizationAction = z.infer; /** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */ export const SourcePathView = z.enum(["downloader", "local", "library"]); export type SourcePathView = z.infer; export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]); export type FileRole = z.infer; export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]); export type FileResultStatus = z.infer; /** 稳定变更游标:bigint 序号的十进制字符串 */ export const ChangeSeq = z.number().int().nonnegative(); export const ChangeCursor = z.string().regex(/^\d{1,16}$/); export type ChangeCursor = z.infer; // ── 内部行表示(与 organization_record / organization_file_result 列一一对应,不含 change_xid) ── export const OrganizationRecord = EntityBase.extend({ taskId: Id.nullable(), trigger: OrganizationTrigger, actorType: ActorType, actorId: z.string().nullable(), mediaEntityId: Id.nullable(), contentUnitId: Id.nullable(), downloadTaskId: Id.nullable(), sourceDownloaderInstanceId: Id.nullable(), sourcePathView: SourcePathView, sourcePath: z.string().nullable(), targetLibraryRootId: Id.nullable(), targetPath: z.string().nullable(), action: OrganizationAction, status: OrganizationStatus, reason: z.string().nullable(), error: JsonObject.nullable(), /** 冻结的整理计划 */ plan: JsonObject, planFingerprint: z.string().nullable(), fileTotal: NonNegInt, fileSucceeded: NonNegInt, fileFailed: NonNegInt, fileSkipped: NonNegInt, startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), changeSeq: ChangeSeq, /** 用户在整理记录里忽略了这条记录的问题(不再算需要处理) */ attentionClearedAt: Timestamp.nullable(), }); export type OrganizationRecord = z.infer; export const OrganizationRecordCreate = OrganizationRecord.pick({ taskId: true, trigger: true, actorType: true, actorId: true, mediaEntityId: true, contentUnitId: true, downloadTaskId: true, sourceDownloaderInstanceId: true, sourcePathView: true, sourcePath: true, targetLibraryRootId: true, targetPath: true, action: true, plan: true, planFingerprint: true, }).partial({ taskId: true, actorId: true, mediaEntityId: true, contentUnitId: true, downloadTaskId: true, sourceDownloaderInstanceId: true, sourcePathView: true, sourcePath: true, targetLibraryRootId: true, targetPath: true, plan: true, planFingerprint: true, }); export type OrganizationRecordCreate = z.infer; export const OrganizationRecordUpdate = OrganizationRecord.pick({ status: true, reason: true, error: true, targetPath: true, fileTotal: true, fileSucceeded: true, fileFailed: true, fileSkipped: true, startedAt: true, finishedAt: true, }).partial(); export type OrganizationRecordUpdate = z.infer; export const OrganizationFileResult = EntityBase.extend({ organizationRecordId: Id, ordinal: z.number().int().nonnegative(), assetOperationId: Id.nullable(), mediaAssetId: Id.nullable(), contentUnitId: Id.nullable(), role: FileRole, mode: OrganizationAction, sourcePath: z.string().min(1), sourceSizeBytes: NonNegInt.nullable(), targetLibraryRootId: Id.nullable(), targetPath: z.string().nullable(), status: FileResultStatus, skipReason: z.string().nullable(), error: JsonObject.nullable(), checksum: z.string().nullable(), startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), }); export type OrganizationFileResult = z.infer; export const OrganizationFileResultCreate = OrganizationFileResult.pick({ organizationRecordId: true, ordinal: true, assetOperationId: true, contentUnitId: true, role: true, mode: true, sourcePath: true, sourceSizeBytes: true, targetLibraryRootId: true, targetPath: true, }).partial({ ordinal: true, assetOperationId: true, contentUnitId: true, role: true, sourceSizeBytes: true, targetLibraryRootId: true, targetPath: true, }); export type OrganizationFileResultCreate = z.infer; export const OrganizationFileResultUpdate = OrganizationFileResult.pick({ assetOperationId: true, mediaAssetId: true, targetPath: true, status: true, skipReason: true, error: true, checksum: true, startedAt: true, finishedAt: true, }).partial(); export type OrganizationFileResultUpdate = z.infer; // ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ── /** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */ export const PublicReason = z.object({ code: z.string().min(1), message: z.string(), }); export type PublicReason = z.infer; /** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */ export const PublicPathRef = z.object({ path: z.string().nullable(), view: z.enum(["downloader", "local", "library"]), downloaderInstanceId: Id.nullable(), libraryRootId: Id.nullable(), }); export type PublicPathRef = z.infer; export const PublicOrganizationFile = z.object({ id: Id, ordinal: z.number().int().nonnegative(), role: FileRole, mode: OrganizationAction, status: FileResultStatus, source: PublicPathRef, target: PublicPathRef, sizeBytes: NonNegInt.nullable(), contentUnitId: Id.nullable(), mediaAssetId: Id.nullable(), operationId: Id.nullable(), skipReason: PublicReason.nullable(), failure: PublicReason.nullable(), startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), }); export type PublicOrganizationFile = z.infer; /** 整理记录关联的未关闭事项(在整理记录里就地处理;选项与「处理」端点同 /decisions/{id}/resolve) */ export const RecordAttentionDecision = Decision.pick({ id: true, kind: true, status: true, title: true, summary: true, options: true, agentRunId: true, evidence: true, }); export type RecordAttentionDecision = z.infer; /** * 需要处理(2026-10-06 去掉待处理中心后,整理侧的问题在整理记录里就地处理): * topic:identity = 没识别出作品(可指定作品 / 让智能体识别);conflict = 目标已存在 / 路径冲突(按选项替换、保留两者或跳过); * other = 其余失败或部分完成(排除、非视频、卡住、执行失败…,重新整理或忽略)。 * agentIdentify:可以让智能体识别(没识别出作品且有关联下载任务)。 */ export const RecordAttention = z.object({ topic: z.enum(["identity", "conflict", "other"]), decision: RecordAttentionDecision.nullable(), agentIdentify: z.boolean(), }); export type RecordAttention = z.infer; export const PublicOrganizationRecord = z.object({ /** 稳定记录 ID */ id: Id, taskId: Id.nullable(), /** 来源下载任务(手动整理本地路径时为 null) */ downloadTaskId: Id.nullable(), /** 相关资产操作账本 ID */ operationIds: z.array(Id), media: z .object({ entityId: Id, kind: MediaKind, title: z.string(), year: z.number().int().nullable(), contentUnitId: Id.nullable(), seasonNumber: z.number().int().nullable(), episodeNumber: z.number().int().nullable(), /** 已确认的外部身份,如 tmdb:movie:693134 */ externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })), /** HIS-1:作品海报(TMDB poster_path,如 /abc.jpg);没有为 null */ posterPath: z.string().nullable().optional(), /** 整理时按二级分类策略得到的分类(如「纪录片」「欧美电影」,取自冻结计划);没有为 null */ category: z.string().nullable().optional(), }) .nullable(), trigger: OrganizationTrigger, /** 手动 / 自动来源(agent 视为自动) */ origin: z.enum(["manual", "auto"]), actor: z.object({ type: ActorType, id: z.string().nullable() }), action: OrganizationAction, status: OrganizationStatus, source: PublicPathRef, target: PublicPathRef, reason: PublicReason.nullable(), counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }), /** HIS-1:逐文件源大小之和(字节);没有文件明细为 null */ sizeBytes: NonNegInt.nullable().optional(), /** HIS-1:来源下载任务摘要(下载器名称、种子 hash);手动整理本地路径时为 null */ download: z .object({ taskId: Id, name: z.string(), hash: z.string(), downloaderName: z.string().nullable() }) .nullable() .optional(), /** includeFiles=false 时为 null */ files: z.array(PublicOrganizationFile).nullable(), createdAt: Timestamp, updatedAt: Timestamp, startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), /** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */ changeSeq: ChangeCursor, /** 需要处理时给出原因与可做的动作;不需要为 null(旧客户端可忽略) */ attention: RecordAttention.nullable().optional(), }); export type PublicOrganizationRecord = z.infer; /** 增量读取请求:after 为上次响应的 nextCursor(首读省略即从头) */ export const OrganizationChangesQuery = z.object({ after: ChangeCursor.optional(), limit: z.coerce.number().int().min(1).max(500).default(100), includeFiles: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).default(true), status: OrganizationStatus.optional(), mediaEntityId: Id.optional(), }); export type OrganizationChangesQuery = z.infer; /** * 增量读取响应:items 按 changeSeq 升序;nextCursor 总是返回(无新数据时等于请求的 after), * hasMore=true 表示应立即继续拉取。同一记录状态更新会以更大的 changeSeq 再次出现。 */ export const OrganizationChangesResponse = z.object({ items: z.array(PublicOrganizationRecord), nextCursor: ChangeCursor, hasMore: z.boolean(), }); export type OrganizationChangesResponse = z.infer; /** 列表查询(控制台):按创建时间倒序的不透明游标分页 */ export const OrganizationListQuery = z.object({ cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), status: OrganizationStatus.optional(), /** 工作队列与历史记录共用存储,历史只含终态。 */ phase: z.enum(["active", "history"]).optional(), trigger: OrganizationTrigger.optional(), mediaEntityId: Id.optional(), downloadTaskId: Id.optional(), /** 作品标题、源/目标路径、种子 hash;支持 * 与 ? 通配 */ q: z.string().max(200).optional(), /** HIS-1:作品类型;none = 没关联作品(未识别) */ kind: z.enum(["movie", "series", "none"]).optional(), /** HIS-1:只看这之后创建的记录 */ since: Timestamp.optional(), /** HIS-1:只看整理类记录(复制/移动/硬链接),不含文件管理的改名、删除、还原 */ organizeOnly: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).optional(), /** * 只看需要处理的记录:有关联的未关闭事项,或失败 / 部分完成、没被同一下载任务后来的整理取代、也没被忽略的 * (口径见 docs/runbook/organize.md「需要处理」) */ attention: z .union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")]) .optional(), }); export type OrganizationListQuery = z.infer; /** HIS-1:统计与列表同一套筛选(不含状态,状态按计数分开给) */ export const OrganizationStatsQuery = OrganizationListQuery.omit({ cursor: true, limit: true, status: true }); export type OrganizationStatsQuery = z.infer; export const OrganizationStats = z.object({ total: NonNegInt, succeeded: NonNegInt, partial: NonNegInt, failed: NonNegInt, /** 排队 + 执行中 */ running: NonNegInt, cancelled: NonNegInt, /** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */ attention: NonNegInt, }); export type OrganizationStats = z.infer; /** * HIS-1:删除整理记录,可选同时删源文件 / 媒体库里的文件(对照 MP deletesrc / deletedest)。 * 文件一律移入回收站(同文件管理删除,可在回收站还原);下载器里的任务不动。 * 删文件只允许管理员(控制台会话或 DEV key);API Key 删文件请走 /files/delete(一次性审批)。 */ export const OrganizationDeleteRequest = z.object({ ids: z.array(Id).min(1).max(200), deleteSource: z.boolean().default(false), deleteTarget: z.boolean().default(false), /** true 只核对会删什么,不动文件与记录 */ dryRun: z.boolean().default(false), }); export type OrganizationDeleteRequest = z.infer; /** 单条记录不能删的原因:running 还在整理 / not_found 记录不存在 / 其余同文件管理删除的 blocked 码 */ export const OrganizationDeleteBlock = z.enum([ "running", "not_found", "download_active", "needs_unbind", "is_root", "root_unavailable", "escape", "outside_roots", "failed", ]); export type OrganizationDeleteBlock = z.infer; export const OrganizationDeleteResult = z.object({ dryRun: z.boolean(), items: z.array( z.object({ id: Id, /** 会移入回收站的源文件 / 媒体库文件数(已不存在的不算) */ sourceFiles: NonNegInt, targetFiles: NonNegInt, bytes: NonNegInt, blocked: OrganizationDeleteBlock.nullable(), /** blocked 时的一句说明(路径 + 原因) */ message: z.string().nullable(), }), ), /** 执行后真正删掉的记录 */ removed: z.array(Id), sourceFiles: NonNegInt, targetFiles: NonNegInt, bytes: NonNegInt, /** 删掉的媒体库文件里已入库的文件数(同时从媒体库移除) */ assets: NonNegInt, /** 删记录后会被自动整理重新处理的下载任务数(该下载任务没有别的整理记录了) */ autoRetry: NonNegInt, /** 文件移入回收站时写的「移入回收站」整理记录 */ trashRecordId: Id.nullable(), }); export type OrganizationDeleteResult = z.infer; /** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */ export const OrganizeRequest = z .object({ downloadTaskId: Id.optional(), /** 执行进程可访问的源路径(与 downloadTaskId 二选一) */ sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(), mediaEntityId: Id.optional(), contentUnitId: Id.optional(), action: z.enum(["copy", "move", "in_place"]), targetLibraryRootId: Id.optional(), /** true 只生成计划(organize.plan),不执行 */ dryRun: z.boolean().default(false), }) .refine((v) => !!v.downloadTaskId !== !!v.sourcePaths, { message: "downloadTaskId 与 sourcePaths 必须二选一" }); export type OrganizeRequest = z.infer; export const OrganizeAccepted = z.object({ recordId: Id.nullable(), taskId: Id, planId: Id.nullable(), /** dryRun 按路径遍历时文件数超过上限(ORGANIZE_WALK_MAX_FILES),计划只含前 N 个文件 */ truncated: z.boolean().optional(), }); export type OrganizeAccepted = z.infer; // ── 媒体库 / 库存叠加 / 首页聚合(W2-C 读模型,均为只读投影) ── /** 各 Slot 状态计数(不用 record:键集固定,缺省 0) */ export const SlotStatusCounts = z.object({ empty: NonNegInt, reserved: NonNegInt, downloading: NonNegInt, verifying: NonNegInt, satisfied: NonNegInt, upgrade_pending: NonNegInt, attention: NonNegInt, }); export type SlotStatusCounts = z.infer; export const LibraryListQuery = z.object({ cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), kind: MediaKind.optional(), /** 标题/原名/别名模糊搜索 */ q: z.string().max(200).optional(), /** gap = 有未满足 Slot;downloading = 有下载中任务;attention = 有 Slot 需处理 */ filter: z.enum(["gap", "downloading", "attention", "satisfied"]).optional(), }); export type LibraryListQuery = z.infer; export const LibraryItem = z.object({ entity: MediaEntity.pick({ id: true, kind: true, title: true, originalTitle: true, year: true, status: true }), assetCount: NonNegInt, slotCounts: SlotStatusCounts, activeDownloads: NonNegInt, lastOrganizedAt: Timestamp.nullable(), /** TMDB 海报路径(metadata.posterPath);前端经图片代理显示,没有时显示无彩占位 */ posterPath: z.string().nullable().optional(), }); export type LibraryItem = z.infer; /** 资产位置 + 执行进程可访问的实际路径(库根离线时仍返回拼接路径,状态看 location.status) */ export const LibraryAssetView = z.object({ asset: MediaAsset, locations: z.array(AssetLocation.extend({ absolutePath: z.string().nullable() })), /** 覆盖的内容单元 */ contentUnitIds: z.array(Id), }); export type LibraryAssetView = z.infer; export const LibraryDownloadView = z.object({ downloadTask: DownloadTask, plan: AcquisitionPlan.nullable(), /** 该下载任务的整理记录(含文件级核验结果) */ organizationRecordIds: z.array(Id), lastOrganizationStatus: OrganizationStatus.nullable(), }); export type LibraryDownloadView = z.infer; /** 没提交到下载器就结束的下载(DL-3):失败 / 取消 / 过期且没有下载任务的计划,进作品的下载历史 */ export const LibraryDroppedPlanView = z.object({ plan: AcquisitionPlan, releaseTitle: z.string().nullable(), }); export type LibraryDroppedPlanView = z.infer; export const LibraryDetail = z.object({ entity: MediaEntity, /** 身份映射状态(含 proposed/conflicted,前端区分展示) */ identities: z.array(ExternalIdentity), /** 元数据来源:metadata 中各字段的来源标注 + 人工锁 */ metadataSources: z.object({ fields: JsonObject, fieldLocks: z.array(z.string()) }), pathBindings: z.array(PathBinding), assets: z.array(LibraryAssetView), slots: z.array(AcquisitionSlot), candidates: z.array(CandidateEvaluation), downloads: z.array(LibraryDownloadView), droppedPlans: z.array(LibraryDroppedPlanView).default([]), /** * 该作品的整理记录(最近 100 条,新→旧):含没有下载任务的手动整理(文件管理按路径整理)。 * 下载历史里已按下载任务列出的记录也在这里,前端按 downloadTaskId 去重展示。 */ organizationRecords: z .array( z.object({ id: Id, status: OrganizationStatus, action: OrganizationAction, trigger: OrganizationTrigger, downloadTaskId: Id.nullable(), sourcePath: z.string().nullable(), targetPath: z.string().nullable(), fileSucceeded: NonNegInt, fileTotal: NonNegInt, createdAt: Timestamp, finishedAt: Timestamp.nullable(), }), ) .optional(), /** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */ scrape: LibraryScrapeState.nullable().optional(), }); export type LibraryDetail = z.infer; /** 站点工作台叠加:按作品返回已有版本 / Slot 缺口 / 下载中 */ export const InventoryQuery = z.object({ /** 逗号分隔的作品 UUID,最多 200 个 */ entityIds: z.string().min(1).max(8000), }); export type InventoryQuery = z.infer; export const InventoryEntry = z.object({ entityId: Id, /** 已入库版本(主视频资产的交付特征) */ versions: z.array( z.object({ assetId: Id, resolution: z.string().nullable(), sourceType: z.string().nullable(), releaseGroup: z.string().nullable(), contentEdition: z.string().nullable(), sizeBytes: NonNegInt.nullable(), available: z.boolean(), }), ), /** 未满足的 Slot(empty/attention/upgrade_pending) */ gaps: z.array(z.object({ slotId: Id, name: z.string(), status: SlotStatus })), downloading: z.array( z.object({ downloadTaskId: Id, name: z.string(), status: DownloadStatus, progress: z.number().min(0).max(1) }), ), /** 有生效中的订阅(active) */ subscribed: z.boolean(), /** * 候选理由:该作品各 Slot 对来源资源的最近一次评估(每个 (Slot, 资源) 一条,新→旧,最多 50 条)。 * 工作台按 sourceReleaseId 取当前资源"为什么选 / 不选"。 */ candidateReasons: z.array( z.object({ slotId: Id, slotName: z.string(), sourceReleaseId: Id, decision: CandidateEvaluation.shape.decision, /** 拒绝原因 message + 评估说明,已合并去重 */ reasons: z.array(z.string()), planId: Id.nullable(), evaluatedAt: Timestamp, }), ), }); export type InventoryEntry = z.infer; export const InventoryResponse = z.object({ items: z.array(InventoryEntry) }); export type InventoryResponse = z.infer; /** Emby 兼容库存:按实例分别给出 present / not_found / unknown(未知 ≠ 缺失) */ /** * 按本地作品(用它已接受的外部身份)或按 TMDB 条目(还没建档的作品,SEARCH-2 搜索页/详情页)查 Emby。 * 两者给其一;都给时以 mediaEntityId 为准。 */ export const MediaServerInventoryQuery = z .object({ mediaEntityId: Id.optional(), tmdbType: z.enum(["movie", "tv"]).optional(), tmdbId: z .string() .regex(/^\d{1,12}$/) .optional(), }) .refine((q) => !!q.mediaEntityId || (!!q.tmdbType && !!q.tmdbId), { message: "mediaEntityId 或 tmdbType + tmdbId 必须给一个", }); export type MediaServerInventoryQuery = z.infer; export const MediaServerInventory = z.object({ /** 按 TMDB 条目查且本地没有建档时为 null */ mediaEntityId: Id.nullable(), instances: z.array( z.object({ instanceId: Id, name: z.string(), status: z.enum(["present", "not_found", "unknown"]), reason: z.string().nullable(), items: z.array( z.object({ itemId: z.string(), type: z.string(), name: z.string(), year: z.number().int().nullable(), path: z.string().nullable(), versions: z.array(JsonObject), }), ), observedAt: Timestamp, }), ), /** 至少一个实例因查询失败 / 超时为 unknown(没有外部 ID 不算);旧服务端不返回 */ partial: z.boolean().optional(), }); export type MediaServerInventory = z.infer; /** 首页六区块(REQ §13.4.3);统计未知时对应字段为 null 并在 notes 说明 */ export const Dashboard = z.object({ generatedAt: Timestamp, automation: z.object({ enabled: z.boolean(), readOnlyReconcile: z.boolean(), today: z.object({ plansCreated: NonNegInt, downloadsSubmitted: NonNegInt, filesImported: NonNegInt, recordsSucceeded: NonNegInt, recordsFailed: NonNegInt, upgrades: NonNegInt, }), activeSubscriptions: NonNegInt, slotCounts: SlotStatusCounts, /** 收录版本满足率 = satisfied / 全部收录版本(0–1);没有收录版本时为 null */ slotSatisfiedRate: z.number().min(0).max(1).nullable(), /** 本地账本计数:作品不含已合并;季 = content_unit.kind=season(不含 retired);资产 = active 的视频 / 原盘目录 */ library: z.object({ entities: NonNegInt, movies: NonNegInt, series: NonNegInt, seasons: NonNegInt, episodes: NonNegInt, assets: NonNegInt, }), }), currentWork: z.object({ downloading: NonNegInt, paused: NonNegInt, stalled: NonNegInt, importing: NonNegInt, reservedPlans: NonNegInt, runningRecords: NonNegInt, agentRuns: z.array(z.object({ id: Id, goal: z.string(), status: AgentRunStatus })), items: z.array( z.object({ downloadTaskId: Id, name: z.string(), status: DownloadStatus, progress: z.number().min(0).max(1), waitReason: z.string().nullable(), }), ), }), needsAttention: z.object({ total: NonNegInt, byKind: z.array(z.object({ kind: DecisionKind, count: NonNegInt })), }), cache: z.object({ volumes: z.array( z.object({ volumeKey: z.string(), instanceIds: z.array(Id), usedPercent: z.number().min(0).max(100).nullable(), totalBytes: NonNegInt.nullable(), usedBytes: NonNegInt.nullable(), pendingBytes: NonNegInt, reservedBytes: NonNegInt, hardThresholdPercent: z.number(), resumePercent: z.number(), pausedByThreshold: NonNegInt, cleanableBytes: NonNegInt, retentionLockedBytes: NonNegInt, notes: z.array(z.string()), }), ), }), sources: z.object({ sites: z.array( z.object({ id: Id, name: z.string(), enabled: z.boolean(), health: z.string(), lastSnapshotAt: Timestamp.nullable(), }), ), feeds: z.array( z.object({ id: Id, name: z.string(), enabled: z.boolean(), health: z.string(), lastSuccessAt: Timestamp.nullable(), consecutiveFailures: NonNegInt, lastError: z.string().nullable(), }), ), downloaders: z.array( z.object({ id: Id, name: z.string(), enabled: z.boolean(), health: z.string(), lastSeenAt: Timestamp.nullable(), }), ), mediaServers: z.array( z.object({ id: Id, name: z.string(), enabled: z.boolean(), health: z.string(), lastSeenAt: Timestamp.nullable(), }), ), }), recentOutput: z.array( z.object({ recordId: Id, mediaEntityId: Id.nullable(), title: z.string().nullable(), action: OrganizationAction, status: OrganizationStatus, targetPath: z.string().nullable(), counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }), finishedAt: Timestamp.nullable(), }), ), }); export type Dashboard = z.infer; /** * 手动整理 dryRun 冻结的计划 artifact(kind=plan,title=organize_plan)的 content: * 原请求 + 冻结计划(与 domain OrganizePlan 同形,这里只约束展示字段)。GET /artifacts/{id} 读取。 */ export const OrganizePlanArtifactContent = z.object({ request: z.record(z.string(), z.unknown()).optional(), /** 源目录文件数超过上限,计划只含前 N 个文件 */ truncated: z.boolean().optional(), status: z.string().optional(), reason: z.string().nullable().optional(), plan: z .object({ targetDirs: z.array(z.string()).optional(), organize: z .object({ operation: z.string(), crossDevice: z.boolean(), requiresApproval: z.boolean(), files: z.array( z.object({ src: z.string(), dst: z.string(), size: z.number(), kind: z.string(), action: z.string(), skipReason: z.string().optional(), }), ), excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })), conflicts: z.array( z.object({ kind: z.string(), blocking: z.boolean(), src: z.string().optional(), detail: z.string().optional(), }), ), }) .optional(), }) .nullable() .optional(), }); export type OrganizePlanArtifactContent = z.infer; /** 让智能体识别整理记录里的作品(没识别出作品的记录):入队一次识别会话 */ export const OrganizationAgentIdentifyResult = z.object({ decisionId: Id, queued: z.boolean() }); export type OrganizationAgentIdentifyResult = z.infer; /** * 需要用户关注的汇总(铃铛、首页、运行状态共用): * - organize:整理记录里需要处理的条数(点进整理记录「需要处理」); * - approvals:需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手); * - health:系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线),恢复后自动消失。 * 集数待确认、未识别资源等不在这里:前者在订阅详情与作品页集数映射里看原因,后者静默留在未识别里。 */ export const AttentionSummary = z.object({ organize: NonNegInt, approvals: z.object({ total: NonNegInt, items: z.array(Decision) }), health: z.object({ total: NonNegInt, items: z.array(Decision) }), }); export type AttentionSummary = z.infer;