Ubuntu 22.04 安装 HandBrake Web 0.8.1 指南

Ubuntu 22.04 从源码构建并运行 HandBrake Web 0.8.1:Node 20+/pnpm/HandBrakeCLI 依赖、四包构建、server 与 worker 进程、systemd 托管、Nginx 反向代理与真实启动/转码回显。

HandBrake WebHandBrake视频转码Node.jsFFmpeg2026/10/27

Ubuntu 22.04 安装 HandBrake Web 0.8.1 指南

目标:在一台 Ubuntu 22.04 服务器上从源码构建并运行 HandBrake Web 0.8.1 的 server 与 worker 两个进程,用 systemd 托管,用 Nginx 对外提供页面与 Socket.IO 长连接入口。全文不使用预编译发行包,所有命令均可在目标机复现;文中回显来自 2026-10-27 的实机验证,构建机与目标机的发行版差异已在「环境信息」中标注。

环境信息

项目取值说明
目标系统Ubuntu 22.04 LTS(x86_64)本文命令以此为准
实测系统Ubuntu 26.04.1 LTS,内核 7.0.0-38-generic构建与运行回显来自该机;22.04 差异已在步骤中注明
Node.jsv24.22.1最低要求 Node.js 20(better-sqlite3 12.x 的原生模块)
pnpm10.16.1项目 packageManager 锁定 pnpm@10.16.1,corepack 自动切换
HandBrakeCLI1.11.0来自 Ubuntu 软件源 handbrake-cli 包,worker 依赖它转码
应用版本HandBrake Web 0.8.1源码 package.json 版本号,AGPL-3.0
监听端口9999源码硬编码,无法通过环境变量修改,只能改反向代理端口
服务端数据目录/opt/handbrake-web/data由 DATA_PATH 指定,存配置、SQLite、日志、预设
媒体目录/srv/handbrake-web/video由 VIDEO_PATH 指定,server 与所有 worker 必须是同一绝对路径
worker 本地目录/opt/handbrake-web/worker-data由 DATA_PATH 指定,存任务日志
演示账号root / q1w2e3r4仅用于 Nginx 基础认证示例,不是应用账号(0.8.1 无登录体系)

⚠️ 实测机不是 22.04,而是 Ubuntu 26.04.1 LTS。源码构建、目录权限检查、SQLite 初始化、Socket.IO 注册、HandBrakeCLI 转码这些行为在两者一致;差异只在系统包名与版本(例如 22.04 的 handbrake-cli 版本低于 1.11.0,见 2.4 节)。

一、部署拓扑与安装前必须知道的约束

1.1 两个进程,一台或多台机器

HandBrake Web 只由两个可执行进程组成:

进程入口职责资源特征
serverserver/build/server.jsExpress 静态页、Socket.IO 服务端、队列调度、SQLite 持久化、目录监听低功耗,1 核 1GB 足够
workerworker/build/worker.js连接 server、领取任务、调用 HandBrakeCLI 转码、回传进度与日志计算密集,单机建议只跑一个

server 与 worker 之间不是 REST 调用,而是 worker 主动发起 Socket.IO 长连接到 server 的 /worker 命名空间;浏览器连接 /client 命名空间。因此防火墙只需放通 server 的 9999 端口,worker 不需要入站端口。协议细节见本专栏后续的 Socket.IO 事件协议篇目。

1.2 三条硬约束

  1. 端口固定 9999。server/src/server-startup.ts 中写的是 const port = 9999;,没有读取 process.env.PORT。想换成 80/443 只能靠 Nginx 反向代理。
  2. 媒体路径必须全集群一致。任务表里存的是绝对路径,worker 拿到的是 server 写入的 input_path、output_path 原字符串。若 server 看到 /srv/media/a.mp4、worker 看到 /mnt/media/a.mp4,转码会在打开文件时失败。跨机部署请用 NFS/SMB 把共享目录挂到完全相同的绝对路径。
  3. 前端静态目录是绝对路径。server/src/routes/client.ts 里写死 const clientBuildPath = path.join('/handbrake-web/client');,并且只有 NODE_ENV == 'production' 才会挂载 express.static。裸机部署若直接把 server 暴露出去,访问 / 会 404(实测见 5.3 节)。生产做法是用 Nginx 托管 client/build,server 只提供 Socket.IO 与日志下载。

1.3 版本与源码来源

  • 仓库:https://github.com/TheNickOfTime/handbrake-web
  • 标签:v0.8.1(package.json 中 "version": "0.8.1")
  • 许可证:AGPL-3.0
  • 构建方式:pnpm workspace,shared、server、worker、client 四个子包,server/worker 用 esbuild 打成单文件,client 用 Vite 打包

