luaws 的 omarchy qBittorrent 状态栏插件:WebUI 监控与远程控制实现拆解

本文是 luaws 对自己在 omarchy 下写的 qBittorrent 状态栏插件(插件 id:luaws.qbittorrent)的完整实现笔记:从 manifest 声明、QML 界面骨架,到一组 shell/python 脚本如何透过 qBittorrent WebUI API 完成「监控 + 控制」,以及这套插件如何注册进 omarchy 状态栏并热重载。所有真实凭据与内网地址均已脱敏,仅供技术交流。

一、这个插件做了什么

一句话概括:在 omarchy 顶栏放一个 qBittorrent 按钮,点击弹出一个下拉控制面板,让用户不打开浏览器 WebUI 就能看到下载状态、并能直接遥控 qBittorrent。

面板能力清单:

  • 实时速度:下载 / 上传速度与累计流量(来自 WebUI transfer/info);
  • 速度趋势图:把每秒采样到的下载速率缓存成一个滑动窗口,用 QML Canvas 画折线;
  • 磁盘剩余:下载目录所在盘剩余空间,低于阈值(默认 20GB)时整条状态栏告警;
  • 种子汇总:下载中 / 做种 / 活动 / 停滞 / 错误 五类计数;
  • 活跃种子 Top:只列「有真实流量」的种子(速度 > 0 且状态可下载/上传),按速率排序取前 5;
  • 分类统计:按 qBittorrent 分类聚合种子数量;
  • 控制动作:一键开始全部 / 停止全部任务、切换全局限速(备选限速模式)、打开 WebUI、刷新;
  • 添加任务:扫描本机 ~/Downloads 下的 .torrent 文件,显示是否已加入 qBittorrent,可一键上传;也支持系统文件选择器(zenity/kdialog)或手动输入路径;
  • 删除本地文件:删掉本机多余的 .torrent 文件(不影响 qBittorrent 任务),带严格白名单保护;
  • 异常通知:检测到 error 状态种子时,弹桌面通知(带去抖,恢复后自动解除)。

所有数据每 3 秒(可配)自动刷新一次;打开面板时立即强制刷新一次。

二、目录结构

插件放在 omarchy 的用户插件目录(无需 root、不碰系统目录):

~/.config/omarchy/plugins/luaws.qbittorrent/
├── manifest.json            # 插件声明:id/入口/可配置项 schema
├── QbittorrentWidget.qml    # 状态栏按钮 + 下拉面板(约 800 行 QML)
├── qbt-status.sh            # 主取数脚本:速度/磁盘/汇总/分类/Top/限速态
├── qbt-all.sh               # 控制:开始 / 停止全部任务
├── qbt-toggle.sh            # 控制:切换全局限速(备选限速模式)
├── qbt-add-file.sh          # 控制:上传 .torrent 添加任务 + --scan 扫描本机种子
├── qbt-del-local.sh         # 控制:删除本地 .torrent(不动 qBittorrent 任务)
├── infohash.py              # 纯 Python 的 bencode 解码器 → 计算 torrent 的 v1 infohash
├── parse_qbt.py             # 解析 qBittorrent JSON → 汇总/分类/Top 管道行
├── qbt-key                  # ★ 只读凭据文件(本机 600 权限,见“鉴权”节)
└── assets/
    ├── qbittorrent.svg      # 状态栏/面板用的官方风格图标
    └── qbittorrent-64.png   # 备用位图

分工很清晰:QML 只负责界面与调度,所有网络/解析都下沉到脚本。QML 通过 Quickshell.Io.Processbash 子进程调脚本,脚本把结果写成「管道分隔的行协议」,QML 用 SplitParser 逐行解析。这样 QML 里几乎看不到 HTTP 细节,脚本也可以脱离界面独立调试。

一个值得提醒的细节:目录里还留着若干 *.bak.* 历史备份(含 qbt-key.bak.*)。发布/备份本插件时请务必排除 qbt-key* 及其备份——凭据文件一旦进了 Git 或网盘就再也无法“撤回”了。

