Files
rootandClaude Opus 5.5 521c86d730 fix(storage): 本地写入口拒绝 Alist 虚拟路径,补字幕在 Alist 库根上明确失败
虚拟前缀 /@alist/… 读到本地是 ENOENT,但 mkdir -p、复制 / 移动目标、写文件、回收站落点会在本机真建出
/@alist 并写入文件。统一守卫:任何本地写类操作参数是虚拟路径即抛 REMOTE_PATH_ON_LOCAL_FS
(remote_path_on_local_fs),不落盘。

- adapters:fs/remote-guard.ts,接在 runFsOp、FsProxy.run(父进程拒绝不起子进程)、copyWithVerify、
  crossDeviceMove、moveSafe、commitNoClobber、sweepStalePartials、writeRootSentinel
- server:storage/local-fs.ts 带守卫的写函数,整理 / 替换 / 刮削 / 回收站 / 上传 / 字幕 / vfs 本地分支改用它
- 补字幕目标在 Alist 媒体库上返回失败原因,撤销同样拒绝

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 00:35:41 +08:00

31 KiB
Raw Permalink Blame History

Alist / OpenList 存储(对齐 MP v3)

对照源(只读,只参考行为):/tmp/umt-moviepilot-v3-review-20261001(v3.0.10-1)app/modules/filemanager/storages/{__init__,alist,alistgo}.py、transhandler.py(__transfer_command 约 1423–1630 行)、app/chain/scraping.py(_save_file / _download_and_save_image → storagechain.upload_file)、app/monitor/{monitor,poller,snapshot}.py。 真机核对:临时 OpenList v4.2.6 容器(Local 驱动 ×2),接口返回形状见 §2.4。 用户决定(2026-10-06):推翻 moviepilot-filemanager.md 末节「网盘暂不做」,按 MP 做 Alist / OpenList(115 直连、rclone 仍不做)。

0. 结论

  • 引入最小的「存储」概念:storage_instance 表登记多个 Alist / OpenList 实例(地址、永久令牌或账号密码、可选路径密码、根路径、限速)。本地仍走现有 fsproxy,远端走新适配器 packages/adapters/src/alist/。
  • 远端路径用虚拟前缀表示:/@alist/<存储 id>/<Alist 内路径>。它是合法的 POSIX 绝对路径,join / dirname / relative 照常可用,整理计划、整理记录、资产位置、回收站里的路径都不用换结构;不认识前缀的旧代码对它做本地 IO 只会得到 ENOENT(本机没有 /@alist),天然失败安全。认识前缀的地方经 apps/server/src/storage/vfs.ts 分派到适配器(MP 用 u115:/x 形式的 FileURI 表达同一件事)。
  • 媒体库目录、整理来源、文件管理都能选存储(对照 MP 目录配置「下载目录 / 媒体库目录各带 storage」):库根的 local_path 存虚拟路径;文件管理多一类根 st:<存储 id>(整个实例可浏览);整理来源选这些根即可。
  • 整理矩阵按 MP:本地→Alist 上传、Alist→本地 下载、同实例 Alist→Alist 用 API copy / move、跨实例 下载再上传(MP 不支持,我们补上)。
  • 网盘方向不做核验(用户决定,2026-10-06):凡涉及 Alist 的上传、下载、复制、移动,成功判定与 MP 一致——接口成功 + 目标存在即算成功,不比对大小、不比对哈希(不同网盘返回的大小 / 哈希不可靠,容易误判失败);移动在这个判定通过后删源。本地 ↔ 本地仍走现有核验(fsproxy、跨卷 copy → 全量 sha256 → 删源)不变。
  • 刮削目标在 Alist:NFO / 图片本地生成后上传到对应目录,已存在策略与本地一致(MP 也是先写临时文件再 upload_file)。
  • 目录监控:Alist 目录作为整理来源,按 MP 快照语义(深度上限、可选按目录修改时间跳过未变子树),复用现有来源快照表与「两次观察稳定」规则。
  • ffprobe:远端文件不下载,直接对签名直链(raw_url / /d/...?sign=)跑 ffprobe,只读文件头(HTTP Range)。MP 核心整理不做 ffprobe(只留事件给插件),我们本地做了「不是视频」核验,远端沿用同一核验但只读头。
  • 相对 MP 的 11 项改进(协调者补充、用户认可)见 §9;其中 3、5、6、7 的「核验」部分被用户随后的决定取消(网盘方向不核验),其余照做。

