Files
UHD_Media_Tool/docs/porting/moviepilot-filemanager.md
T
2026-09-27 22:34:13 +08:00

126 lines
12 KiB
Markdown

# 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 层自有实现替代。