- 整理记录「需要处理」:列表 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
824 lines
30 KiB
TypeScript
824 lines
30 KiB
TypeScript
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>;
|