- site.list / site.health_check / site.refresh_login / site.user_data capabilities over the W9-A site center services; outputs whitelisted (no secret refs, cookies, passwords, login names); execute kinds need automation grant. IDs use snake_case (CapabilityId regex); registry count 17 -> 21. - settings.organize.minSizeBytes (default 50 MiB); file-manager identify / organize and planImport take request > setting > port default. - tests and runbook notes (sites, files). Co-Authored-By: Claude Opus 5.5 <[email protected]>
78 lines
15 KiB
Markdown
78 lines
15 KiB
Markdown
# 文件管理(`/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|delete)`,文件级结果(目录展开到 2000 个文件,超过只记目录一行),返回 `recordId` |
|
|
| 永久删除 | `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`;带幂等键 |
|
|
| 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`)。
|