From 556a5f151e0fe41579dd8b4414d136fff71abbfd Mon Sep 17 00:00:00 2001 From: gru Date: Sat, 18 Apr 2026 19:16:07 +0200 Subject: [PATCH] Add rtorrent_status.py --- rtorrent_status.py | 525 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 rtorrent_status.py diff --git a/rtorrent_status.py b/rtorrent_status.py new file mode 100644 index 0000000..1151695 --- /dev/null +++ b/rtorrent_status.py @@ -0,0 +1,525 @@ +#!/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() \ No newline at end of file