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:
1 parent
0105e5c6ac
commit
88bbed409c
2 files changed
+214
-2
No files matched your search
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 能力 ──
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user