- 新增 docs/runbook/storage-alist.md:登记、行为要点、临时 OpenList 容器真机验收步骤、排查 - organize.md 加「Alist / OpenList 存储」;moviepilot-filemanager.md 末节改为已做; v3 对照保留 rclone 不照抄、网盘改为 Alist 已接;file-manager-v2 存储切换与目的存储两行更新 - 设计文档补实现落点与偏离;矩阵测试补原地整理与目标已存在替换 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
14 KiB
MoviePilot v3 文件管理:行为对照记录
格式:REQ §14.1.2 迁移交付规范。只记录行为、接口形状和交互,不复制 MP 源码或前端模板(MP 后端 GPLv3、前端同属 jxxghp 组织,按
license-and-provenance.md一律"只参考行为")。 对应规格:06-file-manager-spec(已删除,见 gitceb66f8);需求: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、querybackground。 - 链路:
_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:),只支持复制 / 移动,目录监控靠快照比对。