Files
UHD_Media_Tool/docs/runbook/files.md
T
rootandClaude Opus 5.5 abc69886ea 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]>
2026-09-28 20:09:29 +08:00

16 KiB

文件管理(/files,W5-A)

实现:apps/server/src/modules/file-manager/(路由 routes.ts,服务按操作分文件),契约 packages/contracts/src/files.ts(端点登记在 api.ts 的 Api.files),迁移 0024_trash。规格见 docs/plan/06-file-manager-spec.md。 复用 acquisition-organize 的服务导出(planImport、sourceGuard、authorizeMove、hnrBlockForMove、managedTasksUnder、emitRecord、H&R 判定)。W5-A2 给 planImport 加了可选参数(fileOverrides / targetDir / slotId / transfer,老调用不变),原样搬运的计划与终态在 acquisition-organize/services/organize-transfer.ts(planTransfer / finalizeTransfer / relocate)。

根与路径

  • 根只来自登记:启用的 library_root(lib:<id>)+ 下载器路径映射的本机前缀(dl:<instanceId>:<序号>;实例没配映射时取保存路径)。/ 永不出现。
  • 所有路径参数都是 {root, path},path 为根内相对路径("" = 根)。服务端:拒绝 ../. 段与绝对路径 → 父目录 realpath 必须在根 realpath 之下 → 最后一段是符号链接时,目标也必须在根内(否则 path_escape,列表里该行 escape:true 且禁用操作)。执行阶段再 realpath 一次(TOCTOU)。
  • 根健康实时探测(≤2 s):库根看哨兵 .uhd-root(或 sentinel_file),下载根看目录可 stat。离线/超时 → 503,error.message 以 root_unavailable: 开头,不返回空列表。
  • 可写:库根 managed 且进程 W_OK 才能作为 mkdir / 移动 / 复制的目标;register_only 库根只读(不能改名/删除/移出);下载根可以改名/删除/作为移动源,不能作为目标。

错误约定

业务错误的 error.message 以 <reason>: 开头,前端按前缀分支:root_unavailable(503)、path_escape(400)、not_found(404)、exists / plan_stale / download_active / needs_retire / needs_unbind(409)、not_writable / permanent_disabled / approval_required / forbidden(403)。

两段式写操作

  • move / copy / organize / delete:先 dryRun:true 拿 planId(存 artifact kind=plan, title=files_plan,默认 30 分钟有效,FILES_PLAN_TTL_SEC),执行时回传。
  • 执行校验:planId 存在、未过期、未用过,且与本次请求体一致(否则 409 plan_stale);源文件事实未变——文件看 size/mtime/inode,目录看递归文件清单(路径+size+mtime)指纹;organize 另外重算一次 dryRun,前后路径必须与冻结计划一致。
  • 幂等:写请求带 idempotencyKey,按「调用者 + 操作 + 键」唯一(artifact title=files_idem,唯一索引 artifact_files_idem_key)。同键重放返回首次结果;同键不同请求体或首个请求仍在执行 → 409。执行失败不占键。dryRun 不占键。
  • 所有执行都写 audit_event(files.<op>)并发 SSE files.changed {rootId, dirs[]};路径变更同时写 organization_record(rename → rename,移动/复制 → planImport 记录 move/copy,整理 → planImport 原记录,删除 → trash/delete,回收站还原 → restore),外部程序按 changeSeq 增量感知。
  • 整理页回查:GET /files/records?ids=<recordId,…> 或 ?planIds=<planId,…>(move/copy/organize 经 task.idempotency_key = files:<planId>,删除等看 plan.planId),返回公开整理记录 + planRecords{planId→recordId};includeFiles=true 带逐文件结果。

各操作要点

