From 88bbed409cb2fc17c0bd79dff985ad9d265b7e00 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 15:08:08 +0800 Subject: [PATCH] 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 --- packages/contracts/src/api.ts | 41 ++++++++ packages/contracts/src/files.ts | 175 +++++++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 05cd8600..f8b409fa 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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, + }), }, // ── 能力 ── diff --git a/packages/contracts/src/files.ts b/packages/contracts/src/files.ts index c93d30e0..1b94be7c 100644 --- a/packages/contracts/src/files.ts +++ b/packages/contracts/src/files.ts @@ -334,8 +334,33 @@ export type FilesTransferResult = z.infer; // 整理:按 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; + +/** W9-H(G3):目标已存在策略。skip = 跳过该文件;conflict = 交给待处理(默认,整组不执行); + * replace_with_verify = 替换(新文件核验通过后旧文件进回收站) */ +export const FilesOnExisting = z.enum(["skip", "conflict", "replace_with_verify"]); +export type FilesOnExisting = z.infer; + +/** W9-H(G4):命名方式。template = 按命名模板(默认);keep = 保留原文件名(目录仍按模板/绑定) */ +export const FilesNamingMode = z.enum(["template", "keep"]); +export type FilesNamingMode = z.infer; + +/** 命名模板来源:entity 作品覆盖 / root 库根 / global 全局 / override 本次覆写(如保留原文件名) */ +export const NamingTemplateSource = z.enum(["entity", "root", "global", "override"]); +export type NamingTemplateSource = z.infer; + 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; @@ -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; export const FilesOrganizeResult = z.object({ @@ -514,6 +587,8 @@ export type FilesTrashPurgeResult = z.infer; 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; export const FilesIdentifyResult = z.object({ items: z.array(FileIdentifyItem), /** 目录展开后视频数超过上限 */ truncated: z.boolean(), + /** W9-H(G2):因低于最小体积被跳过的项数 */ + excludedBySize: NonNegInt.optional(), }); export type FilesIdentifyResult = z.infer; @@ -648,6 +733,90 @@ export const FilesRecordsResult = z.object({ }); export type FilesRecordsResult = z.infer; +// ── W9-H 文件管理 v2 ── + +/** G1:按 TMDB 候选就地建本地作品(幂等:同 TMDB id 已有映射则复用);剧集同时建季/集单元 */ +export const FilesEntityFromCandidateRequest = FilesExternalCandidate; +export type FilesEntityFromCandidateRequest = z.infer; +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; + +/** 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; +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; + +/** G8:目录摘要(上一级 / 树折叠):父路径 + 子目录(是否还有子目录)+ 本目录视频/已登记计数 */ +export const FilesTreeQuery = z.object({ root: FileRootId, path: FileRelPath }); +export type FilesTreeQuery = z.infer; +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; + +/** G5:整理记录 → 源 FileRef(整理记录"重新整理"打开向导用) */ +export const FilesRecordSourcesQuery = z.object({ recordId: Id }); +export type FilesRecordSourcesQuery = z.infer; +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; + +/** G9:按命名模板给选中文件生成推荐名 */ +export const FilesSuggestNameRequest = z.object({ + root: FileRootId, + path: FileRelPath, + /** 指定作品;缺省用已登记资产的作品,再缺省用识别的高置信候选 */ + mediaEntityId: Id.optional(), +}); +export type FilesSuggestNameRequest = z.infer; +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; + /** SSE `files.changed` 负载 */ export const FilesChangedEvent = z.object({ rootId: FileRootId, dirs: z.array(z.string()) }); export type FilesChangedEvent = z.infer; @@ -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;