💡 技术拓展:本项目把 server 与 worker 分别打包成单文件,而不是保留 node_modules 目录树。这样做的好处是部署时只需拷贝 build/,坏处是 better-sqlite3 这类含 .node 原生模块的依赖仍会被复制到 build/ 外侧(构建日志中的 "Copying non-bundled dependencies")。因此升级 Node 大版本后必须重新 pnpm install 重建原生模块,不能只替换 build/server.js。

二、基础依赖安装

2.1 Node.js 20 及以上

Ubuntu 22.04 自带的 Node.js 是 12.x,不能用。用 NodeSource 源安装 20 LTS 或更新版本:

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
  | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
  | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt-get update
sudo apt-get install -y nodejs

验证(实测回显):

$ node -v
v24.22.1

💡 说明:实测机装的是 24.x,23/22/20 同样可用。项目 @types/node 为 24.x,仅影响类型检查,不影响运行时。

2.2 pnpm 与 corepack

项目 package.json 里声明了 "packageManager": "pnpm@10.16.1"。用 corepack 启用后,进入项目目录执行 pnpm 会自动切到该版本:

sudo corepack enable
cd /opt/handbrake-web/app
pnpm -v

实测回显(在项目目录内执行):

$ pnpm -v
10.16.1

⚠️ 注意:在项目目录外执行 pnpm -v 可能得到系统预装的 9.x,这不代表项目会用它。pnpm.log 末尾的真实记录是 Done in 1m 33.8s using pnpm v10.16.1,说明实际安装用的是 corepack 切换后的 10.16.1。

2.3 编译工具链(better-sqlite3 必需)

better-sqlite3 12.5.0 没有提供当前 Node ABI 的预编译产物时会走 node-gyp 现场编译,必须装 Python3、make、g++:

sudo apt-get install -y build-essential python3 pkg-config

实测 pnpm install 会触发源码编译,日志中出现:

.../node_modules/better-sqlite3 install:   CC(target) Release/obj.target/test_extension/deps/test_extension.o
.../node_modules/better-sqlite3 install:   SOLINK_MODULE(target) Release/obj.target/test_extension.node
.../node_modules/better-sqlite3 install: gyp info ok
.../node_modules/better-sqlite3 install: Done

若缺 g++,这里会直接失败并报 gyp: command not found 或 C++ compiler not detected。

2.4 HandBrakeCLI

worker 通过 execFile('HandBrakeCLI', ['--version']) 取版本、通过 spawn('HandBrakeCLI', ...) 转码,命令名写死,必须让 HandBrakeCLI 出现在 PATH 里,没有 HANDBRAKE_PATH 之类的环境变量可以改路径。

Ubuntu 22.04 的 universe 仓库自带 handbrake-cli:

sudo apt-get install -y handbrake-cli
HandBrakeCLI --version 2>&1 | tail -6

实测回显(实测机为 26.04,包版本 1.11.0):

[21:43:44] Compile-time hardening features are enabled
Cannot load libnvidia-encode.so.1
[21:43:44] qsv: not available on this system
[21:43:44] hb_init: starting libhb thread
[21:43:44] thread 765634fb26c0 started ("libhb")
HandBrake has exited.
HandBrake 1.11.0

⚠️ 注意 1:22.04 仓库里的 HandBrake 版本低于 1.11.0,--version 的输出格式仍然是 HandBrake X.Y.Z,能匹配 worker 的正则,但老版本可能缺少某些新预设或编码器。需要 1.11 时,从 https://github.com/HandBrake/HandBrake/releases 下载对应架构的 .deb 包安装,或按官方文档从源码编译。 ⚠️ 注意 2:--version 的版本号出现在 stdout,而能力探测读取的是 stderr 中的 qsv: is not available on this system 与 Cannot load libnvidia-encode.so.1。如果发行版的输出措辞不同,worker 会抛 No version match in stderr 直接退出(实测复现见 10.2 节)。

2.5 Intel 硬件编码依赖(QSV)

HandBrake Web 只识别 cpu、qsv、nvenc 三种能力。Intel 核显/独显走 QSV,需要在宿主机装 Intel Media Driver(必须是非 free 版,free 版只支持解码):

sudo apt-get install -y intel-media-va-driver-non-free i965-va-driver vainfo
sudo usermod -aG video handbrake
ls -l /dev/dri
vainfo

实测 ls /dev/dri 在无核显直通的机器上不存在,vainfo 会报 error: can't connect to X server 或 vaInitialize failed;这两种情况都说明 QSV 不可用,worker 会如实上报 Capability 'qsv' support = false。

2.6 AMD 硬件编码依赖(VCN/VAAPI)

