This Go app acts as a lightweight proxy API for qBittorrent. It logs into your qBittorrent Web UI, fetches torrent info, caches it, and exposes a simple authenticated HTTP endpoint to get torrent data in JSON format.
docker-compose.yml
services:
qbwrapper:
container_name: qbwrapper
image: ghcr.io/panonim/qbwrapper:latest
ports:
- "9911:9911"
environment:
TZ: Set/Me
USERNAME: ${QB_USERNAME}
PASSWORD: ${QB_PASSWORD}
BASE_URL: ${QB_URL}
AUTH_TOKEN: ${AUTH_TOKEN}
restart: unless-stopped
volumes:
- ./logs:/app/logs # Optional.env
QB_USERNAME=
QB_PASSWORD=
QB_URL=http://IP:PORT
AUTH_TOKEN=REPLACEMEOptional Environment Variables
RATE_LIMIT: "10" # API requests per minute (default: 10)
LOG_RETENTION_DAYS: 3 # 0 for purge-on-restart
LOG_DIR: /app/logs # Where docker should save your logs
DEBUG: true # Logs level
LISTEN_PORT: "9911" # Port on which qBW should listen internally
CACHE_DURATION: 5 # In minutes, set to 0 to disable cacheYou must provide these in a .env file or your environment:
QB_URL— base URL of your qBittorrent Web UI (e.g.,http://localhost:8080)QB_USERNAME— your qBittorrent usernameQB_PASSWORD— your qBittorrent passwordAUTH_TOKEN— This is not a qBittorrent token. It’s a bearer token used by qBWrapper to control access (like a password) to the /qb/torrents endpoint.
Click to expand
- type: custom-api
title: qBittorrent
cache: 1m
options:
hide-summary: false # Hide summary
collapsible: false # Torrents list collapse
hide-bar: false # Hide progress bars
hide-completed: false # Hide finished torrents
hide-inactive: false # Hide inactive torrents
subrequests:
info:
url: "http://${QBW_URL}/qb/torrents"
method: GET
headers:
Authorization: "Bearer ${AUTH_TOKEN}" # your QBW token
template: |
{{ $info := .Subrequest "info" }}
{{ $torrents := $info.JSON.Array "torrents" }}
{{ $hideCompleted := .Options.BoolOr "hide-completed" false }}
{{ $hideInactive := .Options.BoolOr "hide-inactive" false }}
{{ $hideBar := .Options.BoolOr "hide-bar" false }}
{{ $summary := $info.JSON }}
{{ if eq (len $torrents) 0 }}
<div>No torrents found.</div>
{{ else }}
{{ if not (.Options.BoolOr "hide-summary" false) }}
<!-- Summary -->
<div style="display:flex; align-items:center; justify-content:center; text-align:center; margin-bottom:16px; color:#ccc; font-weight:normal; font-size:1.1em;">
<div style="flex:1;">
<div style="font-size:1em; font-weight:normal;">
{{ printf "%.2f MB/s" (div (toFloat ($summary.Int "total_download_speed")) 1048576) }}
</div>
<div style="font-size:0.75em; color:#aaa; margin-top:2px;">DOWNLOAD</div>
</div>
<div style="width:2px; height:2.5rem; margin:0 1rem; border-radius:2px; align-self:center; background:linear-gradient(to bottom, transparent, rgba(255,255,255,0.1) 20%, rgba(255,255,255,0.1) 80%, transparent);"></div>
<div style="flex:1;">
<div style="font-size:1em; font-weight:normal;">{{ $summary.Int "seeding_count" }}</div>
<div style="font-size:0.75em; color:#aaa; margin-top:2px;">SEEDING</div>
</div>
<div style="width:2px; height:2.5rem; margin:0 1rem; border-radius:2px; align-self:center; background:linear-gradient(to bottom, transparent, rgba(255,255,255,0.1) 20%, rgba(255,255,255,0.1) 80%, transparent);"></div>
<div style="flex:1;">
<div style="font-size:1em; font-weight:normal;">{{ $summary.Int "leeching_count" }}</div>
<div style="font-size:0.75em; color:#aaa; margin-top:2px;">LEECHING</div>
</div>
</div>
{{ end }}
<div class="container-list-cup">
<ul class="list collapsible-container" data-collapse-after="3" style="list-style:none; margin:0; padding:0;">
{{ range $t := $torrents }}
{{ $state := $t.String "state" }}
{{ $downloaded := $t.Int "downloaded" }}
{{ $size := $t.Int "size" }}
{{ $progress := mul ($t.Float "progress") 100 }}
{{ if and $hideCompleted (ge $downloaded $size) }}{{ continue }}{{ end }}
{{ if and $hideInactive (not (or (eq $state "downloading") (eq $state "forcedDL") (eq $state "uploading") (eq $state "forcedUP"))) }}{{ continue }}{{ end }}
{{ $isCompleted := ge $downloaded $size }}
{{ $isSeeding := or (eq $state "uploading") (eq $state "forcedUP") }}
{{ $icon := "?" }}
{{ if $isCompleted }}{{ $icon = "✔" }}
{{ else if or (eq $state "downloading") (eq $state "forcedDL") }}{{ $icon = "↓" }}
{{ else if $isSeeding }}{{ $icon = "↑" }}
{{ else if or (eq $state "pausedDL") (eq $state "stoppedDL") (eq $state "pausedUP") (eq $state "stalledDL") (eq $state "stalledUP") (eq $state "queuedDL") (eq $state "queuedUP") }}{{ $icon = "❚❚" }}
{{ else if or (eq $state "error") (eq $state "missingFiles") }}{{ $icon = "!" }}
{{ else if or (eq $state "checkingDL") (eq $state "checkingUP") (eq $state "allocating") }}{{ $icon = "…" }}
{{ else if eq $state "checkingResumeData" }}{{ $icon = "⟳" }}
{{ end }}
{{ $name := $t.String "name" }}
{{ $shortName := $name }}
{{ if gt (len $name) 20 }}{{ $shortName = printf "%s..." (slice $name 0 20) }}{{ end }}
{{ $fmtDownloaded := "" }}
{{ $fmtSize := "" }}
{{ if gt $size 1073741824 }}
{{ $fmtDownloaded = printf "%.2f GB" (div (toFloat $downloaded) 1073741824) }}
{{ $fmtSize = printf "%.2f GB" (div (toFloat $size) 1073741824) }}
{{ else }}
{{ $fmtDownloaded = printf "%.2f MB" (div (toFloat $downloaded) 1048576) }}
{{ $fmtSize = printf "%.2f MB" (div (toFloat $size) 1048576) }}
{{ end }}
{{ $eta := $t.Int "eta" }}
{{ $etaStr := "" }}
{{ if gt $eta 0 }}
{{ $h := div $eta 3600 }}
{{ $m := div (mod $eta 3600) 60 }}
{{ if ge $h 2400 }}{{ $etaStr = "∞" }}
{{ else }}{{ $etaStr = printf "%dh %dm" $h $m }}{{ end }}
{{ else if eq $eta 0 }}{{ $etaStr = "0m" }}
{{ else }}{{ $etaStr = "∞" }}
{{ end }}
<li style="margin-bottom:12px;">
<div style="display:inline-block; max-width:100%;">
<h2 style="font-size: 1.2em; margin-bottom: 4px; color: #ccc; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
{{ $icon }} {{ $shortName }}
</h2>
{{ if and (not $hideBar) (not $isCompleted) (not $isSeeding) }}
<div style="height: 20px; background: #2b2b2b; border-radius: 5px; overflow: hidden; position: relative; font-size: 0.8em; color: #e0e0e0; display: flex; align-items: center; justify-content: space-between; padding: 0 5px; box-sizing: border-box;">
<div style="width: {{ printf "%.1f" $progress }}%; height: 100%; background: linear-gradient(90deg, #555555, #888888); position: absolute; top: 0; left: 0;"></div>
<div style="position: relative; z-index: 1; width: 100%; display: flex; justify-content: space-between;">
<span>{{ $fmtDownloaded }} / {{ $fmtSize }}</span>
<span>{{ $etaStr }}</span>
</div>
</div>
{{ end }}
</div>
</li>
{{ end }}
</ul>
</div>
{{ end }}
You can put this app in the same place as glance and the same .env file, but in case you are using it alone please put this in your .env.
QBW_URL— base URL of your qBittorrent Wrapper (e.g.,http://localhost:9911)AUTH_TOKEN— This is not a qBittorrent token. It’s a bearer token used by qBWrapper to control access (like a password) to the /qb/torrents endpoint.