feat(contracts): file manager v2 backend gaps G1-G9 (W9-H)

- FilesOrganizeItem.external (TMDB candidate, xor mediaEntityId); request
  minSizeBytes / onExisting (skip|conflict|replace_with_verify) / naming
  (template|keep) / refreshMetadata
- FilesOrganizeGroup: per-file action/existing/skipReason, excluded[]
  (below_min_size), entity, naming{mode,templateSource,templateVersion},
  targetSegments, refreshTaskId
- identify: minSizeBytes, item size/skipped/records, excludedBySize
- new endpoints: entity-from-candidate, scrape-batch, tree, record-sources,
  suggest-name; FilesErrorReason not_implemented

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:08:08 +08:00
1 parent 0105e5c6ac
commit 88bbed409c
2 files changed
+214 -2

No files matched your search

+41
View File
@@ -1396,6 +1396,47 @@ export const Api = {
query: files.FilesRecordsQuery,
response: files.FilesRecordsResult,
}),
// W9-H 文件管理 v2 后端缺口
entityFromCandidate: ep({
method: "post",
path: "/files/entity-from-candidate",
scope: "organize.execute",
summary: "按 TMDB 候选就地建本地作品 + 外部映射(accepted/manual)+ 季集单元;幂等",
body: files.FilesEntityFromCandidateRequest,
response: files.FilesEntityFromCandidateResult,
}),
scrapeBatch: ep({
method: "post",
path: "/files/scrape-batch",
scope: "organize.execute",
summary: "批量刷新元数据:按作品去重入队 refreshMetadata(锁定字段不变)",
body: files.FilesScrapeBatchRequest,
response: files.FilesScrapeBatchResult,
}),
tree: ep({
method: "get",
path: "/files/tree",
scope: "organize.read",
summary: "目录摘要:上一级、子目录(是否有下级)、视频/已登记计数(树与上一级用)",
query: files.FilesTreeQuery,
response: files.FilesTreeResult,
}),
recordSources: ep({
method: "get",
path: "/files/record-sources",
scope: "organize.read",
summary: "整理记录的源路径换算成登记目录 FileRef(重新整理用)",
query: files.FilesRecordSourcesQuery,
response: files.FilesRecordSourcesResult,
}),
suggestName: ep({
method: "post",
path: "/files/suggest-name",
scope: "organize.read",
summary: "按命名模板生成推荐名(命名引擎未就绪时 501)",
body: files.FilesSuggestNameRequest,
response: files.FilesSuggestNameResult,
}),
},
// ── 能力 ──
+173 -2
View File
@@ -334,8 +334,33 @@ export type FilesTransferResult = z.infer<typeof FilesTransferResult>;
// 整理:按 mediaEntityId 分组,每组走 planImport(与 /organization-records 同实现)
/** W9-H(G1):整理项的外部候选(识别结果里只有 TMDB 候选、本地还没有作品时);与 mediaEntityId 二选一 */
export const FilesExternalCandidate = z.object({
provider: z.literal("tmdb"),
externalId: z.string().regex(/^\d{1,12}$/, { message: "TMDB id 必须是数字" }),
/** TMDB 条目类型;识别候选的 kind=movie → movie,其余 → tv */
kind: z.enum(["movie", "tv"]),
});
export type FilesExternalCandidate = z.infer<typeof FilesExternalCandidate>;
/** W9-H(G3):目标已存在策略。skip = 跳过该文件;conflict = 交给待处理(默认,整组不执行);
* replace_with_verify = 替换(新文件核验通过后旧文件进回收站) */
export const FilesOnExisting = z.enum(["skip", "conflict", "replace_with_verify"]);
export type FilesOnExisting = z.infer<typeof FilesOnExisting>;
/** W9-H(G4):命名方式。template = 按命名模板(默认);keep = 保留原文件名(目录仍按模板/绑定) */
export const FilesNamingMode = z.enum(["template", "keep"]);
export type FilesNamingMode = z.infer<typeof FilesNamingMode>;
/** 命名模板来源:entity 作品覆盖 / root 库根 / global 全局 / override 本次覆写(如保留原文件名) */
export const NamingTemplateSource = z.enum(["entity", "root", "global", "override"]);
export type NamingTemplateSource = z.infer<typeof NamingTemplateSource>;
export const FilesOrganizeItem = FileRef.extend({
mediaEntityId: Id,
/** 本地作品;与 external 二选一(两者都给或都不给 → 400) */
mediaEntityId: Id.optional(),
/** W9-H(G1):TMDB 候选;预览时就地建本地作品 + 外部映射(幂等,同 TMDB id 不重复建),组里标 entity.created */
external: FilesExternalCandidate.optional(),
contentUnitId: Id.optional(),
season: z.number().int().min(0).max(9999).optional(),
episodes: z.array(z.number().int().min(0).max(99999)).max(50).optional(),
@@ -355,6 +380,14 @@ export const FilesOrganizeRequest = z.object({
dryRun: z.boolean().default(true),
/** 执行时必填:dryRun 返回的全部组 planId */
planIds: z.array(Id).max(200).optional(),
/** W9-H(G2):主视频最小体积(字节),低于的文件排除并标 below_min_size;缺省取设置(未设置时 50 MiB) */
minSizeBytes: NonNegInt.optional(),
/** W9-H(G3):目标已存在策略,缺省 conflict(交给待处理) */
onExisting: FilesOnExisting.optional(),
/** W9-H(G4):命名方式,缺省 template */
naming: FilesNamingMode.optional(),
/** W9-H(G5):执行成功后对每个作品排一次元数据刷新(字段级,锁定字段不变) */
refreshMetadata: z.boolean().optional(),
});
export type FilesOrganizeRequest = z.infer<typeof FilesOrganizeRequest>;
@@ -364,7 +397,24 @@ export const FilesOrganizeGroup = z.object({
contentUnitId: Id.nullable(),
planId: Id.nullable(),
/** 冻结计划的前后路径(绝对路径,来自 organize 计划) */
files: z.array(z.object({ source: z.string(), target: z.string().nullable(), kind: z.string(), size: NonNegInt })),
files: z.array(
z.object({
source: z.string(),
target: z.string().nullable(),
kind: z.string(),
size: NonNegInt,
/** W9-H:计划动作 transfer / replace / skip / hold(hold = 被冲突拦下) */
action: z.enum(["transfer", "replace", "skip", "hold"]).optional(),
/** W9-H(G3):目标已存在时的处理:same 同一文件已在库 / skip / replace / conflict;目标不存在为 null */
existing: z.enum(["same", "skip", "replace", "conflict"]).nullable().optional(),
/** skip 的原因(already_in_library / target_exists / main_not_transferred) */
skipReason: z.string().nullable().optional(),
}),
),
/** W9-H(G2):被排除的文件(below_min_size 低于最小体积 / sample / no_episode 等),不参与整理 */
excluded: z
.array(z.object({ source: z.string(), kind: z.string(), size: NonNegInt.nullable(), reason: z.string() }))
.optional(),
truncated: z.boolean(),
recordId: Id.nullable(),
taskId: Id.nullable(),
@@ -378,6 +428,29 @@ export const FilesOrganizeGroup = z.object({
slotId: Id.nullable().optional(),
/** 该组失败原因(plan_stale / 授权 / H&R 等),其他组不受影响 */
error: z.object({ code: z.string(), message: z.string() }).nullable(),
/** W9-H(G1):该组作品来自 TMDB 候选;created = 本次新建(否则复用已有映射的作品) */
entity: z
.object({ provider: z.literal("tmdb"), externalId: z.string(), created: z.boolean(), title: z.string() })
.nullable()
.optional(),
/** W9-H(G3):本组生效的目标已存在策略 */
onExisting: FilesOnExisting.optional(),
/** W9-H(G2):本组生效的最小体积 */
minSizeBytes: NonNegInt.optional(),
/** W9-H(G4):命名方式与模板来源(同时冻结进 organization_record.plan.naming) */
naming: z
.object({
mode: FilesNamingMode,
templateSource: NamingTemplateSource,
templateVersion: z.number().int().nullable(),
})
.optional(),
/** W9-H(G4):目标目录分段(相对库根):role = root 作品目录 / season 季目录 / dir 其它;bound = 沿用已有路径绑定 */
targetSegments: z
.array(z.object({ text: z.string(), role: z.enum(["root", "season", "dir"]), bound: z.boolean() }))
.optional(),
/** W9-H(G5):执行后排的元数据刷新 run id(refreshMetadata=true 且该组已提交) */
refreshTaskId: z.string().nullable().optional(),
});
export type FilesOrganizeGroup = z.infer<typeof FilesOrganizeGroup>;
export const FilesOrganizeResult = z.object({
@@ -514,6 +587,8 @@ export type FilesTrashPurgeResult = z.infer<typeof FilesTrashPurgeResult>;
export const FilesIdentifyRequest = z.object({
items: z.array(FileRef).min(1).max(200),
/** W9-H(G2):低于该体积(字节)的视频不识别,标 skipped=below_min_size;缺省取设置(未设置则不过滤) */
minSizeBytes: NonNegInt.optional(),
hints: z
.object({ mediaKind: MediaKind.optional(), year: z.number().int().optional(), tmdbId: z.number().int().optional() })
.optional(),
@@ -559,12 +634,22 @@ export const FileIdentifyItem = z.object({
existing: z.object({ assetId: Id.nullable() }),
/** TMDB 不可用等降级提示 */
warnings: z.array(z.string()),
/** W9-H(G2):文件大小 */
size: NonNegInt.optional(),
/** W9-H(G2):below_min_size = 低于最小体积,未识别(candidates 为空) */
skipped: z.literal("below_min_size").nullable().optional(),
/** W9-H(G5):该源文件已有的整理记录(向导标"有 n 条整理记录") */
records: z
.object({ count: NonNegInt, lastRecordId: Id.nullable(), lastStatus: OrganizationStatus.nullable() })
.optional(),
});
export type FileIdentifyItem = z.infer<typeof FileIdentifyItem>;
export const FilesIdentifyResult = z.object({
items: z.array(FileIdentifyItem),
/** 目录展开后视频数超过上限 */
truncated: z.boolean(),
/** W9-H(G2):因低于最小体积被跳过的项数 */
excludedBySize: NonNegInt.optional(),
});
export type FilesIdentifyResult = z.infer<typeof FilesIdentifyResult>;
@@ -648,6 +733,90 @@ export const FilesRecordsResult = z.object({
});
export type FilesRecordsResult = z.infer<typeof FilesRecordsResult>;
// ── W9-H 文件管理 v2 ──
/** G1:按 TMDB 候选就地建本地作品(幂等:同 TMDB id 已有映射则复用);剧集同时建季/集单元 */
export const FilesEntityFromCandidateRequest = FilesExternalCandidate;
export type FilesEntityFromCandidateRequest = z.infer<typeof FilesEntityFromCandidateRequest>;
export const FilesEntityFromCandidateResult = z.object({
mediaEntityId: Id,
created: z.boolean(),
title: z.string(),
year: z.number().int().nullable(),
kind: MediaKind,
/** 本次确保存在的季/集单元数 */
contentUnits: NonNegInt,
});
export type FilesEntityFromCandidateResult = z.infer<typeof FilesEntityFromCandidateResult>;
/** G5:批量刷新元数据(刮削):按作品去重;items 里的文件/目录归到其已登记资产的作品 */
export const FilesScrapeBatchRequest = z.object({
idempotencyKey: IdempotencyKey,
items: z.array(FileRef).max(500).optional(),
mediaEntityIds: z.array(Id).max(200).optional(),
});
export type FilesScrapeBatchRequest = z.infer<typeof FilesScrapeBatchRequest>;
export const FilesScrapeBatchResult = z.object({
submitted: z.array(
z.object({ mediaEntityId: Id, title: z.string(), taskId: z.string(), lockedFields: z.array(z.string()) }),
),
/** 未提交:not_registered(作品没有已登记资产)/ not_found / no_entity(文件不属于任何已登记作品) */
skipped: z.array(z.object({ ref: z.string(), reason: z.string() })),
});
export type FilesScrapeBatchResult = z.infer<typeof FilesScrapeBatchResult>;
/** G8:目录摘要(上一级 / 树折叠):父路径 + 子目录(是否还有子目录)+ 本目录视频/已登记计数 */
export const FilesTreeQuery = z.object({ root: FileRootId, path: FileRelPath });
export type FilesTreeQuery = z.infer<typeof FilesTreeQuery>;
export const FilesTreeResult = z.object({
root: FileRootId,
path: z.string(),
name: z.string(),
/** 上一级相对路径;已在根时为 null */
parent: z.string().nullable(),
dirs: z.array(z.object({ name: z.string(), path: z.string(), hasChildren: z.boolean() })),
/** 子目录超过上限(FILES_LIST_MAX_ENTRIES)只列了前 N 个 */
truncated: z.boolean(),
summary: z.object({ videoCount: NonNegInt, registeredCount: NonNegInt, partial: z.boolean() }),
});
export type FilesTreeResult = z.infer<typeof FilesTreeResult>;
/** G5:整理记录 → 源 FileRef(整理记录"重新整理"打开向导用) */
export const FilesRecordSourcesQuery = z.object({ recordId: Id });
export type FilesRecordSourcesQuery = z.infer<typeof FilesRecordSourcesQuery>;
export const FilesRecordSourcesResult = z.object({
recordId: Id,
mediaEntityId: Id.nullable(),
action: z.string(),
status: OrganizationStatus,
/** 仍存在且落在登记目录里的源(主视频;目录源给目录本身) */
items: z.array(FileRef),
/** 不在任何登记目录或已不存在的源路径(本机路径) */
unresolved: z.array(z.string()),
});
export type FilesRecordSourcesResult = z.infer<typeof FilesRecordSourcesResult>;
/** G9:按命名模板给选中文件生成推荐名 */
export const FilesSuggestNameRequest = z.object({
root: FileRootId,
path: FileRelPath,
/** 指定作品;缺省用已登记资产的作品,再缺省用识别的高置信候选 */
mediaEntityId: Id.optional(),
});
export type FilesSuggestNameRequest = z.infer<typeof FilesSuggestNameRequest>;
export const FilesSuggestNameResult = z.object({
/** 推荐的文件名(文件)或目录名(目录);不可信时 null */
name: z.string().nullable(),
/** 相对库根的完整推荐路径 */
relativePath: z.string().nullable(),
/** name 为 null 的原因(identity_uncertain / naming_failed 等) */
reason: z.string().nullable(),
mediaEntityId: Id.nullable(),
templateSource: NamingTemplateSource.nullable(),
segments: z.array(z.object({ text: z.string(), role: z.string() })),
});
export type FilesSuggestNameResult = z.infer<typeof FilesSuggestNameResult>;
/** SSE `files.changed` 负载 */
export const FilesChangedEvent = z.object({ rootId: FileRootId, dirs: z.array(z.string()) });
export type FilesChangedEvent = z.infer<typeof FilesChangedEvent>;
@@ -665,5 +834,7 @@ export const FilesErrorReason = z.enum([
"needs_unbind",
"permanent_disabled",
"approval_required",
/** W9-H:依赖尚未就绪(如命名引擎),HTTP 501 */
"not_implemented",
]);
export type FilesErrorReason = z.infer<typeof FilesErrorReason>;