From adbb9b59a0f0a2d1d441e27396b36771b7aba931 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 00:04:19 +0800 Subject: [PATCH] =?UTF-8?q?docs(storage):=20Alist=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E4=B8=8E=E7=9C=9F=E6=9C=BA=E9=AA=8C=E6=94=B6=20runbook,?= =?UTF-8?q?=E6=95=B4=E7=90=86=20/=20=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20/=20MP=20=E5=AF=B9=E7=85=A7=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../storage/alist-organize-matrix.test.ts | 63 +++++++++++ docs/design/file-manager-v2.md | 4 +- docs/design/storage-alist.md | 28 ++++- docs/porting/moviepilot-filemanager.md | 16 ++- docs/porting/moviepilot-v3-comparison.md | 2 +- docs/runbook/organize.md | 13 ++- docs/runbook/storage-alist.md | 107 ++++++++++++++++++ 7 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 docs/runbook/storage-alist.md diff --git a/apps/server/test/storage/alist-organize-matrix.test.ts b/apps/server/test/storage/alist-organize-matrix.test.ts index 22e56c2a..d6196702 100644 --- a/apps/server/test/storage/alist-organize-matrix.test.ts +++ b/apps/server/test/storage/alist-organize-matrix.test.ts @@ -253,6 +253,69 @@ describe.skipIf(skip)("整理矩阵:本地 ↔ Alist ↔ Alist", () => { a.mock.failNext(0); }); + it("目标已存在选替换:旧文件移进 Alist 上的回收站,新文件上传到位", async () => { + const f = await film("local"); + const first = await organize(f, "copy", alib.libraryRootId); + const target = `/s1/library/${first.main.target_path}`; + a.mock.put(target, "old-version-content"); + await writeFile(join(h.dl, f.path), `${f.body}-v2`); + const items = [{ root: f.root, path: f.path, mediaEntityId: f.id }]; + const base = { items, action: "copy", targetLibraryRootId: alib.libraryRootId, onExisting: "replace_with_verify" }; + const dry = await h.api("POST", "/files/organize", { ...base, idempotencyKey: key(), dryRun: true }); + expect(dry.body.data.groups[0].files[0]).toMatchObject({ existing: "replace", action: "replace" }); + const exec = await h.api("POST", "/files/organize", { + ...base, + idempotencyKey: key(), + dryRun: false, + planIds: [dry.body.data.groups[0].planId], + }); + const recordId = exec.body.data.recordIds[0] as string; + await executeRecord(h.module.deps().ao, recordId); + const rec = await h.db + .selectFrom("organization_record") + .select("status") + .where("id", "=", recordId) + .executeTakeFirstOrThrow(); + expect(rec.status).toBe("succeeded"); + expect(text(a.mock.read(target))).toBe(`${f.body}-v2`); + const trash = await h.db + .selectFrom("trash_item") + .select(["trash_path", "original_path"]) + .where("original_path", "=", first.main.target_path!) + .executeTakeFirstOrThrow(); + expect(trash.trash_path.startsWith(a.v("/s1/library/.umt-trash/"))).toBe(true); + const parked = trash.trash_path.replace(a.v(""), ""); + expect(text(a.mock.read(parked))).toBe("old-version-content"); + }); + + it("原地整理(Alist 媒体库里的文件):只登记,不动文件", async () => { + n++; + const id = await seedEntity(h.db, { kind: "movie", title: "In Place Film", year: 2015 }); + a.mock.put("/s1/library/In Place Film (2015)/In.Place.Film.2015.1080p.mkv", "in-place-video"); + const items = [{ root: alib.root, path: "In Place Film (2015)/In.Place.Film.2015.1080p.mkv", mediaEntityId: id }]; + const base = { items, action: "in_place", minSizeBytes: 0, scrapeAfterImport: false }; + const dry = await h.api("POST", "/files/organize", { ...base, idempotencyKey: key(), dryRun: true }); + expect(dry.body.data.groups[0].error, JSON.stringify(dry.body)).toBeNull(); + const exec = await h.api("POST", "/files/organize", { + ...base, + idempotencyKey: key(), + dryRun: false, + planIds: [dry.body.data.groups[0].planId], + }); + const recordId = exec.body.data.recordIds[0] as string; + await executeRecord(h.module.deps().ao, recordId); + const rec = await h.db + .selectFrom("organization_record") + .select(["status", "action"]) + .where("id", "=", recordId) + .executeTakeFirstOrThrow(); + expect(rec).toEqual({ status: "succeeded", action: "in_place" }); + expect(text(a.mock.read("/s1/library/In Place Film (2015)/In.Place.Film.2015.1080p.mkv"))).toBe("in-place-video"); + expect((await assetAt(alib.libraryRootId, "In Place Film (2015)/In.Place.Film.2015.1080p.mkv"))?.status).toBe( + "available", + ); + }); + it("撤销:删除整理记录连带媒体库文件 → Alist 上移进回收站(可还原)", async () => { const f = await film("local"); const r = await organize(f, "copy", alib.libraryRootId); diff --git a/docs/design/file-manager-v2.md b/docs/design/file-manager-v2.md index acca7d41..9fa68557 100644 --- a/docs/design/file-manager-v2.md +++ b/docs/design/file-manager-v2.md @@ -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`) | ≠ 按作品绑定或类型 | 保留我们的:已绑定作品沿用原目录 | diff --git a/docs/design/storage-alist.md b/docs/design/storage-alist.md index 2942cf97..22fbe262 100644 --- a/docs/design/storage-alist.md +++ b/docs/design/storage-alist.md @@ -119,7 +119,7 @@ new AlistClient({ baseUrl, auth: {kind:"token",token} | {kind:"password",usernam ## 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 本地缺省 true;OpenList 在 MP 里是开关、缺省关——我们缺省开并可关)。 +- `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//` 编解码;`displayPath()` 给界面「存储名:/路径」。 @@ -184,7 +184,7 @@ ffprobe(§7)判定没有视频流时,远端新文件同样 move 进 `.umt- ## 8. 目录监控(远端快照) -- 来源配置选 Alist 根 + 触发方式「目录快照」:`walkSource` 对远端走 `storage/snapshot.ts`:BFS 列目录,深度 ≤ `snapshotMaxDepth`(MP 5);`snapshotCheckFolderModtime` 开时,子目录 `modified` 不晚于上次扫描时间就沿用上次快照里该子树的文件(根目录每轮都列,用于发现删除,同 MP);列表失败的子树保留旧快照(MP「查询异常时保留基线」)。 +- 来源配置选 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 走同一个令牌桶,不会打爆网盘。 @@ -228,3 +228,27 @@ ffprobe(§7)判定没有视频流时,远端新文件同样 move 进 `.umt- - 前端:存储设置表单、文件管理存储切换。 - 真机:`*.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. 与设计的偏离 + +- 远端目录监控放在 `source-monitor.ts` 的 `walkRemoteSource`,没有单独的 `storage/snapshot.ts`。 +- 文件管理「下载」到浏览器仍守本地同一规则(NFO / 字幕 / 图片 / 文本 ≤ 20 MiB);大文件「下载到本地」用「复制到」本机媒体库(走整理执行与账本)。MP 允许浏览器下载任意文件。 +- Alist 根的目录树与列表不做递归目录摘要(视频数),目录树子目录一律显示可展开,避免逐目录请求网盘。 +- 存储根是 Alist 的 `/`(各挂载的上一级)时,回收站放在被删项所在挂载的 `.umt-trash`。 +- 已整理的文件不能「移动到」非媒体库的 Alist 存储根(会脱离媒体库),只能复制或先从媒体库移除。 +- 补字幕(`subtitles`)只写本机媒体库;Alist 媒体库上的字幕写入不在本次范围(MP 的 Alist 能力里没有)。 +- 库存对账(`reconcile`)对 Alist 库根逐个位置 `get`,受实例限速;没有按目录批量列。 +- 迁移只用了 0090;0091 未使用。 diff --git a/docs/porting/moviepilot-filemanager.md b/docs/porting/moviepilot-filemanager.md index 012a3df4..54e3ed7c 100644 --- a/docs/porting/moviepilot-filemanager.md +++ b/docs/porting/moviepilot-filemanager.md @@ -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:`),只支持复制 / 移动,目录监控靠快照比对。 diff --git a/docs/porting/moviepilot-v3-comparison.md b/docs/porting/moviepilot-v3-comparison.md index c996e9c5..8d7d16e3 100644 --- a/docs/porting/moviepilot-v3-comparison.md +++ b/docs/porting/moviepilot-v3-comparison.md @@ -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 下使用。 建议优先处理:① 文件/手动/下载/历史入口统一来源策略展示;② 成功移动历史改用目标重整;③ 向导明确批量修改选择与实际整理范围;④ 无来源空状态加现有设置入口。完成这些后再讨论成功记录跳过和本次刮削,不把它们提前扩成新业务系统。 diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index 10fa4237..41d75c99 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -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>/`,界面显示「存储名:/路径」。 + ## 来源、分类与存量整理 设置 → 媒体库与目录中维护分类 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`) | diff --git a/docs/runbook/storage-alist.md b/docs/runbook/storage-alist.md new file mode 100644 index 00000000..c0aec00e --- /dev/null +++ b/docs/runbook/storage-alist.md @@ -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 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`),不影响使用 |