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:
rootandClaude Opus 5.5 committed 2026-09-27 20:08:47 +08:00
1 parent 1cd523d3e6
commit 829ca4f7ac
24 files changed
+7488 -9

No files matched your search

+245 -1
View File
@@ -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>;