操作 要点
rename 同目录;sanitizeSegment 不一致 400;目标存在 409 且原文件不动(moveSafe:link+unlink / lstat+rename,事后 inode 比对);视频同主干 sidecar 一起改;已登记位置同事务改 asset_location.relative_path + asset_operation 账本;目录命中 path_binding(含子绑定):旧行 superseded 并 superseded_by_id 指向新 active 行;纳管下载内容 409;DB 失败把文件改回原名
move/copy 目标为 writable 库根下已存在目录;源≠目标、目标不在源内;不覆盖。走 planImport 的原样搬运变体(transfer):目录递归成逐文件计划(保持文件名与结构,空子目录照建,符号链接不搬),dryRun 冻结源指纹 + 逐文件前后路径,执行时两者任一变化 → 409 plan_stale;执行写 organization_record(move/copy) + 入队 verifyAndImport(executeRecord:逐文件账本 org:<recordId>:<ordinal> 可恢复、fsproxy 挂死隔离、跨卷 Copy→核验→删源、EXDEV 不降级)。跨卷 Move 需 settings.organize.allowCrossVolumeMove(关闭时预览 warnings 提示、执行 400)。目标已存在的文件记失败、不覆盖。请求内最多等 FILES_TRANSFER_WAIT_SEC(默认 20 s):完成则 executed.succeeded/failed 为最终结果,否则 executed.pending=true,后台继续,按 executed.recordId/taskId 追踪。move 完成后(finalizeTransfer,记录行锁):已登记 asset_location 逐文件跟随(跨库根改 library_root_id),整项搬完时目录命中的 path_binding 写修订,源侧空目录删除。move 触及纳管下载任务 → 409(请用整理入库);API Key 的 move 按每个源所属作品逐一核对授权(见授权矩阵)
organize 按 (mediaEntityId, contentUnitId, operation, targetDir, slotId) 分组,每组一次 planImport,与 POST /organization-records 同实现;执行需 planIds(按请求匹配各组);单组失败只影响该组(groups[].error);成功组入队 verifyAndImport,返回 recordIds。逐项覆写生效:season/episodes(连续区间)→ planImport.fileOverrides,决定季目录、文件命名与集单元覆盖;contentUnitId(集/分段单元)→ 额外覆盖;targetDir(writable 库根下已存在目录)→ 文件按模板命名直接放进去,不用也不建路径绑定;operation → 该项的 copy/move;slotId → 核验通过后以新资产满足该 Slot(获取中的 Slot 不动)。groups[].warnings 只列无法应用的覆写(电影的季/集、非连续集号、目录项指定集号、不属于该作品的单元/Slot、获取中的 Slot)
delete 默认回收站:<根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>(同卷 rename);根不可写/跨卷 → DATA_DIR/trash/... 复制(上限 FILES_TRASH_CROSS_MAX_BYTES,默认 2 GiB,超出请永久删除)。引用检查:纳管任务未完成或 H&R 未满足 409;已登记资产需 retireAssets(location→removed、asset 无其他位置→retired、满足的 Slot→attention + 待处理);绑定目录需 unbind(→superseded);根本身/只读根拒绝;离线 503。永不连带下载器任务。执行写 `organization_record(action=trash
永久删除 FILES_ALLOW_PERMANENT_DELETE=true 才可用,且仅管理员会话;单项需 confirmName = 文件名
回收站 GET /files/trash;restore(原位被占 → 该项 exists,可带 target 还原到别处;还原不恢复资产状态,需要时重新整理登记;有成功项时写 organization_record(action=restore),返回 recordId);purge 仅管理员会话,缺省只清过期项。"还原到…"的目标必须是可写的 managed 库根(否则 403 not_writable);原位还原时缺失的父目录在根内逐级重建(已存在的必须是真目录,符号链接拒绝 path_escape);跨卷还原受 FILES_TRASH_CROSS_MAX_BYTES 上限(超出该项 too_large),复制经 fsproxy。持久任务 purgeTrash 每日入队(FILES_TRASH_RETENTION_DAYS,默认 7);清除前复核行仍存在,且路径必须在某根的 .umt-trash 或 DATA_DIR/trash 下
identify 只读:parseReleasePath(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ catalog.lookup 能力取远端候选(经进程共享的 TMDB 信号量与 15 s 超时;单次请求去重后最多 20 个远端查询,超出的项只本地召回并给 warning;失败只给 warning)→ rankCandidates 前 5;目录展开视频上限 200;不建作品、不进待识别池
probe 需要 organize.execute;≤20 项;已登记资产写回 media_asset.media_info.probe;未登记缓存 24 h(DATA_DIR/cache/probe)
scrape 仅有 active 资产的作品;入队 refreshMetadata(字段锁优先,不动路径),返回 lockedFields;writeNfo/images 目前返回 not_implemented
download GET /files/download?root=&path=&inline=:仅管理员会话(API Key 含 admin scope 也 403);只允许 NFO/字幕/图片(视频 403),≤ FILES_DOWNLOAD_MAX_BYTES(默认 20 MiB,超出 413 too_large:);路径守卫同其它接口,打开前再 realpath 复核、按文件句柄 stat;流式返回(非 JSON 信封),inline=true 仅对图片生效(SVG 一律附件),带 nosniff + CSP sandbox;写审计 files.download

文件管理 v2 后端缺口(W9-H,docs/design/file-manager-v2.md §15)

# 接口 要点
G1 POST /files/entity-from-candidate {provider:"tmdb", externalId, kind:"movie"|"tv"};/files/organize 项 external(与 mediaEntityId 二选一,都给/都不给 400) 取 TMDB 详情建 media_entity + external_identity(accepted,evidence_level=manual,IMDb 同时登记)+ 别名;剧集按 TMDB 季表建季/集单元(provisional,单季 ≤200 集)。幂等:同 TMDB id 已有 accepted 映射直接复用(跟随合并),并发冲突重试一次。organize 预览阶段就地建(同一请求内同一候选只建一次),组里 entity{provider,externalId,created,title}。TMDB 未配置 400、无此条目 404、上游失败 502(upstream:)。服务在 sources-identity/service/entity-from-candidate.ts(createEntityFromTmdbCandidate),TMDB 客户端经 SourcesRuntime.tmdb()(设置 metadata.tmdb*)
G2 organize minSizeBytes;identify minSizeBytes 缺省取 settings.organize.minSizeBytes(缺省 50 MiB,W10-pre2 起进契约);设置读不到时 organize 退回端口 minVideoBytes,identify 不过滤。请求 / 向导给的值优先,给 0 表示不过滤。自动入库的 planImport 同样按"请求 > 设置 > 端口"取值。设置页还没有这个字段(前端后续在「媒体库与目录 → 整理方式」加),目前只能 PATCH /settings {"organize":{"minSizeBytes":<字节>}} 修改。organize:低于阈值的视频进组 excluded[],reason=below_min_size(sample/no_episode 等照原因名);identify:该项 skipped="below_min_size"、不查候选,结果 excludedBySize 计数;每项带 size
G3 organize onExisting: skip|conflict|replace_with_verify(缺省 conflict) 透传 planImport.onExisting;选 replace 即视为人工授权(grantReplace),执行走现有 replace 分支(新文件核验通过后旧文件进回收站)。预览逐文件 action(transfer/replace/skip/hold)+ existing(same 同一文件已在库 / skip / replace / conflict / null)+ skipReason;conflict 时该组 error.code=plan_blocked。策略进请求指纹,换策略须重新预览
G4 organize naming: template|keep(缺省 template) keep = 保留源文件名(目录仍按模板/绑定/targetDir;改名后撞名 → hold + duplicate_target)。planImport 新参数 namingMode / namingSource,冻结计划 organization_record.plan.naming = {mode, templateSource: entity|root|global|override, templateVersion}(模板来源按 作品 > 库根 > 全局 回落;keep = override)。组响应同名 naming + targetSegments[{text, role: root|season|dir, bound}](bound = 沿用已有路径绑定)
G5 identify 项 records{count,lastRecordId,lastStatus};GET /files/record-sources?recordId=;organize refreshMetadata;POST /files/scrape-batch 整理记录按 organization_file_result.source_path 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 unresolved。refreshMetadata=true:执行成功的组按作品去重各排一次 refreshMetadata(组里 refreshTaskId)。scrape-batch:items(文件/目录 → 其下已登记资产的作品)∪ mediaEntityIds 去重,≤200 个作品;无 active 资产 not_registered、不存在 not_found、文件不属于任何作品 no_entity;带幂等键
G6 GET /files/roots 每项 pendingCount / pendingPartial 仅下载目录:递归数待识别视频(与列表 unidentified 同口径:视频、非临时名、不在纳管下载任务内容内),上限 FILES_LIST_MAX_ENTRIES 项,超出 pendingPartial=true(计数为下限)。按根缓存 5 分钟,该根的 files.changed 事件使缓存失效;库根与离线根为 null
G7 列表项 parsedHint 待识别视频行:parseReleasePath(文件名 + 父/祖父目录)的轻量结果 标题 (年) · S01E02 · 1080p(剧集不带年);不查库,解析不出标题或非待识别行为 null。前端副行直接显示,不再自己解析
G8(海报) identify 候选 posterUrl TMDB poster_path 拼 https://image.tmdb.org/t/p/w154…(TMDB 适配器候选带 posterPath,catalog.lookup 远端项透传);本地作品取 metadata.posterPath,没有时用同一请求里其已关联 TMDB 候选的海报;都没有为 null。前端按 no-referrer 懒加载
S-7 identify 响应 remoteTaskId / remotePending;SSE identify.completed 缺省请求只做本地召回,需要 TMDB 时入队 identifyRemote 并返回 remoteTaskId(引擎 runId);任务结束发 identify.completed {taskId, status, items, truncated, excludedBySize, error}(契约 IdentifyCompletedPayload,失败也发)。前端识别节(useIdentifyLive)与整理向导按 taskId 对账,按 root + path 替换条目(向导保留已选作品 / 季集);事件先于响应到达时暂存再对账。?wait=true 同步查远端(整体 ≤15 s)
G8 GET /files/tree?root=&path= 目录摘要:parent(根为 null)、子目录 hasChildren(隐藏点目录与临时文件)、本目录递归视频数(上限 dirSummaryMax,超出 partial)与已登记位置数
G9 POST /files/suggest-name {root, path, mediaEntityId?} 与整理同一命名引擎与变量来源(effectiveNaming 作品 > 库根 > 全局 + loadNamingEntity),按解析结果渲染整条模板:文件给文件名段,目录给作品目录段(只有季号时给季目录段),同时回 relativePath、segments[{text, role}]、templateSource。作品取 请求指定 > 已登记资产所属 > 解析标题在本地别名里唯一精确命中(同年);都没有 → name=null, reason=identity_uncertain;模板渲染失败 → reason=naming_failed: …

授权矩阵(实现)

操作 管理员会话 / DEV_ADMIN_KEY API Key
roots / list / stat / identify / trash 列表 / records / tree / record-sources / suggest-name ✓ organize.read
mkdir / rename / copy / organize(copy) / restore / scrape / scrape-batch / probe / entity-from-candidate ✓ organize.execute
move / organize(move) ✓ organize.execute + 每个源所属作品(声明作品 ∪ 源下已登记资产的作品)都被持续授权覆盖,或整请求一次性审批;任一不覆盖 → 整体 403 approval_required 并列出未授权项;无法归属作品的源只认审批
delete(回收站) ✓ organize.execute + 每次一次性审批(dryRun 的 planId)
download / trash purge 仅会话(DEV key 403) ✗ 403(含 admin scope)
delete(永久) 仅会话(DEV key 403) ✗ 403

口径:注册表标 auth:"session" 的端点(download、trash purge)由 requireAdminSession 中间件执行,DEV_ADMIN_KEY 等同 admin scope 的 API Key,一律 403;api-registry.test 双向断言标注与中间件一致。永久删除同口径(处理器内 isAdminSession)。Move/删除审批的"管理员免授权"仍放行 DEV key(开发便利,与 /organization-records 的 authorizeMove 一致)。

环境变量

FILES_LIST_MAX_ENTRIES(20000)、FILES_TRASH_RETENTION_DAYS(7)、FILES_TRASH_CROSS_MAX_BYTES(2147483648)、FILES_PLAN_TTL_SEC(1800)、FILES_ALLOW_PERMANENT_DELETE(false)、FILES_TRANSFER_WAIT_SEC(20,移动/复制请求内等待执行完成的秒数)、FILES_DOWNLOAD_MAX_BYTES(20971520)。

错误兜底

处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)不再 500:离线类 errno(EIO/ENOTCONN/ESTALE/ETIMEDOUT/EHOSTDOWN/ENODEV)→ 503 root_unavailable,ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400(fsErrorToFiles)。

已知缺口

  • NFO/图片投影(scrape 的 writeNfo/images)、硬链接、蓝光原盘整目录操作仍未做。
  • 搬运 move 在终态提交前删源侧空目录;若进程恰在两者之间退出,重跑 finalize 幂等,但极端情况下可能留下空目录(无数据风险)。
  • 路径守卫未下沉到 packages/adapters/src/fs/guard.ts(本模块自带按根解析的守卫,整理仍用 sourceGuard)。