Files
UHD_Media_Tool/packages/contracts/src/organization.ts
T
rootandClaude Opus 5.5 1c1a954703 feat(整理): 问题在整理记录就地处理,识别失败自动叫智能体识别
- 整理记录「需要处理」:列表 attention 筛选与统计同口径(没被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略),
  记录带 attention(原因类别、精简事项、能否让智能体识别);忽略端点记 attention_cleared_at(迁移 0078)并关闭关联事项
- 识别失败(organize_identity)按 agent.autoIdentifyOrganize 自动派一次识别会话;新能力 organize.assign_identity
  指定作品后复用人工指定的钩子路径自动重新整理,只放行本事项的识别会话与用户对话;整理记录里可手动「让智能体识别」
- GET /attention:整理需要处理条数、等你批准的智能体操作、系统健康问题(铃铛 / 首页 / 运行状态共用)
- 原因码文案不再指向待处理;命令面板「待处理」换成「需要处理的整理记录」

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

824 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/**
* Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原 / 原地入库
* (后三者只由文件管理的显式删除/还原产生,记录已结束,不经 planImport)
*/
export const OrganizationAction = z.enum([
"copy",
"move",
"rename",
"relocate",
"hardlink",
"trash",
"delete",
"restore",
"in_place",
]);
export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
export const SourcePathView = z.enum(["downloader", "local", "library"]);
export type SourcePathView = z.infer<typeof SourcePathView>;
export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]);
export type FileRole = z.infer<typeof FileRole>;
export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]);
export type FileResultStatus = z.infer<typeof FileResultStatus>;
/** 稳定变更游标:bigint 序号的十进制字符串 */
export const ChangeSeq = z.number().int().nonnegative();
export const ChangeCursor = z.string().regex(/^\d{1,16}$/);
export type ChangeCursor = z.infer<typeof ChangeCursor>;
// ── 内部行表示(与 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<typeof OrganizationRecord>;
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<typeof OrganizationRecordCreate>;
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<typeof OrganizationRecordUpdate>;
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<typeof OrganizationFileResult>;
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<typeof OrganizationFileResultCreate>;
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<typeof OrganizationFileResultUpdate>;
// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ──
/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */
export const PublicReason = z.object({
code: z.string().min(1),
message: z.string(),
});
export type PublicReason = z.infer<typeof PublicReason>;
/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */
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<typeof PublicPathRef>;
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<typeof PublicOrganizationFile>;
/** 整理记录关联的未关闭事项(在整理记录里就地处理;选项与「处理」端点同 /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<typeof RecordAttentionDecision>;
/**
* 需要处理(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<typeof RecordAttention>;
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<typeof PublicOrganizationRecord>;
/** 增量读取请求: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<typeof OrganizationChangesQuery>;
/**
* 增量读取响应: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<typeof OrganizationChangesResponse>;
/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */
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<typeof OrganizationListQuery>;
/** HIS-1:统计与列表同一套筛选(不含状态,状态按计数分开给) */
export const OrganizationStatsQuery = OrganizationListQuery.omit({ cursor: true, limit: true, status: true });
export type OrganizationStatsQuery = z.infer<typeof OrganizationStatsQuery>;
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<typeof OrganizationStats>;
/**
* 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<typeof OrganizationDeleteRequest>;
/** 单条记录不能删的原因: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<typeof OrganizationDeleteBlock>;
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<typeof OrganizationDeleteResult>;
/** 手动发起整理(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<typeof OrganizeRequest>;
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<typeof OrganizeAccepted>;
// ── 媒体库 / 库存叠加 / 首页聚合(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<typeof SlotStatusCounts>;
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<typeof LibraryListQuery>;
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<typeof LibraryItem>;
/** 资产位置 + 执行进程可访问的实际路径(库根离线时仍返回拼接路径,状态看 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<typeof LibraryAssetView>;
export const LibraryDownloadView = z.object({
downloadTask: DownloadTask,
plan: AcquisitionPlan.nullable(),
/** 该下载任务的整理记录(含文件级核验结果) */
organizationRecordIds: z.array(Id),
lastOrganizationStatus: OrganizationStatus.nullable(),
});
export type LibraryDownloadView = z.infer<typeof LibraryDownloadView>;
/** 没提交到下载器就结束的下载(DL-3):失败 / 取消 / 过期且没有下载任务的计划,进作品的下载历史 */
export const LibraryDroppedPlanView = z.object({
plan: AcquisitionPlan,
releaseTitle: z.string().nullable(),
});
export type LibraryDroppedPlanView = z.infer<typeof LibraryDroppedPlanView>;
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<typeof LibraryDetail>;
/** 站点工作台叠加:按作品返回已有版本 / Slot 缺口 / 下载中 */
export const InventoryQuery = z.object({
/** 逗号分隔的作品 UUID,最多 200 个 */
entityIds: z.string().min(1).max(8000),
});
export type InventoryQuery = z.infer<typeof InventoryQuery>;
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<typeof InventoryEntry>;
export const InventoryResponse = z.object({ items: z.array(InventoryEntry) });
export type InventoryResponse = z.infer<typeof InventoryResponse>;
/** 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<typeof MediaServerInventoryQuery>;
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<typeof MediaServerInventory>;
/** 首页六区块(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<typeof Dashboard>;
/**
* 手动整理 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<typeof OrganizePlanArtifactContent>;
/** 让智能体识别整理记录里的作品(没识别出作品的记录):入队一次识别会话 */
export const OrganizationAgentIdentifyResult = z.object({ decisionId: Id, queued: z.boolean() });
export type OrganizationAgentIdentifyResult = z.infer<typeof OrganizationAgentIdentifyResult>;
/**
* 需要用户关注的汇总(铃铛、首页、运行状态共用):
* - 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<typeof AttentionSummary>;