1. MP 的 Alist 能力清单 → 我们的落点

StorageBase 抽象方法逐项(alist.py;alistgo.py 只是换了 schema 名的子类,接口完全相同):

# MP 能力 MP 做法 我们 落点
1 配置 单实例(StorageSchema.Alist / AlistGo 各一份):url、token、username、password 多实例;另加根路径、路径密码、限速、超时、目录监控开关 storage_instance 表、设置 →「存储」
2 令牌 永久令牌优先;否则 /api/auth/login 换临时 token,缓存 2 天提前 5 分钟(@cached ttl) 同;且遇 401 / token 无效自动重登一次再试(§9-2) AlistClient.token()
3 check 能拿到 token 即可用 /api/me + 根路径 /api/fs/get;同时取版本 /api/public/settings 测试连接、健康检查
4 list /api/fs/list,per_page=500 自动翻页(total / pages_total / has_more),refresh 透传 同;refresh 只在用户点刷新或写后确认目标时带(§9-8) AlistClient.list()
5 get_item /api/fs/get,失败一律 None 「不存在」与其它失败分开(§9-1) AlistClient.get() → AlistNotFound / AlistError
6 get_item_strict 只有 not found / not exist 当不存在,其余抛 StorageQueryError 同(这就是我们的缺省语义) 同上
7 create_folder / get_folder /api/fs/mkdir,之后 _delay_get_item(sleep 2s×2 + refresh) mkdir 幂等(Alist 本身递归建);不 sleep 轮询 AlistClient.mkdir()
8 delete /api/fs/remove {dir, names} 同;注意 Alist 删不存在的名字也返回成功 AlistClient.remove()
9 rename /api/fs/rename {path, name} 同;目标同名存在时 Alist 返回 403 file [x] exists,映射为冲突 AlistClient.rename()
10 download /api/fs/get 取 raw_url,没有就拼 /d<path>?sign=;8 KB 块流式写 TEMP_PATH/<name>,失败时返回半截文件 Range 断点续传、签名过期重取、写临时文件成功后改名(§9-7) AlistClient.download()
11 upload PUT /api/fs/put,头 File-Path(URL 编码)、As-Task(缺省 false)、Content-Length、Last-Modified、X-File-Md5/Sha1/Sha256(秒传;先整读一遍算);超时 6000 s;进度回调;之后 _delay_get_item 并按需 rename 同一套头(三种哈希一遍读出);无进度空闲超时;退避重试;As-Task 等任务完成;先查同名、传临时名再改名(Alist 的 put 会静默覆盖同名文件,真机核对过);成功判定 = 接口成功 + 目标存在(同 MP,不核验)(§9-4/5) AlistClient.upload()
12 detail = get_item 同 —
13 copy / copy_item /api/fs/copy {src_dir,dst_dir,names},按原名复制后 rename;之后 sleep 2s×2 看目标 等异步任务完成(/api/task/copy/info?tid=),没有任务 id 时轮询到目标出现;名字不同走暂存目录;成功判定 = 任务成功 + 目标存在(§9-3/4) AlistClient.copyFile()
14 move / move_item 先在源目录 rename 成新名,再 /api/fs/move 暂存目录方案,失败尽量回滚(§9-4);跨驱动 move 也是异步任务,同样等待 AlistClient.moveFile()
15 link / softlink 不支持(pass) 不支持;整理方式只给复制 / 移动(MP transtype = {copy, move}) 整理弹窗按存储过滤方式
16 usage 未实现(pass) /api/admin/storage/list 的 mount_details(total/free/used,驱动支持且令牌是管理员时才有);拿不到显示「未知」不报错(§9-10) AlistClient.usage()
17 snapshot 基类通用实现:递归 list,max_depth=5,可选按目录 mtime 跳过(OpenList 由 OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME 控制),保留上次快照里未遍历子树 同语义;按实例限速;深度、mtime 开关在实例配置(§8) storage/snapshot.ts
18 路径密码 接口带 password 参数,没有配置入口 实例配置可填(加密保存)(§9-11) storage_instance.path_password_ref
19 AlistGo(alistgo.com 文档的 v3 分支) 独立 schema、同实现 不单列:同一个适配器;实例上记 flavor(alist / openlist)只用于展示与版本差异(§2.5) —
20 存储管理动作(扫码登录等 /storage/manage) Alist 无 不适用 —