0.8.1 没有实现 AMD VCN 探测:worker/src/scripts/properties.ts 中 vcn 字段被写死为 false,队列调度也不会把需要 AMD 编码的任务派给任何 worker。AMD 机器仍可装 Mesa 的 VAAPI 驱动用于本地播放/验证,但不要指望它在 HandBrake Web 里被识别为硬件编码能力:

sudo apt-get install -y mesa-va-drivers vainfo
vainfo --display drm --device /dev/dri/renderD128

worker 日志里对应的仍是:

Capability 'vcn' support = false

2.7 ffprobe 与 ffmpeg:不需要单独安装

server 的目录监听(watcher)需要读取媒体宽高、码率、编码器,它用的是 npm 依赖 ffprobe-static 自带的二进制,不从 PATH 找。实测系统里 ffprobe 命令不存在,但 server 构建产物中该二进制存在:

$ which ffprobe
ffprobe: command not found
$ node -e "console.log(require('ffprobe-static').path)"
/opt/handbrake-web/app/node_modules/.pnpm/ffprobe-static@3.1.0/node_modules/ffprobe-static/bin/linux/x64/ffprobe

⚠️ 注意:ffprobe-static 只提供 x64 与 arm64 的 Linux 二进制。在 32 位 ARM 或 musl 发行版上 watcher 的媒体信息规则会失败,此时才需要自行安装 ffmpeg 并改写依赖。

三、源码获取与构建

3.1 拉取 0.8.1 源码

sudo mkdir -p /opt/handbrake-web
sudo chown -R "$USER":"$USER" /opt/handbrake-web
git clone --branch v0.8.1 --depth 1 \
  https://github.com/TheNickOfTime/handbrake-web.git /opt/handbrake-web/app
cd /opt/handbrake-web/app
git log -1 --oneline

--depth 1 只拉标签对应的提交,构建不需要历史。若网络受限,可把仓库地址换成可达的代码托管站点后 git checkout v0.8.1,版本号以 package.json 为准。

3.2 安装依赖

cd /opt/handbrake-web/app
pnpm install

实测耗时与结果:

.../node_modules/better-sqlite3 install: gyp info ok
.../node_modules/better-sqlite3 install: Done

shared postinstall$ tsc -b
shared postinstall: Done
Done in 1m 33.8s using pnpm v10.16.1

shared 子包的 postinstall 会先执行一次 tsc -b,这是正常行为,不是重复构建。

3.3 分四个子包构建

cd /opt/handbrake-web/app
pnpm build:shared
pnpm build:server
pnpm build:worker
pnpm build:client

实测回显(节选,已剔除构建机注入 LD_LIBRARY_PATH 产生的与业务无关的 no version information available 提示):

> @handbrake-web/shared@0.8.1 build
> tsc -b

> @handbrake-web/server@0.8.1 build
> node build.mjs
[server] [build] Starting server application build process...
[server] [build] Cleared existing output from the 'build' directory.
[server] [build] Bundling the server application...
  build/server.js  3.4mb ⚠️
⚡ Done in 277ms
[server] [build] Successfully bundled the server application.
[server] [build] Copying non-bundled dependencies to the build output location...

> @handbrake-web/worker@0.8.1 build
> node build.mjs
[worker] [build] Starting worker application build process...
  build/worker.js  860.2kb
⚡ Done in 86ms
[server] [build] Successfully bundled the worker application.

> @handbrake-web/client@0.8.1 build
> tsr generate && tsc -b && vite build
vite v7.3.0 building client environment for production...
✓ 334 modules transformed.
✓ built in 2.22s

构建期有一条 esbuild 警告,可忽略:

▲ [WARNING] The condition "types" here will never be used as it comes after "default" [package.json]
    ../shared/package.json:33:6

3.4 校验产物

ls -lh /opt/handbrake-web/app/server/build/server.js \
       /opt/handbrake-web/app/worker/build/worker.js \
       /opt/handbrake-web/app/client/build/index.html
ls /opt/handbrake-web/app/client/build/assets | wc -l

实测结果:server.js 约 3.4MB、worker.js 约 860KB、index.html 400 字节、assets 目录 51 个文件(字体、CSS、JS 分片)。

四、目录与配置规划

4.1 建立目录

sudo mkdir -p /opt/handbrake-web/data
sudo mkdir -p /opt/handbrake-web/worker-data
sudo mkdir -p /srv/handbrake-web/video/input
sudo mkdir -p /srv/handbrake-web/video/output
sudo useradd -r -s /usr/sbin/nologin handbrake
sudo chown -R handbrake:handbrake /opt/handbrake-web /srv/handbrake-web
路径用途备份
/opt/handbrake-web/dataconfig.yaml、handbrake.db、log/、presets/必须备份
/opt/handbrake-web/worker-dataworker 侧任务日志临时目录可不备份
/srv/handbrake-web/video输入与输出媒体按媒体库策略备份

