feat(contracts,server,web): files G6–G8 fields and identify S-7 contract

- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the
  unidentified filter, recursive, cached 5 min, invalidated by files.changed)
- G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web
  subline uses it and no longer parses names itself
- G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and
  catalog.lookup, local entities from metadata.posterPath); thumbnails in the
  identify panel
- S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending,
  MediaServerInventory.partial, EventType identify.completed with
  IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard
  merge remote results by root + path, buffering events that beat the response

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent fd2f7bc33d
commit abc69886ea
26 files changed
+510 -134

No files matched your search

@@ -51,6 +51,8 @@ export const CatalogLookupOutput = z.object({
externalId: z.string(),
title: z.string(),
year: z.number().int().nullable(),
/** TMDB poster_path;文件管理识别候选拼海报缩略图 */
posterPath: z.string().nullable().optional(),
}),
),
});
+4
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent.ts";
import { Id, NonNegInt, Timestamp } from "./common.ts";
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution.ts";
import { IdentifyCompletedPayload } from "./files.ts";
import { EvidenceLevel } from "./identity.ts";
import { OrganizationStatus } from "./organization.ts";
import { FeedDuePayload, FeedGapSuspectedPayload, FeedHealth, ReleaseObservedPayload } from "./sources.ts";
@@ -56,6 +57,8 @@ export const EventType = {
decisionCreated: "decision.created",
decisionUpdated: "decision.updated",
identifyCompleted: "identify.completed",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -191,6 +194,7 @@ export const EVENT_PAYLOADS = {
"release.resolved": ReleaseResolvedPayload,
"decision.created": DecisionCreatedPayload,
"decision.updated": DecisionUpdatedPayload,
"identify.completed": IdentifyCompletedPayload,
} as const satisfies Record<EventType, z.ZodType>;
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
+29 -2
View File
@@ -21,7 +21,7 @@ export const FileRelPath = z
.string()
.max(4096)
.refine((p) => !p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === "."), {
message: "路径必须是根内相对路径,不能含 .. 或 .",
message: "路径必须是根内相对路径,不能含 .. 或 .",
})
.default("");
export type FileRelPath = z.infer<typeof FileRelPath>;
@@ -58,6 +58,10 @@ export const FileRoot = z.object({
/** 健康探测失败原因(离线时) */
healthReason: z.string().nullable(),
usage: z.object({ totalBytes: NonNegInt, freeBytes: NonNegInt }).nullable(),
/** G6:下载目录下待识别视频数(与列表"待识别"筛选同口径,递归,缓存 5 分钟);库根与离线根为 null */
pendingCount: NonNegInt.nullable(),
/** G6:递归超过扫描上限时为 true,pendingCount 是下限 */
pendingPartial: z.boolean(),
});
export type FileRoot = z.infer<typeof FileRoot>;
export const FileRootList = z.object({ items: z.array(FileRoot) });
@@ -124,6 +128,8 @@ export const FileEntry = z.object({
media: FileMediaSummary.nullable(),
/** 仅目录且在当前页:{ videoCount, registeredCount, partial } */
dirSummary: z.object({ videoCount: NonNegInt, registeredCount: NonNegInt, partial: z.boolean() }).nullable(),
/** G7:待识别视频的轻量解析(文件名 + 父/祖父目录,纯字符串,不查库),如 `The Last of Us · S01E01 · 1080p`;其它行为 null */
parsedHint: z.string().nullable(),
});
export type FileEntry = z.infer<typeof FileEntry>;
@@ -618,6 +624,8 @@ export const FileIdentifyCandidate = z.object({
level: z.enum(["high", "medium", "low"]),
score: z.number(),
reasons: z.array(z.string()),
/** G8:海报缩略图 URL(TMDB w154);没有海报为 null */
posterUrl: z.string().url().nullable(),
});
export type FileIdentifyCandidate = z.infer<typeof FileIdentifyCandidate>;
@@ -650,9 +658,28 @@ export const FilesIdentifyResult = z.object({
truncated: z.boolean(),
/** W9-H(G2):因低于最小体积被跳过的项数 */
excludedBySize: NonNegInt.optional(),
/**
* S-7:远端(TMDB)查询任务 id(= 引擎 runId)。缺省请求只做本地召回并入队远端任务,查完发 SSE
* `identify.completed`(同 taskId),前端按 root + path 替换条目;null = 无需远端或 `?wait=true` 已同步查完
*/
remoteTaskId: z.string().nullable().optional(),
/** S-7:待远端查询的去重标题数(0 = 无) */
remotePending: NonNegInt.optional(),
});
export type FilesIdentifyResult = z.infer<typeof FilesIdentifyResult>;
/** SSE `identify.completed`:远端识别任务结束(失败也发,status=failed、items 为空) */
export const IdentifyCompletedPayload = z.object({
taskId: z.string(),
status: z.enum(["succeeded", "failed"]),
/** 完整识别结果(本地 + 远端),按 root + path 替换 HTTP 响应里的同名项 */
items: z.array(FileIdentifyItem),
truncated: z.boolean(),
excludedBySize: NonNegInt,
error: z.string().nullable(),
});
export type IdentifyCompletedPayload = z.infer<typeof IdentifyCompletedPayload>;
// 刮削(仅已登记作品)
export const FilesScrapeRequest = z.object({
@@ -699,7 +726,7 @@ export type FilesProbeResult = z.infer<typeof FilesProbeResult>;
export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) });
export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
/** OpenAPI 描述用:实际响应为二进制流 */
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
// 整理记录回查(W5-A2,只读):整理页 `/console/organize?ids=` 预填;planIds 为 /files/move|copy|organize 的 planId
+2
View File
@@ -450,6 +450,8 @@ export const MediaServerInventory = z.object({
observedAt: Timestamp,
}),
),
/** 至少一个实例因查询失败 / 超时为 unknown(没有外部 ID 不算);旧服务端不返回 */
partial: z.boolean().optional(),
});
export type MediaServerInventory = z.infer<typeof MediaServerInventory>;