2. 适配器 packages/adapters/src/alist/

2.1 接口

new AlistClient({ baseUrl, auth: {kind:"token",token} | {kind:"password",username,password} | {kind:"guest"},
                  pathPassword?, fetch?, timeoutMs, idleTimeoutMs, retries, qps, concurrency, taskTimeoutMs, logger? })
.me() .version() .list(path,{refresh}) .get(path,{refresh}) .exists(path) .mkdir(path) .rename(path,newName)
.remove(dir,names) .copyFile(src,dstPath,opts) .moveFile(src,dstPath,opts) .upload(localFile,dstPath,opts)
.download(remotePath,localFile,opts) .rawUrl(path) .usage(path) .waitTask(kind,id,opts)

错误类型:AlistNotFound(object not found / not exist / 404)、AlistConflict(exists)、AlistAuthError(HTTP 401/403 或 code 401)、AlistRateLimited(429 / too many / rate limit / 风控)、AlistUnavailable(连不上 / 超时 / 5xx)、AlistError(其它业务错误)。fsCode 映射到现有 FsErrorCode:NotFound→ENOENT、Conflict→EEXIST、Auth→EACCES、RateLimited/Unavailable→TIMEOUT/IO(可重试)、其它→IO。

2.2 令牌

  • 永久令牌(Alist 后台「设置 → 其他 → 令牌」)优先,原样放 Authorization 头(不加 Bearer,与 MP 一致)。
  • 账号密码:POST /api/auth/login(明文密码,与 MP 一致;不用 /api/auth/login/hash,避免版本差异)。token 缓存在客户端实例里,TTL 47 h 55 m(MP:2 天提前 5 分钟);实例配置变更时客户端重建,缓存随之作废(MP init_storage 删缓存)。
  • 任一请求得到 401 / token is invalidated / token is expired:账号密码模式清缓存重登一次再试;永久令牌模式直接抛 AlistAuthError,健康检查显示「令牌失效」。
  • 访客模式(无令牌无账号):只用于只读公开目录,写操作 Alist 自己会拒。

2.3 限速、超时、重试(§9-8)

  • 每实例一个令牌桶(缺省 5 QPS)+ 并发上限(缺省 4);上传 / 下载数据流不计入 QPS,但计入并发。
  • 普通请求超时 30 s;数据流用空闲超时(缺省 120 s 无字节前进即中止,同 fsproxy hangTimeoutMs 口径)。
  • 幂等读(list/get/me/task info)与可安全重放的写(mkdir)对可重试错误(连不上、5xx、429/风控)指数退避重试,缺省 3 次(0.5 s→1 s→2 s,429 起步 5 s);rename/remove/copy/move 不自动重试(调用方按整理账本重试并先对账)。上传 / 下载在流式中断时按 §2.6/2.7 续传或重传。
  • 日志只记方法、路径、Alist code 与耗时;令牌、密码、sign、raw_url 查询串一律 redact(redactAlist),测试覆盖。

2.4 真机接口形状(OpenList v4.2.6)

