merge: feat/alist-storage

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei

# Conflicts:
#	packages/adapters/deno.json
This commit is contained in:
root committed 2026-10-07 00:48:53 +08:00
commit 339105e151
108 files changed
+7255 -214

No files matched your search

+2 -2
View File
@@ -32,7 +32,7 @@
| # | MP 功能点 | 我们 | v2 决定 |
| --- | --- | --- | --- |
| 1 | 存储切换菜单(local/115/阿里/Alist/SMB/rclone) | ≠ 左栏按"媒体库/下载目录"列登记目录 | 改:登记目录 = 库根 + 下载器路径映射,不接网盘(REQ §7.1.1) |
| 1 | 存储切换菜单(local/115/阿里/Alist/SMB/rclone) | ≠ 左栏按"媒体库/下载目录"列登记目录 | 改:登记目录 = 库根 + 下载器路径映射;2026-10-07 起多一组「Alist 存储」(见 `storage-alist.md`),115 / SMB / rclone 不接 |
| 2 | 初始位置 = 下载目录公共前缀,记住上次存储 | ≠ 默认第一个在线库根 | 改:上次位置(localStorage)> 第一个下载目录 > 第一个库根。整理从下载目录开始 |
| 3 | 面包屑(桌面全段,移动端只显示存储名) | ✓ 可拖放 | 照抄 + 移动端折叠成"…/父/当前" |
| 4 | "上一级"按钮 | ✗ 只有 Backspace | 照抄:工具栏加"上一级" |
@@ -63,7 +63,7 @@
| 29 | 递归重命名目录内媒体(一个失败即中止) | ✗ | 改:放进向导"原地按模板改名"模式,先预览(阶段 3) |
| 30 | 删除确认"是否确认删除{name}?" | ✓+ 回收站 + 引用检查 | 保留 |
| 31 | 新建目录对话框 | ✓+ 名称校验 | 保留 |
| 32 | 整理:目的存储 | — | 不做:只有本机路径 |
| 32 | 整理:目的存储 | ✓ | 媒体库目录可登记在 Alist 上,选目标库根即选存储(见 `storage-alist.md`) |
| 33 | 整理方式 copy/move/link/softlink | ≠ 复制/移动 | 保留两项;硬链接灰显"W5" |
| 34 | 目的路径(自动 / 目录配置下拉 / 手输) | ≠ 目标库根(契约有 targetDir,界面没有) | 改:自动 / 指定库根 / 指定目录(§6.2) |
| 35 | 按源路径推荐目标(`/manual/target-path`) | ≠ 按作品绑定或类型 | 保留我们的:已绑定作品沿用原目录 |
+262
View File
@@ -0,0 +1,262 @@
# 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 接口
```ts
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 未使用。
+14 -2
View File
@@ -124,9 +124,21 @@ filemanager.vue
- 规格中的交互(左树右表、面包屑、行内操作)是通用文件管理器范式,不构成对 MP 表达的复制。
- 若日后参考 MP 的集号定位正则模板(`episode_format`)语法,需单独登记来源,并以 domain 层自有实现替代。
## 网盘来源(115 / rclone):暂不做(2026-10-06)
## 网盘:Alist / OpenList 已做(2026-10-07)
用户评估后决定暂不做;不走挂载。将来若做,只做「网盘作为整理来源」,整理目标仍是本地库。MP 做法备查:
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:`),只支持复制 / 移动,目录监控靠快照比对。
+1 -1
View File
@@ -267,7 +267,7 @@ MP 手动添加入口识别失败时返回 `requires_confirmation`;前端 `Add
### 6.3 保留与最小验证
保留仅复制/移动、同分类多个来源、固定/自动分类、已绑定作品沿用原路径、执行前预览、跨卷核验后删源、H&R、来源监控不回填存量,以及 Agent/TMDB/Local 共用整理。不要照抄 MP 的软/硬链接、rclone、音乐或额外分类发布系统。
保留仅复制/移动、同分类多个来源、固定/自动分类、已绑定作品沿用原路径、执行前预览、跨卷核验后删源、H&R、来源监控不回填存量,以及 Agent/TMDB/Local 共用整理。不要照抄 MP 的软/硬链接、rclone、音乐或额外分类发布系统。网盘已按 MP 接入 Alist / OpenList(`docs/design/storage-alist.md`),115 等网盘挂在 Alist 下使用。
建议优先处理:① 文件/手动/下载/历史入口统一来源策略展示;② 成功移动历史改用目标重整;③ 向导明确批量修改选择与实际整理范围;④ 无来源空状态加现有设置入口。完成这些后再讨论成功记录跳过和本次刮削,不把它们提前扩成新业务系统。
+12 -1
View File
@@ -49,6 +49,17 @@
不做:切换结构后自动改名已整理的文件(仍用「重新整理」)、剧集组季的独立海报。测试:`apps/server/test/file-manager/organize-episode-structure.test.ts`、`apps/web/app/components/organize-wizard/model.test.ts`「季集结构」。
## Alist / OpenList 存储
媒体库目录、整理来源和文件管理都可以用登记在 设置 → 媒体库与目录 →「存储」里的 Alist / OpenList(设计 `docs/design/storage-alist.md`,使用与真机验收 `docs/runbook/storage-alist.md`)。
- 整理矩阵:本地 → Alist 上传、Alist → 本地 下载、同实例 Alist → Alist 用 Alist 的复制 / 移动(等后台任务完成)、跨实例下载再上传。
- 网盘方向不比对大小和哈希(用户决定):接口成功 + 目标存在即算成功,移动在此之后删源;本地 ↔ 本地仍走跨卷全量 sha256 核验。
- 上传 / 下载中断、上传后目标取不到都不删源;连不上、超时、限流按可重试 IO 走整理账本的退避重试,不会被当成「源不存在」。
- 整理记录、失败重试、智能体识别、H&R 与下载器引用检查照旧;删除整理记录连带的 Alist 文件进 Alist 上的 `.umt-trash`,回收站可还原。本地 → Alist 的移动源已删,撤不回本地源。
- 刮削目标在 Alist:NFO 与图片本地生成后上传;整理来源选 Alist 目录时按快照(限深度、按目录修改时间跳过未变子树)发现新文件。
- 记录里的远端路径形如 `/@alist/<存储 id>/<Alist 内路径>`,界面显示「存储名:/路径」。
## 来源、分类与存量整理
设置 → 媒体库与目录中维护分类 YAML 和整理来源。新功能已于 2026-10-01 部署为 v0.3.9,迁移 `0059_organize_source_snapshot` 已执行,见 发布记录(已删除,见 git `ceb66f8`)。
@@ -104,7 +115,7 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue`
| 标题 / 年份 / 季集 | 已有 | `作品 (年) S01E02`;未识别的显示「未识别 · 源文件名」 |
| 二级分类(华语电影等) | 已接入 | 分类规则在设置编辑 YAML;电影 / 剧集为母分类,综艺属于剧集 |
| 源路径 → 目标路径 | 新增 | 列表加「源 → 目标」列(只显示末两段,悬停看全路径);详情「整理前 / 整理后」已有 |
| 存储标签(本地 / SMB…) | 不做 | 只有本地存储,标签不提供信息 |
| 存储标签(本地 / SMB…) | 不做 | 记录里的路径已带存储(Alist 显示「存储名:/路径」),不另加标签 |
| 整理方式 复制 / 移动 | 已有 | 列表「方式」列;整理页聚焦复制/移动结果 |
| 软链接、Rclone 复制 / 移动 | 不做 | 整理服务不支持这几种方式 |
| 大小 | 新增 | 逐文件源大小之和(`sizeBytes`) |
+107
View File
@@ -0,0 +1,107 @@
# Alist / OpenList 存储
设计与 MP 对照:`docs/design/storage-alist.md`。整理侧口径:`docs/runbook/organize.md`「Alist / OpenList 存储」。
## 登记
设置 → 媒体库与目录 →「存储」→ 新增:
| 字段 | 说明 |
| --- | --- |
| 地址 | Alist / OpenList 的访问地址(带端口、子路径),本工具进程要能访问到;容器部署时用容器网络里的地址 |
| 根路径 | 本工具只用这个路径下的内容(如 `/115/影视`),缺省 `/` |
| 认证方式 | 令牌(Alist 后台「设置 → 其他 → 令牌」,推荐)/用户名和密码(换临时令牌,约两天续一次,失效自动重登)/访客 |
| 路径密码 | Alist「元信息」里给目录设了访问密码时填写 |
| 高级 | 每秒请求数(缺省 5)、并发(4)、请求超时、传输无进度超时、等待 Alist 任务上限、重试次数、上传交给 Alist 后台任务(缺省关,同 MP)、目录监控深度(5)与按目录修改时间跳过(开) |
保存后点「测试连接」:显示版本、登录用户、根路径是否可访问、容量(驱动不给或令牌不是管理员时显示「未知」)。凭据加密保存在 `secret` 表(用途 `storage_credential`),接口与日志里都不出现明文;直链的 `sign` 参数进日志前脱敏。
之后:
- **媒体库目录**:新增时「存储」选这个 Alist,路径填 Alist 内路径(目录要先在 Alist 里建好)。Alist 上不写标记文件,目录可访问即在线。
- **整理来源**:来源目录可以选文件管理里的「Alist 存储」根下的目录,触发方式只能手动或目录快照。
- **文件管理**:左栏「Alist 存储」组,每个启用的实例一个根。浏览、新建目录、改名、删除(进 Alist 上的 `.umt-trash`,回收站可还原)、移动 / 复制(Alist 内走 Alist 接口,本机 ↔ Alist 上传 / 下载)、上传、下载小文件、整理、刮削。「刷新」才让 Alist 刷新目录缓存(会穿透到网盘接口)。
运行状态 →「挂载根」里列出每个存储实例的连通性;令牌失效显示「令牌失效或没有权限」。
## 行为要点
- 成功判定(用户决定,同 MP):网盘方向的上传、下载、复制、移动只看接口成功 + 目标存在,不比对大小和哈希;移动在此之后删源。本地 ↔ 本地仍走现有核验。
- 不覆盖:上传先查同名再传临时名 `<名>.uhd-partial` 后改名(Alist 的 put 会静默覆盖同名);复制 / 移动目标已存在报冲突;名字不同的复制 / 移动经 `.umt-stage-*` 暂存目录改名,失败清理。
- 等待:Alist 的复制(v4 一律)与跨驱动移动是后台任务,按任务状态轮询到完成(失败带任务的错误信息),老版本没有任务接口时等目标出现。
- 错误:「不存在」与连不上 / 鉴权 / 服务端错误分开;整理遇到后者按可重试 IO 退避,不当成源不存在。限流 / 风控(429、「too many」)自动退避。
- 下载:写 `<目标>.uhd-partial`,中断后 Range 续传,直链签名过期自动重新获取,成功后不覆盖地改名。
- ffprobe:远端文件不下载,对直链只读文件头;探测不到不阻塞整理。
## 真机验收(临时 OpenList 容器)
用户不提供真实 Alist 时,按下面起一个 OpenList(本地存储驱动)验收。容器名带 `umt-alist-` 前缀,用完删掉。
```sh
W=$PWD/.local/openlist # 工作区的 .local(不进 git)
mkdir -p $W/{data,store1,store2} && chmod -R 777 $W
docker run -d --name umt-alist-it -p 127.0.0.1:55244:5244 \
-v $W/data:/opt/openlist/data -v $W/store1:/mnt/s1 -v $W/store2:/mnt/s2 openlistteam/openlist:latest
docker exec umt-alist-it ./openlist admin set umtAdmin123 # 设管理员密码
B=http://127.0.0.1:55244
TOK=$(curl -s -X POST $B/api/auth/login -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"umtAdmin123"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
# 两个本地存储驱动(/s1、/s2),用来测同驱动与跨驱动
for n in 1 2; do curl -s -X POST $B/api/admin/storage/create -H "Authorization: $TOK" -H 'Content-Type: application/json' \
-d "{\"mount_path\":\"/s$n\",\"driver\":\"Local\",\"addition\":\"{\\\"root_folder_path\\\":\\\"/mnt/s$n\\\",\\\"mkdir_perm\\\":\\\"777\\\"}\"}"; done
# 永久令牌(设置里登记用)
PERM=$(curl -s "$B/api/admin/setting/get?key=token" -H "Authorization: $TOK" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["value"])')
```
部署环境里本工具跑在容器中时,把 OpenList 接到同一个 docker 网络(`docker network connect <app 网络> umt-alist-it`),设置里的地址填 `http://umt-alist-it:5244`。
### 1. 适配器往返(脚本,可对任意地址跑)
```sh
deno run -A scripts/alist-e2e.ts --url $B --token "$PERM" --root /s1 --root2 /s2
# 或 --user admin --password umtAdmin123;退出码 0 = 全部通过
```
步骤:连接 → 建目录 → 上传(秒传头、临时名改名、进度)→ 同名不覆盖 → 同驱动复制并改名 → 移动 → 跨驱动复制 / 移动(任务轮询)→ 下载比对 sha256 → 不存在 ≠ 失败、容量 → 清理。
### 2. 自动化真机测试
```sh
export UMT_ALIST_REAL_URL=$B UMT_ALIST_REAL_TOKEN="$PERM" UMT_ALIST_REAL_ROOT=/s1 UMT_ALIST_REAL_ROOT2=/s2
(cd packages/adapters && deno run -A npm:vitest run src/alist/alist.real.test.ts)
(cd apps/server && DATABASE_URL=... deno run -A npm:vitest run test/storage/alist-organize.real.test.ts)
```
后者在测试库上跑整理往返:本地下载目录 → 整理(复制)到 OpenList 上的媒体库 → 文件管理「移动到」本地媒体库(下载后删远端)→ OpenList 内整理(同实例 API 移动)。
### 3. 界面验收(部署环境)
1. 设置 → 媒体库与目录 → 存储:新增「OpenList」,地址、令牌 `$PERM`,测试连接显示版本与用户。
2. 在 OpenList 里建好 `/s1/media/movies`;媒体库目录新增「OpenList 电影」,存储选 OpenList,路径 `/s1/media/movies`,管理方式「自动维护」。
3. 文件管理左栏出现「Alist 存储 → OpenList」:新建目录、上传一个小文件、改名、删除后到回收站还原、移动到另一个目录、复制到本机媒体库(下载)。
4. 整理矩阵(文件管理选文件 →「整理」,目标选对应媒体库目录,各做一次复制、一次移动):
- 本机下载目录的视频 → OpenList 电影(上传;移动后本机源消失);
- OpenList `/s1/incoming` 下的视频 → 本机媒体库(下载;移动后 OpenList 源消失);
- OpenList `/s1/incoming` → OpenList 电影(Alist 内复制 / 移动)。
整理记录里每条为「成功」,目标路径显示「OpenList:/…」。
5. 刮削写远端:OpenList 电影里已整理的作品点「刷新元数据」,OpenList 目录里出现 `.nfo`、`poster`、`clearlogo.png`。
6. 目录监控:设置 → 整理来源新增,来源选「OpenList」根下的 `incoming`,目标 OpenList 电影,触发「目录快照」,间隔 60 秒;首扫建基线,之后往 `/mnt/s1/incoming`(或经 OpenList 上传)放一个新视频,两次扫描后出现整理记录。
7. 运行状态 → 挂载根里 OpenList 在线;在 OpenList 后台重置令牌后再看,显示「令牌失效或没有权限」。
### 收尾
```sh
docker rm -f umt-alist-it && rm -rf .local/openlist
```
## 排查
| 现象 | 原因 / 处理 |
| --- | --- |
| 测试连接「令牌失效或无权限」 | 令牌被重置或用户没权限;重新复制令牌,或改用账号密码 |
| 测试连接「连不上」 | 地址不对、容器网络不通;在 app 容器里 `curl <地址>/api/public/settings` |
| 根路径不存在 | 根路径要用 Alist 里的完整路径(以挂载路径开头,如 `/115/影视`) |
| 整理卡在「文件操作可重试失败,等待重试」 | Alist 连不上或限流;恢复后任务按退避自动重试,源文件不会被删 |
| 跨网盘复制很慢 | Alist 后台任务在搬数据;在 Alist「任务」页看进度,等待上限在实例高级选项 |
| 目录监控看不到深层新文件 | 超过监控深度,或网盘不更新目录修改时间:调大深度或关掉「按修改时间跳过」 |
| 容量显示「未知」 | 驱动不给容量或令牌不是管理员(`/api/admin/storage/list`),不影响使用 |