mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-10 12:28:27 +00:00
## Add Soulseek (slskd) as a release source, building on #1335 This is @dskvr's work from #1335 rebased onto current `main`, with my changes on top. #1335 has been quiet since 2026-09-13 and I needed the changes below, so I carried it forward instead of starting over. Their two commits are the first two in this branch with their authorship intact, and the source plugin, handler, API client, settings tab, tests, `ReleaseProtocol.SOULSEEK`, test client compose file and frontend bits are all theirs. I haven't changed how a search turns into releases beyond what is listed here. If @dskvr would rather keep #1335 alive and take these as commits on their branch, I can do that instead and close this. ### What I changed **Downloads survive other apps using the same slskd.** The handler found its files by listing slskd's downloads, and another app on the same instance (SoulSync here) sends `DELETE /transfers/downloads/all/completed` after each of its batches. Once that ran, a finished download no longer appeared in the list and the task never saw it complete, even though the files were on disk. With the new `SLSKD_ISOLATE_DOWNLOADS` setting (on by default) each task: - enqueues one batch (`POST /transfers/downloads/batches`) into its own folder, `<SLSKD_DESTINATION_PREFIX>/<task id>` - polls each transfer by id, which slskd still answers after the list has been cleared - decides it is done from the files on disk (sizes, not names), with the `/events` log as a fallback when the transfers are gone and the files aren't visible yet - copies out of that folder, and removes it only if `SLSKD_KEEP_COMPLETED` is off and the import worked With isolation off the handler behaves as it did in #1335. If the batch endpoint isn't there (404/405) it falls back to the original per-file enqueue. **Source changes.** m4b/m4a files are their own release next to loose chapter files, so auto-download can prefer one file over a chapter set. Results from the query variants are merged per peer. The folder text is exposed as `extra["author"]` and `extra["title_raw"]`, which is what auto-download's strict match and pack detection read. `is_available()` also needs an API key now. **Smaller fixes.** Cancelling while files are still being located now removes the transfer records it created. In the settings tab, a cleared form field is no longer replaced by the saved value when testing the connection. The new settings are in `docs/environment-variables.md`. ### Testing `tests/slskd` has 192 passing and 6 live tests that skip without an instance. I wrote the new tests first and checked the handler and source rules by breaking them and confirming a test failed. Ruff is clean. On the whole suite the only failures are 4 in `tests/download` that import `seleniumbase`, which isn't installed in my environment, and they fail the same on `main`. I've been running this on my own deployment against slskd 0.26.0 behind a VPN, with SoulSync active on a second instance. A search returned a couple hundred results from about 70 peers, an epub came down and was imported, and auto-download fetched five audiobooks as single m4b files. That is a day of use, so I don't know how it behaves on other slskd versions or on Windows path mappings. --------- Co-authored-by: sandwich <dskvr@users.noreply.github.com> Co-authored-by: sandwich <299465+dskvr@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
689 lines
22 KiB
Python
Executable File
689 lines
22 KiB
Python
Executable File
#!/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())
|