场景 返回
不存在 HTTP 200,{"code":500,"message":"failed to get obj: object not found"}
令牌无效 HTTP 200,{"code":401,"message":"token is invalidated"}
copy(v4 一律异步,含同驱动) {"code":200,"data":{"message":"Successfully created 1 copy task(s)","tasks":[{"id":"…","state":0,…}]}}
copy 目标目录不存在 {"code":500,"message":"failed to get dst dir: …object not found"} → 先 mkdir
copy / rename 目标已存在 {"code":403,"message":"file [x.bin] exists"}
move 同驱动 {"code":200,"data":{"message":"Move operations completed immediately"}}
move 跨驱动 同 copy,返回 tasks
put(As-Task: false) {"code":200,"data":null};目标已存在时静默覆盖
put(As-Task: true) data.task {id, state, total_bytes}
任务查询 POST /api/task/{copy|move|upload}/info?tid= → state 0 待执行 / 1 执行中 / 2 成功 / 3 取消中 / 4 已取消 / 5 出错 / 6 失败中 / 7 失败 / 8 等待重试 / 9 重试前;error 为错误文本;不存在 {"code":404,"message":"task not found"}
下载 /d/<path>?sign= 支持 Range(206);签名错 → 401
remove 不存在的名字 返回成功(调用方不能据此判断删除了东西)

2.5 Alist v3 与 OpenList v4 的差异

项 Alist v3(≥3.25) OpenList v4 处理
copy / move 返回 同驱动常为同步、data:null;新版本返回 tasks 返回 tasks(move 同驱动立即完成) 有 tasks 等任务;没有则按「目标存在且大小与源一致」退避轮询(§9-3)
任务接口 旧版在 /api/admin/task/copy/info(管理员),新版 /api/task/… /api/task/… 先试 /api/task/,404 再试 /api/admin/task/,都不行退回大小轮询
filtered_total / pages_total / has_more 部分版本没有 有 都按 MP 的判断链兼容
mount_details(容量) 无 驱动支持时有 无则「未知」
hash_info 有(驱动给才非空) 同 不用于判定(用户决定网盘方向不核验)
版本号 /api/public/settings version 同(如 v4.2.6 (Commit …)) 存进实例 version_info 展示

2.6 上传(§9-4/5)

  1. 一遍读出本地文件的 md5 / sha1 / sha256(同一个读流),只作秒传头(照 MP 发);sha256 顺带记为整理记录的 checksum(来自本地源,不拿来比对远端)。
  2. 目标已存在:按调用方策略(整理是「冲突」或「替换」)——不依赖 put 的覆盖。
  3. 传到同目录临时名 <name>.uhd-partial(As-Task 与 MP 缺省一致为 false;实例可改 true,此时轮询任务直到完成)。读流带进度回调、空闲超时、AbortSignal。
  4. 成功判定与 MP 一致:接口成功 + get(临时名, refresh=true) 能取到即成功,不比对大小 / 哈希(用户决定)。
  5. rename 成目标名(替换时先把旧文件移走,见 §6.3)。失败删除临时名。
  6. 失败按可重试错误退避重传(整文件重传:Alist 的 put 不支持续传);重试次数用实例配置。

2.7 下载(§9-7)

  1. get(path) 取 raw_url(没有就 /d<path>?sign=)。
  2. 写到 <目标>.uhd-partial;已有 partial(上次中断)时 Range: bytes=<已有>- 续传,服务端不给 206 就从头写。
  3. 401/403/410(签名过期)→ 重新 get 取新链接再续,最多 3 次;其它可重试错误退避续传。
  4. 流正常结束即成功(与 MP 一致,不比对大小 / 哈希,用户决定);边写边算 sha256 只记为 checksum(本地文件的事实,不用于判定)。
  5. 成功后 link + unlink(不覆盖,同 commitNoClobber)改成目标名;中断保留 partial 供续传,24 h 后由现有 partial 清理带走。

