Add rtorrent_status.py
This commit is contained in:
525
rtorrent_status.py
Normal file
525
rtorrent_status.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user