#!/usr/bin/env python3 """ Test script for download client implementations. Usage: 1. Start the test stack: docker compose -f docker-compose.test-clients.yml up -d 2. Wait for containers to initialize (first run takes ~30s) 3. Run this script to verify clients are accessible: uv run python scripts/test_clients.py 4. Access cwabd at http://localhost:8084 - Go to Settings > Prowlarr > Download Clients - Select a client from the dropdown - Click "Test Connection" to verify Web UIs: - cwabd: http://localhost:8084 - qBittorrent: http://localhost:8080 - Transmission: http://localhost:9091 - 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): uv sync --locked First-Time Setup: qBittorrent: - Check container logs for temporary password: docker logs test-qbittorrent - Login at http://localhost:8080, change password to something known - Default username is 'admin' Transmission: - No setup needed, credentials pre-configured (admin/admin) Deluge: - Access Web UI at http://localhost:8112 (default password: deluge) NZBGet: - No setup needed, credentials pre-configured (admin/admin) SABnzbd: - Complete the setup wizard at http://localhost:8085 - API key will be auto-detected by this script - In cwabd, copy API key from SABnzbd Config > General slskd (Soulseek): - Before the first `up`, write .local/test-clients/slskd/config/slskd.yml. Run `uv run python scripts/test_clients.py --write-slskd-config` to create one with a throwaway Soulseek login and a fresh API key, then create the download folders it names (this script does both). - In Shelfmark: Settings > Soulseek (slskd), URL http://slskd:5030 and the API key from that file; Downloads Path /downloads/slskd/complete. """ import sys import time from pathlib import Path from typing import Any # Test configuration - matches docker-compose.test-clients.yml CONFIG = { # Usenet clients "nzbget": { "url": "http://localhost:6789", "username": "admin", "password": "admin", }, "sabnzbd": { "url": "http://localhost:8085", "api_key": None, # Will be read from config on first run }, # Torrent clients "qbittorrent": { "url": "http://localhost:8080", "username": "admin", "password": "5NCngsHXm", # Temp password from: docker logs test-qbittorrent | grep password }, "transmission": { "url": "http://localhost:9091", "username": "admin", "password": "admin", }, "deluge": { "url": "http://localhost:8112", "password": "deluge", }, "rtorrent": { "url": "http://localhost:8000/RPC2", }, # Soulseek "slskd": { "url": "http://localhost:5030", "config_file": Path(".local/test-clients/slskd/config/slskd.yml"), }, } SLSKD_CONFIG_TEMPLATE = """# slskd test-stack config (generated by scripts/test_clients.py; not committed). # Soulseek creates the account on first login, so any unused username works. web: port: 5030 authentication: disabled: false username: slskd password: slskdslskd api_keys: shelfmark: key: {api_key} role: readwrite cidr: 0.0.0.0/0,::/0 soulseek: username: {soulseek_username} password: {soulseek_password} description: shelfmark test-stack client directories: downloads: /downloads/slskd/complete incomplete: /downloads/slskd/incomplete shares: directories: - /shares """ # Test magnet link (Ubuntu ISO - legal, small metadata) TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso" def test_nzbget() -> bool: """Test NZBGet connection.""" import requests print("\n" + "=" * 50) print("Testing NZBGet") print("=" * 50) url = CONFIG["nzbget"]["url"] username = CONFIG["nzbget"]["username"] password = CONFIG["nzbget"]["password"] try: # Test connection via JSON-RPC rpc_url = f"{url}/jsonrpc" response = requests.post( rpc_url, json={"method": "version", "params": []}, auth=(username, password), timeout=10, ) response.raise_for_status() result = response.json() version = result.get("result", "unknown") print(f" Connected to NZBGet {version}") # Test status response = requests.post( rpc_url, json={"method": "status", "params": []}, auth=(username, password), timeout=10, ) status = response.json().get("result", {}) print(f" Server state: {'Paused' if status.get('ServerPaused') else 'Running'}") print(f" Downloads in queue: {status.get('DownloadedSizeMB', 0)} MB downloaded") print(" SUCCESS: NZBGet is working!") return True except requests.exceptions.ConnectionError: print(" ERROR: Could not connect to NZBGet") print(" Is the container running? docker ps | grep nzbget") return False except Exception as e: print(f" ERROR: {e}") return False def test_sabnzbd() -> bool: """Test SABnzbd connection.""" import requests print("\n" + "=" * 50) print("Testing SABnzbd") print("=" * 50) url = CONFIG["sabnzbd"]["url"] api_key = CONFIG["sabnzbd"]["api_key"] # Try to get API key from config if not set if not api_key: try: ini_path = Path(".local/test-clients/sabnzbd/config/sabnzbd.ini") if ini_path.exists(): with ini_path.open() as f: for line in f: if line.startswith("api_key"): api_key = line.split("=")[1].strip() print(f" Found API key in config: {api_key[:8]}...") break except Exception as e: print(f" Could not read API key from config: {e}") if not api_key: print(" ERROR: No API key configured") print(" Please access http://localhost:8085 and complete initial setup") print(" Then copy the API key from Config > General") return False try: # Test connection response = requests.get( f"{url}/api", params={"apikey": api_key, "mode": "version", "output": "json"}, timeout=10, ) response.raise_for_status() result = response.json() version = result.get("version", "unknown") print(f" Connected to SABnzbd {version}") # Test queue status response = requests.get( f"{url}/api", params={"apikey": api_key, "mode": "queue", "output": "json"}, timeout=10, ) queue = response.json().get("queue", {}) print(f" Queue status: {queue.get('status', 'unknown')}") print(f" Items in queue: {len(queue.get('slots', []))}") print(" SUCCESS: SABnzbd is working!") return True except requests.exceptions.ConnectionError: print(" ERROR: Could not connect to SABnzbd") print(" Is the container running? docker ps | grep sabnzbd") return False except Exception as e: print(f" ERROR: {e}") return False def test_qbittorrent() -> bool: """Test qBittorrent connection.""" print("\n" + "=" * 50) print("Testing qBittorrent") print("=" * 50) try: import qbittorrentapi url = CONFIG["qbittorrent"]["url"] username = CONFIG["qbittorrent"]["username"] password = CONFIG["qbittorrent"]["password"] # Parse URL for host/port from urllib.parse import urlparse parsed = urlparse(url) client = qbittorrentapi.Client( host=parsed.hostname, port=parsed.port or 8080, username=username, password=password, ) # Test connection client.auth_log_in() version = client.app.version print(f" Connected to qBittorrent {version}") # Get torrent list torrents = client.torrents_info() print(f" Active torrents: {len(torrents)}") # Test adding a torrent (then remove it) print(" Testing add/remove torrent...") result = client.torrents_add(urls=TEST_MAGNET, is_paused=True) if result == "Ok.": # Wait a moment for it to be added time.sleep(1) torrents = client.torrents_info() if torrents: test_torrent = torrents[-1] # Most recently added print(f" Added test torrent: {test_torrent.name[:50]}...") print(f" Status: {test_torrent.state}") # Remove it client.torrents_delete(torrent_hashes=test_torrent.hash, delete_files=True) print(" Removed test torrent") else: print(f" Add result: {result}") print(" SUCCESS: qBittorrent is working!") return True except ImportError: print(" ERROR: qbittorrent-api not installed") print(" Run: uv sync --locked") return False except Exception as e: print(f" ERROR: {e}") if "Forbidden" in str(e) or "401" in str(e): print("\n Authentication failed. Check password:") print(" 1. docker logs test-qbittorrent | grep password") print(" 2. Login to http://localhost:8080 and set a known password") return False def test_transmission() -> bool: """Test Transmission connection.""" print("\n" + "=" * 50) print("Testing Transmission") print("=" * 50) try: from urllib.parse import urlparse from transmission_rpc import Client url = CONFIG["transmission"]["url"] parsed = urlparse(url) client = Client( host=parsed.hostname, port=parsed.port or 9091, username=CONFIG["transmission"]["username"], password=CONFIG["transmission"]["password"], ) # Test connection session = client.get_session() print(f" Connected to Transmission {session.version}") # Get torrent list torrents = client.get_torrents() print(f" Active torrents: {len(torrents)}") # Test adding a torrent (then remove it) print(" Testing add/remove torrent...") torrent = client.add_torrent(TEST_MAGNET, paused=True) print(f" Added test torrent: {torrent.name[:50]}...") # Get status status = client.get_torrent(torrent.id) print(f" Status: {status.status} ({status.percent_done * 100:.1f}%)") # Remove it client.remove_torrent(torrent.id, delete_data=True) print(" Removed test torrent") print(" SUCCESS: Transmission is working!") return True except ImportError: print(" ERROR: transmission-rpc not installed") print(" Run: uv sync --locked") return False except Exception as e: print(f" ERROR: {e}") return False def test_deluge() -> bool: """Test Deluge Web UI (JSON-RPC) connection.""" import requests print("\n" + "=" * 50) print("Testing Deluge") print("=" * 50) base_url = CONFIG["deluge"]["url"].rstrip("/") password = CONFIG["deluge"]["password"] rpc_url = f"{base_url}/json" def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any: payload = {"id": rpc_id, "method": method, "params": list(params)} resp = session.post(rpc_url, json=payload, timeout=10) resp.raise_for_status() data = resp.json() if data.get("error"): err = data["error"] if isinstance(err, dict): raise Exception(err.get("message") or str(err)) raise Exception(str(err)) return data.get("result") try: session = requests.Session() # Authenticate to Deluge Web if rpc_call(session, 1, "auth.login", password) is not True: raise Exception("Authentication failed (check Deluge Web UI password)") # Ensure Deluge Web is connected to a daemon if rpc_call(session, 2, "web.connected") is not True: hosts = rpc_call(session, 3, "web.get_hosts") or [] if not hosts: raise Exception( "Deluge Web UI isn't connected to Deluge core (no hosts configured). " "Add/connect a daemon in Deluge Web UI → Connection Manager." ) host_id = hosts[0][0] for entry in hosts: if ( isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"} ): host_id = entry[0] break rpc_call(session, 4, "web.connect", host_id) if rpc_call(session, 5, "web.connected") is not True: raise Exception( "Deluge Web UI couldn't connect to Deluge core. " "Check Deluge Web UI → Connection Manager." ) version = rpc_call(session, 6, "daemon.info") print(f" Connected to Deluge {version}") torrents = rpc_call(session, 7, "core.get_torrents_status", {}, ["name"]) or {} print(f" Active torrents: {len(torrents)}") # Test adding a torrent (then remove it) print(" Testing add/remove torrent...") torrent_id = rpc_call( session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True} ) if torrent_id: torrent_id = str(torrent_id) print(f" Added test torrent: {torrent_id[:20]}...") status = ( rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) or {} ) state = status.get("state", "unknown") if isinstance(status, dict) else "unknown" progress = status.get("progress", 0) if isinstance(status, dict) else 0 print(f" Status: {state} ({progress:.1f}%)") rpc_call(session, 10, "core.remove_torrent", torrent_id, True) print(" Removed test torrent") else: print(" WARNING: Could not add test torrent") print(" SUCCESS: Deluge is working!") return True except requests.exceptions.ConnectionError: print(" ERROR: Could not connect to Deluge Web UI") print(" Is the container running? docker ps | grep deluge") return False except requests.exceptions.Timeout: print(" ERROR: Deluge Web UI connection timed out") return False except Exception as e: print(f" ERROR: {e}") if "auth" in str(e).lower() or "login" in str(e).lower(): print(" Check Deluge Web UI password (default: deluge)") return False def test_rtorrent() -> bool: """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}") # default download directory test default_dir = client.directory.default() print(f" Default download directory: {default_dir}") # 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}") torrents = client.download_list() print(f" Active torrents: {len(torrents)}") 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 # let's test the base path call details = client.d.multicall.filtered( "", "default", f"equal=d.hash=,cat={torrent_id}", "d.base_path=", ) base_path = details[0][0] if details else None print(f" Base path: {base_path}") 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 _read_slskd_api_key() -> str | None: """Pull the shelfmark API key out of the generated slskd.yml (no YAML dependency).""" config_file = CONFIG["slskd"]["config_file"] if not config_file.exists(): return None for line in config_file.read_text().splitlines(): stripped = line.strip() if stripped.startswith("key:"): return stripped.split(":", 1)[1].strip() return None def write_slskd_config() -> Path: """Write a fresh slskd.yml with a throwaway Soulseek login and API key.""" import secrets config_file = CONFIG["slskd"]["config_file"] config_file.parent.mkdir(parents=True, exist_ok=True) suffix = secrets.token_hex(2) config_file.write_text( SLSKD_CONFIG_TEMPLATE.format( api_key=secrets.token_hex(24), soulseek_username=f"shelfmark-dev-{suffix}", soulseek_password=secrets.token_urlsafe(12), ) ) for folder in ( "shares/TestBooks", "../downloads/slskd/complete", "../downloads/slskd/incomplete", ): (config_file.parent.parent / folder).resolve().mkdir(parents=True, exist_ok=True) return config_file def test_slskd() -> bool: """Test slskd connection and Soulseek login state.""" print("\n" + "=" * 50) print("Testing slskd (Soulseek)") print("=" * 50) # The slskd client needs the app's log/config dirs; keep them out of the way. import os import tempfile scratch = tempfile.mkdtemp(prefix="shelfmark-test-clients-") for var, sub in (("LOG_ROOT", ""), ("CONFIG_DIR", "config"), ("TMP_DIR", "tmp")): os.environ.setdefault(var, str(Path(scratch, sub))) sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from shelfmark.release_sources.slskd.api import SlskdClient api_key = _read_slskd_api_key() if not api_key: print(f" ERROR: no API key found in {CONFIG['slskd']['config_file']}") print(" Run: uv run python scripts/test_clients.py --write-slskd-config") return False client = SlskdClient(CONFIG["slskd"]["url"], api_key, timeout=10) success, message = client.test_connection() print(f" {message}") if not success: print(" Is the container running? docker ps | grep slskd") return False print(f" Downloads directory (in slskd): {client.get_download_directory()}") search_id = client.start_search( "shelfmark-connectivity-probe", timeout_ms=1000, response_limit=1 ) print(f" Search API ok (id {search_id[:8]}...)") client.delete_search(search_id) print(" SUCCESS: slskd is working!") return True def main() -> int: if "--write-slskd-config" in sys.argv: print(f"Wrote {write_slskd_config()}") return 0 print("Download Client Test Suite") print("=" * 50) print("Make sure containers are running:") print(" docker compose -f docker-compose.test-clients.yml up -d") results = {} # Test usenet clients print("\n" + "=" * 50) print("USENET CLIENTS") print("=" * 50) results["nzbget"] = test_nzbget() results["sabnzbd"] = test_sabnzbd() # Test torrent clients print("\n" + "=" * 50) print("TORRENT CLIENTS") print("=" * 50) results["qbittorrent"] = test_qbittorrent() results["transmission"] = test_transmission() results["deluge"] = test_deluge() results["rtorrent"] = test_rtorrent() # Test Soulseek print("\n" + "=" * 50) print("SOULSEEK") print("=" * 50) results["slskd"] = test_slskd() # Summary print("\n" + "=" * 50) print("SUMMARY") print("=" * 50) for client, success in results.items(): status = "PASS" if success else "FAIL" print(f" {client}: {status}") passed = sum(results.values()) total = len(results) print(f"\n Total: {passed}/{total} passed") return 0 if passed == total else 1 if __name__ == "__main__": sys.exit(main())