# 部署运行手册(W4-B) 形态:**核心应用在一个容器里,PostgreSQL 与浏览器分别部署**。镜像 `uhd-media-tool` 内一个 `deno compile` 出的二进制 `umt-server`, 容器里同时跑 api、worker(io 组)、worker:agent(Agent 会话)三个进程,api 同端口托管 web 静态产物(`WEB_ROOT=/app/public`),对外只有 **4380** 一个端口。 compose 默认有三个服务:`app` + `postgres` + `camofox`(浏览器服务,只在内网,不映射端口;可关掉或换成自有实例,见 §2.1);`tls` profile 可额外启用 Caddy 提供 HTTPS。 ## 1. 构建镜像 ```sh docker build -t uhd-media-tool:latest . # 或 docker compose --env-file .env.deploy build APP_VERSION=v0.3.11 docker compose --env-file .env.deploy build app # 示例:发布时填写对应版本,写进 web 的 version.json ``` - 多阶段:`denoland/deno:2.9.7`(装依赖 → web 构建 → server 编译)→ `debian:trixie-slim` 运行镜像。 - 运行镜像额外装 `gosu`(降权)及 PG 客户端动态库,从固定的 `postgres:18.6-trixie` 官方镜像复制 pg_dump/pg_restore/psql 与 libpq 18, 外加 `/usr/local/bin/ffprobe`:从 `mwader/static-ffmpeg:9.0.2` 拷单个静态二进制(约 135 MB;Debian `ffmpeg` 包要 200+ 个依赖约 450 MB)。 换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。 - `/app` 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。 - 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server` (库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 `/app` + 非 root 运行)。 子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算, 不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)、二进制自身 (fsproxy 挂死隔离:入库的每个文件操作起一次 `/app/umt-server --role fsworker`,约 150 ms;进度停滞超过 `FS_HANG_TIMEOUT_MS` → SIGTERM → `FS_KILL_GRACE_MS` 后 SIGKILL → 按 TIMEOUT 有限次重试,超限建待处理)。Deno 的 `--allow-run` 按绝对路径放行, 所以二进制必须位于 `/app/umt-server`;挪到别处运行时 spawn 被拒(NotCapable),自动回退进程内看门狗(卡死的系统调用线程无法回收), 日志 `organize.transfer` 的 `fsproxy.mode/workerKind/fallbackReason` 可看出实际方式。 ## 2. 启动 部署目录须包含完整仓库构建上下文。首次启用默认浏览器前,先完成 [浏览器手册 §1.1](browser.md#11-部署自带缺省) 的基础镜像构建,再执行以下 compose 命令。部署配置统一放 `.env.deploy`,开发配置仍用仓库根 `.env`。 ```sh mkdir -p /opt/umt && cd /opt/umt # 部署目录(完整仓库构建上下文与 .env.deploy) cp <仓库>/docker/compose.env.example .env.deploy # 部署配置独立于开发用 .env sed -i "s/^SECRET_MASTER_KEY=.*/SECRET_MASTER_KEY=$(openssl rand -hex 32)/" .env.deploy sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(openssl rand -hex 16)/" .env.deploy docker compose --env-file .env.deploy up -d --build docker compose --env-file .env.deploy ps # app 变成 healthy 即可访问 curl -s localhost:4380/api/v1/health ``` ### 2.1 浏览器服务(camofox) 缺省随栈起一个 camofox-browser 容器(jo-inc/camofox-browser **v1.18.0**,commit `f94c81b`),给原站视图与 Agent 浏览器能力用。 app 启动时按 `BROWSER_PROVIDER_URL`(缺省 `http://camofox:9377`)自动登记名为 `camofox-env` 的浏览器服务, `depends_on` 只要求已启动、不等健康:camofox 挂了 app 照常运行,界面标"浏览器不可用"。 - **镜像**:上游不发布镜像。先按 [浏览器手册 §1.1](browser.md#11-部署自带缺省),用 `docker/camofox/Dockerfile` 构建基础镜像 `camofox-browser:1.18.0`(锁上游 git tag,修正构建与信号转发)。 compose 再用 `docker/browser-desktop/Dockerfile` 从该基础镜像构建默认桌面镜像 `umt-browser-desktop:1.18.0`。基础镜像首次构建需要 GitHub 源码和约 630 MB 的 Camoufox 包;桌面层复用浏览器包。 - **访问凭据**:`.env.deploy` 里设 `CAMOFOX_ACCESS_KEY`(`openssl rand -hex 24`),camofox 与 app 共用;不设则内网免认证。 - **资源**:当前默认桌面镜像 `mem_limit` 为 3g、`shm_size` 为 2gb;每个站点会话一个 BrowserContext,另有桌面常驻进程。 早期约 200 MB 的空闲测量属于纯截图镜像,不能直接作为桌面模式的内存基准。 - **登录状态**:按站点/账号存在卷 `umt-camofox`(`/root/.camofox/profiles`,cookies + localStorage), 在会话空闲回收、关闭会话与 `docker compose --env-file .env.deploy stop/restart`(SIGTERM)时落盘;**容器被 SIGKILL / OOM 杀掉时, 上次落盘之后的新登录会丢**(需重新登录)。 - **接自有实例**:`.env.deploy` 里 `BROWSER_PROVIDER_URL=<地址>`、`BROWSER_ACCESS_KEY=<它的 CAMOFOX_ACCESS_KEY>`、`CAMOFOX_REPLICAS=0`。 - **不要浏览器**:`CAMOFOX_REPLICAS=0` + `BROWSER_ENABLED=false`。 **SECRET_MASTER_KEY** 生成一次后不再更换:站点 Cookie、下载器密码、模型 API Key 都用它加密存在 `secret` 表。 把它存进密码管理器或离线介质,**和数据库备份分开保管**;备份里不含它,丢了它备份中的凭证无法解密。 容器启动顺序(`docker/entrypoint.sh`): 1. 以 root 启动:建 `DATA_DIR`(`/data`)下的 `agent-sessions/ backups/ exports/ tmp/`,属主不是 `PUID:PGID` 时 `chown -R`, 然后 `gosu PUID:PGID` 降权重跑自己。`PUID=0` 直接拒绝。 2. `--role migrate`:业务表迁移 + 任务引擎 schema 迁移。 3. `--role preflight`:密钥不是占位符、非 root、数据库可达、`WEB_ROOT/index.html` 存在、**DATA_DIR 各子目录与 Agent 会话目录可写** (失败即退出);再逐个检查已启用的**库根**与**下载器路径映射**(`view_kind=downloader` 的 local 前缀)可读可写—— 这些是用户挂载的目录,不可用只打 `warn` 日志并写审计 `preflight.path_unavailable`,不阻止启动。 4. 同时起 `--role api`、`--role worker`(io 组:下载提交/入库复制/缓存清理/资产对账/抓取等 + outbox 中继 + 后台编排循环)、 `--role worker:agent`(只跑 runAgent/resolveDecision);任一退出就停掉其余并以非 0 退出,由 `restart: unless-stopped` 拉起。 两组 worker 只认领自己的任务,Agent 长会话不占文件复制的并发槽。 健康检查:镜像 `HEALTHCHECK` 用二进制自带 fetch 打 `127.0.0.1:4380/api/v1/health`(`--role probe`),不装 curl。 ## 3. 首次初始化管理员 api 首次启动时在日志里打印一次性 setup token(重启会换新的,旧的作废): ```sh docker compose --env-file .env.deploy logs app | grep 'setup token' ``` 浏览器打开 `http://<主机>:4380`,登录页进入初始化模式,填 token、用户名、密码(≥10 字符)。 命令行等价:`POST /api/v1/auth/setup {setupToken, username, password}`。之后在 设置 → API Key 创建给外部程序用的 Key。 ## 4. 卷与路径约定 | 容器内路径 | 来源 | 用途 | 权限要求 | |---|---|---|---| | `/data` | 命名卷 `umt-data`(或宿主目录) | `agent-sessions/`(Pi JSONL 会话,崩溃恢复点)、`backups/`、`exports/`(映射清单)、`tmp/`(`TMPDIR`) | entrypoint 自动 chown 给 `PUID:PGID` | | `/media/...` | 用户 bind mount | 媒体库根:控制台 设置 → 媒体库根 登记的 `localPath` | 需对 `PUID:PGID` 可读写;**不会被自动 chown** | | `/downloads/...` | 用户 bind mount | 下载缓存:路径映射的 local 前缀(下载器报告的 remote 路径 → 容器内路径) | 同上 | | `/var/lib/postgresql`(postgres) | 命名卷 `umt-pg18-data`,内部 `18/docker` | PG18 数据库;旧16卷不能直接挂入 | — | | `/root/.camofox`(camofox) | 命名卷 `umt-camofox` | 浏览器登录状态(`profiles/`,按站点/账号)、上游 cookies/uploads/traces 目录 | — | - 媒体目录在 `docker-compose.yml` 的 `app.volumes` 里取消注释或另写 `docker-compose.override.yml`: ```yaml services: app: volumes: - /srv/media:/media - /srv/downloads:/downloads ``` - bind mount 的相对路径按**第一个 compose 文件所在目录**解析,不是按当前目录;不确定时写绝对路径。 源目录不存在时 Docker 会以 root 属主新建它,库根登记就会因哨兵写不进而失败。 - 路径映射:下载器容器里看到的是 `/downloads/complete/...`,本容器也挂成 `/downloads` 时 remote 前缀与 local 前缀相同; 挂到别处就登记 `remote_prefix=/downloads`、`local_prefix=<本容器内路径>`。目前整理只支持复制和移动。 - 登记库根时服务端会在目录里写哨兵文件 `.uhd-root`,写不进去直接 400(提示检查挂载与属主)。 - **PUID/PGID**:设成宿主媒体目录的属主(`stat -c '%u:%g' /srv/media`)。改 PUID/PGID 后重启容器,entrypoint 会把 `/data` 重新 chown。 也可以 `docker run --user` 直接指定(此时跳过 chown,`/data` 需事先可写)。 - Agent 会话目录缺省 `DATA_DIR/agent-sessions`,可用 `AGENT_SESSION_DIR` 覆盖,或在控制台 设置 → Pi Agent → Agent 运行 → 会话目录 (`settings.agent.sessionDir`,优先于环境变量)改成别的绝对路径(三个进程在同一容器里,天然共享)。改到 `/data` 之外时:目录要对 `PUID:PGID` 可写(entrypoint 不会 chown 它),`umt backup` 只打包 `DATA_DIR/agent-sessions`,需自行备份;有运行中的会话时不要改。 ## 5. 常用环境变量 | 变量 | 缺省 | 说明 | |---|---|---| | `SECRET_MASTER_KEY` | 必填 | 凭证加密主密钥,`openssl rand -hex 32` | | `POSTGRES_PASSWORD` | 必填 | compose 拼进 `DATABASE_URL` | | `UMT_PORT` / `UMT_BIND` | `4380` / `0.0.0.0` | 宿主映射端口与绑定地址;放在反代后面时绑 `127.0.0.1` | | `PUID` / `PGID` | `10001` | 运行用户 | | `DATA_DIR` | `/data` | 应用数据目录(开发机缺省 `./data`) | | `AGENT_SESSION_DIR` | `DATA_DIR/agent-sessions` | Agent 会话目录 | | `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` | 空 | io worker 文件 / 网络 / 维护三条道的并发;空 = 用 设置 → 自动化 → 高级 →「后台任务并发」(缺省 2 / 4 / 1,热生效),设了以变量为准。见 dev.md「IO Worker 分道」 | | `WORKER_CONCURRENCY` | 空 | 旧变量(原 io 组总并发),现映射到网络道(`WORKER_NETWORK_CONCURRENCY` 未设时) | | `WORKER_AGENT_CONCURRENCY` | `2` | agent 组 worker 并发槽(每个 Agent 会话占一槽直到结束) | | `ORGANIZE_WALK_MAX_FILES` | `5000` | 手动按路径整理时递归遍历的文件数上限;dryRun 超出返回 `truncated: true`,正式执行拒绝 | | `BACKUP_KEEP` | `14` | 备份保留份数,0 不轮转 | | `LOG_LEVEL` | `info` | pino 级别 | | `CAMOFOX_ACCESS_KEY` | 空 | camofox 访问凭据(camofox 与 app 共用);空 = 内网免认证 | | `BROWSER_PROVIDER_URL` | `http://camofox:9377` | app 登记的浏览器服务地址(`camofox-env`);接自有实例时改 | | `BROWSER_ACCESS_KEY` | `CAMOFOX_ACCESS_KEY` | 该服务的访问凭据,只在进程内使用,不入库 | | `BROWSER_ENABLED` | `true` | `false` = 停用 `camofox-env`,界面标"浏览器不可用" | | `BROWSER_KEEPALIVE_MS` | `120000` | 桌面接管 / 人工接管 / 智能体正在用的浏览器会话的保活间隔,0 = 关闭;要比 camofox 标签页空闲回收(5 分钟)短(`docs/runbook/browser.md` §12) | | `CAMOFOX_REPLICAS` | `1` | `0` = 不起 camofox 容器 | | `CAMOFOX_SESSION_TIMEOUT_MS` / `CAMOFOX_BROWSER_IDLE_TIMEOUT_MS` | `1800000` / `300000` | 会话空闲回收(落盘登录状态)/ 浏览器空闲退出 | | `CAMOFOX_MAX_SESSIONS` / `CAMOFOX_MEM_LIMIT` | `8` / `2g` | 最多并发会话 / 容器内存上限 | ## 6. 升级 发布前检查旧镜像的实际在途任务:在任务页核对 queued / running / waiting,尤其是本次变更的任务名称和版本。优先等它们完成;需要中止的任务先保留原输入、冻结计划和操作记录,确认中止后通过现有入口重新发起。已经产生文件操作证据的任务要先核实执行结果,再决定后续处理。没有旧在途任务时不长期保留旧 handler;业务表前滚不能替代任务版本兼容判断。 本次整理来源功能增加 `scanOrganizeSource@1` 和迁移 `0059_organize_source_snapshot`,沿用 `verifyAndImport`。迁移不会扫描或整理已有目录;用户新增来源后先建立基线,存量必须单独触发。 本机测试环境的标准做法: ```sh cd <仓库> && git pull deno task release # 工作区须干净、CHANGELOG 已有本版条目;打 tag → 构建 → 只替换 app → 核对健康版本 ``` `scripts/release.sh --no-build` 用已有镜像只重建容器。真有要保留的数据时,先 `docker compose --env-file .env.deploy exec app umt backup` 再发布。手动等价命令: ```sh docker compose --env-file .env.deploy up -d --build app # 重建镜像并替换容器;启动时自动迁移 docker compose --env-file .env.deploy ps && docker compose --env-file .env.deploy logs --tail=50 app ``` 迁移只前滚。升级失败要回退时:用旧镜像 + 升级前的备份恢复(见 [backup-restore.md](backup-restore.md)),不要用旧镜像直接连新库。 ## 7. 日志与排障 - `docker compose --env-file .env.deploy logs -f app`:pino JSON,一行一条;worker 日志带 `group`(io / agent)。 - `worker 事件循环延迟超过阈值`(warn,`lagMs`):worker 主线程被同步重活占住超过 5s,租约续期可能受影响,带上前后日志排查。json-file 驱动,单文件 10 MB × 5 份。 - entrypoint 自身的输出(`entrypoint: ...`)说明哪个进程先退出、退出码多少。 - 自检失败(`check=data_dir`):`/data` 卷不可写,检查是否挂了只读卷或 `--user` 与卷属主不一致。 - 挂载路径告警(`挂载路径不可用`):看审计页 `preflight.path_unavailable` 事件,核对 bind mount 与 PUID/PGID。 - 进容器排查:`docker compose --env-file .env.deploy exec app bash`(root);以运行用户执行命令用 `gosu 10001:10001 `。 - 一次性子命令:`docker compose --env-file .env.deploy exec app umt migrate|preflight|export-mappings|backup`、 `docker compose --env-file .env.deploy run --rm --no-deps app restore <备份>`。 ## 验证记录 2026-09-27,W4-B,Docker 29 / compose v2,在干净 worktree(HEAD 8c6a922 + 本次改动)构建, 临时项目名 `umtw4b`、app 端口 127.0.0.1:4390、pg 端口 127.0.0.1:55440,`/media` 绑定一个属主 10001 的临时目录。 | 步骤 | 结果 | |---|---| | `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功(缓存命中 2 分 24 秒);镜像 489 MB(压缩后 107 MB):二进制 213 MB、gosu+postgresql-client 79 MB、web 2 MB | | `compose up -d` | postgres healthy 后 app 启动,约 6 秒 healthy;22 个迁移(编号 0017 空缺)+ 引擎迁移,`启动自检通过 dataDir=/data sessionDir=/data/agent-sessions` | | 进程 | 两个 `umt-server`(api、worker)uid/gid 均为 10001;`/data/*` 属主 10001;`/app/umt-server` 为 `r-xr-xr-x root`,运行用户写入 → Permission denied | | `GET /` 与 `/console/settings` | 200,SPA 由同端口托管 | | setup token(日志)→ `POST /auth/setup` → `POST /auth/login` | 成功,拿到会话与 csrf cookie | | 会话 + csrf `POST /api-keys {scopes:[admin]}` | 返回 `umt_…` 明文一次 | | API Key `GET /organization-records` | 200 | | API Key `POST /library-roots {localPath:/media}` | 成功,宿主目录出现 `.uhd-root`(属主 10001);容器内 root 属主目录登记 → 400 `库根不可写 … EACCES` | | `PATCH /settings {pi:{provider:fake}}` → `POST /agent/runs {mode:plan_only}` | run `succeeded`,会话文件 `/data/agent-sessions/.jsonl`(18.7 KB,属主 10001) | | 宿主 `chmod 555` 媒体目录后 `umt preflight` | 退出码 0,`warn 挂载路径不可用: verify-media 不可写: EACCES`,审计 `preflight.path_unavailable` 一条;恢复权限后 warnings=0 | | `umt backup` | `db.dump` 245 KB + `mappings.json` + `agent-sessions.tar.gz` + `manifest.txt` + `SHA256SUMS`;pg_dump 17.11 对服务端 16.14 | | 清库 | `drop schema public cascade` + 删 `openworkflow` schema → 0 张表 | | app 运行中 `umt restore` | 拒绝:`数据库还有 13 个其它连接` | | `compose stop app` → `compose run --rm --no-deps app restore --yes` | 退出码 0;63 张表恢复;抽查 admin_user 1 / api_key 1 / library_root 1 / media_entity 3 / external_identity 3 / agent_run 1 / agent_run_event 9 与备份前一致(audit_event +1 为恢复审计、settings +1 为 automation 行);映射清单逐表比对"一致";`automation={"readOnlyReconcile": true}`,审计 `ops.read_only_reconcile_on` | | 恢复后 `up -d app` | healthy;`GET /settings` → `automation {enabled: true, readOnlyReconcile: true}` | | `BACKUP_KEEP=3` 连续备份 3 次 | 最旧一份被轮转删除,剩 3 份 | | `PUID=1234 PGID=1234` 空卷启动 | `/data` 及子目录 chown 为 1234,进程 uid 1234;`PUID=0` → 退出码 2 | | `compose stop app` | 0.8 秒,api/worker 均收到 SIGTERM 正常退出 | | 清理 | `compose down -v`、删除测试镜像;未 prune 全局 build cache | 首次演练发现:镜像里的 pg_restore 17 直连 16 版服务端会因 `SET transaction_timeout` 失败(单事务回滚,库未被破坏), 已改为 pg_restore 输出 SQL、过滤该行后由 psql 单事务回放,复测通过。 2026-09-27,W4-D,临时项目名 `umtw4d`、app 127.0.0.1:4391、pg 127.0.0.1:55441(验证后已 `down -v` 并删除镜像)。 | 步骤 | 结果 | |---|---| | `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功;镜像内容 160.5 MB 压缩(W4-B 107 MB),新增层:静态 ffprobe 135 MB(未压缩),二进制 214 MB | | `compose up -d` | 约 8 秒 healthy;`/api/v1/health` → `status=ok role=api db=ok` | | 进程 | `entrypoint.sh serve` 下三个 `umt-server`:`--role api`、`--role worker`(日志 `group=io`,9 个任务,并发 4,模块循环 4 个)、`--role worker:agent`(`runAgent@1`/`resolveDecision@1`,并发 2,无模块循环),均为 uid 10001 | | ffprobe | 容器内 `gosu 10001 ffprobe` 探测一个 2 KB WAV → `pcm_s16le`;`ffprobe version 7.1.1` | | 编译权限 | 同一组编译参数的探针二进制在容器内以 10001 运行:node `execFile("ffprobe")` 成功、`Deno.Command("sha256sum")` 成功、`Deno.Command("ls")` → `NotCapable` | 2026-09-27,W5-E(生产 fsworker),临时项目名 `umtw5e`、app 127.0.0.1:4395、pg 127.0.0.1:55442,`/media` 绑定属主 10001 的临时目录; 在干净 worktree(HEAD 89ea684 + 本次改动)构建。`FS_HANG_TIMEOUT_MS=3000`、`FS_KILL_GRACE_MS=500`。 | 步骤 | 结果 | |---|---| | `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功(约 2 分 30 秒,缓存命中依赖层) | | setup → login → admin API Key → `POST /library-roots {localPath:/media}` → 改 `managed`;SQL 插 3 个 movie 作品 | 成功 | | 手动整理 `POST /organization-records` dryRun → 不带 dryRun 执行(Copy,1.5 GB 稀疏文件 `/media/_incoming/Check.Movie.2021…mkv`) | 记录 succeeded,1.8 s;日志 `organize.transfer` 带 `fsproxy:{mode:"subprocess",workerKind:"self",spawned:5,timeouts:0,fallbackReason:null}` | | 以 `UMT_FS_TEST_HANG_AFTER_BYTES=1048576 UMT_FS_TEST_HOOKS=1` 重建 app 后整理 60 MB 文件 | 容器内 `/proc` 看到 `/app/umt-server --role fsworker`(uid 10001,父进程为 `--role worker`)存活约 3–4 s 后消失,无残留子进程 | | 超时链路 | 每次尝试:停滞 3 s → SIGTERM(worker 屏蔽)→ SIGKILL → `TIMEOUT`;共 3 次(`FS_TRANSFER_MAX_ATTEMPTS`)后账本 `{"code":"TIMEOUT","message":"fs copy 挂死:进度停滞超过 3000 ms"}`,`fsproxy.timeouts=3 killed=3`,待处理「文件操作挂死,重试已达上限」;目标目录只留被 KILL 的 `.uhd-partial`(下次写前按年龄清理),无最终名半成品 | | 清理 | `compose down -v`、删除测试镜像与临时目录 | `UMT_FS_TEST_HANG_AFTER_BYTES` 只用于这类验收(让数据类操作的 worker 读到 n 字节后卡死 JS 线程并屏蔽 SIGTERM),必须同时设置 `UMT_FS_TEST_HOOKS=1` 才生效;单独设置不生效,但 `preflight` 会打 warn。生产不要设置。 ## 容器经宿主代理访问境外站点(可选) 宿主上的代理(如 mihomo)常只监听 `127.0.0.1`,容器从网桥够不到。改动最小的做法:加一个只绑在 docker 网桥地址上的中继,再放行 docker 网段到该端口;代理本身配置不动。写在本机 `docker-compose.override.yml`(不进 git): ```yaml services: proxy-relay: image: alpine/socat:1.8.0.0 network_mode: host restart: unless-stopped command: ["TCP-LISTEN:7891,bind=172.17.0.1,fork,reuseaddr", "TCP:127.0.0.1:7890"] app: environment: HTTP_PROXY: http://172.17.0.1:7891 HTTPS_PROXY: http://172.17.0.1:7891 # 同网络的服务名都要列上(含下载器:qbittorrent / transmission / deluge / aria2 等),与 compose 缺省 UMT_NO_PROXY 一致 NO_PROXY: localhost,127.0.0.1,::1,postgres,camofox,proxy-relay,qbittorrent,qbittorrent-nox,qb,transmission,deluge,aria2,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 camofox: environment: { HTTP_PROXY: http://172.17.0.1:7891, HTTPS_PROXY: http://172.17.0.1:7891, NO_PROXY: localhost,127.0.0.1,app,10.0.0.0/8,172.16.0.0/12 } ``` compose 已给 app 设了缺省 `NO_PROXY`(`UMT_NO_PROXY` 可改),override 里重写 `NO_PROXY` 时别漏掉下载器的服务名。 防火墙只放行 docker 网段:`ufw allow from 172.16.0.0/12 to 172.17.0.1 port 7891 proto tcp`。compose 默认网络已固定在 `UMT_SUBNET`(缺省 172.30.0.0/24,与本机其他网络冲突时在 `.env.deploy` 改成 172.16.0.0/12 内的空闲网段,如 172.16.80.0/24),否则 compose 自动分配的 192.168.x 网段不在放行范围内。Deno 的 fetch 会读 `HTTP(S)_PROXY`/`NO_PROXY`;TMDB、境外 Pi 上游、camofox 访问境外站都经此代理,站内(postgres/camofox)不走代理。 ## 测试用 qB(override 示例) 只用于本地模拟"下载完成 → 自动整理"(SIM-1):qB 与 app 共用 compose 网络,app 经 `http://qbittorrent:8080` 访问;不映射公网端口,本机调试只绑 `127.0.0.1:8081`。写在本机 `docker-compose.override.yml`(不进 git): ```yaml services: app: environment: # 配了 HTTP(S)_PROXY 时必须把 qbittorrent 加进 NO_PROXY:Deno 按主机名匹配,不会因解析到内网 IP 而直连 NO_PROXY: localhost,127.0.0.1,::1,postgres,camofox,proxy-relay,qbittorrent,qbittorrent-nox,qb,transmission,deluge,aria2,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 qbittorrent: image: lscr.io/linuxserver/qbittorrent:5.2.4 # v4 / 5.0–5.2 都兼容(QB-1 实测 5.1.2、5.2.4) restart: unless-stopped environment: { PUID: "10001", PGID: "10001", TZ: Asia/Shanghai, WEBUI_PORT: "8080" } volumes: - /opt/umt-demo/downloads:/downloads # 与 app 挂同一路径,路径映射 /downloads ↔ /downloads - umt-qb-config:/config ports: - "127.0.0.1:8081:8080" volumes: umt-qb-config: ``` ```sh docker compose --env-file .env.deploy --profile tls up -d --no-deps qbittorrent # 只起 qB docker compose --env-file .env.deploy --profile tls logs qbittorrent | grep 'temporary password' # 首次启动的临时密码 # 改 NO_PROXY 后只重建 app 容器、不重建镜像(APP_VERSION 与当前运行镜像一致): APP_VERSION=<当前版本> docker compose --env-file .env.deploy --profile tls up -d --no-deps --no-build app ``` - 本机经 8081 访问时 qB 的 Host 头校验要求端口与 `WEBUI_PORT` 一致:curl 加 `-H 'Host: localhost:8080'`,否则 401。 要从宿主直接用映射端口访问,就让两边端口相同(`WEBUI_PORT=18082` + `127.0.0.1:18082:18082`)。 - 用临时密码登录后 `POST /api/v2/app/setPreferences`:`web_ui_password`(随机强密码,存本机凭据文件)、`save_path=/downloads`、 `dht/pex/lsd/upnp=false`、`up_limit`/`dl_limit` 限速、`auto_tmm_enabled=false`、`torrent_content_layout=Original`。 - 控制台登记:下载器实例 `http://qbittorrent:8080`、管理标签 `umt`、`settings.savePath=/downloads`;路径映射 downloader `/downloads` ↔ `/downloads`; 自动整理要求库根为 `managed`(`register_only` 时 `no_library_root`)。 - 模拟完成:按发布名把样片放进下载目录,用真实 piece 哈希做种子(testkit `makeTorrent({ realPieces: true })` 返回 `contents`, 写进保存目录;缺省 pieces 是伪值,qB 校验不到 100%), 带标签 `umt` 加入 qB,校验后即 100% 做种,`downloads.sync` 接管。 ## 下载器认证与登录兼容(QB-1) 控制台 设置 → qBittorrent 实例 的「认证方式」三选一,存 `downloader_instance.settings.authKind`,凭证只写、存 `secret_ref`、不回显: | 认证方式 | 凭证 | 请求 | 适用 | | --- | --- | --- | --- | | 用户名和密码(`password`) | 密码 | `POST /api/v2/auth/login` 取会话 cookie,之后带 cookie;会话失效(401/403)自动重登一次 | 所有版本 | | API Key(`api_key`) | WebUI 生成的 `qbt_…` | 每个请求带 `Authorization: Bearer `,不调 `/auth/login` | qB 5.2+ | | 无认证(`none`) | 无(切到它时删除已存凭证) | 不登录、不带凭证 | qB 开了"对本地主机 / 白名单子网跳过认证"的内网 | 旧实例没有 `authKind`:有用户名按账密,无用户名但有凭证按 API Key,都没有按无认证(与改前行为一致)。 API Key 在 qB 5.2+ WebUI 选项 → WebUI → API Key 处生成 / 轮换(等价 `POST /api/v2/app/rotateAPIKey`,返回 `{"apiKey":"qbt_…"}`; 轮换后旧 Key 立即失效,记得回控制台更新)。 登录判定(对照 qbittorrent-api `auth_log_in`): - 成功:HTTP 200 且体为 `Ok.`(≤5.1),或 204 空体(5.2+)。会话 cookie 名不固定(≤5.1 `SID`,5.2+ `QBT_SID_<容器内 WebUI 端口>`, 也可能被自定义),一律取 `Set-Cookie` 里的会话 cookie 原名原值回传。 - 失败分类:200 `Fails.` / 401 → 凭据错误(`auth`);403 "IP 已被封禁" / 429 → 限流(`rate`,默认连续 5 次失败封 1 小时, 等封禁过期或重启 qB);API Key 错误 qB 返回 403 → `auth`;无认证被要求认证 → `auth`;5xx → `protocol`。 - 实测矩阵(`packages/adapters/src/qbittorrent/qbittorrent.real.test.ts`,设 `QB_REAL` 才跑):5.1.2 账密 / 无认证 / 失败分类, 5.2.4 账密 / API Key / 无认证 / 失败分类,各跑 登录 → 添加(testkit `makeTorrent({ realPieces: true })` 真实 piece 哈希, 内容写进保存目录,qB 校验即完成)→ 查询 → 标签 → 暂停恢复 → 删除(不删数据)。 ## v0.3.10 发布补充 2026-10-01 已发布 v0.3.10,执行 `0060_manual_acquisition`:`acquisition_plan.slot_id` 允许为空,支持独立手动下载;`download_task.import_plan_id` 保存本次入库规划,并以外键引用计划。部署前备份与校验通过,只替换 app 容器;线上接口、版本一致及页面冒烟通过。后续普通 Compose 启动使用已更新的 `.env.deploy` 版本与源码标记。具体证据见 发布记录(已删除,见 git `ceb66f8`)。 ### 当前测试入口 用户要求测试自签 HTTPS 后,4380 已由 Caddy 接管为 `https://服务器地址:4380`;原 443 入口保留,app 本机 HTTP 调试入口为 `http://127.0.0.1:4381`。配置保存在 `.env.deploy` 与本机 `docker-compose.override.yml`,重建了 app/Caddy 容器,镜像及数据库版本保持 v0.3.10/0060。证书由原 Caddy 内部 CA 签发,健康、管理员接口和 HTTPS 页面冒烟通过。 ## v0.3.11 修复发布 搜索结果标题接入已有资源详情抽屉,已发布 v0.3.11;数据库保持 `0060`。浏览器桌面修复镜像为 `umt-browser-desktop:1.18.0-umt.1`,内置有效的最大化配置,已验证上下黑边消失。`.env.deploy` 已记录当前应用版本、源码标记和浏览器镜像,4380 HTTPS/443 HTTPS/本机 4381 HTTP 映射保留。具体验证见 发布记录(已删除,见 git `ceb66f8`)。