diff --git a/docs/plan/06-file-manager-spec.md b/docs/plan/06-file-manager-spec.md new file mode 100644 index 00000000..d39d9dc0 --- /dev/null +++ b/docs/plan/06-file-manager-spec.md @@ -0,0 +1,236 @@ +# 06 文件管理页规格(`/console/files`) + +> 来源:`00-first-version-plan.md` §6 追加范围;对照 `docs/porting/moviepilot-filemanager.md`(下称"MP 账本",痛点编号 P1–P12)。 +> 需求约束:REQ §2.1 五原则、§7.1/§7.1.1(路径绑定固定、媒体级主动整理)、§8.2(扫描与"失败≠不存在")、§8.3(Copy/Move、删除保护)。 +> 本文只定规格,不含实现。实现时 contracts 先行(`packages/contracts/src/files.ts`)。 + +## 1. 目标与边界 + +**目标**:覆盖 MP 文件管理的全部操作(浏览、存储/根切换、面包屑、目录树、排序过滤、新建目录、重命名、删除、识别、刮削、整理、批量、预览/下载),并在四个方面做得更好: +1. **有业务上下文**:每一行都显示"已登记资产 / 纳管下载中 / 临时文件 / 待识别",不用挨个点识别(P5)。 +2. **安全**:只能浏览登记过的根;删除进回收站并做引用检查;改名同步资产与路径绑定(P1/P2/P3)。 +3. **一条向导走完整理**:识别 → 逐项确认作品/季/集/Slot → dryRun 预览前后路径 → 执行 → 跳转整理记录(P7/P8)。 +4. **大目录与离线可用**:游标分页、截断提示、挂载离线显式报错而不是显示空目录(P6/P12)。 + +**边界(不做)**: +- 整库迁移、底层分支盘/存储池调度(REQ §7.1.1);网盘存储(115/阿里/Alist/SMB/rclone)——只有本机可达路径(含 NFS/FUSE 挂载)。 +- 不提供文件上传、在线编辑、解压;下载只允许非媒体小文件(≤ 20 MB,如 NFO/字幕/图片),视频不经浏览器下载。 +- **浏览器无媒体库写权限原则不变**:所有写操作由服务端执行,浏览器只提交意图;服务端做守卫、授权、审计。 +- 硬链接/软链接创建、空目录上溯清理、蓝光原盘整目录操作:列为 W5(与 `moviepilot-rust-transfer.md` 的 W5 项一起)。 +- 不改元数据自动刷新语义:刷新不改路径;只有本页的"重命名/移动/整理"是显式路径变更。 + +## 2. 后端 API + +### 2.1 模块归属 + +- 新建子模块 `apps/server/src/modules/acquisition-organize/files/`(路由 `routes/files.ts` + 服务 `services/files*.ts`),复用 `organize-guard.ts`、`planImport`、`volumes.ts`。 +- `POST /files/identify` 需要身份候选排序,归 `sources-identity` 模块注册(遵守"模块间不互相 import service",它只读 DB + 调 TMDB 适配器,不落库)。 +- **路径守卫下沉**:把 `organize-guard.ts` 里的 `sourceGuard` 的纯 IO 部分抽到 `packages/adapters/src/fs/guard.ts`(输入:根列表;输出:`check(p) → realpath`),两个模块共用;根列表由各自从 DB 读取。 +- 前缀统一 `/files`,scope:读 `organize.read`;写 `organize.execute`,另加 §2.4 的授权规则。 + +### 2.2 读接口 + +**`GET /files/roots`** → `FileRoot[]` +| 字段 | 说明 | +| --- | --- | +| `id` | `lib:` 或 `dl::` | +| `kind` | `library` \| `download` | +| `name` / `hostPath` | 显示名 / 本机路径(下载根 = 路径映射的 hostPath;无映射时取 localSavePath) | +| `mediaKind` | 库根的 movie/tv/mixed;下载根为 null | +| `writable` | 库根 `management_mode='managed'` 且进程对目录有 W_OK;`register_only` 恒 false(只读登记) | +| `health` | `healthy/degraded/offline/unknown` + `checkedAt`,来自 `checkRoot`(哨兵)实时探测,≤2 s 超时 | +| `usage` | `diskUsage` 的 total/free,失败为 null | + +与 `allowedSourceRoots` 同源,根 `/` 永不出现。 + +**`GET /files?root=&path=&cursor=&limit=&sort=&order=&q=&filter=&hidden=`** → `{ root, path, entries, nextCursor, total, truncated, scannedAt }` +- `path` 是根内相对路径(`""` = 根);服务端 `join(root.hostPath, path)` 后过守卫。响应里不回显绝对路径以外的东西。 +- 目录读取用 `opendir` 流式;单次最多读 `FILES_LIST_MAX_ENTRIES`(默认 20 000)项,超出 `truncated=true`,前端提示"请用搜索或进入子目录"。 +- 排序 `name`(自然排序,目录优先)/ `mtime` / `size` / `status`;`order=asc|desc`;`q` 为子串或 glob(大小写不敏感);`filter` 可多选:`video|subtitle|registered|unregistered|downloading|temp|unidentified`;`hidden=false` 默认隐藏点文件和 `.uhd-partial`/`.umt-trash`/哨兵。 +- 分页:排序在服务端全量(≤上限)完成后按 `limit`(默认 200,最大 1000)切片,`cursor` 为不透明偏移 + 目录快照 mtime;目录 mtime 变了返回 `stale: true` 让前端重载。 +- 每个 entry: + +| 字段 | 说明 | +| --- | --- | +| `name` / `type`(`file\|dir\|symlink\|other`)/ `size` / `mtime` | lstat;符号链接不跟随,仅标记,目标在根外时 `escape: true` 并禁用所有操作 | +| `ext` / `fileKind` | video/subtitle/audio/nfo/image/other(同 domain/organize 的分类) | +| `status` | 见下表,只取一个主状态,按优先级 | +| `asset` | 已登记时:`{ assetId, locationId, mediaEntityId, title, year, contentUnit(S01E02), slotId?, slotStatus?, isPrimary }` | +| `download` | 路径落在纳管 `download_task.content_path`(经 hostPathOf 映射)内:`{ taskId, status, progress, hnr: satisfied\|unsatisfied\|unknown }` | +| `media` | 已登记资产取 `media_asset.media_info` 摘要(分辨率/HDR/编码/音轨数);未登记不探测,仅在 `/files/probe` 后缓存 | +| `dirSummary` | 目录项:懒计算,仅列表当前页的目录给 `{ videoCount, registeredCount }`,上限 500 项/目录,超出给 `partial: true` | + +`status` 优先级:`temp`(`.uhd-partial`、`.!qB`、`.part`)> `downloading`(纳管任务未完成)> `registered`(有 available 的 asset_location)> `seeding`(纳管任务已完成未入库/或 Copy 后仍做种)> `unidentified`(视频且无资产)> `plain`。 +- 资产关联查询:本页 entries 批量 `WHERE library_root_id=? AND relative_path = ANY(?)`,下载关联按任务 content_path 前缀,不逐行查库。 + +**`GET /files/stat?root=&path=`** → 单项 entry + `{ realPath, inode, device, nlink, sidecars[], bindings[] }`。`bindings` 为命中该目录的 path_binding(作品/季);`nlink>1` 标记"共享 inode,禁止原地改写"(REQ §8.3)。 + +**`GET /files/download?root=&path=`**:仅 `fileKind ∈ {nfo, subtitle, image}` 且 ≤ 20 MB;`image` 同时作为预览。 + +**挂载离线**:根哨兵检查失败或 `readdir` 报 EIO/ENOTCONN/ESTALE/ETIMEDOUT → `503 root_unavailable`,不返回空列表(原则 4,P12)。 + +### 2.3 写接口 + +所有写请求体都带 `idempotencyKey`(前端生成 UUID);同键重放返回首次结果(存 `task.idempotency_key`)。所有写操作:守卫 → 授权 → 前置检查 → 执行 → `audit_event`(action `files.`,payload 含 root/相对路径/结果)→ SSE `files.changed { rootId, dirs[] }`。 + +| 端点 | 请求 | 行为 | +| --- | --- | --- | +| `POST /files/mkdir` | `{root, path, name}` | `sanitizeSegment(name)` 后与原值不同则 400;`mkdir` 非递归,已存在 409;只允许 writable 根 | +| `POST /files/rename` | `{root, path, newName, withSidecars=true, dryRun}` | 同目录改名,见 §2.3.1 | +| `POST /files/move` | `{items:[{root,path}], target:{root,path}, mode:"move"\|"copy", dryRun, planId?}` | 见 §2.3.2 | +| `POST /files/delete` | `{items:[{root,path}], mode:"trash"\|"permanent", dryRun}` | 见 §2.3.3 | +| `POST /files/trash/restore` / `GET /files/trash` / `POST /files/trash/purge` | — | 回收站列表/还原/清空 | +| `POST /files/identify` | `{items:[{root,path}], hints?:{mediaKind,year,tmdbId}}` | 见 §2.3.4,只读 | +| `POST /files/organize` | 见 §2.3.5 | 复用 `/organization-records` | +| `POST /files/scrape` | `{mediaEntityId, writeNfo:boolean, images:boolean}` | 见 §2.3.6 | +| `POST /files/probe` | `{items:[{root,path}]}`(≤ 20 个视频) | `ffprobe` 摘要;已登记资产写回 `media_asset.media_info`,未登记只缓存 24 h(DATA_DIR/cache/probe) | + +#### 2.3.1 rename + +- `newName` 过 `sanitizeSegment`;不得含 `/`;目标已存在 → 409(**不覆盖**,修 P3)。实现用 `link+unlink` 或 `renameat2(RENAME_NOREPLACE)` 语义,不能用会覆盖的裸 rename;Deno 下用"先 lstat 再 rename + 事后 inode 比对"并在 `asset_operation` 记账。 +- `withSidecars`:视频改名时同 stem 的字幕/NFO/图片一起改(复用 `domain/organize/sidecars.ts` 的匹配规则),dryRun 返回全部前后名。 +- **已登记资产**:同一事务里更新 `asset_location.relative_path`(含 sidecar 的 location),写 `asset_operation` 账本(commit 阶段);发 `organization_record(trigger=manual, action=rename)`(枚举已有 `rename`)让外部程序增量感知。 +- **目录改名命中 path_binding**:这是显式路径变更(REQ §7.1.1),插入新绑定并把旧绑定置 `superseded`、`superseded_by_id` 指向新行,`bound_by` = actor;该目录下所有 asset_location 的 relative_path 前缀批量替换。**仅译名刷新不触发**,只有本端点和整理会改。 +- 目录内有纳管下载任务(做种中):拒绝 409 —— 改名会让 qB 找不到文件;提示先完成/移交。 +- 递归自动改名(MP 的 recursive)不照搬:改为 §3.4 向导里"按模板重命名"的一种模式,先预览全部,再一次执行,单项失败不影响其他项并列出失败清单(修 P9)。 + +#### 2.3.2 move / copy + +- 目标必须是 writable 根下的已存在目录;源 ≠ 目标、目标不在源内。 +- **已登记资产或含已登记资产的目录**:不走裸 FS,转成 organize plan(记录 action 用已有的 `relocate`/`copy`):`planImport({sourcePaths, action, targetLibraryRootId, ...})` 的"原样搬运"变体(保持文件名,目标目录由用户指定)——需要在 `ImportRequest` 加 `targetDir`(绝对路径,须在库根下)与 `keepNames: true`。计划照常检查冲突、下载器引用、可用空间、H&R;执行走现有 `verifyAndImport` → 逐文件可恢复、跨卷 Copy→核验→删源、EXDEV 不降级。完成后 asset_location/path_binding 更新由整理流程负责。 +- **未登记文件**(如下载根里的散文件、临时整理):同样走 organize plan,但 `registerAssets=false`,只产生 organization_record,不建资产。这样搬运都是可恢复的,不存在第二套搬运代码。 +- `dryRun=true` 返回 planId + 前后路径表 + 冲突/空间/H&R 结论;执行必须带 dryRun 返回的 `planId`,服务端校验计划内容哈希与当前请求一致,且源文件 size/mtime 与计划冻结时一致,否则 409 `plan_stale`(dryRun 一致性)。 +- `mode=move` 授权同 `/organization-records`:admin 会话,或 API Key + grant/approval;H&R 未满足 → 409 + 待处理,不静默改 Copy。 + +#### 2.3.3 delete(原则 5) + +- 默认 `mode=trash`:同卷移入 `<根>/.umt-trash///<原相对路径>`(rename,O(1));根不可写或跨卷时移入 `DATA_DIR/trash/` 需 Copy,超过 `FILES_TRASH_CROSS_MAX_BYTES`(默认 2 GiB)拒绝并提示用永久删除。`trash_item` 表记录原根、原相对路径、actor、时间、关联资产快照。 +- 保留 `FILES_TRASH_RETENTION_DAYS`(默认 7);清理由持久任务 `purgeTrash` 每日执行,删前复核 `trash_item` 仍存在。还原:原路径被占用 → 409,可选"还原到…"。 +- `mode=permanent`:设置项 `files.allowPermanentDelete`(默认 false)开启后才可用,且仅 admin 会话;前端二次确认输入文件名。 +- **引用检查(两种模式都做,dryRun 可预览)**: + | 情况 | 处理 | + | --- | --- | + | 命中纳管 download_task 的 content_path(未完成或仍在做种/H&R 未满足) | 拒绝 409,列出任务 | + | 已登记资产(asset_location available) | 允许,但必须勾选"同时退役资产":asset_location→`removed`,media_asset→`retired`,若为 Slot 满足资产则 Slot 退回 `attention` 并建待处理;写 organization_record(action=delete) | + | 目录是 library_root 本身、`path_binding` 绑定目录 | 根本身拒绝;绑定目录需勾选"解除绑定"(绑定置 superseded),否则 409 | + | 共享 inode(nlink>1) | 允许,提示"只删除这一处链接" | + | 根离线/stat 失败 | 拒绝(原则 4),不当作已删除 | +- 删除永不连带 qB 任务;qB 侧清理仍只走 retention 流程。 + +#### 2.3.4 identify(只读) + +- 对每个选中项:`parseReleasePath`(文件路径含父目录上下文)→ 本地实体优先召回(title_alias pg_trgm + external_identity)→ 不足时查 TMDB(走缓存)→ `rankCandidates`。目录项先展开为其中视频(上限 200)。 +- 返回每项:`{ parsed(标题/年份/季集/分辨率/来源/发布组), candidates:[{mediaEntityId?, provider, externalId, title, year, kind, level, reasons[]}] (≤5), suggestion:{mediaEntityId?, contentUnit?, slotId?}, existing:{assetId?} }`。 +- **不写库**:不建 media_entity、不写 identity_revision、不进待识别池;用户在向导里确认后才由 organize 落库。`resolveRelease` 需要拆出纯排序函数(`rankForPath`)供这里调用。 + +#### 2.3.5 organize + +- 请求:`{ items:[{root, path, mediaEntityId, contentUnitId?, season?, episodes?, slotId?}], action:"copy"|"move", targetLibraryRootId?, dryRun, planId? }`。 +- 服务端按 `mediaEntityId` 分组,每组调用一次 `POST /organization-records` 同一实现(`planImport`),因此自动与手动整理产出同格式记录(REQ §7.1.1)。 +- **缺口**:现有 `OrganizeRequest` 只接受整批一个 `mediaEntityId/contentUnitId`,没有逐文件季集覆写。需扩 contracts:`fileOverrides: [{path, contentUnitId?, season?, episodes?}]`,planImport 在 facts 生成时优先用覆写值;`slotId` 用于指定满足哪个 Slot(缺省由 Slot 评估选)。 +- dryRun 返回每组 `planId`;执行请求携带全部 planId,逐组执行,任一组 plan_stale 只让该组失败。执行返回 `recordIds`,前端跳 `/console/organize?ids=`。 + +#### 2.3.6 scrape + +- 只对**已登记作品**:等价于 `POST /media-entities/{id}/refresh`(字段级、用户锁优先、不动路径),可选 `writeNfo`/`images` 把最终字段投影成 NFO/海报写到绑定目录。 +- 写 NFO 规则(REQ §7.3):临时文件 + 原子提交;目标 NFO 若非本系统生成(无 `` 标记)且字段已被用户锁定来源为"NFO 导入",不覆盖,返回 `skipped`;不写媒体容器字节。NFO 投影本体目前未实现,列入后端工作量。 +- 未登记文件点"刮削" → 前端引导先走识别/整理,不提供"直接按路径刮削写 NFO"(避免对未登记内容写文件,修 P10)。 + +### 2.4 授权矩阵 + +| 操作 | admin 会话 | API Key | +| --- | --- | --- | +| roots/list/stat/download/identify/probe(只读缓存) | ✓ | `organize.read` | +| mkdir / rename / copy / organize(copy) | ✓ | `organize.execute` | +| move / organize(move) | ✓ | `organize.execute` + automation_grant 覆盖目标作品,或一次性 approval(绑定 planId 哈希) | +| delete(trash) | ✓ | `organize.execute` + approval(每次) | +| delete(permanent)、trash purge | 仅 admin 会话 | ✗ | + +Agent 侧暂不暴露 files.* 能力;若加,只开放 `files.list/identify`(只读)。 + +## 3. 前端规格 `/console/files` + +### 3.1 布局 + +- 桌面(≥ lg):左栏 `pg-chrome` 贴边 260 px(可拖宽,存 localStorage):根列表(按 库/下载 分组,每根带健康 StatusBadge 和剩余空间)+ 当前根的懒加载目录树;右侧 `pg-slab`:工具栏(面包屑、搜索、筛选、排序、视图、显示隐藏文件开关、新建目录、刷新)+ 列表 + 底部状态栏(项数、选中数/总大小、截断提示)。 +- 选中单项时右侧抽屉 `DetailSheet`:文件信息、资产/作品/Slot 链接、下载任务链接、sidecars、ffprobe 摘要、路径绑定;图片/NFO/字幕文本预览。 +- 移动端(< md):根和树收进 `Sheet`,列表单列卡片(名称 + 状态徽标 + 大小/时间一行),长按进入多选,操作放底部操作栏;面包屑折叠为"…/父/当前"。 +- URL 状态:`?root=&path=&q=&sort=&sel=` 可分享、可回退;刷新保持位置。 + +### 3.2 列表 + +- 列:选择框 | 图标 + 名称(已登记显示作品标题副行)| 状态徽标 | 季集 | 大小(目录显示视频数/已登记数)| 修改时间 | 行操作(⋯)。 +- 状态徽标(`StatusBadge` + 文字,不只靠颜色):已登记(success)/ 下载中 n%(neutral)/ 做种中·H&R 未满足(warning)/ 临时文件(neutral)/ 待识别(warning)/ 链接逃逸(destructive)。 +- 虚拟滚动(新增依赖 `@tanstack/react-virtual`,需登记许可 MIT);服务端分页 200 条,滚到底自动取下一页。 +- 快捷筛选 chips:全部 / 视频 / 待识别 / 已登记 / 下载中 / 临时文件;搜索框支持子串与 glob,300 ms 防抖,服务端过滤。 +- 空态:空目录(`EmptyState plain`,提供"新建目录")、过滤无结果(提供"清除筛选")、根离线(destructive 提示 + 上次健康时间 + "重试")、截断("只显示前 20 000 项,请搜索")、无权限。 + +### 3.3 操作 + +- 入口三处一致:行 ⋯ 菜单、右键菜单(桌面)、多选后的浮动操作栏。菜单按条件禁用并在 tooltip 说明原因(例如"做种中,不能改名")。 +- 单项:打开 / 重命名(F2)/ 移动到… / 复制到… / 整理到… / 识别 / 刮削(已登记)/ 探测 / 删除(Del)/ 复制路径(本机路径 + 下载器路径 + 媒体服务器路径三种,取路径映射)/ 在整理记录中查看。 +- 多选:整理向导 / 识别 / 移动到 / 复制到 / 删除 / 探测。 +- 重命名:行内编辑;右侧"建议名"按钮(按模板渲染,来自 identify 结果);显示将一起改名的 sidecars;已登记/绑定目录时提示"将同步资产与路径绑定"。 +- 移动/复制到:目标选择器复用目录树,只列 writable 根;先显示 dryRun 预览(前后路径、冲突、空间、H&R),再确认。 +- 删除:对话框列出引用检查结果,默认"移入回收站(7 天后清除)";需要额外勾选的项逐条列出。 +- 拖拽:把列表项拖到树节点或面包屑段 = "移动到…",松手后一律打开 dryRun 预览对话框,不直接执行;按住 Alt 为复制。 +- 键盘:↑↓ 移动焦点,Enter 进入/打开,Backspace 上一级,Space 勾选,Shift+点击/Shift+↑↓ 区间选,Ctrl/Cmd+A 全选,F2 改名,Del 删除,`/` 聚焦搜索,Esc 清除选择;列表为 `role="grid"`,焦点可见。 +- 长任务(整理/移动)提交后 toast 带任务链接,列表通过 SSE `files.changed` 与 `organization.*` 事件自动刷新受影响目录。 + +### 3.4 批量整理向导(Sheet 全高,4 步) + +1. **识别**:对选中项调 `/files/identify`,表格:源文件 | 解析(标题/年/季集/质量)| 候选作品(下拉,显示等级与理由,可搜索改选)| 季/集(可编辑,支持批量"集号 +N 偏移")| Slot(下拉,默认自动)。同一目录的项默认共享作品选择;"应用到同组"按钮。未决项(无候选/低置信)高亮,必须处理或排除才能下一步。 +2. **选项**:动作 Copy/Move(Move 在有 H&R 未满足项时禁用并说明)、目标库根(按作品类型过滤)、是否按模板重命名(默认是)、已存在目标处理(跳过/冲突待处理)。 +3. **预览**:调用 dryRun,按作品分组显示前后路径对照(差异高亮)、新建目录、将创建/沿用的路径绑定(已绑定显示"沿用 旧目录名",体现原则 2)、冲突、空间、截断警告。任何组失败可返回第 1 步修改。 +4. **执行**:提交 planId,显示每组状态;完成后按钮"查看整理记录"跳 `/console/organize?ids=…`。 + +### 3.5 与 MP 的对比(更好用的具体点) + +| MP | 我们 | +| --- | --- | +| 从 `/` 浏览整个文件系统(P1) | 只列登记的库根与下载根,带健康与空间 | +| 列表无业务状态(P5) | 每行显示已登记/下载中/做种/待识别,并链接作品、Slot、任务 | +| 删除直接 rmtree(P2) | 回收站 + 引用检查 + 资产退役联动 | +| 改名不同步历史、可能覆盖(P3) | 不覆盖;同步 asset_location、path_binding 修订、sidecars;发整理记录 | +| 无通用移动/复制(P4) | 移动/复制到 + 拖拽,统一走可恢复的 organize plan | +| 识别单结果只读,与整理分离(P7) | 候选 Top5 可改选,结果直接进入向导 | +| 批量整理只能一组参数(P8) | 逐项作品/季/集/Slot,按作品分组预览 | +| 递归改名失败即中止(P9) | 全量预览,逐项结果,失败不影响其他 | +| 刮削恒覆盖 NFO(P10) | 字段锁 + 仅对已登记作品 + 非本系统 NFO 不覆盖 | +| 无键盘/拖拽/右键/隐藏文件开关(P11) | 全部提供 | +| 离线显示空目录、大目录全量返回(P6/P12) | 503 显式离线;游标分页 + 截断提示 | + +### 3.6 设计纪律 + +遵守 `docs/design/README.md`:只用语义 token;材质:左栏 `pg-chrome`,列表 `pg-slab`,菜单/对话框 `pg-float`,卡片内空态用 `EmptyState plain`;复用 `PageHeader`、`FilterBar`、`DataTable`(虚拟化版本新增 `virtual` 选项而非另造表格)、`DetailSheet`、`ConfirmDialog`、`StatusBadge`、`EmptyState`;新增共享件 `DirTree`、`PathBreadcrumb`、`ContextMenu`(基于 ui/popover)放 `components/console/`。导航:`nav.ts` 加"文件"工作区。`deno task check:tokens` 必须绿。 + +## 4. 不变量、测试与实施拆分 + +### 4.1 数据与安全不变量 + +1. 任何请求解析出的 realpath 必须在某个登记根之下;`..`、绝对 name、符号链接逃逸、根本身写操作一律拒绝;遍历中每个文件复核(TOCTOU:执行阶段再次 realpath)。 +2. 浏览器永远不直接写文件;所有写操作有 audit_event 与幂等键。 +3. 路径变更只来自显式操作(rename/move/organize),且同事务更新 asset_location 与 path_binding 修订;元数据刷新不改路径。 +4. 删除不影响 qB 任务;纳管做种文件不可改名/删除/Move(除非 H&R 满足且有授权)。 +5. 失败≠不存在:离线/超时不改变任何资产状态、不显示为空。 +6. 执行只接受 dryRun 产出的 planId,且源文件事实未变。 +7. 不覆盖已存在文件;replace 只在整理流程的 `replace_with_verify` 策略下发生。 + +### 4.2 测试要求 + +- **路径逃逸**:`..` 段、URL 编码、符号链接指向根外(文件与目录)、`name` 含 `/`、根为 `/` 的配置被过滤;执行期把文件换成符号链接(TOCTOU)。 +- **删除保护**:纳管做种文件 409;已登记资产未勾选退役 409、勾选后 location=removed/Slot→attention;根本身拒绝;回收站还原到原位、原位被占 409;purge 只删过期项;离线根拒绝。 +- **rename 同步**:文件改名 → asset_location 与 sidecar 同步;目录改名命中绑定 → 新绑定 active、旧绑定 superseded 且链接;之后新集入库进入新目录;同名存在 409 且原文件未动;做种目录 409。 +- **dryRun 一致性**:dryRun 与执行对同一输入产出相同文件对;dryRun 后改动源(size/mtime)执行返回 plan_stale;planId 与请求体不匹配 403/409;幂等键重放不重复执行。 +- **列表**:分页稳定性、超大目录截断(生成 25 000 个空文件)、离线 503、状态徽标优先级、批量资产关联只发常数次查询。 +- **前端**:键盘导航、多选区间、向导步骤门禁(未决项不能下一步)、移动端布局快照;e2e:下载根散文件 → 识别 → 向导 Copy 入库 → 整理记录出现 → 列表状态变"已登记"。 + +### 4.3 实施拆分(两路并行,contracts 先合并) + +| 路 | 范围 | 估计 | +| --- | --- | --- | +| 0. contracts(后端路先做,半天) | `files.ts` schema、`OrganizeRequest` 扩 `fileOverrides/targetDir/keepNames/registerAssets`、organization action 加 `delete`(已有 rename/relocate)、`trash_item` 迁移 | 0.5 d | +| A. 后端 | guard 下沉到 adapters;roots/list/stat/download(含资产/下载关联与分页);mkdir/rename(同步资产+绑定);move/copy/organize 适配 planImport;回收站 + purgeTrash 任务 + 引用检查;identify(sources-identity 拆 rankForPath);probe;scrape(refresh 包装 + NFO 投影);授权矩阵、审计、幂等;§4.2 后端测试 | 5–6 d | +| B. 前端 | 路由与布局、根/树/面包屑、虚拟列表与筛选、详情抽屉、行/右键/批量操作、重命名/移动/删除对话框(含 dryRun 预览)、4 步整理向导、拖拽与键盘、移动端、空态/错误态;前端测试 + e2e | 5 d | + +B 可在 contracts 合并后用 MSW/mock 先行;联调 1 d。NFO 投影若排期紧可拆到 W5,scrape 先只做字段刷新。 diff --git a/docs/porting/moviepilot-filemanager.md b/docs/porting/moviepilot-filemanager.md new file mode 100644 index 00000000..f344a7b3 --- /dev/null +++ b/docs/porting/moviepilot-filemanager.md @@ -0,0 +1,125 @@ +# MoviePilot v3 文件管理:行为对照记录 + +> 格式:REQ §14.1.2 迁移交付规范。只记录行为、接口形状和交互,**不复制 MP 源码或前端模板**(MP 后端 GPLv3、前端同属 jxxghp 组织,按 `license-and-provenance.md` 一律"只参考行为")。 +> 对应规格:`docs/plan/06-file-manager-spec.md`;需求:REQ §2.1、§7.1/§7.1.1、§8.2/§8.3。 + +## 0. 上游版本与核验范围 + +| 项 | 值 | +| --- | --- | +| 后端 | `/opt/moviepilot-plus/upstream/MoviePilot`,提交 `e6820a8`(v3.0.0) | +| 前端 | `/opt/moviepilot-plus/upstream/MoviePilot-Frontend`,提交 `17b9d51` | +| 核验方式 | 只读源码;未运行服务;GitHub issue 不可访问,"痛点"一节是按代码推断,标 **推断** | + +读过的文件: + +| 层 | 路径 | 职责 | +| --- | --- | --- | +| 端点 | `app/api/endpoints/storage.py`(挂在 `/api/v1/storage`,`app/api/routers.py:72`) | list / mkdir / delete / download / image / rename / manage | +| 端点 | `app/api/endpoints/transfer.py` | `/transfer/name`(推荐名)、`/transfer/manual`(手动整理/预览)、`/manual/target-path`、`/manual/history` | +| 端点 | `app/api/endpoints/media.py` | `/media/recognize_file`(识别)、`/media/scrape/{storage}`(刮削) | +| 端点 | `app/api/endpoints/history.py:403` | 删除整理历史,可选 `deletesrc` / `deletedest` | +| 链 | `app/chain/storage.py` | `StorageChain`:把调用透传给 `run_module`;`delete_media_file` 带空目录上溯清理 | +| 模块 | `app/modules/filemanager/storages/{local,smb,rclone,alist,alistgo,alipan,u115}.py` | 存储后端,统一基类 `StorageBase`(`storages/__init__.py`) | +| Schema | `app/schemas/file.py` | `FileURI`(storage+path,`u115:/x` 形式 URI)、`FileItem`、`StorageUsage`、`StorageTransType` | +| 前端 | `src/pages/filemanager.vue` → `src/views/reorganize/FileBrowserView.vue` → `src/components/filebrowser/{FileBrowser,FileToolbar,FileNavigator,FileList}.vue` | 页面 | +| 前端 | `src/components/dialog/{FileRenameDialog,ReorganizeDialog,ScrapeDialog,MediaInfoDialog,FileNewFolderDialog,ProgressDialog}.vue` | 操作弹窗 | + +## 1. 后端端点清单 + +所有 storage 端点都用 POST + 请求体 `FileItem`(`storage` + `path`,目录 path 以 `/` 结尾),鉴权 `get_current_active_manage_user`(有"管理"权限的用户即可;`/manage` 要超级用户)。 + +| 端点 | 参数 | 返回 | 说明 | +| --- | --- | --- | --- | +| `POST /storage/list` | body `FileItem`;query `sort=name\|time`(默认按 mtime 倒序)、`keyword`(glob,`*`/`?`,忽略大小写) | `FileItem[]` | 一次返回整个目录,**无分页**;目录在前(local 实现先列子目录再列文件),再整体按 sort 重排;path 指向文件时返回单元素 | +| `POST /storage/mkdir` | body 父目录 `FileItem`;query `name` | `{success}` | local:`mkdir(parents=True, exist_ok=True)`,已存在也算成功 | +| `POST /storage/delete` | body `FileItem` | `{success}` | local:文件 `unlink`,目录 `rmtree`;**无回收站、无确认引用** | +| `POST /storage/download` | body `FileItem` | 文件流 | local 直接 `FileResponse(原路径)`;网盘先下载到临时目录 | +| `POST /storage/image` | body `FileItem` | `image/jpeg` 字节 | 图片预览,同样整文件读入内存 | +| `POST /storage/rename` | body `FileItem`;query `new_name`、`recursive` | `{success,message}` | 见 §2.1 | +| `POST /storage/manage` | `{target, action, params}` | 透传 | 网盘登录/二维码等存储管理动作,与文件管理页无直接关系 | +| `GET /transfer/name` | `path`、`filetype=file\|dir` | `{name}` | 识别后按重命名模板给出推荐名;目录取模板中的媒体根目录名 | +| `GET /media/recognize_file` | `path`、`media_source?` | `Context`(meta_info + media_info) | 识别,只读 | +| `POST /media/scrape/{storage}` | body `FileItem`;query `media_source?`、`media_id?`、`type_name?`、`music_type?` | `{success,message}` | 刮削,见 §2.5 | +| `POST /transfer/manual` | body `ManualTransferItem`;query `background` | 预览项或执行结果 | 手动整理,见 §2.4 | +| `POST /transfer/manual/target-path` | 同上 | 推荐目标存储/路径 | 按源路径匹配"目录配置"(下载目录 → 媒体库目录) | +| `POST /transfer/manual/history` | 同上 | `{reorganize, history_count}` | 源文件是否已有成功整理记录(提示"重新整理") | + +`StorageBase` 抽象方法:`list / create_folder / get_folder / get_item / get_parent / delete / rename / download / upload / detail / copy / move / link / softlink / usage / snapshot`。文件管理页只用到前 8 个;copy/move/link 只在整理(TransHandler)里用。 + +## 2. 各操作的业务规则 + +### 2.1 重命名 + +- 非递归:`StorageChain.rename_file` → 存储后端 `rename(path, parent/new_name)`。local 是 `os.rename`(经 fsproxy)。 +- **不更新 transferhistory、不通知媒体服务器、不处理同名字幕/NFO**。已整理文件改名后,历史里的 `dest` 变成悬空路径;之后"重新整理""删除历史连带删目标"都会指向旧路径(推断:删不到文件、提示失败或静默成功)。 +- 不检查目标名是否已存在;local `os.rename` 在 POSIX 上会**静默覆盖同名文件**(推断,rename(2) 语义)。 +- `recursive=true`(弹窗里的"自动重命名目录内媒体文件"):只遍历**一层**子文件,过滤媒体/字幕/音频扩展名,逐个 `recognize_by_path` → `recommend_name` → 改名;**任何一个识别失败就中止并返回**,前面已改的不回滚;最后再把目录本身改成用户输入的名字。进度走 `ProgressKey.BatchRename`。 +- 推荐名:前端弹窗里有"自动识别名称"按钮,调 `/transfer/name`。 + +### 2.2 新建目录 / 下载 / 预览 + +- mkdir:无名称校验(`/`、`..` 由存储后端自己处理;local 用 `Path(parent)/name`,`name="../x"` 可以跳出当前目录 —— 推断)。 +- download/image:local 直接读原路径,**无根目录限制**;管理用户可读容器内任意文件(配合 list 从 `/` 开始浏览)。 + +### 2.3 删除 + +- `/storage/delete`:直接 unlink/rmtree。不查 transferhistory、不查下载器是否仍在做种、不查是否媒体库根。前端只有一次确认弹窗。 +- 另一条路径是"整理历史 → 删除"(`/history/transfer`,`deletesrc`/`deletedest`):走 `StorageChain.delete_media_file`:拒绝删除路径层级 ≤ 2 的目录(`/`、`/media`);文件删除后向上清理"不含媒体文件"的空目录,碰到配置的下载目录/媒体库目录边界就停;蓝光目录整目录删;删源时还会发下载文件删除事件让下载器侧处理(推断,由事件订阅方实现)。 +- 文件管理页的删除**不走** `delete_media_file`,因此没有层级保护、也不清理空父目录。 + +### 2.4 整理(手动转移) + +- 入口:行菜单"整理"或多选"批量整理" → `ReorganizeDialog` → `POST /transfer/manual`。 +- 请求字段(`ManualTransferItem`):`fileitem`/`fileitems`(或 `logid`/`logids` 从历史重整)、`target_storage`、`target_path`、`transfer_type`(copy/move/link/softlink,取决于存储 `support_transtype`)、`type_name`、`media_source`+`media_id`(手动指定作品)、`season`、`episode_group`、`episode_detail`(集号列表)、`episode_format`(集号定位正则模板)、`episode_offset`、`episode_part`、`min_filesize`、`scrape`、`library_type_folder`、`library_category_folder`、`from_history`、`preview`、`reorganize`、query `background`。 +- 链路:`_execute_manual_transfer` → `TransferChain.manual_transfer` → 与自动整理同一个 `do_transfer`(识别 → 定向 → 冻结计划 → TransHandler 执行)→ 写 transferhistory。细节见 `moviepilot-transfer.md`。 +- `preview=true`:走同一链路但不执行,逐文件返回预览项(源、目标、失败原因),前端分页展示(`previewPage`,每页 20)。 +- 从历史重整:成功的 move 以 `dest` 作为新输入;失败的以 `src` 作为输入,并在执行(非预览)时清理旧 dest。 +- `target-path` 端点用源路径匹配"目录配置"给默认目标;多个源命中不同配置时返回空,让用户自己选。 + +### 2.5 识别与刮削 + +- 识别:`MediaChain.recognize_by_path(path)` → 返回 `Context`,前端 `MediaInfoDialog` 展示识别出的元信息(标题/年份/季集/资源属性)和匹配到的媒体。**只读,不落库**;只给一个结果,不给候选列表,也不能在弹窗里改选。 +- 刮削:`ScrapeDialog` 允许选数据源(themoviedb/douban/bangumi/anilist/musicbrainz/…)并可填原生 ID;未填 ID 时按路径识别。后端 `ScrapingChain.scrape_metadata(fileitem, meta, mediainfo, overwrite=True)`:在文件旁写 NFO、下载海报/剧照。**overwrite 恒为 true**,没有字段锁;对目录会递归处理其中文件(推断,见 ScrapingChain)。音乐走 `scrape_music_metadata`。 +- 批量刮削:前端逐个串行调用,失败项汇总提示。 + +## 3. 前端页面结构 + +``` +filemanager.vue +└─ FileBrowserView 读 Storages/Directories 公共设置,决定初始存储与路径(下载目录公共前缀,记住上次存储于 localStorage) + └─ FileBrowser 布局:左树右表,可拖动分隔条(宽度存 localStorage),PWA 下悬浮"新建目录"按钮 + ├─ FileToolbar 存储切换菜单 | 面包屑(itemstack) | 排序切换(name/time) | 上一级 | 新建目录 + ├─ FileNavigator 目录树:懒加载子目录,展开状态,只显示目录 + └─ FileList 过滤框(glob,大小写开关)| 刷新 | 选择模式(批量刮削/整理/删除) + VVirtualScroll 列表:图标(按扩展名)、名称、大小;桌面 hover 出操作按钮,移动端"⋮"菜单 + 行菜单:识别 / 刮削 / 重命名 / 整理 / 删除 + 点文件:图片→大图预览;其他→缩略图 + 大小 + 修改时间 + 识别/下载按钮 +``` + +- 空态区分"过滤无结果"和"空目录";加载中整块替换为 LoadingBanner。 +- 进度:长操作共用 `ProgressDialog`(识别、批量删除、批量刮削、递归重命名)。 +- 权限:`canManage` 控制新建目录按钮;其他操作靠后端拒绝。 + +## 4. 已知痛点(推断,供我们的规格对照) + +| # | 痛点 | 依据 | +| --- | --- | --- | +| P1 | 浏览范围是整个文件系统(`/` 起),误删/越权读取风险大 | local.list 根为 `/`;download 读原路径 | +| P2 | 删除无回收站、无引用检查,可能删掉仍在做种的文件或整个媒体库子树 | `/storage/delete` 直接 rmtree | +| P3 | 重命名不同步 transferhistory、字幕/NFO,可能静默覆盖 | §2.1 | +| P4 | 没有"移动到/复制到"通用操作;只能通过整理弹窗间接搬 | 行菜单只有 5 项 | +| P5 | 列表不标注文件是否已整理、是否在下载器中、是否临时文件,只能挨个"识别" | FileItem 无业务字段 | +| P6 | 大目录一次全量返回,无分页、无截断提示,网盘/网络挂载目录会卡 | list 无分页 | +| P7 | 识别只给单一结果,错了只能去整理弹窗手填 ID;识别和整理是两个弹窗,结果不互通 | MediaInfoDialog 只读 | +| P8 | 批量整理一次只能对所有选中项用同一组参数(同一作品/季/集规则),混合目录要分多次 | ReorganizeDialog 单表单 | +| P9 | 递归重命名遇到一个失败就中止,已改部分不回滚,无预览 | §2.1 | +| P10 | 刮削 overwrite 恒为真,会覆盖用户手改的 NFO | §2.5 | +| P11 | 无键盘导航、无拖拽、无右键菜单、不显示隐藏文件开关;排序只有名称/时间 | FileList 无 keydown/drag/contextmenu | +| P12 | 挂载离线时 list 返回空数组,界面显示"空目录",与真实空目录无法区分 | local.list 捕获 FileNotFound 返回 [] | + +## 5. 许可提醒 + +- MP 后端 GPLv3;前端仓库同组织,按 `license-and-provenance.md` 同等对待。本项目只迁移**行为和信息架构**,不复制 Python/Vue 源码、模板、i18n 文案、图标映射表。 +- 规格中的交互(左树右表、面包屑、行内操作)是通用文件管理器范式,不构成对 MP 表达的复制。 +- 若日后参考 MP 的集号定位正则模板(`episode_format`)语法,需单独登记来源,并以 domain 层自有实现替代。