# MoviePilot v3 文件管理:行为对照记录 > 格式:REQ §14.1.2 迁移交付规范。只记录行为、接口形状和交互,**不复制 MP 源码或前端模板**(MP 后端 GPLv3、前端同属 jxxghp 组织,按 `license-and-provenance.md` 一律"只参考行为")。 > 对应规格:`06-file-manager-spec`(已删除,见 git `ceb66f8`);需求: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 层自有实现替代。 ## 网盘:Alist / OpenList 已做(2026-10-07) 2026-10-06 先定的「网盘来源暂不做」被用户推翻:按 MP 做 Alist / OpenList(MP `storages/alist.py`、`alistgo.py`),不做 115 直连与 rclone(115 等网盘挂在 Alist 下用)。设计与 MP 能力对照见 `docs/design/storage-alist.md`,使用与真机验收见 `docs/runbook/storage-alist.md`。 | MP | 我们 | | --- | --- | | 存储切换菜单(local / Alist / AlistGo …) | 文件管理左栏「Alist 存储」组,每个实例一个根;Alist 上的媒体库目录在「媒体库」组 | | Alist 浏览 / 新建目录 / 改名 / 删除 / 下载 | 有;删除进 Alist 上的 `.umt-trash`(MP 直接删),下载走服务端流式转发 | | 上传 | 有(`PUT /files/upload`,同名不覆盖) | | 整理 本地 ↔ Alist ↔ Alist(同存储) | 有;另补跨实例(下载再上传,MP 不支持);成功判定同 MP(接口成功 + 目标存在) | | 刮削写远端(本地生成后 upload) | 有,已存在策略与本地一致 | | 远程目录监控(快照比对) | 有,复用整理来源的两次观察稳定规则 | | 存储用量 usage | MP 未实现;我们读 `mount_details`,拿不到显示「未知」 | 旧结论里的 115 / rclone 做法备查: - 115(`storages/u115.py`):官方开放平台 `proapi.115.com/open/*`;PKCE 设备码扫码登录(`authDeviceCode` → `qrcodeapi` 轮询 → `deviceCodeToToken`,`refreshToken` 续期);`ufile/files` 分页列目录(带 sha1、pickcode)、`folder/get_info`、`ufile/move|copy|update|delete`、`ufile/downurl` 换直链下载、`upload/init` 秒传 + OSS 分片上传;限流 API 3 qps、下载 1 qps,风控暂停 1 小时。AppID 须自行申请(MP 用自家应用 `100197847`)。 - rclone(`storages/rclone.py`):每个操作调一次命令行(`lsjson` / `copyto` / `moveto`,远程名固定 `MP:`),只支持复制 / 移动,目录监控靠快照比对。