3. 存储抽象(服务端)

  • 表 storage_instance(迁移 0090):id, name, kind('alist'), flavor('alist'|'openlist'), base_url, root_path, auth_kind('token'|'password'|'guest'), username, secret_ref, path_password_ref, enabled, options jsonb, health_status, last_health_at, health_detail, version_info, created_at, updated_at。凭据走现有 secret 表(SecretPurpose 加 storage_credential),API 只写不读(与下载器 / 媒体服务器同语义:省略或空串不变、null 清除)。
  • options:qps(5)、concurrency(4)、timeoutMs(30000)、idleTimeoutMs(120000)、taskTimeoutMs(21600000)、retries(3)、uploadAsTask(false)、snapshotMaxDepth(5)、snapshotCheckFolderModtime(true,同 MP OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME 缺省开,可关)。
  • apps/server/src/storage/:
    • registry.ts:按 id 取实例 + 解密凭据 → AlistClient(按 updated_at 缓存,配置一改就重建)。
    • vpath.ts:/@alist/<id>/<path> 编解码;displayPath() 给界面「存储名:/路径」。
    • vfs.ts:本地 / 远端统一的 lstat / realpath / readdir / statSafe / checkRoot / mkdirp / readFile / writeFileAtomic / rename / move / remove;本地分支原样调用 node:fs / fsproxy。
    • transfer.ts:整理矩阵(§5)。
  • 不为抽象重写本地:本地路径照旧走 fsproxy(挂死隔离、跨卷 copy → 核验 → 删源全部不变);只在会遇到远端路径的调用点换成 vfs。

4. 设置与界面

  • 设置 → 媒体库与目录 →「存储」:列表 + 新增 / 编辑(名称、类型 Alist/OpenList、地址、根路径、认证方式 令牌 / 账号密码 / 访客、令牌或密码、路径密码、高级:限速 / 并发 / 超时 / 上传任务模式 / 监控深度 / 按目录时间跳过)+「测试连接」(显示版本、当前用户、根目录是否可写、容量)。
  • 媒体库目录表单多一个「存储」下拉(本机 / 各 Alist 实例);选 Alist 时路径为 Alist 内路径(可点浏览),不写哨兵文件,健康 = 根目录可访问。
  • 文件管理:左栏「存储」组(对照 MP 存储切换菜单),每个启用实例一个根 st:<id>(从实例根路径开始);Alist 库根照常出现在「媒体库」组。操作:浏览、新建目录、改名、删除、移动到 / 复制到、上传、下载、整理、刮削。
  • 整理来源:来源目录可选 Alist 根;触发方式只能「手动 / 目录快照」(qB 标签对远端无意义)。

5. 整理矩阵

源 → 目标 复制 移动 成功判定 MP
本地 → 本地 现有(fsproxy、跨卷 copy → sha256 → 删源) 现有 现有核验(不变) 同
本地 → Alist 上传(§2.6) 上传成功后删本地源;删源失败 = 「已整理,源未删」待处理(同跨卷) 接口成功 + 目标存在 同(上传成功即删源)
Alist → 本地 下载(§2.7) 下载成功后删远端源;删源失败同上 下载流完整结束(临时文件改名成功) 下载后 SystemUtils.move;失败可能留半截
Alist → Alist 同实例 API copy(等任务) API move(等任务) 任务成功 + 目标存在 同(不等任务,sleep 后看目标)
Alist → Alist 跨实例 下载到 DATA_DIR/cache/storage-relay/ → 上传 → 删暂存 同,上传成功后删远端源 两段各自的判定 不支持(不支持 X 到 Y 的文件整理)
原地整理(Alist 库根) 只登记,不动文件 — 文件存在 MP 无
  • 硬链接 / 软链接:远端不支持(MP 同),弹窗只给复制 / 移动;原地整理不受影响。
  • 跨卷开关 allowCrossVolumeMove 只管本地跨卷;涉及远端的移动一律「复制 / 上传 / 下载成功 → 删源」,不受该开关限制(与 MP 一致:网盘移动就是上传后删源)。
  • 网盘方向不核验大小 / 哈希是用户决定(不同网盘返回的大小 / 哈希不可靠);本地 ↔ 本地仍走现有核验。
  • 失败路径(都有测试):上传中断 / 接口失败 / 上传后目标取不到 → 删远端临时文件、不删源;下载中断 → 保留 partial、不删源;删源失败 → 记录 attention;可重试错误(连不上、限流、超时)走整理账本现有的「未到上限不入账、任务重试」。
  • 整理记录、失败重试、智能体兜底识别(识别不到作品自动派)、H&R / 下载器引用检查都照旧:它们只看路径字符串与账本;远端源不会有下载器引用,自然放行。
  • 计划阶段:源 / 目标的 stat、目标冲突、同尺寸是否同一文件(本地用 sha256;远端只在两边 hash_info 有同类哈希时判同,否则按冲突)都走 vfs。
  • 撤销:整理记录「删除」可连带删源 / 媒体库文件——Alist 上的文件移进该存储根下的 .umt-trash/(同本地布局,回收站可还原);本地→Alist 的移动撤不回本地源(源已删),说明里写清。