4.2 首次启动生成 config.yaml

server 第一次启动时若 /data/config.yaml 不存在会自动写入默认值。实测生成的完整内容:

config:
  version: 2
paths:
  media-path: /video
  input-path: /video
  output-path: ""
presets:
  show-default-presets: true
  allow-preset-creator: false
application:
  queue-startup-behavior: 0
  update-check-interval: 12

各项含义:

  • paths.media-path:页面文件浏览器的根目录。默认 /video 是给固定挂载路径的场景用的,裸机部署必须改成 /srv/handbrake-web/video,否则页面浏览不到任何文件。
  • paths.input-path:新建任务时输入目录的默认起点,必须是 media-path 的子路径。
  • paths.output-path:输出目录默认值,留空表示与输入同目录。
  • presets.show-default-presets:是否显示内置预设(从 /var/lib/handbrake/preset_builtin.json 读取)。
  • application.queue-startup-behavior:0 表示沿用数据库里上次的状态,1 表示启动即置为 Idle,2 表示启动即 Stopped。
  • application.update-check-interval:更新检查间隔(小时),0 关闭检查。

改成绝对路径后重启 server:

paths:
  media-path: /srv/handbrake-web/video
  input-path: /srv/handbrake-web/video/input
  output-path: /srv/handbrake-web/video/output

⚠️ 注意:VIDEO_PATH 环境变量与 config.yaml 的 paths 是两套东西。VIDEO_PATH 只用于启动时的目录权限检查;页面里浏览的路径来自 config.yaml。两者不一致时服务能起来,但页面上看不到文件,这是最容易踩的坑。

4.3 内置预设文件

server 启动时读取 DEFAULT_PRESETS_PATH,默认 /var/lib/handbrake/preset_builtin.json。该文件由 handbrake-cli 包提供,路径可能随发行版不同。实测机上文件在 /usr/share/handbrake/preset_builtin.json,因此启动前先确认:

dpkg -L handbrake-cli | grep preset_builtin

若默认路径不存在,server 会在启动阶段抛错退出。两种处理方式:

# 方式一:做符号链接,让默认路径成立
sudo mkdir -p /var/lib/handbrake
sudo ln -s /usr/share/handbrake/preset_builtin.json /var/lib/handbrake/preset_builtin.json

# 方式二:用环境变量指到真实路径(写进 systemd 单元)
DEFAULT_PRESETS_PATH=/usr/share/handbrake/preset_builtin.json

4.4 用户预设目录

/opt/handbrake-web/data/presets/ 由 server 自动创建。子目录名即预设分类,例如 presets/Web/MyPreset.json。从桌面版 HandBrake 导出的 JSON 直接放进去,重启 server 即可加载。

五、启动 server

5.1 前台启动(先手工验证)

cd /opt/handbrake-web/app/server
sudo -u handbrake env \
  DATA_PATH=/opt/handbrake-web/data \
  VIDEO_PATH=/srv/handbrake-web/video \
  DEFAULT_PRESETS_PATH=/usr/share/handbrake/preset_builtin.json \
  NODE_ENV=production \
  SERVER_URL=192.168.1.20 \
  node build/server.js

环境变量说明:

变量必填作用
DATA_PATH建议数据目录,默认 cwd/../data。不设时按进程工作目录推导,systemd 下容易指错
VIDEO_PATH建议媒体目录,只用于启动权限检查
DEFAULT_PRESETS_PATH视发行版内置预设 JSON 路径,默认 /var/lib/handbrake/preset_builtin.json
NODE_ENV生产必填设为 production 才会托管前端静态资源,并启用版本检查
SERVER_URL可选只影响启动日志里打印的地址,不改变监听行为
PORT不存在源码没有读取该变量,写 PORT=8080 仍然监听 9999

5.2 启动日志回显

实测启动输出:

21:35:45 [server] [permissions] Checking necessary directory permissions...
21:35:45 [server] [permissions] Checking permissions for the path '/opt/data/quzuo/.trae/tmp/handbrake-web/bare/data'.
21:35:45 [server] [permissions] Finished checking permissions across 2 paths with 0 errors and 0 warnings.
21:35:45 [server] [config] [migration] The config file's schema (v2) is up to date.
21:35:45 [server] [config] The config file at '/opt/data/quzuo/.trae/tmp/handbrake-web/bare/data/config.yaml' has been loaded.
21:35:45 [server] [presets] Default presets have been loaded from '/opt/data/quzuo/.trae/tmp/handbrake-web/preset_builtin.json'.
21:35:45 [server] [presets] 2 presets have been loaded from '/opt/data/quzuo/.trae/tmp/handbrake-web/bare/data/presets'.
21:35:45 [server] [database] [migration] Running 0 migrations...
21:35:45 [server] [database] [migration] All migrations have completed.
21:35:45 [server] [database] The database is ready!
21:35:45 [server] [queue] Existing queue status 'Stopped' retreived from the database.
21:35:45 [server] Available at 'http://localhost:9999'.
21:35:45 [server] [version] Checking if there is an application update...
21:35:46 [server] [version] The application is up to date.