三、manifest.json:插件的身份证

omarchy 插件是一个带 manifest.json 的目录,kinds 声明它是哪种插件、entryPoints 指向真正的 QML 入口:

{
  "schemaVersion": 1,
  "id": "luaws.qbittorrent",
  "name": "qBittorrent",
  "version": "1.0.0",
  "author": "luaws",
  "kinds": ["bar-widget"],
  "entryPoints": { "barWidget": "QbittorrentWidget.qml" },
  "barWidget": {
    "displayName": "qBittorrent",
    "category": "Apps",
    "defaultSection": "right",
    "allowMultiple": false,
    "defaults": {
      "url": "http://192.168.XX.XX:8080",
      "refreshInterval": 3000,
      "diskWarnThreshold": 20,
      "speedHistoryCap": 60
    },
    "schema": [
      { "key": "url",            "type": "string",  "label": "qBittorrent WebUI 地址" },
      { "key": "refreshInterval", "type": "integer", "min": 1000, "max": 60000, "defaultValue": 3000, "label": "刷新间隔(毫秒)" },
      { "key": "diskWarnThreshold","type": "number", "min": 0, "max": 500, "defaultValue": 20, "label": "磁盘剩余告警阈值(GB)" },
      { "key": "speedHistoryCap", "type": "integer", "min": 10, "max": 120, "defaultValue": 60, "label": "速度趋势点数" }
    ]
  }
}

要点:

  • kinds: ["bar-widget"] + entryPoints.barWidget 告诉 omarchy 这是状态栏组件;QML 根类型必须继承框架提供的 Panel
  • barWidget.defaultsschema 一起构成了可视化配置表单:用户可在 omarchy 设置界面改 URL、刷新间隔等,QML 里用 setting("url", <默认值>) 读回;
  • defaultSection: "right"allowMultiple: false 控制默认摆放位置与是否允许重复实例;
  • 描述字段里写明了 WebUI 内网地址,供状态栏管理界面展示(本文档中已脱敏为 192.168.XX.XX)。

四、QML 骨架:按钮 + 下拉面板

QML 顶部引入 QtQuick / Quickshell 与 omarchy 自带的 qs.Commonsqs.Ui 两个模块(后者提供 PanelBarIconButtonKeyboardPanelPanelHeroInfoRow 风格族等组件)。

4.1 根类型与配置

Panel {
  id: root
  moduleName: "luaws.qbittorrent"
  ipcTarget: "luaws.qbittorrent"

  readonly property string url: setting("url", "http://192.168.XX.XX:8080")
  readonly property int refreshInterval: Math.max(1000, Number(setting("refreshInterval", 3000)))
  readonly property double diskWarnThreshold: Number(setting("diskWarnThreshold", 20))
  readonly property int speedHistoryCap: Math.max(10, Number(setting("speedHistoryCap", 60)))
  readonly property string scriptDir: "~/.config/omarchy/plugins/luaws.qbittorrent"
  ...
}

QML 侧的状态是一组普通 propertyconnStatusdlSpeed/ulSpeedfreeSpaceText、五类计数、categories / topTorrents / rateHistory 三个数组模型、限速开关 speedLimitsOn 等,再由它们派生出 onlinediskLowhasErrorsalarming 等只读判定,直接驱动按钮颜色、tooltip 和告警圈。

4.2 定时刷新:Timer + Process + SplitParser

核心取数循环只有十来行:

Timer {
  interval: root.refreshInterval          // 默认 3000ms
  running: true
  repeat: true
  triggeredOnStart: true
  onTriggered: root.refreshNow()
}

Process {
  id: statusProc
  running: false
  command: ["/bin/sh", "-c", "cd " + root.scriptDir + " && bash qbt-status.sh"]
  stdout: SplitParser { onRead: data => root.parseLine(data) }
}

