虚拟前缀 /@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
31 KiB
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 分钟);实例配置变更时客户端重建,缓存随之作废(MPinit_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)
- 一遍读出本地文件的 md5 / sha1 / sha256(同一个读流),只作秒传头(照 MP 发);sha256 顺带记为整理记录的
checksum(来自本地源,不拿来比对远端)。 - 目标已存在:按调用方策略(整理是「冲突」或「替换」)——不依赖 put 的覆盖。
- 传到同目录临时名
<name>.uhd-partial(As-Task 与 MP 缺省一致为 false;实例可改 true,此时轮询任务直到完成)。读流带进度回调、空闲超时、AbortSignal。 - 成功判定与 MP 一致:接口成功 +
get(临时名, refresh=true)能取到即成功,不比对大小 / 哈希(用户决定)。 rename成目标名(替换时先把旧文件移走,见 §6.3)。失败删除临时名。- 失败按可重试错误退避重传(整文件重传:Alist 的 put 不支持续传);重试次数用实例配置。
2.7 下载(§9-7)
get(path)取raw_url(没有就/d<path>?sign=)。- 写到
<目标>.uhd-partial;已有 partial(上次中断)时Range: bytes=<已有>-续传,服务端不给 206 就从头写。 - 401/403/410(签名过期)→ 重新 get 取新链接再续,最多 3 次;其它可重试错误退避续传。
- 流正常结束即成功(与 MP 一致,不比对大小 / 哈希,用户决定);边写边算 sha256 只记为
checksum(本地文件的事实,不用于判定)。 - 成功后
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,同 MPOPENLIST_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 未使用。