日志关键字含义:

  • permissions ... 0 errors and 0 warnings:DATA_PATH 与 VIDEO_PATH 都可读写,权限正确;
  • database is ready:better-sqlite3 原生模块加载成功,SQLite 已建表;
  • Available at:HTTP 与 Socket.IO 已监听 9999;
  • [version] 只有 NODE_ENV=production 才会出现,development 下会静默跳过。

5.3 前端静态资源的正确落地方式

服务端代码把静态目录写死为 /handbrake-web/client,且只在 NODE_ENV=production 时启用。裸机上有两种做法:

做法 A:让写死路径成立(适合单机)

sudo mkdir -p /handbrake-web
sudo ln -s /opt/handbrake-web/app/client/build /handbrake-web/client
ls -l /handbrake-web/client/index.html

做法 B:Nginx 托管前端(推荐,见第八章)

不做符号链接,server 只负责 Socket.IO 与 /logs/jobs,页面由 Nginx 从 client/build 读取。

如果不做 A 也不做 B,直接访问 http://<IP>:9999/,实测会得到 404,server 日志里是:

NotFoundError: Not Found
    at createHttpError (/opt/handbrake-web/app/server/build/server.js:39402:16)
    at SendStream.error2 [as error] (.../server.js:39040:35)
    at SendStream.pipe (.../server.js:39196:18)
    at sendfile (.../server.js:39913:12)
    at ServerResponse.sendFile (.../server.js:39657:7)
    at .../server.js:61779:9

原因不是权限,也不是端口,而是 NODE_ENV 未设为 production 时走的是 server/build/../html/development/index.html 这个不存在的开发页路径。这是 0.8.1 在裸机部署下最容易误判为"安装失败"的地方。

5.4 验证页面可访问

构建产物本身是自包含的静态站点。实测用临时静态监听器校验 client/build 可正常返回(生产环境由 Nginx 提供同一份文件):

$ curl -sSI http://127.0.0.1:8088/
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.14.4
Date: Tue, 27 Oct 2026 13:45:23 GMT
Content-type: text/html
Content-Length: 400
Last-Modified: Tue, 27 Oct 2026 13:25:37 GMT

$ curl -s http://127.0.0.1:8088/ | head -10
<!DOCTYPE html>
<html lang="en" data-theme="dark">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>HandBrake Web</title>
		<script type="module" crossorigin src="/assets/index-KDtZ52pI.js"></script>
		<link rel="stylesheet" crossorigin href="/assets/index-DzUjQMTP.css">
	</head>
	<body>
		<div id="root"></div>
	</body>
</html>

Content-Length: 400 与 index.html 大小一致,说明是构建产物而不是目录列表。

六、启动 worker

6.1 环境变量

cd /opt/handbrake-web/app/worker
sudo -u handbrake env \
  WORKER_ID=worker-01 \
  SERVER_URL=http://192.168.1.20 \
  SERVER_PORT=9999 \
  DATA_PATH=/opt/handbrake-web/worker-data \
  VIDEO_PATH=/srv/handbrake-web/video \
  node build/worker.js
变量必填作用
WORKER_ID必填worker 唯一身份,写进 database 的 worker_id 字段;缺失时进程直接退出
SERVER_URL必填server 地址,可带或不带 http://;带 https:// 时走 TLS
SERVER_PORT必填server 端口,必须是 9999(或反向代理暴露的端口)
DATA_PATH建议worker 本地数据目录,默认 /tmp/handbrake-web;任务日志写在其下 log/
VIDEO_PATH建议媒体目录,只用于启动权限检查,必须与 server 侧一致

⚠️ 注意:SERVER_URL 写成 http://host 时 final 地址是 http://host:9999/worker;若 SERVER_URL 里已带端口(如 http://host:8128),代码仍会再拼一个 :9999,得到 http://host:8128:9999/worker 这种非法地址并连接失败。端口只能放在 SERVER_PORT。

6.2 启动日志回显

实测输出:

