docs: MoviePilot file manager study and our file manager spec
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
3a9b6f7f33
commit
23309042dc
2 files changed
+361
No files matched your search
@@ -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:<library_root.id>` 或 `dl:<downloader_instance.id>:<mapping idx>` |
|
||||
| `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.<op>`,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/<yyyymmdd>/<uuid>/<原相对路径>`(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 若非本系统生成(无 `<!-- umt -->` 标记)且字段已被用户锁定来源为"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 先只做字段刷新。
|
||||
@@ -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 层自有实现替代。
|
||||
Reference in new issue
Block a user