Process.running = true 即启动子进程;脚本的 stdout 被 SplitParser 按行切好,每来一行就回调 parseLine()。这套「QML 定时器 → 子进程 → 行协议 → 属性更新」是 omarchy 数据型组件的通用套路,本插件的所有按钮动作(限速、批量开始/停止、上传、删除)也都是同一个模式,只是脚本不同。

4.3 刷新去重与防闪烁

高频刷新最容易踩两个坑:上一轮还没跑完又来一轮每次刷新 Repeater 重建导致闪烁。插件的解法:

function refreshNow() {
  if (root.refreshing) { root.pendingRefresh = true; return }   // 合并重叠请求
  root.refreshing = true
  statusProc.running = true
}
  • refreshing 为真时后续请求只置 pendingRefresh,待进程 onExited 后再补跑一轮;
  • categories / topTorrents 先写进临时缓冲 _catBuf/_topBuf,全部行收完后用 sameList()(长度 + JSON.stringify 比对)判断内容是否真的变了,变了才赋值,避免无谓的 Repeater 重建;
  • _parseOk(收到 sum 行才置真)作为“本轮解析成功”的门闩:只有解析成功才允许用新结果覆盖旧值,脚本超时/失败时旧画面不闪不空。

4.4 面板结构

下拉面板是 KeyboardPanel + PanelKeyCatcher:支持键盘(o 打开 WebUI、Tab 切换面板、Esc 关闭),内容是一个 ScrollViewColumn,从上到下依次是:

Hero(qBittorrent · 在线/离线)
├── Transfer        实时 ↓/↑ 速率 + 累计流量
├── Canvas          下载速率趋势折线(自绘)
├── Disk            磁盘剩余(低时红色告警条)
├── Active          有真实流量的活跃种子 Top5
├── Torrents        下载/做种/活动/停滞/错误 五格计数卡
├── Categories      分类 → 数量
├── (告警 BorderSurface:磁盘低 / 错误种子)
└── Actions         开始全部 / 停止所有 / 打开 Web / 限速开关
                    / 上传种子文件(可折叠) / 刷新

Canvas 趋势图值得一提:rateHistory[{t, dl, ul}] 的滑动窗口(默认 60 点,超长掐头),每次属性变化 requestPaint()onPaint 里先找窗口内最大值做自适应纵轴,再画横向网格线 + 折线 + 末端圆点,全程不依赖第三方图表库。

状态栏按钮侧,BarIconButtoniconComponent 做了三层叠加:常态显示 qBittorrent SVG;有下载任务时换成绿色下载箭头字形;磁盘低或离线时套一圈呼吸红圈NumberAnimation 循环透明度),配合 active: opened || alarming 让按钮整体进入警示态。tooltip 直接绑定实时数据,悬停即看摘要。

4.5 控制动作的“后置刷新”

按钮触发控制类脚本(如限速切换)后,状态是异步变化的,所以插件普遍用延迟刷新

function toggleSpeedLimits() {
  if (root.bar) root.bar.run("bash -c 'cd " + root.scriptDir + " && bash qbt-toggle.sh'")
  root.speedLimitRefreshTimer.restart()      // 1.2s 后 refreshNow()
}

即“发完命令,稍等再拉一次全量状态”,避免控制请求与下一次状态轮询打架。

五、鉴权方式:只读 key 文件 + Bearer Token

这部分是安全设计的重点,也是本插件最有意思的演进点。

qBittorrent WebUI 从 v5 起不再依赖「用户名/密码 + cookie 会话」,而是支持长期 Bearer API Key:用户在 WebUI 里生成一个 key,所有 API 请求带 Authorization: Bearer <key> 即可,脚本里连 cookie 管理都省了。

插件把 key 单独放在插件目录的 qbt-key 文件里(本机权限 600,仅属主可读),脚本启动时读取:

# 取数脚本(qbt-status.sh)的鉴权片段
KEY=""
if [[ -f "qbt-key" ]]; then read -r KEY < qbt-key; fi   # 从只读 key 文件读取
[[ -z "$KEY" ]] && KEY="$QBT_KEY"                        # 允许用环境变量覆盖