21:36:04 [worker] The data directory '/opt/data/quzuo/.trae/tmp/handbrake-web/bare/worker-data' is not a child of '/tmp' and already exists.
21:36:04 [worker] [permissions] Checking necessary directory permissions...
21:36:04 [worker] [permissions] Finished checking permissions across 2 paths with 0 errors and 0 warnings.
21:36:04 [worker-01] [properties] HandBrake Web Application Version = 0.8.1
21:36:04 [worker-01] [properties] HandBrake Version = 1.11.0
21:36:04 [worker-01] [properties] Capability 'cpu' support = true
21:36:04 [worker-01] [properties] Capability 'qsv' support = false
21:36:04 [worker-01] [properties] Capability 'nvenc' support = false
21:36:04 [worker-01] [properties] Capability 'vcn' support = false
21:36:04 [worker-01] The worker process has started.
21:36:04 [worker-01] [socket] Connected to the server 'http://127.0.0.1:9999/worker' with id 'UdrZ-E5H-b07ha-pAAAB'.
21:36:04 [worker-01] [socket] The server is requesting this worker's properties...
21:36:04 [worker-01] [socket] The server is requesting the status of this worker...
21:36:04 [worker-01] [socket] This worker is currently not busy with a job - reporting to the server.

对应的 server 侧注册日志:

21:36:04 [server] [socket] Worker 'worker-01' has connected with ID 'UdrZ-E5H-b07ha-pAAAB'.
21:36:04 [server] [socket] Getting worker 'worker-01' properties...
21:36:04 [server] [socket] Worker properties = {
  "version": { "handbrake": "1.11.0", "application": "0.8.1" },
  "capabilities": { "cpu": true, "qsv": false, "nvenc": false, "vcn": false }
}
21:36:04 [server] [socket] Checking worker 'worker-01' for an existing job in progress...
21:36:04 [server] [socket] Worker 'worker-01' is not busy with an existing job.

两端日志都能看到同一个 worker-01 与同一连接 ID,说明注册成功、握手完成。

6.3 多 worker

同一台机器不建议跑多个 worker(编码会把 CPU 打满)。跨机扩容时,在第二台机器重复第六.1 节,只改 WORKER_ID,其余 VIDEO_PATH 必须指向同一份共享存储的同一路径。server 不校验 WORKER_ID 是否重复:两个 worker 用同一个 ID 会同时注册成功,但队列里 worker_id 字段只能记一个,调度会把任务派给其中一个,表现为"任务重复/状态错乱"。因此 ID 必须自行保证唯一。

七、systemd 托管

7.1 server 单元

# /etc/systemd/system/handbrake-server.service
[Unit]
Description=HandBrake Web 0.8.1 server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=handbrake
Group=handbrake
WorkingDirectory=/opt/handbrake-web/app/server
Environment=DATA_PATH=/opt/handbrake-web/data
Environment=VIDEO_PATH=/srv/handbrake-web/video
Environment=DEFAULT_PRESETS_PATH=/usr/share/handbrake/preset_builtin.json
Environment=NODE_ENV=production
Environment=SERVER_URL=192.168.1.20
ExecStart=/usr/bin/node build/server.js
Restart=always
RestartSec=5
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

7.2 worker 单元

# /etc/systemd/system/handbrake-worker.service
[Unit]
Description=HandBrake Web 0.8.1 worker
After=network-online.target handbrake-server.service
Wants=network-online.target

[Service]
Type=simple
User=handbrake
Group=handbrake
SupplementaryGroups=video
WorkingDirectory=/opt/handbrake-web/app/worker
Environment=WORKER_ID=worker-01
Environment=SERVER_URL=http://192.168.1.20
Environment=SERVER_PORT=9999
Environment=DATA_PATH=/opt/handbrake-web/worker-data
Environment=VIDEO_PATH=/srv/handbrake-web/video
ExecStart=/usr/bin/node build/worker.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

⚠️ 注意 Restart=always 而不是 on-failure:实测缺少 WORKER_ID 时 worker 打印错误后调用的是 process.exit(0),退出码是 0。用 on-failure 会永久停在 inactive 状态,且 systemctl status 显示 status=0/SUCCESS,非常容易误判为"启动成功但没连接"。

7.3 启用与检查

sudo systemctl daemon-reload
sudo systemctl enable --now handbrake-server.service
sudo systemctl enable --now handbrake-worker.service
systemctl status handbrake-server.service --no-pager
systemctl status handbrake-worker.service --no-pager

systemctl status 每行只查一个单元,便于定位是 server 还是 worker 起不来。

八、Nginx 反向代理

8.1 站点配置

