feat(server): W2-C rules, downloads, import, organization records, library and dashboard
- Rules API: subscriptions, acquisition slots (revision lock), policy revisions (draft -> publish = automation_grant, rollback as new revision), preference tags with suppression records (agent cannot re-add suppressed fingerprints). - Tasks: evaluateAcquisition (mode/evaluate/budget/cache volume -> frozen plan with slot reservation, candidate reasons as evidence artifacts), submitDownload (withAttemptToken fencing, already_exists adoption), verifyAndImport (frozen organization_record + file results, resumable copy/move/cross-volume steps, assets/locations/bindings in one tx), cleanupDownloadCache (verified+retention+H&R only, assertSafeToDeleteData), reconcileLibrary (root sentinel, suspected -> confirmed missing). - Worker loop: qB sync with tag adoption, no-progress evidence, hard threshold pause / resume-watermark resume, sweepers for deferred plans and imports. - Public organization records API incl. changes cursor (change_xid guard), media library list/detail, inventory overlay, Emby inventory, dashboard. - Contracts: SlotTemplate, AcquisitionPolicyRules, CandidateEvaluation, library/inventory/dashboard read models. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1cd523d3e6
commit
829ca4f7ac
24 files changed
+7488
-9
No files matched your search
@@ -1,6 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { AgentRunStatus, DecisionKind } from "./agent.ts";
|
||||
import { AssetLocation, MediaAsset, PathBinding } from "./assets.ts";
|
||||
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
|
||||
import { MediaKind } from "./content.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";
|
||||
|
||||
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
|
||||
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
|
||||
@@ -286,3 +291,242 @@ export const OrganizeAccepted = z.object({
|
||||
planId: Id.nullable(),
|
||||
});
|
||||
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(),
|
||||
});
|
||||
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>;
|
||||
|
||||
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),
|
||||
});
|
||||
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) }),
|
||||
),
|
||||
});
|
||||
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(未知 ≠ 缺失) */
|
||||
export const MediaServerInventoryQuery = z.object({ mediaEntityId: Id });
|
||||
export type MediaServerInventoryQuery = z.infer<typeof MediaServerInventoryQuery>;
|
||||
|
||||
export const MediaServerInventory = z.object({
|
||||
mediaEntityId: Id,
|
||||
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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
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,
|
||||
}),
|
||||
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>;
|
||||
@@ -245,3 +245,54 @@ export type PreferenceSuppressionCreate = z.infer<typeof PreferenceSuppressionCr
|
||||
/** 唯一允许的更新是解除抑制 */
|
||||
export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() });
|
||||
export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUpdate>;
|
||||
|
||||
// ── 收录策略规则与候选评估(W2-C) ──
|
||||
|
||||
/** Slot 模板:策略规则里用于自动建 Slot(新作品收录 / 订阅缺省 Slot);字段同 AcquisitionSlot 规则列 */
|
||||
export const SlotTemplate = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
hardConditions: JsonObject.optional(),
|
||||
softPreferences: JsonObject.optional(),
|
||||
fallback: JsonObject.optional(),
|
||||
upgradeThreshold: JsonObject.optional(),
|
||||
completion: JsonObject.optional(),
|
||||
});
|
||||
export type SlotTemplate = z.infer<typeof SlotTemplate>;
|
||||
|
||||
/**
|
||||
* kind=acquisition 的 policy_revision.rules 结构(结构化数据,不是可执行脚本)。
|
||||
* 发布即对 scope 范围内的正常下载/追更/整理生成持续授权 automation_grant(REQ §6.4)。
|
||||
*/
|
||||
export const AcquisitionPolicyRules = z.object({
|
||||
/** 新作品收录:发行/首播在窗口内且库里没有的作品,按模板建订阅与 Slot */
|
||||
newTitle: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
windowDays: z.number().int().min(1).max(3650).optional(),
|
||||
mediaKinds: z.array(SubscriptionMediaKind).optional(),
|
||||
slots: z.array(SlotTemplate),
|
||||
})
|
||||
.optional(),
|
||||
/** 订阅没有 Slot 时套用的缺省 Slot */
|
||||
defaultSlots: z.array(SlotTemplate).optional(),
|
||||
/** 整理方式覆盖(缺省读 settings.organize.defaultMode) */
|
||||
organizeMode: z.enum(["copy", "move"]).optional(),
|
||||
/** Move 在 H&R 未满足时经授权改用 Copy */
|
||||
allowCopyWhenHnrPending: z.boolean().optional(),
|
||||
});
|
||||
export type AcquisitionPolicyRules = z.infer<typeof AcquisitionPolicyRules>;
|
||||
|
||||
/** 候选对某个 Slot 的确定性评估(供作品详情"为什么选/不选") */
|
||||
export const CandidateEvaluation = z.object({
|
||||
slotId: Id,
|
||||
sourceReleaseId: Id,
|
||||
title: z.string(),
|
||||
/** accept / upgrade / reject / unknown_field / skipped(被模式、预算、阈值、总开关等挡住) */
|
||||
decision: z.enum(["accept", "upgrade", "reject", "unknown_field", "skipped"]),
|
||||
mode: z.enum(["new_title", "catch_up", "upgrade"]).nullable(),
|
||||
rejected: z.array(z.object({ code: z.string(), message: z.string() })),
|
||||
reasons: z.array(z.string()),
|
||||
planId: Id.nullable(),
|
||||
evaluatedAt: Timestamp,
|
||||
});
|
||||
export type CandidateEvaluation = z.infer<typeof CandidateEvaluation>;
|
||||
Reference in new issue
Block a user