Files
tools_scripts/rtorrent_status.py
2026-04-18 19:16:07 +02:00

525 lines
15 KiB
Python

#!/usr/bin/env python3
import argparse
import shutil
import socket
import sys
import time
import xmlrpc.client
def human_bytes(n):
units = ["B", "KiB", "MiB", "GiB", "TiB"]
n = float(n)
i = 0
while n >= 1024 and i < len(units) - 1:
n /= 1024.0
i += 1
return f"{n:.1f} {units[i]}"
def human_rate(n):
return f"{human_bytes(n)}/s"
def human_duration(sec):
sec = int(max(0, sec))
d, sec = divmod(sec, 86400)
h, sec = divmod(sec, 3600)
m, s = divmod(sec, 60)
if d:
return f"{d}d {h}h {m}m"
if h:
return f"{h}h {m}m {s}s"
if m:
return f"{m}m {s}s"
return f"{s}s"
def truncate(text, width):
text = str(text)
if width <= 0:
return ""
if len(text) <= width:
return text
if width <= 3:
return "." * width
return text[: width - 3] + "..."
def progress_bar(percent, width=20):
percent = max(0.0, min(100.0, percent))
filled = int(round((percent / 100.0) * width))
return "[" + "#" * filled + "-" * (width - filled) + "]"
def term_width(default=120):
try:
return shutil.get_terminal_size((default, 30)).columns
except Exception:
return default
def clear_screen():
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
def line(char="-", width=None):
if width is None:
width = term_width()
return char * max(10, width)
class SCGIXMLRPC:
def __init__(self, host=None, port=None, unix_socket=None, request_uri="/RPC2", timeout=5):
self.host = host
self.port = port
self.unix_socket = unix_socket
self.request_uri = request_uri
self.timeout = timeout
def _send_scgi(self, body):
headers = {
"CONTENT_LENGTH": str(len(body)),
"SCGI": "1",
"REQUEST_METHOD": "POST",
"REQUEST_URI": self.request_uri,
"CONTENT_TYPE": "text/xml",
}
hdr = b""
for k, v in headers.items():
hdr += k.encode() + b"\x00" + v.encode() + b"\x00"
payload = str(len(hdr)).encode() + b":" + hdr + b"," + body
if self.unix_socket:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
addr = self.unix_socket
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
addr = (self.host, self.port)
sock.settimeout(self.timeout)
sock.connect(addr)
sock.sendall(payload)
chunks = []
while True:
try:
data = sock.recv(65536)
except socket.timeout:
break
if not data:
break
chunks.append(data)
sock.close()
return b"".join(chunks)
def call(self, method, *params):
body = xmlrpc.client.dumps(params, methodname=method, allow_none=True).encode()
raw = self._send_scgi(body)
if b"\r\n\r\n" in raw:
_, raw = raw.split(b"\r\n\r\n", 1)
result, _ = xmlrpc.client.loads(raw)
if len(result) == 1:
return result[0]
return result
class TimeoutTransport(xmlrpc.client.Transport):
def __init__(self, timeout=5):
super().__init__()
self.timeout = timeout
def make_connection(self, host):
conn = super().make_connection(host)
conn.timeout = self.timeout
return conn
class HTTPXMLRPC:
def __init__(self, url, timeout=5):
transport = TimeoutTransport(timeout=timeout)
self.proxy = xmlrpc.client.ServerProxy(url, allow_none=True, transport=transport)
def call(self, method, *params):
func = self.proxy
for part in method.split("."):
func = getattr(func, part)
return func(*params)
def safe_call(rt, method, *params, default=None):
try:
return rt.call(method, *params)
except Exception:
return default
def get_client(args):
if args.url:
return HTTPXMLRPC(args.url, timeout=args.timeout)
if args.socket:
return SCGIXMLRPC(unix_socket=args.socket, request_uri=args.path, timeout=args.timeout)
return SCGIXMLRPC(host=args.host, port=args.port, request_uri=args.path, timeout=args.timeout)
def get_uptime(rt, now):
startup = safe_call(rt, "system.startup_time")
if startup is not None:
try:
return int(now) - int(startup)
except Exception:
pass
session_open = safe_call(rt, "session.time_open")
if session_open is not None:
try:
return int(now) - int(session_open)
except Exception:
pass
return None
def fetch_torrents(rt):
rows = safe_call(
rt,
"d.multicall2",
"",
"main",
"d.hash=",
"d.name=",
"d.complete=",
"d.down.rate=",
"d.up.rate=",
"d.size_bytes=",
"d.completed_bytes=",
"d.peers_connected=",
"d.is_open=",
"d.is_active=",
default=None,
)
if rows is None:
rows = safe_call(
rt,
"d.multicall",
"main",
"d.hash=",
"d.name=",
"d.complete=",
"d.down.rate=",
"d.up.rate=",
"d.size_bytes=",
"d.completed_bytes=",
"d.peers_connected=",
"d.is_open=",
"d.is_active=",
default=[],
)
torrents = []
for idx, row in enumerate(rows or [], 1):
try:
h, name, complete, dr, ur, size_b, done_b, peers, is_open, is_active = row
torrents.append({
"index": idx,
"hash": str(h),
"name": str(name),
"complete": int(complete),
"down_rate": int(dr),
"up_rate": int(ur),
"size": int(size_b),
"done": int(done_b),
"peers": int(peers),
"is_open": int(is_open),
"is_active": int(is_active),
})
except Exception:
continue
return torrents
def torrent_percent(t):
if t["size"] <= 0:
return 0.0
return (t["done"] / t["size"]) * 100.0
def torrent_state(t, checking_hash=None):
if checking_hash and t["hash"] == checking_hash:
return "CHECKING"
if t["down_rate"] > 0:
return "DOWN"
if t["up_rate"] > 0:
return "SEED"
if t["complete"] == 1:
return "DONE"
if t["is_active"] == 1:
return "ACTIVE"
return "STOPPED"
def sort_torrents(torrents, sort_key):
if sort_key == "name":
return sorted(torrents, key=lambda t: t["name"].lower())
if sort_key == "down":
return sorted(torrents, key=lambda t: t["down_rate"], reverse=True)
if sort_key == "up":
return sorted(torrents, key=lambda t: t["up_rate"], reverse=True)
if sort_key == "progress":
return sorted(torrents, key=lambda t: torrent_percent(t), reverse=True)
if sort_key == "size":
return sorted(torrents, key=lambda t: t["size"], reverse=True)
return sorted(
torrents,
key=lambda t: (t["down_rate"] + t["up_rate"], t["peers"], torrent_percent(t)),
reverse=True,
)
def stats_summary(torrents):
total = len(torrents)
downloading = sum(1 for t in torrents if t["down_rate"] > 0)
seeding = sum(1 for t in torrents if t["up_rate"] > 0 and t["complete"] == 1)
active = sum(1 for t in torrents if t["is_active"] == 1 or t["down_rate"] > 0 or t["up_rate"] > 0)
completed = sum(1 for t in torrents if t["complete"] == 1)
stopped = sum(1 for t in torrents if t["is_active"] == 0 and t["down_rate"] == 0 and t["up_rate"] == 0)
return {
"total": total,
"downloading": downloading,
"seeding": seeding,
"active": active,
"completed": completed,
"stopped": stopped,
}
def render_header(version, libversion, uptime, global_down, global_up, total_down, total_up, torrents, cycle=None, interval=None):
width = term_width()
stats = stats_summary(torrents)
print(line("=", width))
title = " rTorrent Console Monitor "
if cycle is not None and interval is not None:
title += f"| refresh {interval}s | cycle {cycle} "
print(title.center(width))
print(line("=", width))
print(
f"Version: {version} (libtorrent {libversion}) "
f"Uptime: {human_duration(uptime) if uptime is not None else 'n/a'}"
)
print(
f"Rates: DOWN {human_rate(global_down):>12} "
f"UP {human_rate(global_up):>12}"
)
print(
f"Session: DOWN {human_bytes(total_down):>12} "
f"UP {human_bytes(total_up):>12}"
)
print(
f"Torrents: total={stats['total']} active={stats['active']} "
f"downloading={stats['downloading']} seeding={stats['seeding']} "
f"done={stats['completed']} stopped={stats['stopped']}"
)
print(line("-", width))
def render_scan_status(torrents, scan_count, checking_index):
if not torrents:
print("No torrents found.")
return None
limit_scan = min(len(torrents), max(1, scan_count))
current = torrents[checking_index % limit_scan]
state = torrent_state(current, current["hash"])
pct = torrent_percent(current)
print("Current check")
print(
f" [{(checking_index % limit_scan) + 1:02d}/{limit_scan:02d}] "
f"{state:<8} "
f"{pct:6.2f}% "
f"DOWN {human_rate(current['down_rate']):>10} "
f"UP {human_rate(current['up_rate']):>10} "
f"PEERS {current['peers']:<3} "
f"{truncate(current['name'], max(20, term_width() - 70))}"
)
print(line("-", term_width()))
return current["hash"]
def render_table(torrents, top, checking_hash=None):
width = term_width()
if not torrents:
print("No torrents found.")
return
top = min(len(torrents), max(1, top))
name_w = max(20, width - 78)
print(f"{'STATE':<9} {'DONE':<8} {'DOWN':>12} {'UP':>12} {'PEERS':>5} {'SIZE':>10} NAME")
print(line("-", width))
for t in torrents[:top]:
pct = torrent_percent(t)
state = torrent_state(t, checking_hash)
name = truncate(t["name"], name_w)
print(
f"{state:<9} "
f"{pct:6.2f}% "
f"{human_rate(t['down_rate']):>12} "
f"{human_rate(t['up_rate']):>12} "
f"{t['peers']:>5} "
f"{human_bytes(t['size']):>10} "
f"{name}"
)
print(line("-", width))
top_item = torrents[0]
pct = torrent_percent(top_item)
print(
f"Selected: {truncate(top_item['name'], max(20, width - 12))}\n"
f"Progress: {pct:6.2f}% {progress_bar(pct, 30)} "
f"State: {torrent_state(top_item, checking_hash)} "
f"Peers: {top_item['peers']} "
f"Size: {human_bytes(top_item['size'])}"
)
def render_footer():
print(line("-", term_width()))
print("Ctrl+C to exit")
def get_snapshot(rt):
now = int(rt.call("system.time"))
version = safe_call(rt, "system.client_version", default="unknown")
libversion = safe_call(rt, "system.library_version", default="unknown")
global_down = int(safe_call(rt, "throttle.global_down.rate", default=0) or 0)
global_up = int(safe_call(rt, "throttle.global_up.rate", default=0) or 0)
total_down = int(safe_call(rt, "throttle.global_down.total", default=0) or 0)
total_up = int(safe_call(rt, "throttle.global_up.total", default=0) or 0)
uptime = get_uptime(rt, now)
torrents = fetch_torrents(rt)
return version, libversion, uptime, global_down, global_up, total_down, total_up, torrents
def single_view(rt, args):
try:
snapshot = get_snapshot(rt)
except Exception as e:
print(f"CRITICAL: rTorrent is not responding: {e}")
sys.exit(2)
version, libversion, uptime, global_down, global_up, total_down, total_up, torrents = snapshot
torrents = sort_torrents(torrents, args.sort)
render_header(version, libversion, uptime, global_down, global_up, total_down, total_up, torrents)
checking_hash = render_scan_status(torrents, args.scan, 0)
render_table(torrents, args.top, checking_hash=checking_hash)
render_footer()
def interactive_view(rt, args):
cycle = 0
checking_index = 0
try:
while True:
cycle += 1
snapshot = get_snapshot(rt)
version, libversion, uptime, global_down, global_up, total_down, total_up, torrents = snapshot
torrents = sort_torrents(torrents, args.sort)
clear_screen()
render_header(
version,
libversion,
uptime,
global_down,
global_up,
total_down,
total_up,
torrents,
cycle=cycle,
interval=args.interval,
)
checking_hash = render_scan_status(torrents, args.scan, checking_index)
render_table(torrents, args.top, checking_hash=checking_hash)
render_footer()
if torrents:
checking_index = (checking_index + 1) % min(len(torrents), max(1, args.scan))
time.sleep(args.interval)
except KeyboardInterrupt:
clear_screen()
print("Exiting rTorrent console monitor.")
sys.exit(0)
except Exception as e:
clear_screen()
print(f"CRITICAL: monitor error: {e}")
sys.exit(2)
def build_parser():
p = argparse.ArgumentParser(
prog="rtorrent_status.py",
description="Live rTorrent status monitor over SCGI/XML-RPC.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
epilog=(
"Examples:\n"
" python3 rtorrent_status.py\n"
" python3 rtorrent_status.py --host 127.0.0.1 --port 5000 -i\n"
" python3 rtorrent_status.py --socket /run/rtorrent/rpc.sock -i\n"
" python3 rtorrent_status.py --url http://127.0.0.1/RPC2 --sort down --top 15\n"
),
)
conn = p.add_argument_group("connection")
conn.add_argument("--url", help="HTTP XML-RPC endpoint, for example http://127.0.0.1/RPC2")
conn.add_argument("--host", default="127.0.0.1", help="SCGI host")
conn.add_argument("--port", type=int, default=5000, help="SCGI port")
conn.add_argument("--socket", help="SCGI unix socket path, for example /run/rtorrent/rpc.sock")
conn.add_argument("--path", default="/RPC2", help="SCGI request URI")
conn.add_argument("--timeout", type=int, default=5, help="Connection timeout in seconds")
view = p.add_argument_group("view")
view.add_argument("-i", "--interactive", action="store_true", help="Enable live interactive console mode")
view.add_argument("--interval", type=int, default=3, help="Refresh interval in seconds")
view.add_argument("--top", type=int, default=10, help="Number of torrents to display in the main table")
view.add_argument("--scan", type=int, default=15, help="Number of torrents included in the rotating check")
view.add_argument(
"--sort",
choices=["activity", "name", "down", "up", "progress", "size"],
default="activity",
help="Sort order for torrents",
)
return p
def main():
parser = build_parser()
args = parser.parse_args()
rt = get_client(args)
if args.interactive:
interactive_view(rt, args)
else:
single_view(rt, args)
if __name__ == "__main__":
main()