# /etc/nginx/sites-available/handbrake-web
server {
    listen 8128;
    server_name handbrake.example.com;

    # 前端静态文件(构建产物)
    root /opt/handbrake-web/app/client/build;
    index index.html;

    # 基础认证:0.8.1 无登录体系,必须在外层加
    auth_basic "HandBrake Web";
    auth_basic_user_file /etc/nginx/.htpasswd-handbrake;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # Socket.IO 长连接(/client 与 /worker 两个命名空间共用)
    location /socket.io/ {
        proxy_pass http://127.0.0.1:9999;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }

    # 任务日志下载
    location /logs/ {
        proxy_pass http://127.0.0.1:9999;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

生成口令文件:

sudo apt-get install -y apache2-utils
sudo htpasswd -cb /etc/nginx/.htpasswd-handbrake root q1w2e3r4
sudo ln -s /etc/nginx/sites-available/handbrake-web /etc/nginx/sites-enabled/handbrake-web
sudo nginx -t
sudo systemctl reload nginx

8.2 关键点

  • 必须转发 Upgrade 与 Connection 头,否则 Socket.IO 从 WebSocket 降级为长轮询,队列进度刷新会明显延迟。
  • proxy_read_timeout 默认 60 秒,而 Socket.IO 心跳间隔较短但长时间无任务时连接会空闲;调到 3600 秒可避免夜间空闲断连。
  • 前端由 Nginx 托管后,server 的 NODE_ENV 可以保持 development,访问 / 的 404 不再影响使用,因为请求根本不会到 server。
  • auth_basic 只保护 HTTP。worker 是直连 9999 的后台进程,不走 Nginx,因此不需要账号;但要确保 9999 只监听内网,不要暴露到公网。

九、端到端验证

9.1 造一个测试视频

ffmpeg -f lavfi -i testsrc=size=640x360:rate=24 -f lavfi -i sine=frequency=440 \
  -t 60 -c:v libx264 -c:a aac /srv/handbrake-web/video/input/sample.mp4

实测使用的是同规格的 60 秒 640×360 样片,文件大小 5.3MB、码率 733kb/s。

9.2 通过 Socket.IO 提交任务

页面操作等价于 /client 命名空间的 add-job 事件。自动化脚本如下(queue-test.js):

const { io } = require('socket.io-client');
const s = io('http://127.0.0.1:9999/client');
const input = '/srv/handbrake-web/video/input/sample.mp4';
const output = '/srv/handbrake-web/video/output/sample-out.mkv';
let started = false;

s.on('connect', () => console.log('client connected', s.id));
s.on('queue-update', (q) => {
  console.log('queue-update:', JSON.stringify(q.map(j => ({
    id: j.job_id, stage: j.transcode_stage, pct: j.transcode_percentage,
    fps: j.transcode_fps_average, worker: j.worker_id
  }))));
  if (q.length && !started) { started = true; s.emit('start-queue'); }
  if (q.length && q[0].transcode_stage === 3) { process.exit(0); }
});

setTimeout(() => {
  s.emit('add-job', {
    input_path: input,
    output_path: output,
    preset_category: 'Default: General',
    preset_id: 'Very Fast 720p30'
  }, () => console.log('add-job ack'));
}, 1500);

任务需要手动点"Start"(或脚本发 start-queue)才会被调度,这与页面上"队列默认停止"的行为一致。

9.3 服务端实录

21:37:09 [server] [socket] Client '_mpqHiYEAhkURdb9AAAF' has connected.
21:37:10 [server] [socket] Client '_mpqHiYEAhkURdb9AAAF' has requested to add a job for '.../video/input/sample.mp4' to the queue.
21:37:10 [server] [database] Inserted a new job with id '1' into the database at order index '1'.
21:37:10 [server] [queue] There are 1 available jobs and 1 eligible for 'worker-01'.
21:37:10 [server] [queue] Assigning worker with ID 'worker-01' to job with id '1'.
21:37:10 [server] [database] Sucessfully updated the status of 'queue' to 'Active'.
21:37:10 [server] [database] Status for job with id '1' has changed from 'Waiting' to 'Scanning'.
21:37:10 [server] [queue] Worker 'worker-01' has started work on job '1'.
21:37:11 [server] [database] Status for job with id '1' has changed from 'Scanning' to 'Transcoding'.
21:37:15 [server] [database] Status for job with id '1' has changed from 'Transcoding' to 'Finished'.
21:37:15 [server] [queue] There are no active workers, setting queue to 'Idle'.
21:37:15 [server] [log] Log file from worker 'worker-01' has been written to '.../data/log/worker-01-job-1.log'.

状态序列 Waiting → Scanning → Transcoding → Finished 与源码枚举一致(TranscodeStage 中 Finished 的值为 3)。

9.4 worker 转码实录

21:37:10 [worker-01] [socket] Request to transcode queue entry '1'.
21:37:10 [worker-01] [worker] Sucessfully wrote preset to file.
21:37:11 [worker-01] [transcode] Opening .../video/input/sample.mp4...
21:37:11 [worker-01] [transcode] + Using preset: Very Fast 720p30
21:37:11 [worker-01] [transcode] [processing] 9.03 %
21:37:12 [worker-01] [transcode] [processing] 45.07 %
21:37:15 [worker-01] [transcode] [processing] 99.93 %
21:37:15 [worker-01] [transcode] Encode done!
21:37:15 [worker-01] [transcode] Renaming the file 'sample-out.transcoding.mkv' to 'sample-out.mkv'.
21:37:15 [worker-01] [transcode] Removed the preset file 'preset.json'.
21:37:15 [worker-01] [transcode] [finished] 100.00%
21:37:15 [worker-01] [log] Sending the log 'worker-01-job-1.log' to the server.

转码期间 worker 先把输出写成 sample-out.transcoding.mkv,成功后删掉旧文件并重命名为最终文件名,避免半成品被播放器扫描。预设被写到 /tmp/preset.json(路径写死),再以 --preset-import-file 传给 HandBrakeCLI。

9.5 核对产物与数据

ls -lh /srv/handbrake-web/video/output/sample-out.mkv
ls -lh /opt/handbrake-web/data/
ls -lh /opt/handbrake-web/data/log/

实测产物:sample-out.mkv 4.6MB;数据目录含 config.yaml 225B、handbrake.db 56KB、handbrake.db-wal 226KB、handbrake.db-shm 32KB;日志目录含 server-2026-10-27.log 与 worker-01-job-1.log。

⚠️ 备份注意:数据库使用 WAL 模式,直接 cp handbrake.db 会丢掉 -wal 里尚未 checkpoint 的事务。停机备份要连 handbrake.db-wal、handbrake.db-shm 一起复制,或在运行时执行 sqlite3 handbrake.db 'PRAGMA wal_checkpoint(full);' 后再复制。

十、故障排查

10.1 常见问题速查

现象根因处理
访问 / 返回 404,日志有 NotFoundError: Not FoundNODE_ENV 不是 production,开发页路径不存在设 NODE_ENV=production 并把前端放到 /handbrake-web/client,或用 Nginx 托管
页面能开但文件浏览器一片空白config.yaml 的 paths.media-path 还是 /video改成宿主机绝对路径并重启
worker 启动即退出,status=0/SUCCESS缺 WORKER_ID,代码走 process.exit(0)补 WORKER_ID,并把 Restart 设为 always
worker 报 Could not get the worker's infoHandBrakeCLI 不在 PATH,或动态库冲突which HandBrakeCLI;清掉污染的 LD_LIBRARY_PATH
报 version 'GLIBCXX_3.4.xx' not found进程继承了错误的 LD_LIBRARY_PATH启动单元里不要注入第三方 LD_LIBRARY_PATH
EADDRINUSE: address already in use :::99999999 被占用或残留进程未退ss -ltnp | grep 9999,停掉旧进程
database is locked同一 handbrake.db 被两个 server 进程打开同一数据目录只允许一个 server
worker 连不上 serverSERVER_URL 里混入了端口,或防火墙拦截端口只写 SERVER_PORT;nc -vz server 9999

10.2 实测复现的两个真实报错

缺 WORKER_ID:

21:45:24 [undefined] No 'WORKER_ID' envrionment variable is set - this worker will not be set up.
$ echo $?
0

注意文案里的 envrionment 是源码拼写错误;日志后半句是"请通过部署配置的环境变量设置"的提示,与本篇的启动方式一致,可忽略其措辞。关键是退出码 0。

LD_LIBRARY_PATH 污染导致能力探测崩溃:

HandBrakeCLI: /root/.trae-cn-server/bin/.../libstdc++.so.6: version `GLIBCXX_3.4.29' not found (required by /usr/lib/x86_64-linux-gnu/libjxl.so.0.11)
HandBrakeCLI: /root/.trae-cn-server/bin/.../libgcc_s.so.1: version `GCC_13.0.0' not found (required by /usr/lib/x86_64-linux-gnu/libhwy.so.1)
[capabilities] [error] Could not get the worker's info.

这是实验机被 IDE 进程注入 LD_LIBRARY_PATH 导致,生产机的 systemd 单元默认不继承该变量,不会出现。若确实需要清理,用一层包装脚本:

#!/bin/sh
export LD_LIBRARY_PATH=
exec /usr/bin/HandBrakeCLI "$@"

把该脚本放到 PATH 靠前的位置并命名 HandBrakeCLI,worker 调用时就会先清空变量。

10.3 升级注意事项

升级到新版本时不要只替换 build/:

  1. git fetch --tags && git checkout <新标签>;
  2. pnpm install(重建原生模块);
  3. 依次执行 pnpm build:shared/server/worker/client;
  4. 重启 server 与全部 worker;
  5. 首次启动会自动跑数据库与 config.yaml 的迁移(日志中的 Running N migrations...)。

数据库迁移是单向的,降级版本前必须先备份 /opt/handbrake-web/data。

十一、参考