QBT="$QBT_URL/api/v2"
GB() { curl -s --max-time 5 -H "Authorization: Bearer $KEY" --connect-timeout 4 "$@"; }

要点:

  • key 不进 QML、不进 manifest、不进脚本正文,全部集中在一个文件里,便于单独保护与轮换;环境变量 QBT_KEY 作为备选注入通道;
  • 目录里其它敏感源文件(manifest、QML)也是 600,shell 脚本对外只保留 --xrwx--x--x),最大限度压缩可读面;
  • 拿不到 key 时优雅降级qbt-status.sh 直接输出一排占位行(conn|offline、速度/磁盘为 --)后退出,QML 显示“离线”而不是报错;控制类脚本则输出 xxx|error|缺少凭据
  • WebUI 地址可用环境变量 QBT_URL 覆盖,默认值取自 manifest,两者都会经 QML 拼接进脚本命令。

本文绝不展示 qbt-key 内容——它就是一行真实 token,公开文章里只应出现 ********。脚本头注释里还残留着旧版「第一行用户名、第二行密码 + /tmp cookie」的说明,属于演进过程的遗留,实际代码已完全走 Bearer 分支。

六、各脚本职责与 WebUI API 端点

全部请求都打在 qBittorrent WebUI API v2 上,前缀 $QBT_URL/api/v2。汇总如下:

脚本 职责 用的 API 端点 方法
qbt-status.sh 主取数 transfer/infosync/maindata?rid=0 GET
qbt-all.sh 开始/停止全部 torrents/starttorrents/stophashes=all POST
qbt-toggle.sh 全局限速开关 sync/maindata?rid=0 + torrents/setSpeedLimitsMode GET/POST
qbt-add-file.sh 上传/扫描种子 torrents/info(去重)、torrents/add(multipart) GET/POST
qbt-del-local.sh 删本地种子文件 无(纯本地文件操作)
infohash.py / parse_qbt.py 哈希计算 / JSON 解析 无(本地计算)

6.1 qbt-status.sh —— 主取数,两次请求搞定一切

这是最核心的脚本。设计目标是减少 HTTP 请求与子进程开销:每轮只发两次请求。

  1. GET transfer/info —— 小而快,取四个字段:dl_info_speedup_info_speeddl_info_dataup_info_data(后两者是累计字节数);
  2. GET sync/maindata?rid=0 —— 一次大请求,返回服务器状态(磁盘剩余、限速开关)和全量种子字典 torrents: {hash: {name,state,progress,dlspeed,upspeed,category,...}}

也就是说,种子的状态统计不再单独请求 torrents/info,直接从 maindata 里解析,每轮省掉一次大请求;旧版单独的 app/version 健康检查也删了——登录请求成功本身就等价于“在线”。

取数用 curl + grep -oE '"dl_info_speed":[0-9]+' | cut -d: -f2 这种轻量正则,避免对整包 JSON 反复跑 python(子进程很贵);只有解析种子字典这一处才把原始 JSON 管道给 parse_qbt.py

最后按「管道行协议」输出:

conn|online
global|↓速率|↑速率|累计下载|累计上传      # 数字已用 awk 人性化:KB/MB/GB
disk|<人类可读剩余>|<GB数值>              # free_space_on_disk<=0 时输出 --
limit|true|false                          # use_alt_speed_limits
sum|<下载中>|<做种>|<活动>|<停滞>|<错误>
cat|<分类名>|<数量>                       # 每分类一行
top|<名称>|<进度>|<速率>|<状态>           # 活跃种子 Top5
rate|<rawDL>|<rawUL>                      # 原始字节数,供 QML 存趋势

6.2 parse_qbt.py —— 纯文本 JSON 解析器

