163 lines
5.6 KiB
Python
163 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
RTorrent Client - Communicate with rtorrent via XMLRPC over SCGI socket.
|
|
"""
|
|
|
|
import socket
|
|
import xmlrpc.client
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
class SCGITransport(xmlrpc.client.Transport):
|
|
"""SCGI transport for communicating with rtorrent via Unix socket."""
|
|
|
|
def __init__(self, socket_path: str):
|
|
super().__init__()
|
|
self.socket_path = socket_path
|
|
|
|
def single_request(self, host, handler, request_body, verbose=False):
|
|
# Create SCGI request
|
|
headers = f"CONTENT_LENGTH\x00{len(request_body)}\x00SCGI\x001\x00"
|
|
request = f"{len(headers)}:{headers},{request_body.decode('utf-8')}"
|
|
|
|
# Connect to socket
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.connect(self.socket_path)
|
|
sock.send(request.encode("utf-8"))
|
|
|
|
# Read response
|
|
response = b""
|
|
while True:
|
|
data = sock.recv(4096)
|
|
if not data:
|
|
break
|
|
response += data
|
|
sock.close()
|
|
|
|
# Parse response - skip HTTP headers
|
|
if b"\r\n\r\n" in response:
|
|
response = response.split(b"\r\n\r\n", 1)[1]
|
|
|
|
return self.parse_response(response)
|
|
|
|
def parse_response(self, response_body):
|
|
p, u = xmlrpc.client.getparser()
|
|
p.feed(response_body)
|
|
p.close()
|
|
return u.close()
|
|
|
|
|
|
class RTorrentClient:
|
|
"""Client for communicating with rtorrent via XMLRPC over SCGI socket."""
|
|
|
|
def __init__(self, socket_path: str = "/home/user/rtorrent/.session/rpc.socket"):
|
|
self.socket_path = socket_path
|
|
transport = SCGITransport(socket_path)
|
|
self.proxy = xmlrpc.client.ServerProxy(
|
|
"http://localhost/RPC2", transport=transport
|
|
)
|
|
|
|
def get_loaded_hashes(self) -> set[str]:
|
|
"""Get set of info hashes for all currently loaded torrents."""
|
|
try:
|
|
downloads = self.proxy.download_list("")
|
|
return set(h.upper() for h in downloads)
|
|
except Exception as e:
|
|
print(f"Error getting loaded torrents: {e}")
|
|
return set()
|
|
|
|
def load_torrent(self, torrent_path: Path, download_dir: Path) -> bool:
|
|
"""
|
|
Load a torrent file and set its download directory.
|
|
Uses load.start_verbose to load and immediately start/hash-check.
|
|
|
|
Args:
|
|
torrent_path: Path to the .torrent file
|
|
download_dir: Directory where the data already exists
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
# load.start_verbose with d.directory.set to specify download location
|
|
# This will hash-check existing files instead of re-downloading
|
|
self.proxy.load.start_verbose(
|
|
"", str(torrent_path), f'd.directory.set="{download_dir}"'
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error loading torrent {torrent_path}: {e}")
|
|
return False
|
|
|
|
def get_torrent_info(self, info_hash: str) -> Optional[Dict]:
|
|
"""Get info about a loaded torrent."""
|
|
try:
|
|
name = self.proxy.d.name(info_hash)
|
|
message = self.proxy.d.message(info_hash)
|
|
tied_file = self.proxy.d.tied_to_file(info_hash)
|
|
directory = self.proxy.d.directory(info_hash)
|
|
base_path = self.proxy.d.base_path(info_hash) # Actual data path
|
|
is_multi_file = self.proxy.d.is_multi_file(info_hash)
|
|
return {
|
|
"hash": info_hash,
|
|
"name": name,
|
|
"message": message,
|
|
"tied_file": tied_file,
|
|
"directory": directory,
|
|
"base_path": base_path, # Full path to data (file or folder)
|
|
"is_multi_file": is_multi_file,
|
|
}
|
|
except Exception as e:
|
|
print(f"Error getting torrent info for {info_hash}: {e}")
|
|
return None
|
|
|
|
def get_unregistered_torrents(self) -> List[Dict]:
|
|
"""
|
|
Find all torrents with 'unregistered' or 'not registered' tracker errors.
|
|
|
|
Returns:
|
|
List of torrent info dicts for torrents with registration errors
|
|
"""
|
|
unregistered = []
|
|
try:
|
|
hashes = self.proxy.download_list("")
|
|
for info_hash in hashes:
|
|
try:
|
|
message = self.proxy.d.message(info_hash)
|
|
if message and (
|
|
"unregistered" in message.lower()
|
|
or "not registered" in message.lower()
|
|
):
|
|
info = self.get_torrent_info(info_hash)
|
|
if info:
|
|
unregistered.append(info)
|
|
except Exception:
|
|
continue
|
|
except Exception as e:
|
|
print(f"Error scanning for unregistered torrents: {e}")
|
|
return unregistered
|
|
|
|
def remove_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
|
|
"""
|
|
Remove a torrent from rtorrent.
|
|
|
|
Args:
|
|
info_hash: The info hash of the torrent to remove
|
|
delete_files: If True, also delete downloaded files (default: False)
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if delete_files:
|
|
# This would delete the data - NOT what we want
|
|
self.proxy.d.erase(info_hash)
|
|
else:
|
|
# Just remove from rtorrent, keep files
|
|
self.proxy.d.erase(info_hash)
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error removing torrent {info_hash}: {e}")
|
|
return False
|