Feature: rTorrent client support (#463)

This adds in rtorrent for
https://github.com/calibrain/shelfmark/issues/420

The one weird thing I noticed is that the download path needs to be the
same for both (that's not how I typically set it up)

But it definitely adds to rtorrent and gives progress.

**rTorrent client integration:**

* Added a new `RTorrentClient` class in
`shelfmark/release_sources/prowlarr/clients/rtorrent.py` that implements
the download client interface for rTorrent using XML-RPC, supporting
adding, removing, and querying torrent status.
* Registered the rTorrent client in the client registry in
`shelfmark/release_sources/prowlarr/clients/__init__.py`.

**Settings and configuration:**

* Extended the Prowlarr client settings UI and backend
(`shelfmark/release_sources/prowlarr/settings.py`) to add rTorrent as a
selectable client, provide rTorrent-specific configuration fields (URL,
username, password, label, download directory), and implement a
connection test action.
[[1]](diffhunk://#diff-052272b85804cb61162870f262cc7544ef321596ff3ebf08117a6c25afaa3ec5R390)
[[2]](diffhunk://#diff-052272b85804cb61162870f262cc7544ef321596ff3ebf08117a6c25afaa3ec5R539-R582)
[[3]](diffhunk://#diff-052272b85804cb61162870f262cc7544ef321596ff3ebf08117a6c25afaa3ec5R198-R225)

**Test environment and scripts:**

* Updated `docker-compose.test-clients.yml` to add an rTorrent service
for local testing, including configuration, ports, and documentation
updates.
[[1]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R17-L19)
[[2]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R39)
[[3]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R66)
[[4]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R164-R181)
* Enhanced `scripts/test_clients.py` to include rTorrent in the test
suite, with logic for connecting, adding, and removing torrents via
XML-RPC.
[[1]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR26)
[[2]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR57)
[[3]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR88-R90)
[[4]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR395-R468)
[[5]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR492)

Closes https://github.com/calibrain/shelfmark/issues/420
This commit is contained in:
Patrick Veverka
2026-01-16 14:28:21 +00:00
committed by GitHub
parent 0d7a12ca7c
commit ba906c45df
6 changed files with 1143 additions and 1 deletions
+21 -1
View File
@@ -14,9 +14,9 @@
# - Deluge: http://localhost:8112 (admin / deluge)
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
#
services:
shelfmark:
build:
@@ -36,6 +36,7 @@ services:
# - Deluge host: deluge (port 58846)
# - NZBGet: http://nzbget:6789
# - SABnzbd: http://sabnzbd:8080
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
ports:
- "8084:8084"
volumes:
@@ -62,6 +63,7 @@ services:
- qbittorrent
- transmission
- deluge
- rtorrent
restart: unless-stopped
prowlarr:
@@ -159,3 +161,21 @@ services:
- "6881:6881"
- "6881:6881/udp"
restart: unless-stopped
rtorrent:
image: crazymax/rtorrent-rutorrent:latest # linuxserver has deprecated their rtorrent image
container_name: test-rtorrent
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
volumes:
- ./.local/test-clients/rtorrent/config:/config
- ./.local/test-clients/downloads:/downloads
ports:
- "8000:8000" # XMLRPC
- "8089:8080" # ruTorrent Web UI
- "9000:9000" # SCGI port
- "50000:50000" # Incoming connections
- "6881:6881/udp"
restart: unless-stopped
+80
View File
@@ -23,6 +23,7 @@ Web UIs:
- Deluge: http://localhost:8112
- NZBGet: http://localhost:6789
- SABnzbd: http://localhost:8085
- rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent)
Prerequisites (for running this script locally):
pip install requests transmission-rpc deluge-client qbittorrent-api
@@ -53,6 +54,7 @@ First-Time Setup:
import sys
import time
from xmlrpc import client
# Test configuration - matches docker-compose.test-clients.yml
CONFIG = {
@@ -83,6 +85,9 @@ CONFIG = {
"username": "admin",
"password": "admin",
},
"rtorrent": {
"url": "http://localhost:8000/RPC2",
},
}
# Test magnet link (Ubuntu ISO - legal, small metadata)
@@ -387,6 +392,80 @@ def test_deluge():
print(" 3. Or access Web UI at http://localhost:8112 (password: deluge)")
return False
def test_rtorrent():
"""Test rTorrent connection."""
print("\n" + "=" * 50)
print("Testing rTorrent")
print("=" * 50)
try:
import xmlrpc.client
url = "http://localhost:8000/RPC2"
client = xmlrpc.client.ServerProxy(url)
# Test connection
version = client.system.library_version()
print(f" Connected to rTorrent {version}")
# Get torrent list
torrents = client.download_list()
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
label = "automated"
commands = []
if label:
commands.append(f"d.custom1.set={label}")
download_dir = "/downloads"
if download_dir:
commands.append(f"d.directory_base.set={download_dir}")
# rtorrent is weird in that it doesn't return the torrent ID/hash on add
client.load.start("", TEST_MAGNET, ";".join(commands))
# but we know that it is 3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0 from the magnet link
torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes
print(f" Added test torrent: {torrent_id}")
torrent_list = client.d.multicall.filtered(
"",
"default",
f"equal=d.hash=,cat={torrent_id}",
"d.hash=",
"d.state=",
"d.completed_bytes=",
"d.size_bytes=",
"d.down.rate=",
"d.up.rate=",
"d.custom1=",
"d.complete=",
)
torrent = torrent_list[0]
if not torrent:
print(" ERROR: Could not find added torrent in list")
return False
client.d.erase(torrent_id)
print(" Removed test torrent")
print(" SUCCESS: rTorrent is working!")
return True
except ImportError:
print(" ERROR: xmlrpc.client not available")
return False
except Exception as e:
print(f" ERROR: {e}")
if "Connection refused" in str(e):
print(" Is the container running? docker ps | grep rtorrent")
return False
def main():
print("Download Client Test Suite")
@@ -410,6 +489,7 @@ def main():
results["qbittorrent"] = test_qbittorrent()
results["transmission"] = test_transmission()
results["deluge"] = test_deluge()
results["rtorrent"] = test_rtorrent()
# Summary
print("\n" + "=" * 50)
@@ -298,3 +298,4 @@ from shelfmark.release_sources.prowlarr.clients import nzbget # noqa: F401, E40
from shelfmark.release_sources.prowlarr.clients import sabnzbd # noqa: F401, E402
from shelfmark.release_sources.prowlarr.clients import transmission # noqa: F401, E402
from shelfmark.release_sources.prowlarr.clients import deluge # noqa: F401, E402
from shelfmark.release_sources.prowlarr.clients import rtorrent # noqa: F401, E402
@@ -0,0 +1,289 @@
"""
rTorrent download client for Prowlarr integration.
Uses xmlrpc to communicate with rTorrent's RPC interface.
"""
from typing import Optional, Tuple
from urllib.parse import urlparse
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
extract_torrent_info,
)
logger = setup_logger(__name__)
@register_client("torrent")
class RTorrentClient(DownloadClient):
"""rTorrent download client using xmlrpc."""
protocol = "torrent"
name = "rtorrent"
def __init__(self):
"""Initialize rTorrent client with settings from config."""
from xmlrpc.client import ServerProxy
url = config.get("RTORRENT_URL", "")
if not url:
raise ValueError("RTORRENT_URL is required")
self._base_url = url.rstrip("/")
username = config.get("RTORRENT_USERNAME", "")
password = config.get("RTORRENT_PASSWORD", "")
if username and password:
parsed = urlparse(self._base_url)
self._base_url = (
f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
)
self._rpc = ServerProxy(self._base_url)
self._download_dir = config.get("RTORRENT_DOWNLOAD_DIR", "")
self._label = config.get("RTORRENT_LABEL", "")
@staticmethod
def is_configured() -> bool:
"""Check if rTorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
url = config.get("RTORRENT_URL", "")
return client == "rtorrent" and bool(url)
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to rTorrent."""
try:
version = self._rpc.system.client_version()
return True, f"Connected to rTorrent {version}"
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
"""
Add torrent by URL (magnet or .torrent).
Args:
url: Magnet link or .torrent URL
name: Display name for the torrent
category: Category for organization (uses configured label if not specified)
Returns:
Torrent hash (info_hash).
Raises:
Exception: If adding fails.
"""
try:
torrent_info = extract_torrent_info(url)
commands = []
label = category or self._label
if label:
logger.debug(f"Setting rTorrent label: {label}")
commands.append(f"d.custom1.set={label}")
download_dir = self._download_dir or self._get_download_dir()
if download_dir:
logger.debug(f"Setting rTorrent download directory: {download_dir}")
commands.append(f"d.directory_base.set={download_dir}")
if torrent_info.torrent_data:
logger.debug(f"Adding torrent data directly to rTorrent for: {name} with commands: {commands} with data size: {len(torrent_info.torrent_data)}")
self._rpc.load.raw_start(
"", torrent_info.torrent_data, ";".join(commands)
)
else:
logger.debug(f"Adding torrent URL to rTorrent for: {name} with commands: {commands} with URL: {url}")
add_url = torrent_info.magnet_url or url
self._rpc.load.start("", add_url, ";".join(commands))
torrent_hash = torrent_info.info_hash
if not torrent_hash:
raise Exception("Could not determine torrent hash from URL")
logger.debug(f"Added torrent to rTorrent: {torrent_hash}")
return torrent_hash
except Exception as e:
logger.error(f"rTorrent add failed: {e}")
raise
def get_status(self, download_id: str) -> DownloadStatus:
"""
Get torrent status by hash.
Args:
download_id: Torrent info_hash
Returns:
Current download status.
"""
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
download_id = download_id.upper()
torrent_list = self._rpc.d.multicall.filtered(
"",
"default",
f"equal={{d.hash=,cat={download_id}}}",
"d.hash=",
"d.state=",
"d.completed_bytes=",
"d.size_bytes=",
"d.down.rate=",
"d.up.rate=",
"d.custom1=",
"d.complete=",
)
logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}")
if not torrent_list:
logger.warning(f"Torrent not found in rTorrent: {download_id}")
return DownloadStatus.error("Torrent not found")
torrent = torrent_list[0]
if not torrent:
logger.warning(f"Torrent data is empty for: {download_id}")
return DownloadStatus.error("Torrent not found")
logger.debug(f"Torrent data for {download_id}: {torrent}")
(
torrent_hash,
state,
bytes_downloaded,
bytes_total,
down_rate,
up_rate,
custom_category,
complete,
) = torrent
if bytes_total > 0:
progress = (bytes_downloaded / bytes_total) * 100
else:
progress = 0
bytes_left = max(0, bytes_total - bytes_downloaded)
state_map = {
0: ("stopped", "Stopped"),
1: ("started", "Started"),
}
state_str, message = state_map.get(state, ("unknown", "Unknown state"))
if complete and not message:
message = "Complete"
eta = None
if down_rate > 0 and bytes_left > 0:
eta_seconds = bytes_left / down_rate
if eta_seconds < 604800:
eta = int(eta_seconds)
file_path = None
if complete:
file_path = self._get_torrent_path(download_id)
return DownloadStatus(
progress=min(100, progress),
state="complete" if complete else state_str,
message=message,
complete=complete,
file_path=file_path,
download_speed=down_rate if down_rate > 0 else None,
eta=eta,
)
except Exception as e:
error_type = type(e).__name__
logger.error(f"rTorrent get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a torrent from rTorrent.
Args:
download_id: Torrent info_hash
delete_files: Whether to also delete files
Returns:
True if successful.
"""
try:
if delete_files:
self._rpc.d.delete_tied(download_id)
self._rpc.d.erase(download_id)
else:
self._rpc.d.stop(download_id)
self._rpc.d.erase(download_id)
logger.info(
f"Removed torrent from rTorrent: {download_id}"
+ (" (with files)" if delete_files else "")
)
return True
except Exception as e:
error_type = type(e).__name__
logger.error(f"rTorrent remove failed ({error_type}): {e}")
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where torrent files are located.
Args:
download_id: Torrent info_hash
Returns:
Content path (file or directory), or None.
"""
try:
return self._get_torrent_path(download_id)
except Exception as e:
error_type = type(e).__name__
logger.debug(f"rTorrent get_download_path failed ({error_type}): {e}")
return None
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
"""Check if a torrent for this URL already exists in rTorrent."""
try:
torrent_info = extract_torrent_info(url)
if not torrent_info.info_hash:
return None
try:
status = self.get_status(torrent_info.info_hash)
if status.state != DownloadStatus.error("").state:
return (torrent_info.info_hash, status)
except Exception:
pass
return None
except Exception as e:
logger.debug(f"Error checking for existing torrent: {e}")
return None
def _get_download_dir(self) -> str:
"""Get the download directory from rTorrent config."""
try:
download_dir = self._rpc.directory.default()
return download_dir
except Exception:
return "/downloads"
def _get_torrent_path(self, download_id: str) -> Optional[str]:
"""Get the file path of a torrent by hash."""
try:
base_path = self._rpc.d.directory(download_id)
return base_path if base_path else None
except Exception:
return None
@@ -195,6 +195,34 @@ def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str,
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_rtorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
"""Test the rTorrent connection using current form values."""
from shelfmark.core.config import config
from urllib.parse import urlparse
from xmlrpc.client import ServerProxy
current_values = current_values or {}
url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "")
username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "")
password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "")
if not url:
return {"success": False, "message": "rTorrent URL is required"}
try:
# Add HTTP auth to URL if credentials provided
if username and password:
parsed = urlparse(url)
url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
rpc = ServerProxy(url.rstrip("/"))
version = rpc.system.client_version()
return {"success": True, "message": f"Connected to rTorrent {version}"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_nzbget_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
"""Test the NZBGet connection using current form values."""
import requests
@@ -359,6 +387,7 @@ def prowlarr_clients_settings():
{"value": "qbittorrent", "label": "qBittorrent"},
{"value": "transmission", "label": "Transmission"},
{"value": "deluge", "label": "Deluge"},
{"value": "rtorrent", "label": "rTorrent"},
],
default="",
),
@@ -507,6 +536,50 @@ def prowlarr_clients_settings():
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
# --- rTorrent Settings ---
TextField(
key="RTORRENT_URL",
label="rTorrent URL",
description="XML-RPC URL of your rTorrent instance",
placeholder="http://rtorrent:6881/RPC2",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_USERNAME",
label="Username",
description="HTTP Basic auth username (if authentication enabled)",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
PasswordField(
key="RTORRENT_PASSWORD",
label="Password",
description="HTTP Basic auth password",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
ActionButton(
key="test_rtorrent",
label="Test Connection",
description="Verify your rTorrent configuration",
style="primary",
callback=_test_rtorrent_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_LABEL",
label="Book Label",
description="Label to assign to book downloads in rTorrent",
placeholder="cwabd",
default="cwabd",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_DOWNLOAD_DIR",
label="Download Directory",
description="Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified)",
placeholder="/downloads",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
# Note: Torrent client download path must be mounted identically in both containers.
# Torrents are always copied (not moved) to preserve seeding capability.
+679
View File
@@ -0,0 +1,679 @@
"""
Unit tests for the rTorrent client.
These tests mock the xmlrpc library to test the client logic
without requiring a running rTorrent instance.
"""
from unittest.mock import MagicMock, patch
import pytest
import sys
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
def make_config_getter(values):
"""Create a config.get function that returns values from a dict."""
def getter(key, default=""):
return values.get(key, default)
return getter
def create_mock_xmlrpc_module():
"""Create a mock xmlrpc.client module."""
mock_module = MagicMock()
mock_module.ServerProxy = MagicMock()
return mock_module
class TestRTorrentClientIsConfigured:
"""Tests for RTorrentClient.is_configured()."""
def test_is_configured_when_all_set(self, monkeypatch):
"""Test is_configured returns True when properly configured."""
config_values = {
"PROWLARR_TORRENT_CLIENT": "rtorrent",
"RTORRENT_URL": "http://localhost:8080/RPC2",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
from shelfmark.release_sources.prowlarr.clients.rtorrent import RTorrentClient
assert RTorrentClient.is_configured() is True
def test_is_configured_wrong_client(self, monkeypatch):
"""Test is_configured returns False when different client selected."""
config_values = {
"PROWLARR_TORRENT_CLIENT": "qbittorrent",
"RTORRENT_URL": "http://localhost:8080/RPC2",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
from shelfmark.release_sources.prowlarr.clients.rtorrent import RTorrentClient
assert RTorrentClient.is_configured() is False
def test_is_configured_no_url(self, monkeypatch):
"""Test is_configured returns False when URL not set."""
config_values = {
"PROWLARR_TORRENT_CLIENT": "rtorrent",
"RTORRENT_URL": "",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
from shelfmark.release_sources.prowlarr.clients.rtorrent import RTorrentClient
assert RTorrentClient.is_configured() is False
class TestRTorrentClientTestConnection:
"""Tests for RTorrentClient.test_connection()."""
def test_test_connection_success(self, monkeypatch):
"""Test successful connection."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.system.client_version.return_value = "0.9.8"
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
success, message = client.test_connection()
assert success is True
assert "0.9.8" in message
def test_test_connection_failure(self, monkeypatch):
"""Test failed connection."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.system.client_version.side_effect = Exception("Connection refused")
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
success, message = client.test_connection()
assert success is False
assert "failed" in message.lower()
def test_test_connection_with_auth(self, monkeypatch):
"""Test connection with HTTP authentication."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "testuser",
"RTORRENT_PASSWORD": "testpass",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.system.client_version.return_value = "0.9.8"
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
success, message = client.test_connection()
assert success is True
assert "0.9.8" in message
class TestRTorrentClientAddDownload:
"""Tests for RTorrentClient.add_download()."""
def test_add_download_magnet(self, monkeypatch):
"""Test adding a torrent via magnet link."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
# Mock extract_torrent_info
mock_torrent_info = MagicMock()
mock_torrent_info.torrent_data = None
mock_torrent_info.magnet_url = "magnet:?xt=urn:btih:abc123def456"
mock_torrent_info.info_hash = "abc123def456"
mock_torrent_info.is_magnet = True
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
with patch(
"shelfmark.release_sources.prowlarr.clients.torrent_utils.extract_torrent_info",
return_value=mock_torrent_info,
):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules[
"shelfmark.release_sources.prowlarr.clients.rtorrent"
]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
result_hash = client.add_download(
"magnet:?xt=urn:btih:abc123def456", "Test Torrent"
)
assert result_hash == "abc123def456"
mock_rpc.load.start.assert_called_once()
args = mock_rpc.load.start.call_args[0]
assert args[1] == "magnet:?xt=urn:btih:abc123def456"
assert "d.custom1.set=cwabd" in args[2]
assert "d.directory_base.set=/downloads" in args[2]
def test_add_download_torrent_file(self, monkeypatch):
"""Test adding a torrent via raw torrent data."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
# Mock extract_torrent_info
mock_torrent_info = MagicMock()
mock_torrent_info.torrent_data = b"raw_torrent_data"
mock_torrent_info.magnet_url = None
mock_torrent_info.info_hash = "abc123def456"
mock_torrent_info.is_magnet = False
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
with patch(
"shelfmark.release_sources.prowlarr.clients.torrent_utils.extract_torrent_info",
return_value=mock_torrent_info,
):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules[
"shelfmark.release_sources.prowlarr.clients.rtorrent"
]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
result_hash = client.add_download(
"http://example.com/test.torrent", "Test Torrent"
)
assert result_hash == "abc123def456"
mock_rpc.load.raw_start.assert_called_once()
args = mock_rpc.load.raw_start.call_args[0]
assert args[1] == b"raw_torrent_data"
assert "d.custom1.set=cwabd" in args[2]
def test_add_download_failure(self, monkeypatch):
"""Test failure when adding a download."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.load.start.side_effect = Exception("RPC Error")
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
mock_torrent_info = MagicMock()
mock_torrent_info.torrent_data = None
mock_torrent_info.magnet_url = "magnet:..."
mock_torrent_info.info_hash = "abc"
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
with patch(
"shelfmark.release_sources.prowlarr.clients.rtorrent.extract_torrent_info",
return_value=mock_torrent_info,
):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules[
"shelfmark.release_sources.prowlarr.clients.rtorrent"
]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
with pytest.raises(Exception) as excinfo:
client.add_download("magnet:...", "Test")
assert "RPC Error" in str(excinfo.value)
class TestRTorrentClientGetStatus:
"""Tests for RTorrentClient.get_status()."""
def test_get_status_downloading(self, monkeypatch):
"""Test status for downloading torrent."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = [
[
"abc123def456",
2,
524288000,
1048576000,
1024000,
0,
"cwabd",
]
]
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
status = client.get_status("abc123def456")
assert status.progress == 50.0
assert status.state_value == "downloading"
assert status.complete is False
assert status.download_speed == 1024000
def test_get_status_seeding(self, monkeypatch):
"""Test status for seeding (complete) torrent."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = [
[
"abc123def456",
4,
1048576000,
1048576000,
0,
2048000,
"cwabd",
]
]
mock_rpc.d.get_base_path.return_value = "/downloads/test-torrent"
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
status = client.get_status("abc123def456")
assert status.progress == 100.0
assert status.state_value == "complete"
assert status.complete is True
assert status.file_path == "/downloads/test-torrent"
assert status.message == "Seeding"
def test_get_status_torrent_not_found(self, monkeypatch):
"""Test status when torrent not found."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = []
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
status = client.get_status("nonexistent")
assert status.state_value == "error"
assert status.complete is False
def test_get_status_with_eta(self, monkeypatch):
"""Test status with ETA calculation."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = [
[
"abc123def456",
2,
524288000,
1048576000,
1048576,
0,
"cwabd",
]
]
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
status = client.get_status("abc123def456")
assert status.eta is not None
assert status.eta == 500
class TestRTorrentClientRemove:
"""Tests for RTorrentClient.remove()."""
def test_remove_success(self, monkeypatch):
"""Test successful torrent removal."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
result = client.remove("abc123def456", delete_files=False)
assert result is True
mock_rpc.d.stop.assert_called_once_with("abc123def456")
mock_rpc.d.erase.assert_called_once_with("abc123def456")
def test_remove_with_files(self, monkeypatch):
"""Test torrent removal with file deletion."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
result = client.remove("abc123def456", delete_files=True)
assert result is True
mock_rpc.d.delete_tied.assert_called_once_with("abc123def456")
mock_rpc.d.erase.assert_called_once_with("abc123def456")
def test_remove_failure(self, monkeypatch):
"""Test failed torrent removal."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.d.stop.side_effect = Exception("Connection lost")
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
result = client.remove("abc123def456")
assert result is False
class TestRTorrentClientGetDownloadPath:
"""Tests for RTorrentClient.get_download_path()."""
def test_get_download_path_success(self, monkeypatch):
"""Test getting download path successfully."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.d.get_base_path.return_value = "/downloads/test-file"
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
path = client.get_download_path("abc123def456")
assert path == "/downloads/test-file"
def test_get_download_path_failure(self, monkeypatch):
"""Test getting download path when torrent not found."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_USERNAME": "",
"RTORRENT_PASSWORD": "",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
"RTORRENT_LABEL": "cwabd",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
make_config_getter(config_values),
)
mock_rpc = MagicMock()
mock_rpc.d.get_base_path.side_effect = Exception("Torrent not found")
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
RTorrentClient,
)
client = RTorrentClient()
path = client.get_download_path("nonexistent")
assert path is None