6. 核验、回收站、替换与 fsproxy 的关系

6.0 核验口径

  • 本地 ↔ 本地:现有核验不变(复制按 verifyHash,跨卷移动删源前全量 sha256)。
  • 涉及 Alist 的方向:不比对大小 / 哈希(用户决定)。判定只看接口成功与目标存在;整理记录里的 checksum 在能顺带得到本地文件 sha256 时记上(上传的源 / 下载的结果),只是事实记录。

6.1 fsproxy

本地 IO 仍全部经 fsproxy 子进程(挂死隔离)。远端 IO 在主进程里经 fetch:网络挂死由空闲超时兜住,不会卡系统调用。跨存储传输中「读本地 / 写本地」一侧直接用流(上传读本地文件、下载写本地 partial),其挂死同样由空闲超时判定。

6.2 回收站

文件管理删除、整理记录删除连带的文件、替换下来的旧文件:Alist 上与本地同布局 <根>/.umt-trash/<yyyymmdd>/<uuid>/<相对路径>,用 Alist 同驱动 move(立即完成)。还原 = move 回去;清除 / 过期 = remove。跨根不需要(回收站总在同一存储根内)。MP 无回收站,Alist 驱动自己的回收站(如 Local 的 recycle_bin_path)不依赖。

6.3 替换(目标已存在且选「替换」/ 洗版)

本地:现有原子替换。Alist:旧文件先 move 到 <根>/.umt-trash/replacement-staging/…,新文件按 §2.6 上传 / 或 copy 到位,成功后旧文件登记回收站;失败把旧文件 move 回原处。MP「总是覆盖」是先删目标再传,我们保留旧文件可还原。

6.4 「不是视频」隔离

ffprobe(§7)判定没有视频流时,远端新文件同样 move 进 .umt-trash 并登记回收站。

7. ffprobe

  • 远端文件:AlistClient.rawUrl() 取签名直链,ffprobe -rw_timeout 30000000 <url>;ffprobe 只读容器头与少量包(HTTP Range),不全量下载。直链进日志前 redact。
  • 本地 → Alist:收尾探测目标(远端 URL);URL 探测失败(驱动不给直链、302 到需要 Cookie 的网盘)时退回探测本地源(复制时还在;移动时在删源前已于执行阶段对源探测并缓存)。都拿不到 → 探测结果「未知」,不阻塞(同本地 ffprobe 失败的口径)。
  • MP:核心整理不做 ffprobe,只有 TransferRenameEventData 给插件。我们比 MP 多的「不是视频」核验对远端照样生效。

8. 目录监控(远端快照)

  • 来源配置选 Alist 根 + 触发方式「目录快照」:walkSource 对远端走 walkRemoteSource(file-manager/source-monitor.ts):BFS 列目录,深度 ≤ snapshotMaxDepth(MP 5);snapshotCheckFolderModtime 开时,子目录 modified 不晚于上次扫描时间就沿用上次快照里该子树的文件(根目录每轮都列,用于发现删除,同 MP);列表失败的子树保留旧快照(MP「查询异常时保留基线」)。
  • 之后完全复用现有语义:首扫建基线不整理;新文件 / 变化文件需连续两次观察大小与修改时间一致才整理;结果按现有批次识别上限分批;失败写 organize_source_snapshot.last_error,保留旧快照。
  • 扫描间隔沿用来源配置 intervalSec(MP 按文件数自适应 adjust_interval;我们已有逐来源间隔,不再自适应)。
  • 按实例限速:快照的 list 走同一个令牌桶,不会打爆网盘。

9. 相对 MP 的改进(协调者补充,用户认可)

