#!/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()