first commit

This commit is contained in:
root
2026-05-19 13:43:37 +00:00
commit 9dcd0abd7d
107 changed files with 33622 additions and 0 deletions

286
scripts/rtorrent_cli.py Normal file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""
rtorrent_cli.py - simple CLI for bulk rTorrent management over XML-RPC/SCGI.
Default endpoint:
scgi://127.0.0.1:5000
Examples:
python3 rtorrent_cli.py ping
python3 rtorrent_cli.py list
python3 rtorrent_cli.py list --only-stopped --only-complete
python3 rtorrent_cli.py show HASH
python3 rtorrent_cli.py start HASH
python3 rtorrent_cli.py bulk-start --only-stopped --only-complete
python3 rtorrent_cli.py bulk-stop --name-regex "ubuntu|debian"
python3 rtorrent_cli.py bulk-announce --only-active
python3 rtorrent_cli.py bulk-check-hash --only-stopped --name-regex "movie"
python3 rtorrent_cli.py dump-methods
"""
from __future__ import annotations
import argparse
import json
import re
import socket
import sys
import xmlrpc.client
from dataclasses import dataclass, asdict
from typing import Any, Iterable
from urllib.parse import urlparse
DEFAULT_URL = "scgi://127.0.0.1:5000"
# ----------------------------
# SCGI XML-RPC transport
# ----------------------------
class SCGITransport(xmlrpc.client.Transport):
def __init__(self, host: str, port: int, timeout: int = 15):
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
def request(self, host: str, handler: str, request_body: bytes, verbose: bool = False):
body = request_body.encode("utf-8") if isinstance(request_body, str) else request_body
headers = {
"CONTENT_LENGTH": str(len(body)),
"SCGI": "1",
"REQUEST_METHOD": "POST",
"REQUEST_URI": handler or "/RPC2",
}
header_bytes = b""
for key, value in headers.items():
header_bytes += key.encode("utf-8") + b"\x00" + value.encode("utf-8") + b"\x00"
packet = str(len(header_bytes)).encode("ascii") + b":" + header_bytes + b"," + body
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(packet)
response = b""
while True:
chunk = sock.recv(65536)
if not chunk:
break
response += chunk
# rTorrent over SCGI usually returns raw XML body,
# but some proxies may prepend HTTP headers.
if b"\r\n\r\n" in response:
response = response.split(b"\r\n\r\n", 1)[1]
return self.parse_response_bytes(response)
def parse_response_bytes(self, data: bytes):
p, u = self.getparser()
p.feed(data)
p.close()
return u.close()
def make_rpc_client(url: str, timeout: int):
parsed = urlparse(url)
if parsed.scheme == "scgi":
if not parsed.hostname:
raise ValueError("SCGI URL must include a host, e.g. scgi://127.0.0.1:5000")
transport = SCGITransport(parsed.hostname, parsed.port or 5000, timeout=timeout)
return xmlrpc.client.ServerProxy(
"http://rtorrent/RPC2",
transport=transport,
allow_none=True,
)
return xmlrpc.client.ServerProxy(url, allow_none=True)
# ----------------------------
# Helpers
# ----------------------------
@dataclass
class Torrent:
hash: str
name: str
state: int
active: int
complete: int
size_bytes: int
completed_bytes: int
ratio: int
down_rate: int
up_rate: int
message: str
@property
def stopped(self) -> bool:
return self.state == 0
@property
def started_or_paused(self) -> bool:
return self.state == 1
@property
def is_active(self) -> bool:
return self.active == 1
@property
def is_complete(self) -> bool:
return self.complete == 1
def rpc_error(exc: Exception, context: dict[str, Any] | None = None) -> dict[str, Any]:
payload = {
"ok": False,
"error_type": exc.__class__.__name__,
"error": str(exc),
}
if context:
payload["context"] = context
return payload
def print_json(data: Any) -> None:
print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False))
def call_method(rpc, method: str, *args):
return getattr(rpc, method)(*args)
def safe_call(rpc, method: str, *args, context: dict[str, Any] | None = None):
try:
return True, call_method(rpc, method, *args)
except Exception as exc:
return False, rpc_error(exc, context=context or {"method": method, "args": args})
def human_bytes(num: int) -> str:
value = float(num)
for unit in ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]:
if abs(value) < 1024:
return f"{value:.1f} {unit}"
value /= 1024
return f"{value:.1f} EiB"
# ----------------------------
# rTorrent API
# ----------------------------
def get_torrent_hashes(rpc, view: str = "main") -> list[str]:
ok, result = safe_call(rpc, "d.multicall2", "", view, "d.hash=")
if not ok:
raise RuntimeError(json.dumps(result, ensure_ascii=False))
hashes: list[str] = []
for row in result:
if isinstance(row, list) and row:
hashes.append(str(row[0]))
elif isinstance(row, str):
hashes.append(row)
return hashes
def list_torrents(rpc, view: str = "main") -> list[Torrent]:
methods = [
"d.hash=",
"d.name=",
"d.state=",
"d.is_active=",
"d.complete=",
"d.size_bytes=",
"d.completed_bytes=",
"d.ratio=",
"d.down.rate=",
"d.up.rate=",
"d.message=",
]
ok, result = safe_call(rpc, "d.multicall2", "", view, *methods)
if not ok:
raise RuntimeError(json.dumps(result, ensure_ascii=False))
torrents: list[Torrent] = []
for row in result:
torrents.append(Torrent(
hash=str(row[0]),
name=str(row[1]),
state=int(row[2]),
active=int(row[3]),
complete=int(row[4]),
size_bytes=int(row[5]),
completed_bytes=int(row[6]),
ratio=int(row[7]),
down_rate=int(row[8]),
up_rate=int(row[9]),
message=str(row[10]),
))
return torrents
def get_torrent(rpc, hash_: str) -> Torrent:
torrents = list_torrents(rpc)
for torrent in torrents:
if torrent.hash.lower() == hash_.lower():
return torrent
raise KeyError(f"Torrent not found: {hash_}")
def filter_torrents(torrents: Iterable[Torrent], args) -> list[Torrent]:
result = list(torrents)
if getattr(args, "only_stopped", False):
result = [t for t in result if t.stopped]
if getattr(args, "only_started", False):
result = [t for t in result if t.started_or_paused]
if getattr(args, "only_active", False):
result = [t for t in result if t.is_active]
if getattr(args, "only_complete", False):
result = [t for t in result if t.is_complete]
if getattr(args, "only_incomplete", False):
result = [t for t in result if not t.is_complete]
if getattr(args, "name_regex", None):
pattern = re.compile(args.name_regex, re.IGNORECASE)
result = [t for t in result if pattern.search(t.name)]
if getattr(args, "hash_regex", None):
pattern = re.compile(args.hash_regex, re.IGNORECASE)
result = [t for t in result if pattern.search(t.hash)]
return result
def torrent_to_dict(t: Torrent) -> dict[str, Any]:
data = asdict(t)
data["size"] = human_bytes(t.size_bytes)
data["completed"] = human_bytes(t.completed_bytes)
data["ratio_float"] = round(t.ratio / 1000, 3)
return data
# ----------------------------
# Commands
# ----------------------------
def cmd_ping(rpc, args) -> int:
ok, result = safe_call(rpc, "system.client_version")
if ok:
print_json({"ok": True, "client_version": result})
return 0
# fallback for older builds
ok, result = safe_call(rpc, "system.listMethods")
print_json({"ok": ok, "result": result})
return 0 if ok else 1