从 stdin 读 JSON,兼容两种输入sync/maindata{torrents: {...}} 字典结构,以及旧格式 torrents/info 的数组,先归一化成统一的种子列表,再做三段输出:

  • 汇总state_grp() 把 qBittorrent 的细粒度状态(downloading/forceddl/uploading/stalledUP/error/missingFiles/checking/queued...)归并成 dl/seed/act/stall/err/queued/check/other 八类;下载中 = dl + queued + check
  • 分类:按 category 计数后降序输出;
  • Top:只保留“有真实流量”的种子——过滤掉 paused/stopped/error/stalled(非 stalleddl)/unknown,要求 max(dlspeed, upspeed) > 0,按速率排序取前 5,名称截断到 26 字符,并给出中文状态标签(下载/上传/校验/排队/元数据/停滞DL)。
for r in rows[:5]:
    print('top|' + r[1] + '|%.0f%%'%(r[2]*100) + '|' + r[3] + '|' + r[4])

6.3 qbt-all.sh —— 批量开始/停止

code=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $KEY" -d "hashes=all" "$QBT/torrents/stop")
# 200 → all|stop-ok ;否则 all|error|HTTP xxx

qBittorrent 约定 hashes=all 即“全部任务”,torrents/start / torrents/stop 在 v5 上直接可用。脚本只关心 HTTP 状态码,QML 据此把“操作失败 / 已开始全部 / 已停止全部”显示在 Actions 区顶部,并延时 1.2s 后刷新状态。

6.4 qbt-toggle.sh —— 全局限速切换

qBittorrent 的“备选限速”(Alt Speed Limits)对应 server_state.use_alt_speed_limits。脚本流程是读 → 翻转 → 写 → 读回

MAIN0=$(curl -s -H "$AUTH" "$QBT/sync/maindata?rid=0")        # 1) 读当前开关
NOW=$(echo "$MAIN0" | grep -oE '"use_alt_speed_limits":(true|false)' | cut -d: -f2)
[[ "$NOW" != "true" ]] && NEWMODE=1 || NEWMODE=0

curl -s -H "$AUTH" -X POST "$QBT/torrents/setSpeedLimitsMode" -d "mode=$NEWMODE"  # 2) 写

MAIN=$(curl -s -H "$AUTH" "$QBT/sync/maindata?rid=0")         # 3) 读回做最终确认
echo "limit|${ALT:-false}"

写完之后再读一次而不是直接信任返回值,保证按钮高亮与服务器真实状态一致。注释里特意标注了这是 v5 的正确端点(旧版是 torrents/setSpeedLimitsMode 语义不同),这类“端点随版本漂移”的坑很值得记录。

6.5 qbt-add-file.sh —— 上传 .torrent 添加任务

三种用法,一个脚本:

  1. qbt-add-file.sh <path>...:直接上传指定文件;
  2. 无参数:优先 kdialog、回退 zenity 弹系统文件选择器;
  3. --scan <dir>:扫目录列出候选 .torrent,供面板内选择,不真正上传。

添加前的关键一步是去重:先 GET torrents/info 拉回当前所有任务的 infohash_v1 集合,再对每个候选文件用 infohash.py 算本地 hash,两边比对后标记 added=1/0,面板里就能画出 ✅(已添加,不再重复上传)或 ➕(未添加,可点上传)。

真正的上传是 multipart:

curl -s --max-time 30 -H "Authorization: Bearer $KEY" \
     -F "torrents=@$f" "$QBT/torrents/add"

扫描输出也走管道协议:fs|文件名|路径|infohash|added,QML 逐行解析成 localTorrents 模型喂给 Repeater。行内还兼容 fs| 前缀剥离与 \r 清理——那是面板点击传递路径时可能带入的脏数据。

6.6 infohash.py —— 手写 bencode 求 SHA1

没有用第三方库,用约 60 行 Python 实现了一个递归 bencode 解码器(支持 integer/list/dict/string 四种类型),然后info 字典按键排序重新 bencode 后取 SHA1——这正是 BitTorrent v1 infohash 的定义:

def bencode(obj):
    if isinstance(obj, int):   return b'i%de' % obj
    if isinstance(obj, bytes): return b'%d:%s' % (len(obj), obj)
    if isinstance(obj, list):  return b'l' + b''.join(bencode(x) for x in obj) + b'e'
    if isinstance(obj, dict):  # 字典键必须排序后再编码
        return b'd' + b''.join(bencode(k)+bencode(obj[k]) for k in sorted(obj)) + b'e'
    raise TypeError(type(obj))

