diff --git a/docker-compose.test-clients.yml b/docker-compose.test-clients.yml index 874dbee9..c31a4bcb 100644 --- a/docker-compose.test-clients.yml +++ b/docker-compose.test-clients.yml @@ -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 diff --git a/scripts/test_clients.py b/scripts/test_clients.py index 1a7b4370..9890ead7 100755 --- a/scripts/test_clients.py +++ b/scripts/test_clients.py @@ -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) diff --git a/shelfmark/release_sources/prowlarr/clients/__init__.py b/shelfmark/release_sources/prowlarr/clients/__init__.py index 70fd202f..ef7476b9 100644 --- a/shelfmark/release_sources/prowlarr/clients/__init__.py +++ b/shelfmark/release_sources/prowlarr/clients/__init__.py @@ -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 diff --git a/shelfmark/release_sources/prowlarr/clients/rtorrent.py b/shelfmark/release_sources/prowlarr/clients/rtorrent.py new file mode 100644 index 00000000..8c8ffbdc --- /dev/null +++ b/shelfmark/release_sources/prowlarr/clients/rtorrent.py @@ -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 diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index ee0a23b8..11e23258 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -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. diff --git a/tests/prowlarr/test_rtorrent_client.py b/tests/prowlarr/test_rtorrent_client.py new file mode 100644 index 00000000..bd3711b3 --- /dev/null +++ b/tests/prowlarr/test_rtorrent_client.py @@ -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