# MP 缺陷 我们 测试
1 网络错误 / 非 200 / code≠200 都返回 None,调用方当「不存在」,可能重复上传或误判 不存在(object not found)抛 AlistNotFound;连接失败 / 鉴权 / 服务端错误是其它错误类型;vfs 映射为 ENOENT 与 IO/TIMEOUT/EACCES;整理遇到后者按可重试失败,不当不存在 alist/client.test.ts「错误分类」、整理「连不上不当不存在」
2 token 固定缓存 2 天,不处理 401 401 / token 无效自动重登一次再试;永久令牌失效直接报鉴权错误并进健康检查 「令牌失效重登」「永久令牌失效」
3 copy/move 跨驱动是异步任务,MP sleep 2s×2 后 get,大文件必误判 有任务 id 就轮询任务(指数退避 0.5 s→最大 10 s,超时可配,缺省 6 h),失败拿任务 error;无任务 id 轮询到目标出现为止 「异步任务成功 / 失败 / 超时」「无任务 id 等目标出现」
4 先按原名复制再改名,目标有同名会冲突 / 覆盖 执行前查目标冲突(按整理冲突策略);名字不同或原名在目标目录已被占用时先放暂存目录 .umt-stage-<uuid> 改名再移入,失败清理暂存 「改名复制走暂存」「目标冲突不覆盖」
5 上传前整读一遍算三种哈希;PUT 绝对超时 6000 s;无重试 三种哈希同一遍读出(只作秒传头,照 MP);空闲超时;退避重试;As-Task 轮询任务到完成 「上传进度 / 中断 / 重试 / As-Task」
6 上传后只确认目标存在 保持 MP 判定(用户决定):接口成功 + 目标存在即成功,不比对大小 / 哈希;只是确认目标时不靠 sleep 「上传后目标取不到不删源」
7 下载无续传、raw_url 过期不处理、可能留半截文件当成品 Range 续传、签名过期重取再续、先写临时文件成功后改名(不比对大小 / 哈希,用户决定) 「下载续传 / 签名过期 / 中断不改名」
8 _delay_get_item 每次 refresh=true,穿透网盘 API 易触发风控 只在写后确认目标与用户手动刷新时 refresh;每实例 QPS + 并发限制(可配,缺省保守),限流 / 风控退避 「refresh 只在需要时」「限流退避」
9 快照每次整树递归 深度上限、按目录 mtime 跳过未变子树、按实例限速、结果分批 「快照深度 / mtime 跳过 / 列表失败保留」
10 usage 未实现 mount_details 能拿到就显示,拿不到「未知」不报错 「容量未知不报错」
11 路径密码无配置入口 实例可配(加密保存),list/get 带上;日志 redact 「路径密码透传与 redact」

10. 不做的部分与理由

MP 有 不做 理由
Alist 的硬链接 / 软链接整理方式 不做 MP 本身也不支持(link/softlink 为空实现,transtype 只有复制 / 移动)
AlistGo 作为独立存储类型 合并 接口完全相同(MP 子类只改了 schema 名);实例上记 flavor 展示即可
远端目录监控的按文件数自适应间隔(adjust_interval) 不做 我们已有逐来源的扫描间隔配置,再自适应会让用户设的间隔失效
远端监控「启动前已存在的文件不处理」以外的整理存量 已有 「整理存量」对远端同样可用(这不是不做,列此说明口径一致)
115 / 阿里云盘 / SMB / rclone 存储 不做 用户只要求 Alist / OpenList;115 等可以挂到 Alist 下用
下载到浏览器时先落 MP 临时目录再回传 改为流式代理 不占本地盘;仍只允许管理员会话

11. 迁移

  • 0090_storage_instance:storage_instance 表;secret_purpose 枚举加 storage_credential。
  • 0091 未使用(预留;整理来源快照沿用 organize_source_snapshot,库根沿用 library_root.local_path 存虚拟路径,不需要新列)。