print(hashlib.sha1(bencode(info)).hexdigest())   # 40 位小写 hex

解析失败/非 torrent 输入一律输出空串,由调用方容错。这也解释了为什么 6.5 里能拿到和 qBittorrent 一致的 hash 做去重。

6.7 qbt-del-local.sh —— 只删本地,带白名单

删除按钮只作用于本机文件,绝不碰 qBittorrent 里的任务。为了防误删加了双重校验:路径必须在 ~/Downloads/ 之下(另兼容 /home/*/Downloads/ 通配),且后缀必须是 .torrent,否则输出 del|denied

七、告警与通知:把“异常”顶到用户眼前

  • 状态栏三层告警表达:磁盘低 / 离线 → 图标呼吸红圈 + 按钮进入警示色;错误种子 → tooltip 追加“错误 N”;
  • 面板内两个 BorderSurface 告警条,用 urgent 配色 + 字形醒目提示;
  • 桌面通知带去抖notifyTimer 延迟 3 秒,确认持续异常才弹一次 omarchy-notification-sendnotifiedErr 防重复轰炸,恢复正常自动复位。

八、注册与热重载

  • 注册:插件目录放好后,在 ~/.config/omarchy/shell.jsonbar.layout.<section> 数组里按 id 引用实例即可(本插件注册在 right 区,与其它用户插件并列):
{ "bar": { "layout": { "right": [ { "id": "luaws.qbittorrent" } ] } } }
  • 生效/热重载:omarchy 对用户插件与 shell.json 改动大多支持热重载;但结构性变更(改了入口类型、manifest 结构等)建议重启 shell,验证流程一般是:
omarchy restart shell
journalctl --user -t omarchy-shell --no-pager | tail -60   # 看有无 QML 报错

排错经验:面板类插件(继承 Panel)会打印一条 IpcHandler ... will not be usedWARN,那是 Panel 基类与显式 IPC 双注册导致的正常现象,别当成错误。QML 大括号/语法错误会让整个插件静默不加载但 shell 不一定崩,务必查 journal。

  • 冒烟测试:不改代码就能验证按钮与面板,用 omarchy-shell 的 IPC 直接开关面板:
omarchy-shell shell toggle luaws.qbittorrent
  • 可视化配置:manifest 的 defaults + schema 会渲染成设置表单,用户改完的值经 setting(key, default) 注入 QML——所以“换一台 WebUI 地址 / 改刷新间隔”不需要碰任何脚本。

九、小结:这个实现值得借鉴的地方

  1. 分层清晰:QML 管 UI/调度、脚本管 HTTP/解析、Python 管算法,单测脚本 = bash qbt-status.sh | cat 直接看行协议,调试成本极低;
  2. 行协议即接口conn|/global|/sum|/cat|/top|/rate| 是 QML 与脚本间稳定的“API”,改任何一侧都不影响另一侧,也便于以后换数据源;
  3. 请求瘦身:主取数两请求/轮(transfer/info + sync/maindata),全量种子从 maindata 复用,能省则省;轻量字段用 grep/cut 而非反复起 python;
  4. 对失败的预期:无凭据、解析失败、HTTP 非 200、磁盘值非法(-1)……每条路径都有降级输出,UI 端还有 _parseOk 门闩防止半截数据顶掉好画面;
  5. 安全习惯:凭据集中在一个 600 的只读文件、可环境变量覆盖、公开文章一律脱敏;代价是若换 qBittorrent 实例或换 key,只需替换一个文件。

如果你也在 omarchy(或其它 Quickshell 系 shell)里维护状态栏插件,这套「manifest 声明 + QML Panel + 子进程取数 + 管道行协议」的骨架可以整套搬走——把 qbt 脚本换成任何 HTTP API 的查询器即可。