omarchy 状态栏插件实现:qBittorrent 监控与控制
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.Process 起 bash 子进程调脚本,脚本把结果写成「管道分隔的行协议」,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.defaults与schema一起构成了可视化配置表单:用户可在 omarchy 设置界面改 URL、刷新间隔等,QML 里用setting("url", <默认值>)读回;defaultSection: "right"、allowMultiple: false控制默认摆放位置与是否允许重复实例;- 描述字段里写明了 WebUI 内网地址,供状态栏管理界面展示(本文档中已脱敏为
192.168.XX.XX)。
四、QML 骨架:按钮 + 下拉面板
QML 顶部引入 QtQuick / Quickshell 与 omarchy 自带的 qs.Commons、qs.Ui 两个模块(后者提供 Panel、BarIconButton、KeyboardPanel、PanelHero、InfoRow 风格族等组件)。
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 侧的状态是一组普通 property:connStatus、dlSpeed/ulSpeed、freeSpaceText、五类计数、categories / topTorrents / rateHistory 三个数组模型、限速开关 speedLimitsOn 等,再由它们派生出 online、diskLow、hasErrors、alarming 等只读判定,直接驱动按钮颜色、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 关闭),内容是一个 ScrollView 包 Column,从上到下依次是:
Hero(qBittorrent · 在线/离线)
├── Transfer 实时 ↓/↑ 速率 + 累计流量
├── Canvas 下载速率趋势折线(自绘)
├── Disk 磁盘剩余(低时红色告警条)
├── Active 有真实流量的活跃种子 Top5
├── Torrents 下载/做种/活动/停滞/错误 五格计数卡
├── Categories 分类 → 数量
├── (告警 BorderSurface:磁盘低 / 错误种子)
└── Actions 开始全部 / 停止所有 / 打开 Web / 限速开关
/ 上传种子文件(可折叠) / 刷新
Canvas 趋势图值得一提:rateHistory 是 [{t, dl, ul}] 的滑动窗口(默认 60 点,超长掐头),每次属性变化 requestPaint(),onPaint 里先找窗口内最大值做自适应纵轴,再画横向网格线 + 折线 + 末端圆点,全程不依赖第三方图表库。
状态栏按钮侧,BarIconButton 的 iconComponent 做了三层叠加:常态显示 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 脚本对外只保留--x(rwx--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/info、sync/maindata?rid=0 |
GET |
qbt-all.sh |
开始/停止全部 | torrents/start、torrents/stop(hashes=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 请求与子进程开销:每轮只发两次请求。
GET transfer/info—— 小而快,取四个字段:dl_info_speed、up_info_speed、dl_info_data、up_info_data(后两者是累计字节数);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 添加任务
三种用法,一个脚本:
qbt-add-file.sh <path>...:直接上传指定文件;- 无参数:优先
kdialog、回退zenity弹系统文件选择器; --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-send,notifiedErr防重复轰炸,恢复正常自动复位。
八、注册与热重载
- 注册:插件目录放好后,在
~/.config/omarchy/shell.json的bar.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 used的 WARN,那是Panel基类与显式 IPC 双注册导致的正常现象,别当成错误。QML 大括号/语法错误会让整个插件静默不加载但 shell 不一定崩,务必查 journal。
- 冒烟测试:不改代码就能验证按钮与面板,用 omarchy-shell 的 IPC 直接开关面板:
omarchy-shell shell toggle luaws.qbittorrent
- 可视化配置:manifest 的
defaults+schema会渲染成设置表单,用户改完的值经setting(key, default)注入 QML——所以“换一台 WebUI 地址 / 改刷新间隔”不需要碰任何脚本。
九、小结:这个实现值得借鉴的地方
- 分层清晰:QML 管 UI/调度、脚本管 HTTP/解析、Python 管算法,单测脚本 =
bash qbt-status.sh | cat直接看行协议,调试成本极低; - 行协议即接口:
conn|/global|/sum|/cat|/top|/rate|是 QML 与脚本间稳定的“API”,改任何一侧都不影响另一侧,也便于以后换数据源; - 请求瘦身:主取数两请求/轮(
transfer/info+sync/maindata),全量种子从 maindata 复用,能省则省;轻量字段用grep/cut而非反复起 python; - 对失败的预期:无凭据、解析失败、HTTP 非 200、磁盘值非法(-1)……每条路径都有降级输出,UI 端还有
_parseOk门闩防止半截数据顶掉好画面; - 安全习惯:凭据集中在一个
600的只读文件、可环境变量覆盖、公开文章一律脱敏;代价是若换 qBittorrent 实例或换 key,只需替换一个文件。
如果你也在 omarchy(或其它 Quickshell 系 shell)里维护状态栏插件,这套「manifest 声明 + QML Panel + 子进程取数 + 管道行协议」的骨架可以整套搬走——把 qbt 脚本换成任何 HTTP API 的查询器即可。