# Runs INSIDE guest 9202. Asks qBittorrent's own Web API and reads the container's exact byte counters. # The admin password is read from the container env at use (as the controller does) and never printed. import json, subprocess, sys, time, urllib.request, urllib.parse, http.client, socket, re, uuid def sh(*a): return subprocess.run(a, capture_output=True, text=True).stdout.strip() C = sys.argv[1] if len(sys.argv) > 1 else 'qbittorrent' IP = sh('docker', 'inspect', '-f', '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}', C).split()[0] PID = sh('docker', 'inspect', '-f', '{{.State.Pid}}', C) CID = sh('docker', 'inspect', '-f', '{{.Id}}', C) BASE = f'http://{IP}:8080' PW = sh('docker', 'exec', C, 'printenv', 'QBT_ADMIN_PASSWORD') def req(method, path, data=None, hdr=None, raw=False): h = {'Referer': BASE} h.update(hdr or {}) if COOKIE: h['Cookie'] = COOKIE r = urllib.request.Request(BASE + path, data=data, headers=h, method=method) try: with urllib.request.urlopen(r, timeout=20) as resp: b = resp.read(); return resp.status, (b if raw else (json.loads(b) if b and b[:1] in b'[{' else b.decode())) except urllib.error.HTTPError as e: return e.code, e.read().decode()[:200] COOKIE = None code, _ = None, None r = urllib.request.Request(BASE + '/api/v2/auth/login', data=urllib.parse.urlencode({'username': 'admin', 'password': PW}).encode(), headers={'Referer': BASE, 'Content-Type': 'application/x-www-form-urlencoded'}, method='POST') with urllib.request.urlopen(r, timeout=20) as resp: m = re.search(r'((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)', '; '.join(resp.headers.get_all('Set-Cookie') or [])) print('login', resp.status, 'cookie' if m else 'NO COOKIE') COOKIE = f'{m.group(1)}={m.group(2)}' def netdev(): for line in open(f'/proc/{PID}/net/dev'): if line.strip().startswith('eth0:'): f = line.split(':', 1)[1].split(); return int(f[8]) def engine_stats(): s = socket.socket(socket.AF_UNIX); s.connect('/var/run/docker.sock') s.sendall(f'GET /containers/{CID}/stats?stream=false&one-shot=true HTTP/1.0\r\n\r\n'.encode()) b = b'' while True: x = s.recv(65536) if not x: break b += x j = json.loads(b.split(b'\r\n\r\n', 1)[1]) return sum(n['tx_bytes'] for n in j.get('networks', {}).values()), j['cpu_stats']['cpu_usage']['total_usage'] def cli_stats(): return sh('docker', 'stats', '--no-stream', '--format', '{{.NetIO}}', C) mode = sys.argv[2] if len(sys.argv) > 2 else 'info' if mode == 'info': print('version', req('GET', '/api/v2/app/version')[1], 'webapi', req('GET', '/api/v2/app/webapiVersion')[1]) print('transfer/info', req('GET', '/api/v2/transfer/info')[1]) p = req('GET', '/api/v2/app/preferences')[1] print('prefs', {k: p.get(k) for k in ('up_limit', 'dl_limit', 'alt_up_limit', 'alt_dl_limit', 'scheduler_enabled', 'limit_tcp_overhead', 'limit_lan_peers', 'limit_utp_rate', 'max_uploads', 'enable_upload_suggestions')}) print('uploadLimit', req('GET', '/api/v2/transfer/uploadLimit')[1], 'speedLimitsMode', req('GET', '/api/v2/transfer/speedLimitsMode')[1]) elif mode == 'add': bnd = uuid.uuid4().hex tor = open('/root/uwtest.torrent', 'rb').read() parts = [] for k, v in (('savepath', '/mnt/felhom-drives/scratch_hdd/userdata/media/torrents'), ('skip_checking', 'true'), ('category', '')): parts.append(f'--{bnd}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()) parts.append(f'--{bnd}\r\nContent-Disposition: form-data; name="torrents"; filename="uwtest.torrent"\r\nContent-Type: application/x-bittorrent\r\n\r\n'.encode() + tor + b'\r\n') parts.append(f'--{bnd}--\r\n'.encode()) print('add', req('POST', '/api/v2/torrents/add', b''.join(parts), {'Content-Type': f'multipart/form-data; boundary={bnd}'})) time.sleep(3) print('torrents', [(t['name'], t['state'], t['progress']) for t in req('GET', '/api/v2/torrents/info')[1]]) elif mode == 'setlimit': print('setUploadLimit', req('POST', '/api/v2/transfer/setUploadLimit', urllib.parse.urlencode({'limit': sys.argv[3]}).encode(), {'Content-Type': 'application/x-www-form-urlencoded'})) print('uploadLimit now', req('GET', '/api/v2/transfer/uploadLimit')[1], 'prefs.up_limit', req('GET', '/api/v2/app/preferences')[1].get('up_limit')) elif mode == 'setpref': print('setPreferences', req('POST', '/api/v2/app/setPreferences', urllib.parse.urlencode({'json': json.dumps({'up_limit': int(sys.argv[3])})}).encode(), {'Content-Type': 'application/x-www-form-urlencoded'})) print('uploadLimit now', req('GET', '/api/v2/transfer/uploadLimit')[1], 'prefs.up_limit', req('GET', '/api/v2/app/preferences')[1].get('up_limit')) elif mode == 'peer': print('addPeers', req('POST', '/api/v2/torrents/addPeers', urllib.parse.urlencode({'hashes': 'all', 'peers': sys.argv[3]}).encode(), {'Content-Type': 'application/x-www-form-urlencoded'})) print('reannounce', req('POST', '/api/v2/torrents/reannounce', urllib.parse.urlencode({'hashes': 'all'}).encode(), {'Content-Type': 'application/x-www-form-urlencoded'})) elif mode == 'post': print(sys.argv[3], req('POST', sys.argv[3], urllib.parse.urlencode(json.loads(sys.argv[4])).encode(), {'Content-Type': 'application/x-www-form-urlencoded'})) elif mode == 'sample': dur, step = int(sys.argv[3]), int(sys.argv[4]) t0 = time.time(); prev = None while time.time() - t0 <= dur: ti = req('GET', '/api/v2/transfer/info')[1] tor = req('GET', '/api/v2/torrents/info')[1] etx, ecpu = engine_stats() row = {'t': round(time.time() - t0, 1), 'qb_up_speed': ti['up_info_speed'], 'qb_up_data': ti['up_info_data'], 'qb_tor_uploaded': sum(x.get('uploaded', 0) for x in tor), 'proc_tx': netdev(), 'engine_tx': etx, 'cli': cli_stats()} if prev: dt = row['t'] - prev['t'] row['rate_qb_data_Mbit'] = round((row['qb_up_data'] - prev['qb_up_data']) * 8 / dt / 1e6, 2) row['rate_proc_Mbit'] = round((row['proc_tx'] - prev['proc_tx']) * 8 / dt / 1e6, 2) row['rate_engine_Mbit'] = round((row['engine_tx'] - prev['engine_tx']) * 8 / dt / 1e6, 2) print(json.dumps(row), flush=True); prev = row time.sleep(step)