12. 测试

  • 适配器:packages/adapters/src/alist/*.test.ts,进程内 mock Alist(mock-server.ts)覆盖全部接口、错误码、令牌失效重登、异步任务、上传下载进度与中断、续传、签名过期、限流。
  • 服务端:整理矩阵每个方向复制 / 移动各一条端到端(mock 存储),含「上传中断不删源」「上传后目标取不到不删源」「下载中断不改名不删源」;刮削写远端;目录监控发现远端新文件;文件管理各操作路由。
  • 前端:存储设置表单、文件管理存储切换。
  • 真机:*.real.test.ts,UMT_ALIST_REAL_URL / UMT_ALIST_REAL_TOKEN 等环境变量开启,缺省跳过;跑本地 → OpenList → 本地往返。
  • 真机验收(用户不提供真实 Alist,协调者自起 OpenList 容器):步骤见 docs/runbook/storage-alist.md;脚本 scripts/alist-e2e.ts 可对任意 base URL + 令牌跑往返与 Alist 内移动。

13. 实现落点(2026-10-07)

部分 文件
适配器 packages/adapters/src/alist/{client,errors,limiter,redact}.ts;测试用模拟 mock.ts(导出 @umt/adapters/alist-mock)
存储注册表 / 虚拟路径 / vfs / 传输 / 远端探测 apps/server/src/storage/{registry,vpath,vfs,transfer,probe}.ts
设置接口 apps/server/src/modules/auth-settings/storage-routes.ts;契约 packages/contracts/src/storage.ts
文件管理 file-manager/common.ts(st: 根、resolveIn 远端分支)、list.ts、mutate.ts、trash.ts、transfer.ts、tree.ts、download.ts、preview.ts、upload.ts
整理 organize-plan.ts、organize-exec.ts(transferFile 远端分派)、organize-transfer.ts、organize-finalize.ts、replacement.ts、organize-guard.ts、reconcile.ts
刮削 / 目录监控 acquisition-organize/services/scrape.ts(atomicWrite 远端上传)、file-manager/source-monitor.ts(walkRemoteSource)
健康 system/health.ts(storage_instance 挂载项)
前端 components/settings/storages.tsx、instances.tsx(库根选存储)、components/files/*(存储组、上传、显示路径)

14. 本地写入守卫

虚拟前缀读到本地只会 ENOENT,但写类操作会在本机真建出 /@alist/…。所以本地写入口统一拒绝虚拟路径(FsError("REMOTE_PATH_ON_LOCAL_FS"),消息以 remote_path_on_local_fs: 开头,不落盘):

  • adapters:fs/remote-guard.ts,接在 runFsOp、FsProxy.run(父进程就拒绝,不起子进程)、copyWithVerify、crossDeviceMove、moveSafe、commitNoClobber、sweepStalePartials、writeRootSentinel。读类(stat / checkRoot / sha256 / match)不拦。
  • server:storage/local-fs.ts(node:fs/promises 的写函数换成带守卫的版本),整理执行、替换、刮削、原样搬运、回收站、改名 / 新建目录、上传、探测缓存、补字幕、vfs / transfer 的本地分支都从这里取。
  • 测试:packages/adapters/src/fs/remote-guard.test.ts、apps/server/test/storage/remote-path-guard.test.ts。

15. 与设计的偏离

  • 远端目录监控放在 source-monitor.ts 的 walkRemoteSource,没有单独的 storage/snapshot.ts。
  • 文件管理「下载」到浏览器仍守本地同一规则(NFO / 字幕 / 图片 / 文本 ≤ 20 MiB);大文件「下载到本地」用「复制到」本机媒体库(走整理执行与账本)。MP 允许浏览器下载任意文件。
  • Alist 根的目录树与列表不做递归目录摘要(视频数),目录树子目录一律显示可展开,避免逐目录请求网盘。
  • 存储根是 Alist 的 /(各挂载的上一级)时,回收站放在被删项所在挂载的 .umt-trash。
  • 已整理的文件不能「移动到」非媒体库的 Alist 存储根(会脱离媒体库),只能复制或先从媒体库移除。
  • 补字幕(subtitles)只写本机媒体库;目标在 Alist 媒体库上时明确失败(「目标视频在 Alist 存储上,暂不支持补字幕」),不往本机写(MP 的 Alist 能力里没有)。
  • 库存对账(reconcile)对 Alist 库根逐个位置 get,受实例限速;没有按目录批量列。
  • 迁移只用了 0090;0091 未使用。