From 6ec9cc0687787efb2ba8087afabcc0306ad8b9eb Mon Sep 17 00:00:00 2001 From: sandwich Date: Sat, 12 Sep 2026 18:32:11 +0200 Subject: [PATCH 1/2] feat(slskd): Shelfmark can now search the Soulseek network and download from it through a slskd instance, added as a self-contained source plugin with a full test suite. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soulseek is different from the torrent and usenet sources Shelfmark already supports: a search result is a file sitting on a specific peer, and only the slskd daemon that found it can fetch it, so search and download ship together as one plugin package under shelfmark/release_sources/slskd/ rather than as a new download-client protocol. The API layer wraps just the slice of slskd's REST interface Shelfmark needs and deals with its quirks (asynchronous searches that need polling and cleanup, transfer records that must be cancelled before they can be removed, older versions that return an empty body on enqueue). The source turns peer responses into releases that fit the existing release list, and the handler drives a download through slskd's transfer queue and then hands the finished files to Shelfmark's normal post-processing pipeline, so naming templates, library delivery and multi-user requests all work unchanged. Configuration is a new settings tab, and the completed-files location can be given either as a direct path or through the existing Remote Path Mappings mechanism. The main pieces: - API client — SlskdClient with API-key auth, connection test (verifies the key and that slskd is logged in to Soulseek), search create/poll/collect/delete, transfer enqueue/list/get/cancel, and lookup of slskd's downloads directory. - Release source (indexer) — runs title+author then bare-title queries; ebooks become one release per file, audiobooks group a peer's folder of audio files into one multi-file release (archives kept separate); locked files are skipped, results are filtered by your Supported Formats and ranked free slot → shortest queue → fastest upload; custom columns show peer, slot availability, format and size. - Download handler — enqueues every file of a release, polls progress with speed and remote queue position, fails fast on peer errors, honours cancel and a configurable queue timeout, locates completed files at slskd's // layout (via the Downloads Path setting or a path mapping for the new slskd client), stages multi-file releases into TMP_DIR so stranger files in a shared folder are never imported, and removes the transfer record and empty folder after a successful import; the download spec is persisted on the task so retries survive a restart. - Settings — "Soulseek (slskd)" tab: enable, URL, API key, Test Connection, downloads path, search wait, max peer responses, queue timeout, remove-completed toggle. - Supporting changes — ReleaseProtocol.SOULSEEK, source registration, slskd in the path-mapping client dropdown, frontend protocol type and dot colour, readme and environment-variable docs. - Test tooling — an slskd service in docker-compose.test-clients.yml and scripts/test_clients.py --write-slskd-config to bootstrap a config with a throwaway Soulseek login and API key. - Tests — 148 new tests covering the API client, release building, the full handler lifecycle against a scripted fake slskd (success, multi-file staging, peer failure, cancel, queue timeout, missing and unsafe paths, cleanup), the settings tab, and the queue → task → retry round-trip through the orchestrator, plus live tests that run against a real instance when SLSKD_TEST_URL/SLSKD_TEST_API_KEY are set. Everything was verified end to end against a running slskd 0.26.0 logged in to Soulseek — a real search returned 37 releases, one was downloaded and its size confirmed on disk, and cancelling a 15-file audiobook mid-transfer left no records behind — and the full suite of 3172 tests, ruff, basedpyright, vulture and the frontend typecheck all pass. --- docker-compose.test-clients.yml | 20 + docs/environment-variables.md | 94 +++ readme.md | 3 +- scripts/test_clients.py | 122 ++++ shelfmark/config/settings.py | 1 + shelfmark/release_sources/__init__.py | 2 + shelfmark/release_sources/slskd/__init__.py | 22 + shelfmark/release_sources/slskd/api.py | 353 ++++++++++ shelfmark/release_sources/slskd/handler.py | 596 ++++++++++++++++ shelfmark/release_sources/slskd/settings.py | 147 ++++ shelfmark/release_sources/slskd/source.py | 485 +++++++++++++ src/frontend/src/types/index.ts | 2 +- src/frontend/src/utils/colorMaps.ts | 1 + tests/slskd/__init__.py | 0 tests/slskd/conftest.py | 24 + tests/slskd/test_api.py | 391 +++++++++++ tests/slskd/test_handler.py | 686 +++++++++++++++++++ tests/slskd/test_live_slskd.py | 80 +++ tests/slskd/test_orchestrator_integration.py | 115 ++++ tests/slskd/test_settings.py | 122 ++++ tests/slskd/test_source.py | 486 +++++++++++++ 21 files changed, 3750 insertions(+), 2 deletions(-) create mode 100644 shelfmark/release_sources/slskd/__init__.py create mode 100644 shelfmark/release_sources/slskd/api.py create mode 100644 shelfmark/release_sources/slskd/handler.py create mode 100644 shelfmark/release_sources/slskd/settings.py create mode 100644 shelfmark/release_sources/slskd/source.py create mode 100644 tests/slskd/__init__.py create mode 100644 tests/slskd/conftest.py create mode 100644 tests/slskd/test_api.py create mode 100644 tests/slskd/test_handler.py create mode 100644 tests/slskd/test_live_slskd.py create mode 100644 tests/slskd/test_orchestrator_integration.py create mode 100644 tests/slskd/test_settings.py create mode 100644 tests/slskd/test_source.py diff --git a/docker-compose.test-clients.yml b/docker-compose.test-clients.yml index 1562a88e4..e66f5bbf0 100644 --- a/docker-compose.test-clients.yml +++ b/docker-compose.test-clients.yml @@ -16,6 +16,7 @@ # - NZBHydra: http://localhost:5076 (no auth by default) # - SABnzbd: http://localhost:8085 (complete setup wizard for API key) # - rTorrent: http://localhost:8000 (admin / admin - if auth enabled) +# - slskd: http://localhost:5030 (slskd / slskdslskd; API key in .local/test-clients/slskd/config/slskd.yml) # services: @@ -39,6 +40,7 @@ services: # - NZBHydra: http://nzbhydra:5076 # - SABnzbd: http://sabnzbd:8080 # - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI) + # - slskd: http://slskd:5030 (downloads land in /downloads/slskd/complete) ports: - "8084:8084" volumes: @@ -68,6 +70,7 @@ services: - transmission - deluge - rtorrent + - slskd restart: unless-stopped prowlarr: @@ -198,3 +201,20 @@ services: - 5076:5076 restart: unless-stopped + slskd: + image: slskd/slskd:latest + container_name: test-slskd + environment: + - TZ=UTC + # Bootstrap: write .local/test-clients/slskd/config/slskd.yml first (see + # scripts/test_clients.py for a template). It holds the Soulseek login, the + # API key Shelfmark uses, and the download directories below. + - SLSKD_CONFIG=/app/slskd.yml + volumes: + - ./.local/test-clients/slskd/config:/app + - ./.local/test-clients/slskd/shares:/shares:ro + - ./.local/test-clients/downloads:/downloads + ports: + - "5030:5030" # Web UI / API + - "50300:50300" # Soulseek peer listen port + restart: unless-stopped diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 5ac035fac..06629f829 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -16,6 +16,7 @@ This document lists all configuration options that can be set via environment va - [Advanced](#advanced) - [Prowlarr](#prowlarr) - [Newznab](#newznab) +- [Soulseek (slskd)](#soulseek-slskd) - [AudiobookBay](#audiobookbay) - [IRC](#irc) - [Download Clients](#download-clients) @@ -1397,6 +1398,99 @@ Automatically retry search without category filtering if no results are found +## Soulseek (slskd) + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `SLSKD_ENABLED` | Enable searching and downloading books through slskd | boolean | `false` | +| `SLSKD_URL` | Base URL of the slskd web interface | string | _none_ | +| `SLSKD_API_KEY` | An API key from slskd's configuration (web.authentication.api_keys) with the readwrite role | string (secret) | _none_ | +| `SLSKD_DOWNLOAD_PATH` | Where slskd's completed downloads folder is mounted inside Shelfmark. Leave empty when both containers see it at the same path, or use Settings > Advanced > Remote Path Mappings with client 'slskd'. | string | _none_ | +| `SLSKD_SEARCH_TIMEOUT` | How long a Soulseek search collects peer responses. Longer waits find more peers but make every search slower. | number | `15` | +| `SLSKD_RESPONSE_LIMIT` | Stop a search early once this many peers have answered | number | `100` | +| `SLSKD_QUEUE_TIMEOUT_MINUTES` | Give up when the peer has not started sending within this long. Set to 0 to wait indefinitely. | number | `60` | +| `SLSKD_REMOVE_COMPLETED` | Clear the transfer from slskd's download list after Shelfmark imports it | boolean | `true` | + +
+Detailed descriptions + +#### `SLSKD_ENABLED` + +**Enable Soulseek source** + +Enable searching and downloading books through slskd + +- **Type:** boolean +- **Default:** `false` + +#### `SLSKD_URL` + +**slskd URL** + +Base URL of the slskd web interface + +- **Type:** string +- **Default:** _none_ + +#### `SLSKD_API_KEY` + +**API Key** + +An API key from slskd's configuration (web.authentication.api_keys) with the readwrite role + +- **Type:** string (secret) +- **Default:** _none_ + +#### `SLSKD_DOWNLOAD_PATH` + +**Downloads Path** + +Where slskd's completed downloads folder is mounted inside Shelfmark. Leave empty when both containers see it at the same path, or use Settings > Advanced > Remote Path Mappings with client 'slskd'. + +- **Type:** string +- **Default:** _none_ + +#### `SLSKD_SEARCH_TIMEOUT` + +**Search Wait (seconds)** + +How long a Soulseek search collects peer responses. Longer waits find more peers but make every search slower. + +- **Type:** number +- **Default:** `15` +- **Constraints:** min: 3, max: 120 + +#### `SLSKD_RESPONSE_LIMIT` + +**Max Peer Responses** + +Stop a search early once this many peers have answered + +- **Type:** number +- **Default:** `100` +- **Constraints:** min: 10, max: 1000 + +#### `SLSKD_QUEUE_TIMEOUT_MINUTES` + +**Queue Timeout (minutes)** + +Give up when the peer has not started sending within this long. Set to 0 to wait indefinitely. + +- **Type:** number +- **Default:** `60` +- **Constraints:** min: 0, max: 1440 + +#### `SLSKD_REMOVE_COMPLETED` + +**Remove completed transfers from slskd** + +Clear the transfer from slskd's download list after Shelfmark imports it + +- **Type:** boolean +- **Default:** `true` + +
+ ## AudiobookBay | Variable | Description | Type | Default | diff --git a/readme.md b/readme.md index b893a0458..41786d3ca 100644 --- a/readme.md +++ b/readme.md @@ -17,7 +17,7 @@ Works great alongside the following library tools, with support for automatic im ## ✨ Features - **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple configured sources in one place -- **Multiple Sources** - Configurable web, torrent, usenet, and IRC source support +- **Multiple Sources** - Configurable web, torrent, usenet, IRC, and Soulseek (slskd) source support - **Audiobook Support** - Full audiobook search and download with dedicated processing - **Flexible Search** - Search metadata providers (Hardcover, Open Library, Google Books) for rich book and audiobook discovery, or query configured sources directly - **Multi-User & Requests** - Share your instance with others, let users browse and request books, and manage approvals with configurable notifications @@ -149,6 +149,7 @@ Some of the additional options available in Settings: - **Additional audiobook sources** - Configure additional sources for audiobook discovery - **Direct Download mirrors** - Supply your own Anna's Archive mirror URLs; Auto mode tries them in the order listed. The `annas-archive.is` domain does not currently work as a source — use `annas-archive.gl` instead (checked August 2026; mirror availability changes) - **IRC** - Add details for IRC book sources and download directly from the UI. Most networks serve audiobooks from the same channel as ebooks (on `irc.irchighway.net` that's `#ebooks`, while `#bookz` is effectively inactive), so leave the separate audiobook channel blank unless your network actually indexes one. IRC audiobooks usually arrive as ZIP/RAR archives — keep those enabled under Supported Audiobook Formats or the releases are filtered out of results +- **Soulseek (slskd)** - Search the Soulseek network and download through a [slskd](https://github.com/slskd/slskd) instance. Point Shelfmark at slskd's URL with an API key (`web.authentication.api_keys` in `slskd.yml`, role `readwrite`), and mount slskd's downloads folder into the Shelfmark container. If the folder sits at a different path inside Shelfmark, set **Downloads Path** in the slskd settings or add a Remote Path Mapping for client `slskd`. Ebook results are single files; audiobook results group a peer's folder of audio files into one release - **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header - **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming - **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS diff --git a/scripts/test_clients.py b/scripts/test_clients.py index 789e464e9..4f6e97b5b 100755 --- a/scripts/test_clients.py +++ b/scripts/test_clients.py @@ -47,6 +47,14 @@ - 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 @@ -84,8 +92,38 @@ "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" @@ -522,7 +560,85 @@ def test_rtorrent() -> bool: 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:") @@ -546,6 +662,12 @@ def main() -> int: 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") diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 543f185d0..9ed2f9b0d 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -1923,6 +1923,7 @@ def advanced_settings() -> list[SettingsField]: {"value": "rtorrent", "label": "rTorrent"}, {"value": "nzbget", "label": "NZBGet"}, {"value": "sabnzbd", "label": "SABnzbd"}, + {"value": "slskd", "label": "slskd"}, ], "defaultValue": "qbittorrent", }, diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index 8901ee7ee..3aa7776df 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -25,6 +25,7 @@ class ReleaseProtocol(StrEnum): TORRENT = "torrent" # BitTorrent NZB = "nzb" # Usenet NZB DCC = "dcc" # IRC DCC + SOULSEEK = "soulseek" # Soulseek peer transfer (via slskd) class SourceUnavailableError(Exception): @@ -433,6 +434,7 @@ def cancel(self, task_id: str) -> bool: "shelfmark.release_sources.irc", "shelfmark.release_sources.newznab", "shelfmark.release_sources.prowlarr", + "shelfmark.release_sources.slskd", ) _builtin_source_state = {"loaded": False} diff --git a/shelfmark/release_sources/slskd/__init__.py b/shelfmark/release_sources/slskd/__init__.py new file mode 100644 index 000000000..d1245b074 --- /dev/null +++ b/shelfmark/release_sources/slskd/__init__.py @@ -0,0 +1,22 @@ +""" +slskd release source plugin. + +Searches the Soulseek network through a slskd instance and downloads the chosen +files with it. Unlike torrent/usenet sources, search and download are the same +service: a result is a file on a specific peer, and only slskd can fetch it. + +Includes: +- SlskdSource: Search integration +- SlskdHandler: Download handling via slskd transfers +""" + +# Import submodules to trigger decorator registration +from shelfmark.release_sources.slskd import ( + handler as handler, +) +from shelfmark.release_sources.slskd import ( + settings as settings, +) +from shelfmark.release_sources.slskd import ( + source as source, +) diff --git a/shelfmark/release_sources/slskd/api.py b/shelfmark/release_sources/slskd/api.py new file mode 100644 index 000000000..929b38e24 --- /dev/null +++ b/shelfmark/release_sources/slskd/api.py @@ -0,0 +1,353 @@ +"""slskd API client - talks to a slskd (Soulseek daemon) instance over its REST API. + +Only the small slice of the v0 API that Shelfmark needs is wrapped here: searches, +download transfers, and the two read-only endpoints used to test the connection and +learn where slskd writes completed files. +""" + +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote + +import requests + +from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url +from shelfmark.download.network import get_ssl_verify + +logger = setup_logger(__name__) + +API_PREFIX = "/api/v0" +_HTTP_UNAUTHORIZED = 401 +_HTTP_FORBIDDEN = 403 +_HTTP_NOT_FOUND = 404 + +# slskd transfer states are "Phase" or "Completed, Outcome" strings. +TRANSFER_STATE_COMPLETED_PREFIX = "Completed" +TRANSFER_STATE_SUCCEEDED = "Completed, Succeeded" + + +class SlskdError(Exception): + """Raised when slskd returns an error or an unexpected payload.""" + + +class SlskdAuthError(SlskdError): + """Raised when slskd rejects the configured API key.""" + + +def transfer_is_complete(state: str | None) -> bool: + """Whether a slskd transfer state string is terminal.""" + return str(state or "").startswith(TRANSFER_STATE_COMPLETED_PREFIX) + + +def transfer_succeeded(state: str | None) -> bool: + """Whether a slskd transfer state string is the successful terminal state.""" + return str(state or "").strip() == TRANSFER_STATE_SUCCEEDED + + +class SlskdClient: + """Client for the slskd v0 REST API.""" + + def __init__(self, url: str, api_key: str, timeout: int = 30) -> None: + self.base_url = normalize_http_url(url).rstrip("/") + self.api_key = api_key + self.timeout = timeout + self._session = requests.Session() + + # ── plumbing ────────────────────────────────────────────────────────────── + + def _api_url(self, path: str) -> str: + base = self.base_url + base = base.removesuffix(API_PREFIX) + return f"{base}{API_PREFIX}/{path.lstrip('/')}" + + def _request( + self, + method: str, + path: str, + *, + json: Any = None, + params: dict[str, Any] | None = None, + ) -> requests.Response: + url = self._api_url(path) + headers = {"Accept": "application/json"} + if self.api_key: + headers["X-API-Key"] = self.api_key + + logger.debug("slskd API: %s %s", method, url) + response = self._session.request( + method, + url, + headers=headers, + json=json, + params=params, + timeout=self.timeout, + verify=get_ssl_verify(url), + ) + if response.status_code in (_HTTP_UNAUTHORIZED, _HTTP_FORBIDDEN): + raise SlskdAuthError("slskd rejected the API key") + response.raise_for_status() + return response + + @staticmethod + def _json(response: requests.Response) -> Any: + if not response.content: + return None + try: + return response.json() + except ValueError as e: + raise SlskdError(f"slskd returned a non-JSON response: {e}") from e + + # ── application / options ───────────────────────────────────────────────── + + def get_application(self) -> dict[str, Any]: + """Return slskd's application state (version, Soulseek server connection...).""" + data = self._json(self._request("GET", "application")) + if not isinstance(data, dict): + raise SlskdError("slskd application state has an unexpected shape") + return data + + def get_download_directory(self) -> str | None: + """Return the directory slskd writes completed downloads to (as slskd sees it).""" + data = self._json(self._request("GET", "options")) + if not isinstance(data, dict): + return None + directories = data.get("directories") + if not isinstance(directories, dict): + return None + downloads = str(directories.get("downloads") or "").strip() + return downloads or None + + def test_connection(self) -> tuple[bool, str]: + """Test connectivity and Soulseek login state. Returns (success, message).""" + logger.info("Testing slskd connection to: %s", self.base_url) + try: + app = self.get_application() + except SlskdAuthError: + return False, "Invalid API key" + except requests.exceptions.ConnectionError: + return False, "Could not connect. Check the URL." + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else "unknown" + return False, f"HTTP error {status}" + except (requests.exceptions.RequestException, SlskdError) as e: + return False, f"Connection failed: {e!s}" + + version = app.get("version") + version_text = "" + if isinstance(version, dict): + version_text = str(version.get("current") or version.get("full") or "").strip() + elif isinstance(version, str): + version_text = version.strip() + label = f"slskd {version_text}".strip() + + server = app.get("server") + server_state = "" + logged_in = False + if isinstance(server, dict): + server_state = str(server.get("state") or "").strip() + logged_in = bool(server.get("isLoggedIn")) + + if not logged_in: + state_text = server_state or "unknown" + return ( + False, + f"Connected to {label}, but it is not logged in to Soulseek " + f"(server state: {state_text}). Check the Soulseek credentials in slskd.", + ) + return True, f"Connected to {label} (logged in to Soulseek)" + + def is_logged_in(self) -> bool: + """Whether slskd currently has a logged-in Soulseek session.""" + try: + server = self.get_application().get("server") + except requests.exceptions.RequestException, SlskdError: + return False + return isinstance(server, dict) and bool(server.get("isLoggedIn")) + + # ── searches ────────────────────────────────────────────────────────────── + + def start_search( + self, + search_text: str, + *, + timeout_ms: int, + response_limit: int, + file_limit: int = 10000, + ) -> str: + """Start a network search and return its slskd id.""" + payload: dict[str, Any] = { + "searchText": search_text, + "searchTimeout": int(timeout_ms), + "responseLimit": int(response_limit), + "fileLimit": int(file_limit), + } + data = self._json(self._request("POST", "searches", json=payload)) + search_id = data.get("id") if isinstance(data, dict) else None + if not search_id: + raise SlskdError("slskd did not return a search id") + return str(search_id) + + def get_search(self, search_id: str) -> dict[str, Any]: + """Return the state of a search (without its responses).""" + data = self._json(self._request("GET", f"searches/{quote(search_id, safe='')}")) + if not isinstance(data, dict): + raise SlskdError("slskd search state has an unexpected shape") + return data + + def get_search_responses(self, search_id: str) -> list[dict[str, Any]]: + """Return the peer responses collected so far for a search.""" + data = self._json(self._request("GET", f"searches/{quote(search_id, safe='')}/responses")) + if not isinstance(data, list): + return [] + return [r for r in data if isinstance(r, dict)] + + def delete_search(self, search_id: str) -> None: + """Forget a search. Best effort; slskd retains them otherwise.""" + try: + self._request("DELETE", f"searches/{quote(search_id, safe='')}") + except (requests.exceptions.RequestException, SlskdError) as e: + logger.debug("Failed to delete slskd search %s: %s", search_id, e) + + def search( + self, + search_text: str, + *, + wait_seconds: float, + response_limit: int, + poll_interval: float = 0.5, + stop_after: float | None = None, + ) -> list[dict[str, Any]]: + """Run a search to completion and return its responses. + + slskd searches are asynchronous: they run for ``searchTimeout`` after the last + response or until ``responseLimit`` peers have answered. This blocks until the + search reports itself complete or ``wait_seconds`` (or ``stop_after``, a + ``time.monotonic()`` deadline) elapses, then collects whatever has arrived. + """ + if not search_text.strip(): + return [] + + search_id = self.start_search( + search_text, + timeout_ms=int(wait_seconds * 1000), + response_limit=response_limit, + ) + + deadline = time.monotonic() + wait_seconds + poll_interval + if stop_after is not None: + deadline = min(deadline, stop_after) + + try: + while True: + state = self.get_search(search_id) + if state.get("isComplete") or transfer_is_complete(state.get("state")): + break + if time.monotonic() >= deadline: + logger.debug("slskd search '%s' still running at deadline", search_text) + break + time.sleep(poll_interval) + + responses = self.get_search_responses(search_id) + finally: + self.delete_search(search_id) + + logger.debug("slskd search '%s': %d responses", search_text, len(responses)) + return responses + + # ── transfers ───────────────────────────────────────────────────────────── + + def enqueue_downloads( + self, + username: str, + files: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + """Ask slskd to download ``files`` (``{"filename", "size"}``) from ``username``. + + Returns the transfer records slskd created. Newer slskd versions return them in + the response body; older ones return an empty 201, in which case the transfers + are looked up by filename from the user's download list. + """ + payload = [{"filename": str(f["filename"]), "size": int(f.get("size") or 0)} for f in files] + response = self._request( + "POST", f"transfers/downloads/{quote(username, safe='')}", json=payload + ) + data = self._json(response) + + if isinstance(data, dict): + failed = data.get("failed") + if isinstance(failed, list) and failed: + first = failed[0] if isinstance(failed[0], dict) else {} + reason = first.get("reason") or first.get("message") or "unknown reason" + raise SlskdError(f"slskd refused {len(failed)} file(s): {reason}") + enqueued = data.get("enqueued") + if isinstance(enqueued, list): + return [t for t in enqueued if isinstance(t, dict)] + + wanted = {str(f["filename"]) for f in files} + return [t for t in self.list_downloads(username) if str(t.get("filename") or "") in wanted] + + def list_downloads(self, username: str) -> list[dict[str, Any]]: + """Return every download transfer slskd tracks for ``username``, flattened.""" + try: + response = self._request("GET", f"transfers/downloads/{quote(username, safe='')}") + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == _HTTP_NOT_FOUND: + return [] + raise + data = self._json(response) + return _flatten_transfers(data) + + def get_transfer(self, username: str, transfer_id: str) -> dict[str, Any] | None: + """Return one transfer record, or None when slskd no longer tracks it.""" + try: + response = self._request( + "GET", + f"transfers/downloads/{quote(username, safe='')}/{quote(transfer_id, safe='')}", + ) + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == _HTTP_NOT_FOUND: + return None + raise + data = self._json(response) + return data if isinstance(data, dict) else None + + def cancel_transfer(self, username: str, transfer_id: str, *, remove: bool) -> bool: + """Cancel a transfer; with ``remove`` also drop it from slskd's list.""" + try: + self._request( + "DELETE", + f"transfers/downloads/{quote(username, safe='')}/{quote(transfer_id, safe='')}", + params={"remove": "true" if remove else "false"}, + ) + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == _HTTP_NOT_FOUND: + return False + raise + return True + + +def _flatten_transfers(data: Any) -> list[dict[str, Any]]: + """Flatten slskd's ``{username, directories: [{directory, files: [...]}]}`` shape.""" + if isinstance(data, list): + # GET /transfers/downloads returns one entry per user. + transfers: list[dict[str, Any]] = [] + for entry in data: + transfers.extend(_flatten_transfers(entry)) + return transfers + if not isinstance(data, dict): + return [] + directories = data.get("directories") + if not isinstance(directories, list): + return [] + transfers = [] + for directory in directories: + if not isinstance(directory, dict): + continue + files = directory.get("files") + if not isinstance(files, list): + continue + transfers.extend(f for f in files if isinstance(f, dict)) + return transfers diff --git a/shelfmark/release_sources/slskd/handler.py b/shelfmark/release_sources/slskd/handler.py new file mode 100644 index 000000000..fa088cd3a --- /dev/null +++ b/shelfmark/release_sources/slskd/handler.py @@ -0,0 +1,596 @@ +"""slskd download handler - enqueues Soulseek transfers in slskd and collects the files. + +slskd writes completed downloads to its own downloads directory, laid out as +``//``. Shelfmark needs to see that directory +too (a shared volume), either at the same path, at the path configured in +``SLSKD_DOWNLOAD_PATH``, or through a remote path mapping for the ``slskd`` client. +""" + +from __future__ import annotations + +import math +import time +from contextlib import suppress +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import requests + +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.core.path_mappings import ( + parse_remote_path_mappings, + remap_remote_to_local_with_match, +) +from shelfmark.download.clients.base_handler import ( + COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS, +) +from shelfmark.download.clients.base_handler import ( + COMPLETED_PATH_RETRY_INTERVAL as _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL, +) +from shelfmark.download.clients.base_handler import ( + COMPLETED_PATH_TIMEOUT_SETTING, + _coerce_completed_path_timeout_seconds, +) +from shelfmark.download.fs import run_blocking_io +from shelfmark.download.postprocess.packs import PackFile +from shelfmark.release_sources import DownloadHandler, register_handler +from shelfmark.release_sources.slskd.api import ( + SlskdClient, + SlskdError, + transfer_is_complete, + transfer_succeeded, +) +from shelfmark.release_sources.slskd.source import ( + SOURCE_NAME, + build_client_from_config, + remote_directory_name, + split_remote_path, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from threading import Event + + from shelfmark.core.models import DownloadTask + +logger = setup_logger(__name__) + +POLL_INTERVAL = 2.0 +COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL +COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS +# Polls with the transfer missing from slskd before giving up (~30s at POLL_INTERVAL). +MAX_MISSING_POLLS = 15 +DEFAULT_QUEUE_TIMEOUT_MINUTES = 60 +PATH_MAPPING_HOST = "slskd" +# After cancelling, how many times to retry removing the leftover records. +CANCEL_REMOVE_PASSES = 3 +CANCEL_REMOVE_INTERVAL = 0.5 +_BYTES_PER_MB = 1024 * 1024 +_SECONDS_PER_MINUTE = 60 + +_SLSKD_ERRORS = (requests.exceptions.RequestException, SlskdError, ValueError, TypeError) + + +@dataclass(frozen=True) +class SlskdDownloadSpec: + """What to fetch: every file of one release, all shared by one peer.""" + + username: str + directory: str + files: list[dict[str, Any]] # {"filename": remote path, "size": bytes} + + @property + def filenames(self) -> list[str]: + return [str(f["filename"]) for f in self.files] + + @property + def total_size(self) -> int: + return sum(max(int(f.get("size") or 0), 0) for f in self.files) + + def to_context(self) -> dict[str, Any]: + return { + "username": self.username, + "directory": self.directory, + "files": [ + {"filename": str(f["filename"]), "size": int(f.get("size") or 0)} + for f in self.files + ], + } + + +@dataclass +class _TransferSnapshot: + """Aggregated state of every transfer in a release at one poll.""" + + found: int = 0 + bytes_transferred: int = 0 + speed: float = 0.0 + all_succeeded: bool = False + failed_state: str | None = None + failed_filename: str | None = None + in_progress: int = 0 + queued_remotely: int = 0 + queue_positions: list[int] = field(default_factory=list) + + +def _parse_spec(username: object, directory: object, raw_files: object) -> SlskdDownloadSpec | None: + user = str(username or "").strip() + if not user or not isinstance(raw_files, list): + return None + files: list[dict[str, Any]] = [] + for raw in raw_files: + if not isinstance(raw, dict): + continue + filename = str(raw.get("filename") or "").strip() + if not filename: + continue + try: + size = int(raw.get("size") or 0) + except TypeError, ValueError: + size = 0 + files.append({"filename": filename, "size": max(size, 0)}) + if not files: + return None + resolved_directory = str(directory or "").strip() or split_remote_path(files[0]["filename"])[0] + return SlskdDownloadSpec(username=user, directory=resolved_directory, files=files) + + +def spec_from_release_data(release_data: dict[str, Any]) -> SlskdDownloadSpec | None: + """Read the download spec from a release payload's ``extra`` block.""" + extra = release_data.get("extra") + if not isinstance(extra, dict): + return None + return _parse_spec(extra.get("username"), extra.get("directory"), extra.get("files")) + + +def spec_from_task(task: DownloadTask) -> SlskdDownloadSpec | None: + """Read the download spec persisted on the task for restart-safe retries.""" + context = getattr(task, "retry_source_context", None) + if not isinstance(context, dict): + return None + return _parse_spec(context.get("username"), context.get("directory"), context.get("files")) + + +def resolve_local_download_root(client: SlskdClient) -> tuple[Path | None, str | None]: + """Where slskd's completed downloads live, as seen from Shelfmark. + + Returns ``(path, None)`` or ``(None, error message)``. + """ + override = str(config.get("SLSKD_DOWNLOAD_PATH", "") or "").strip() + if override: + return Path(override), None + + try: + remote_root = client.get_download_directory() + except _SLSKD_ERRORS as e: + return None, f"Could not read the downloads directory from slskd: {e}" + if not remote_root: + return None, ( + "slskd did not report a downloads directory. Set 'Downloads Path' in the " + "slskd settings." + ) + + mappings = parse_remote_path_mappings(config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])) + remapped, matched = remap_remote_to_local_with_match( + mappings=mappings, host=PATH_MAPPING_HOST, remote_path=remote_root + ) + if matched and remapped is None: + return None, ( + f"Remote path mapping rejected unsafe path '{remote_root}'. " + "Check Settings > Advanced > Remote Path Mappings." + ) + return remapped or Path(remote_root), None + + +def local_path_for(root: Path, remote_filename: str) -> Path | None: + """Compute where slskd stored ``remote_filename`` under ``root``. + + slskd keeps only the last remote folder name, so ``@@a\\Books\\Author\\x.epub`` + lands in ``/Author/x.epub``. Names that would escape ``root`` are refused. + """ + directory, basename = split_remote_path(remote_filename) + folder = remote_directory_name(directory) + parts = [p for p in (folder, basename) if p] + if not basename or any(p in {".", ".."} for p in parts): + return None + return root.joinpath(*parts) + + +def _snapshot(transfers: list[dict[str, Any]], spec: SlskdDownloadSpec) -> _TransferSnapshot: + wanted = {name: index for index, name in enumerate(spec.filenames)} + latest: dict[str, dict[str, Any]] = {} + for transfer in transfers: + filename = str(transfer.get("filename") or "") + if filename not in wanted or transfer.get("removed"): + continue + # slskd can keep several records for one file (a retry after a failure); + # prefer whichever is not terminal-failed, then the newest. + previous = latest.get(filename) + if previous is None or _prefer(transfer, previous): + latest[filename] = transfer + + snap = _TransferSnapshot(found=len(latest)) + succeeded = 0 + for filename, transfer in latest.items(): + state = str(transfer.get("state") or "") + snap.bytes_transferred += max(int(transfer.get("bytesTransferred") or 0), 0) + if transfer_succeeded(state): + succeeded += 1 + continue + if transfer_is_complete(state): + if snap.failed_state is None: + snap.failed_state = state + snap.failed_filename = filename + continue + if state.startswith("InProgress"): + snap.in_progress += 1 + snap.speed += float(transfer.get("averageSpeed") or 0) + elif "Remotely" in state: + snap.queued_remotely += 1 + position = transfer.get("placeInQueue") + if isinstance(position, int) and position > 0: + snap.queue_positions.append(position) + snap.all_succeeded = snap.found == len(spec.files) and succeeded == len(spec.files) + return snap + + +def _prefer(candidate: dict[str, Any], current: dict[str, Any]) -> bool: + candidate_failed = transfer_is_complete(candidate.get("state")) and not transfer_succeeded( + candidate.get("state") + ) + current_failed = transfer_is_complete(current.get("state")) and not transfer_succeeded( + current.get("state") + ) + if candidate_failed != current_failed: + return current_failed + return str(candidate.get("requestedAt") or "") >= str(current.get("requestedAt") or "") + + +def _progress_message(snap: _TransferSnapshot, spec: SlskdDownloadSpec, percent: float) -> str: + if snap.in_progress: + msg = f"{percent:.0f}%" + if snap.speed > 0: + msg += f" ({snap.speed / _BYTES_PER_MB:.1f} MB/s)" + if len(spec.files) > 1: + msg += f" - {snap.in_progress} of {len(spec.files)} files transferring" + return msg + if snap.queued_remotely: + if snap.queue_positions: + return f"Queued at peer (position {min(snap.queue_positions)})" + return "Queued at peer" + return "Waiting for peer" + + +@register_handler(SOURCE_NAME) +class SlskdHandler(DownloadHandler): + """Download a Soulseek release through slskd.""" + + def __init__(self) -> None: + # task_id -> (client, username, transfer ids, local paths) for post-import cleanup + self._cleanup_refs: dict[str, tuple[SlskdClient, str, list[str], list[Path]]] = {} + + # ── queue-time hooks ────────────────────────────────────────────────────── + + def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]: + spec = spec_from_release_data(release_data) + if spec is None: + return {} + return { + "retry_download_protocol": "soulseek", + "retry_source_context": spec.to_context(), + } + + def list_files(self, release_data: dict[str, Any]) -> list[PackFile] | None: + spec = spec_from_release_data(release_data) + if spec is None: + return None + return [ + PackFile(path=split_remote_path(f["filename"])[1], size=int(f.get("size") or 0) or None) + for f in spec.files + ] + + # ── overridable timing (tests patch these) ──────────────────────────────── + + def _poll_interval(self) -> float: + return POLL_INTERVAL + + def _completed_path_retry_interval(self) -> float: + return COMPLETED_PATH_RETRY_INTERVAL + + def _completed_path_timeout_seconds(self) -> float: + fallback = self._completed_path_retry_interval() * COMPLETED_PATH_MAX_ATTEMPTS + return _coerce_completed_path_timeout_seconds( + config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback), fallback + ) + + def _queue_timeout_seconds(self) -> float: + raw = config.get("SLSKD_QUEUE_TIMEOUT_MINUTES", DEFAULT_QUEUE_TIMEOUT_MINUTES) + minutes = float(DEFAULT_QUEUE_TIMEOUT_MINUTES) + if isinstance(raw, (int, float, str)) and not isinstance(raw, bool): + with suppress(ValueError): + minutes = float(raw) + if not math.isfinite(minutes) or minutes < 0: + minutes = float(DEFAULT_QUEUE_TIMEOUT_MINUTES) + return minutes * _SECONDS_PER_MINUTE + + def _get_client(self) -> SlskdClient | None: + return build_client_from_config() + + # ── download ────────────────────────────────────────────────────────────── + + def download( + self, + task: DownloadTask, + cancel_flag: Event, + progress_callback: Callable[[float], None], + status_callback: Callable[[str, str | None], None], + ) -> str | None: + """Enqueue the release in slskd, wait for it, and return the completed path.""" + if cancel_flag.is_set(): + status_callback("cancelled", "Cancelled") + return None + + spec = spec_from_task(task) + if spec is None: + status_callback("error", "Soulseek file details are missing for this release") + logger.warning("slskd task %s has no download spec", task.task_id) + return None + + client = self._get_client() + if client is None: + status_callback("error", "slskd is not configured") + return None + + transfer_ids: list[str] = [] + try: + status_callback("resolving", "Sending to slskd") + try: + enqueued = client.enqueue_downloads(spec.username, spec.files) + except _SLSKD_ERRORS as e: + logger.exception("Failed to enqueue slskd download") + status_callback("error", f"Failed to add to slskd: {e}") + return None + transfer_ids = [str(t["id"]) for t in enqueued if t.get("id")] + logger.info( + "slskd: enqueued %d file(s) from %s for '%s'", + len(spec.files), + spec.username, + task.title, + ) + + snap = self._poll(task, spec, client, cancel_flag, progress_callback, status_callback) + if snap is None: + return None + transfer_ids = self._current_transfer_ids(client, spec) or transfer_ids + + root, root_error = resolve_local_download_root(client) + if root is None: + status_callback("error", root_error or "Could not locate slskd downloads") + return None + + local_paths = self._wait_for_files(root, spec, cancel_flag, status_callback) + if local_paths is None: + if cancel_flag.is_set(): + status_callback("cancelled", "Cancelled") + return None + + result = self._finalize(task, local_paths) + except Exception as e: + logger.exception("slskd download error") + status_callback("error", str(e)) + return None + + if result is not None: + self._cleanup_refs[task.task_id] = (client, spec.username, transfer_ids, local_paths) + return result + + def _poll( + self, + task: DownloadTask, + spec: SlskdDownloadSpec, + client: SlskdClient, + cancel_flag: Event, + progress_callback: Callable[[float], None], + status_callback: Callable[[str, str | None], None], + ) -> _TransferSnapshot | None: + poll_interval = self._poll_interval() + queue_timeout = self._queue_timeout_seconds() + started = time.monotonic() + missing_polls = 0 + total = spec.total_size + + while not cancel_flag.is_set(): + try: + transfers = client.list_downloads(spec.username) + except _SLSKD_ERRORS as e: + logger.warning("slskd status check failed: %s", e) + status_callback("resolving", "Waiting for slskd...") + if cancel_flag.wait(timeout=poll_interval): + break + continue + + snap = _snapshot(transfers, spec) + if snap.found < len(spec.files): + missing_polls += 1 + if missing_polls >= MAX_MISSING_POLLS: + status_callback("error", "Transfer disappeared from slskd") + return None + status_callback("resolving", "Waiting for slskd...") + if cancel_flag.wait(timeout=poll_interval): + break + continue + missing_polls = 0 + + if snap.failed_state is not None: + outcome = snap.failed_state.split(",", 1)[-1].strip() or snap.failed_state + message = f"Peer transfer failed: {outcome}" + if len(spec.files) > 1 and snap.failed_filename: + message += f" ({split_remote_path(snap.failed_filename)[1]})" + logger.error("slskd transfer failed for %s: %s", task.task_id, snap.failed_state) + status_callback("error", message) + self._cancel_transfers(client, spec, remove=True) + return None + + percent = (snap.bytes_transferred / total * 100) if total > 0 else 0.0 + percent = max(0.0, min(100.0, percent)) + progress_callback(percent) + + if snap.all_succeeded: + progress_callback(100.0) + return snap + + if ( + queue_timeout > 0 + and snap.bytes_transferred == 0 + and time.monotonic() - started > queue_timeout + ): + minutes = int(queue_timeout // _SECONDS_PER_MINUTE) + status_callback( + "error", f"Peer did not start the transfer within {minutes} minutes" + ) + self._cancel_transfers(client, spec, remove=True) + return None + + status_callback("downloading", _progress_message(snap, spec, percent)) + if cancel_flag.wait(timeout=poll_interval): + break + + # Cancelled + self._cancel_transfers(client, spec, remove=True) + status_callback("cancelled", "Cancelled") + return None + + def _current_transfer_ids(self, client: SlskdClient, spec: SlskdDownloadSpec) -> list[str]: + try: + transfers = client.list_downloads(spec.username) + except _SLSKD_ERRORS: + return [] + wanted = set(spec.filenames) + return [ + str(t["id"]) + for t in transfers + if t.get("id") and str(t.get("filename") or "") in wanted and not t.get("removed") + ] + + def _cancel_transfers( + self, client: SlskdClient, spec: SlskdDownloadSpec, *, remove: bool + ) -> None: + """Cancel the release's transfers and, if asked, drop their records too. + + slskd's DELETE only removes a transfer that is already complete; on a live one + it cancels and leaves a "Completed, Cancelled" record behind. So removal takes a + second pass once the cancellations have landed. + """ + self._delete_transfers(client, spec, remove=remove) + if not remove: + return + for _ in range(CANCEL_REMOVE_PASSES): + remaining = self._current_transfer_ids(client, spec) + if not remaining: + return + self._cancel_remove_wait() + self._delete_transfers(client, spec, remove=True) + + def _cancel_remove_wait(self) -> None: + time.sleep(CANCEL_REMOVE_INTERVAL) + + def _delete_transfers( + self, client: SlskdClient, spec: SlskdDownloadSpec, *, remove: bool + ) -> None: + for transfer_id in self._current_transfer_ids(client, spec): + try: + client.cancel_transfer(spec.username, transfer_id, remove=remove) + except _SLSKD_ERRORS as e: + logger.warning("Failed to cancel slskd transfer %s: %s", transfer_id, e) + + def _wait_for_files( + self, + root: Path, + spec: SlskdDownloadSpec, + cancel_flag: Event, + status_callback: Callable[[str, str | None], None], + ) -> list[Path] | None: + paths: list[Path] = [] + for filename in spec.filenames: + path = local_path_for(root, filename) + if path is None: + status_callback("error", f"Refusing unsafe Soulseek file name: {filename}") + return None + paths.append(path) + + retry_interval = self._completed_path_retry_interval() + timeout = self._completed_path_timeout_seconds() + attempts = ( + 1 + if retry_interval <= 0 or timeout <= 0 + else int(math.ceil(timeout / retry_interval)) + 1 + ) + + missing: list[Path] = list(paths) + for attempt in range(1, attempts + 1): + if cancel_flag.is_set(): + return None + missing = [p for p in paths if not run_blocking_io(p.exists)] + if not missing: + return paths + if attempt < attempts: + status_callback("locating", "Waiting for completed files...") + if cancel_flag.wait(timeout=retry_interval): + return None + + first_missing = missing[0] + logger.error("slskd completed file not found: %s (downloads root %s)", first_missing, root) + status_callback( + "error", + f"Completed file not found at '{first_missing}'. Mount slskd's downloads folder " + "into Shelfmark and set 'Downloads Path' (or a remote path mapping for 'slskd') " + "to where it is visible.", + ) + return None + + def _finalize(self, task: DownloadTask, local_paths: list[Path]) -> str | None: + if len(local_paths) == 1: + logger.debug("slskd download complete: %s", local_paths[0]) + return str(local_paths[0]) + + # Several files share slskd's per-folder directory with anything else that was + # ever downloaded from a folder of the same name, so gather just ours. + from shelfmark.download.staging import STAGE_MOVE, build_staging_dir, stage_path + + staging_dir = build_staging_dir("slskd", task.task_id) + for path in local_paths: + stage_path(path, staging_dir, STAGE_MOVE) + logger.debug("slskd download staged %d files into %s", len(local_paths), staging_dir) + return str(staging_dir) + + # ── after post-processing ───────────────────────────────────────────────── + + def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None: + refs = self._cleanup_refs.pop(task.task_id, None) + if refs is None or not success: + return + client, username, transfer_ids, local_paths = refs + + if config.get("SLSKD_REMOVE_COMPLETED", True): + for transfer_id in transfer_ids: + try: + client.cancel_transfer(username, transfer_id, remove=True) + except _SLSKD_ERRORS as e: + logger.debug("Failed to remove slskd transfer %s: %s", transfer_id, e) + + # The orchestrator moved the file(s) out; drop the per-folder directory slskd + # created if nothing else is left in it. + for parent in {p.parent for p in local_paths}: + try: + if run_blocking_io(parent.is_dir) and not any(run_blocking_io(parent.iterdir)): + run_blocking_io(parent.rmdir) + except OSError as e: + logger.debug("Could not remove empty slskd folder %s: %s", parent, e) + + def cancel(self, task_id: str) -> bool: + """Cancellation is driven by the queue's cancel flag inside ``download``.""" + logger.debug("Cancel requested for slskd task: %s", task_id) + self._cleanup_refs.pop(task_id, None) + return True diff --git a/shelfmark/release_sources/slskd/settings.py b/shelfmark/release_sources/slskd/settings.py new file mode 100644 index 000000000..b20d3444a --- /dev/null +++ b/shelfmark/release_sources/slskd/settings.py @@ -0,0 +1,147 @@ +"""slskd settings registration.""" + +from typing import Any + +from shelfmark.core.settings_registry import ( + ActionButton, + CheckboxField, + HeadingField, + NumberField, + PasswordField, + SettingsField, + TextField, + register_settings, +) +from shelfmark.core.utils import normalize_http_url + + +def _test_slskd_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: + """Test the slskd connection using unsaved form values when available.""" + from shelfmark.core.config import config + from shelfmark.release_sources.slskd.api import SlskdClient + + current_values = current_values or {} + raw_url = str(current_values.get("SLSKD_URL") or config.get("SLSKD_URL", "") or "") + api_key = str(current_values.get("SLSKD_API_KEY") or config.get("SLSKD_API_KEY", "") or "") + + if not raw_url.strip(): + return {"success": False, "message": "slskd URL is required"} + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "slskd URL is invalid"} + + if not api_key.strip(): + return {"success": False, "message": "slskd API key is required"} + + try: + success, message = SlskdClient(url, api_key.strip()).test_connection() + except Exception as e: # noqa: BLE001 — surface any unexpected error to the UI + return {"success": False, "message": f"Connection failed: {e!s}"} + else: + return {"success": success, "message": message} + + +@register_settings( + name="slskd_config", + display_name="Soulseek (slskd)", + icon="download", + order=43, +) +def slskd_config_settings() -> list[SettingsField]: + """slskd connection and download settings.""" + return [ + HeadingField( + key="slskd_heading", + title="Soulseek via slskd", + description=( + "Search the Soulseek network and download files through a " + "[slskd](https://github.com/slskd/slskd) instance. slskd must be logged in " + "to Soulseek, and its downloads folder must be visible to Shelfmark." + ), + ), + CheckboxField( + key="SLSKD_ENABLED", + label="Enable Soulseek source", + default=False, + description="Enable searching and downloading books through slskd", + ), + TextField( + key="SLSKD_URL", + label="slskd URL", + description="Base URL of the slskd web interface", + placeholder="http://slskd:5030", + required=False, + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + PasswordField( + key="SLSKD_API_KEY", + label="API Key", + description=( + "An API key from slskd's configuration (web.authentication.api_keys) with " + "the readwrite role" + ), + required=False, + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + ActionButton( + key="test_slskd", + label="Test Connection", + description="Verify the URL and API key, and that slskd is logged in to Soulseek", + style="primary", + callback=_test_slskd_connection, + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + TextField( + key="SLSKD_DOWNLOAD_PATH", + label="Downloads Path", + description=( + "Where slskd's completed downloads folder is mounted inside Shelfmark. " + "Leave empty when both containers see it at the same path, or use " + "Settings > Advanced > Remote Path Mappings with client 'slskd'." + ), + placeholder="/downloads/slskd/complete", + required=False, + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + NumberField( + key="SLSKD_SEARCH_TIMEOUT", + label="Search Wait (seconds)", + description=( + "How long a Soulseek search collects peer responses. Longer waits find " + "more peers but make every search slower." + ), + default=15, + min_value=3, + max_value=120, + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + NumberField( + key="SLSKD_RESPONSE_LIMIT", + label="Max Peer Responses", + description="Stop a search early once this many peers have answered", + default=100, + min_value=10, + max_value=1000, + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + NumberField( + key="SLSKD_QUEUE_TIMEOUT_MINUTES", + label="Queue Timeout (minutes)", + description=( + "Give up when the peer has not started sending within this long. " + "Set to 0 to wait indefinitely." + ), + default=60, + min_value=0, + max_value=1440, + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + CheckboxField( + key="SLSKD_REMOVE_COMPLETED", + label="Remove completed transfers from slskd", + default=True, + description="Clear the transfer from slskd's download list after Shelfmark imports it", + show_when={"field": "SLSKD_ENABLED", "value": True}, + ), + ] diff --git a/shelfmark/release_sources/slskd/source.py b/shelfmark/release_sources/slskd/source.py new file mode 100644 index 000000000..c2d3bc3c9 --- /dev/null +++ b/shelfmark/release_sources/slskd/source.py @@ -0,0 +1,485 @@ +"""slskd release source - searches the Soulseek network through a slskd instance. + +Soulseek results are files shared by peers, not curated releases. Ebooks map one +file to one release. Audiobooks are usually a folder of per-chapter audio files, so +audiobook results are grouped by (peer, directory) into a single multi-file release. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from hashlib import sha256 +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from shelfmark.core.search_plan import ReleaseSearchPlan + from shelfmark.metadata_providers import BookMetadata + +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS, normalize_http_url +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.release_sources import ( + ColumnAlign, + ColumnColorHint, + ColumnRenderType, + ColumnSchema, + LeadingCellConfig, + LeadingCellType, + Release, + ReleaseColumnConfig, + ReleaseProtocol, + ReleaseSource, + SortOption, + SourceActionButton, + register_source, +) +from shelfmark.release_sources.prowlarr.source import _parse_size +from shelfmark.release_sources.slskd.api import SlskdClient + +logger = setup_logger(__name__) + +SOURCE_NAME = "slskd" +DISPLAY_NAME = "Soulseek (slskd)" + +# Soulseek searches complete on their own after slskd's per-search timeout; this caps +# the whole Shelfmark-side search across every query variant. +SLSKD_SEARCH_TIMEOUT_SECONDS = 90.0 +DEFAULT_SEARCH_WAIT_SECONDS = 15 +DEFAULT_RESPONSE_LIMIT = 100 +MAX_QUERY_VARIANTS = 3 + +_DEFAULT_EBOOK_FORMATS = ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"] +_DEFAULT_AUDIOBOOK_FORMATS = [*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS] + + +@dataclass(frozen=True) +class PeerFile: + """One shared file from a search response, with its remote path split apart.""" + + username: str + filename: str # Full remote path exactly as slskd reports it + directory: str # Remote directory (Soulseek separators preserved) + basename: str + extension: str # Lowercase, no dot + size: int + + +def split_remote_path(filename: str) -> tuple[str, str]: + """Split a Soulseek path into (directory, basename). + + Soulseek paths use backslashes, but some clients share with forward slashes. + """ + text = str(filename or "") + cut = max(text.rfind("\\"), text.rfind("/")) + if cut < 0: + return "", text + return text[:cut], text[cut + 1 :] + + +def remote_directory_name(directory: str) -> str: + """Return the last segment of a remote directory path.""" + return split_remote_path(directory)[1] or directory + + +def file_extension(basename: str) -> str: + """Return the lowercase extension of a file name, without the dot.""" + name = str(basename or "") + if "." not in name: + return "" + return name.rsplit(".", 1)[1].strip().lower() + + +def make_source_id(username: str, remote_path: str) -> str: + """Stable, cache-safe id for a (peer, remote path) pair.""" + digest = sha256(f"{username}\0{remote_path}".encode()).hexdigest()[:24] + return f"{SOURCE_NAME}:{digest}" + + +def _normalize_formats(raw: object, defaults: list[str]) -> set[str]: + if isinstance(raw, str): + raw = [part for part in raw.replace(",", " ").split() if part] + if not isinstance(raw, (list, tuple)): + return {f.lower() for f in defaults} + formats = {str(item).strip().lower().lstrip(".") for item in raw if str(item).strip()} + return formats or {f.lower() for f in defaults} + + +def get_supported_formats(content_type: str | None) -> set[str]: + """Return the configured formats for the requested content type.""" + if check_audiobook(content_type): + return _normalize_formats( + config.get("SUPPORTED_AUDIOBOOK_FORMATS", None), _DEFAULT_AUDIOBOOK_FORMATS + ) + return _normalize_formats(config.get("SUPPORTED_FORMATS", None), _DEFAULT_EBOOK_FORMATS) + + +def _coerce_int(value: object) -> int: + if isinstance(value, bool): + return 0 + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + try: + return int(float(value.strip())) + except ValueError: + return 0 + return 0 + + +def iter_peer_files(response: dict[str, Any]) -> list[PeerFile]: + """Extract the downloadable files from one slskd search response. + + Locked files (the peer requires a friend/user list) are skipped: slskd would + just queue them forever. + """ + username = str(response.get("username") or "").strip() + raw_files = response.get("files") + if not username or not isinstance(raw_files, list): + return [] + + peer_files: list[PeerFile] = [] + for raw in raw_files: + if not isinstance(raw, dict) or raw.get("isLocked"): + continue + filename = str(raw.get("filename") or "").strip() + if not filename: + continue + directory, basename = split_remote_path(filename) + if not basename: + continue + peer_files.append( + PeerFile( + username=username, + filename=filename, + directory=directory, + basename=basename, + extension=file_extension(basename), + size=max(_coerce_int(raw.get("size")), 0), + ) + ) + return peer_files + + +def _peer_extra(response: dict[str, Any]) -> dict[str, Any]: + upload_speed = _coerce_int(response.get("uploadSpeed")) + queue_length = _coerce_int(response.get("queueLength")) + has_free_slot = bool(response.get("hasFreeUploadSlot")) + if has_free_slot: + availability = "Free slot" + elif queue_length > 0: + availability = f"Queue {queue_length}" + else: + availability = "Busy" + return { + "username": str(response.get("username") or ""), + "upload_speed": upload_speed, + "queue_length": queue_length, + "has_free_upload_slot": has_free_slot, + "availability": availability, + } + + +def _strip_extension(basename: str) -> str: + if "." not in basename: + return basename + stem = basename.rsplit(".", 1)[0] + return stem or basename + + +def file_release(peer_file: PeerFile, response: dict[str, Any], content_type: str) -> Release: + """Build a single-file release.""" + is_audio = check_audiobook(content_type) + return Release( + source=SOURCE_NAME, + source_id=make_source_id(peer_file.username, peer_file.filename), + title=_strip_extension(peer_file.basename), + format=peer_file.extension or None, + language=None, + size=_parse_size(peer_file.size), + size_bytes=peer_file.size or None, + download_url=None, + info_url=None, + protocol=ReleaseProtocol.SOULSEEK, + indexer=peer_file.username, + content_type="audiobook" if is_audio else "ebook", + extra={ + **_peer_extra(response), + "directory": peer_file.directory, + "directory_name": remote_directory_name(peer_file.directory), + "file_count": 1, + "files": [{"filename": peer_file.filename, "size": peer_file.size}], + "formats": [peer_file.extension] if peer_file.extension else [], + }, + ) + + +def directory_release( + files: list[PeerFile], response: dict[str, Any], content_type: str +) -> Release: + """Build a multi-file release from every matching file in one peer directory.""" + first = files[0] + total_size = sum(f.size for f in files) + formats: list[str] = [] + for f in files: + if f.extension and f.extension not in formats: + formats.append(f.extension) + formats.sort(key=lambda ext: -sum(1 for f in files if f.extension == ext)) + directory_name = remote_directory_name(first.directory) or first.username + is_audio = check_audiobook(content_type) + + return Release( + source=SOURCE_NAME, + source_id=make_source_id(first.username, first.directory + "\\"), + title=f"{directory_name} ({len(files)} files)", + format=formats[0] if formats else None, + language=None, + size=_parse_size(total_size), + size_bytes=total_size or None, + download_url=None, + info_url=None, + protocol=ReleaseProtocol.SOULSEEK, + indexer=first.username, + content_type="audiobook" if is_audio else "ebook", + extra={ + **_peer_extra(response), + "directory": first.directory, + "directory_name": directory_name, + "file_count": len(files), + "files": [{"filename": f.filename, "size": f.size} for f in files], + "formats": formats, + }, + ) + + +def build_releases( + responses: list[dict[str, Any]], + *, + content_type: str, + formats: set[str], +) -> list[Release]: + """Turn slskd search responses into releases, filtered to ``formats``.""" + releases: list[Release] = [] + seen_ids: set[str] = set() + group_audio = check_audiobook(content_type) + + for response in responses: + matching = [f for f in iter_peer_files(response) if f.extension in formats] + if not matching: + continue + + if not group_audio: + candidates = [file_release(f, response, content_type) for f in matching] + else: + by_directory: dict[str, list[PeerFile]] = {} + for peer_file in matching: + by_directory.setdefault(peer_file.directory, []).append(peer_file) + candidates = [] + for grouped in by_directory.values(): + # An archive is a whole audiobook on its own; keep those separate from + # the loose chapter files next to them. + archives = [f for f in grouped if f.extension in ARCHIVE_FORMATS] + loose = [f for f in grouped if f.extension not in ARCHIVE_FORMATS] + candidates.extend(file_release(f, response, content_type) for f in archives) + if len(loose) == 1: + candidates.append(file_release(loose[0], response, content_type)) + elif loose: + candidates.append(directory_release(loose, response, content_type)) + + for release in candidates: + if release.source_id in seen_ids: + continue + seen_ids.add(release.source_id) + releases.append(release) + + return releases + + +def rank_key(release: Release) -> tuple: + """Sort releases so the peers most likely to actually serve the file come first.""" + extra = release.extra + return ( + 0 if extra.get("has_free_upload_slot") else 1, + _coerce_int(extra.get("queue_length")), + -_coerce_int(extra.get("upload_speed")), + release.title.lower(), + ) + + +def _config_int(key: str, default: int, *, minimum: int, maximum: int) -> int: + value = _coerce_int(config.get(key, default)) + if value <= 0: + value = default + return max(minimum, min(maximum, value)) + + +def build_client_from_config() -> SlskdClient | None: + """Build a client from settings, or None when the URL is missing/invalid.""" + raw_url = str(config.get("SLSKD_URL", "") or "").strip() + if not raw_url: + return None + url = normalize_http_url(raw_url) + if not url: + return None + api_key = str(config.get("SLSKD_API_KEY", "") or "").strip() + return SlskdClient(url, api_key) + + +def _plan_queries(plan: ReleaseSearchPlan) -> list[str]: + """Search strings to try, most specific first, with duplicates removed.""" + queries: list[str] = [] + if plan.manual_query: + queries.append(plan.manual_query) + for variant in plan.title_variants: + queries.append(variant.query) + # Peers rarely name files with the author; the bare title is the fallback. + if variant.author: + queries.append(variant.title) + + unique: list[str] = [] + seen: set[str] = set() + for query in queries: + cleaned = " ".join(str(query or "").split()) + key = cleaned.lower() + if not cleaned or key in seen: + continue + seen.add(key) + unique.append(cleaned) + return unique[:MAX_QUERY_VARIANTS] + + +@register_source(SOURCE_NAME) +class SlskdSource(ReleaseSource): + """Release source for the Soulseek network via slskd.""" + + name = SOURCE_NAME + display_name = DISPLAY_NAME + supported_content_types: ClassVar[list[str]] = ["ebook", "audiobook"] + + def get_column_config(self) -> ReleaseColumnConfig: + return ReleaseColumnConfig( + columns=[ + ColumnSchema( + key="extra.username", + label="Peer", + render_type=ColumnRenderType.TEXT, + align=ColumnAlign.LEFT, + width="minmax(100px, 1fr)", + hide_mobile=False, + sortable=True, + sort_key="indexer", + ), + ColumnSchema( + key="extra.availability", + label="Slot", + render_type=ColumnRenderType.TEXT, + align=ColumnAlign.CENTER, + width="80px", + hide_mobile=True, + ), + ColumnSchema( + key="format", + label="Format", + render_type=ColumnRenderType.BADGE, + align=ColumnAlign.CENTER, + width="70px", + hide_mobile=False, + color_hint=ColumnColorHint(type="map", value="format"), + uppercase=True, + sortable=True, + ), + ColumnSchema( + key="size", + label="Size", + render_type=ColumnRenderType.SIZE, + align=ColumnAlign.CENTER, + width="80px", + hide_mobile=False, + sortable=True, + sort_key="size_bytes", + ), + ], + grid_template="minmax(0,2fr) minmax(100px,1fr) 80px 70px 80px", + leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), + cache_ttl_seconds=600, + supported_filters=["format"], + extra_sort_options=[ + SortOption(label="Upload speed", sort_key="extra.upload_speed"), + SortOption( + label="Queue length", sort_key="extra.queue_length", default_direction="asc" + ), + ], + action_button=SourceActionButton(label="Search again"), + ) + + def _get_client(self) -> SlskdClient | None: + return build_client_from_config() + + def search( + self, + book: BookMetadata, + plan: ReleaseSearchPlan, + *, + expand_search: bool = False, + content_type: str = "ebook", + ) -> list[Release]: + """Search Soulseek for files matching the book.""" + client = self._get_client() + if client is None: + logger.warning("slskd not configured - skipping search") + return [] + + queries = _plan_queries(plan) + if not queries: + logger.warning("slskd: no search query available") + return [] + + formats = get_supported_formats(content_type) + wait_seconds = _config_int( + "SLSKD_SEARCH_TIMEOUT", DEFAULT_SEARCH_WAIT_SECONDS, minimum=3, maximum=120 + ) + response_limit = _config_int( + "SLSKD_RESPONSE_LIMIT", DEFAULT_RESPONSE_LIMIT, minimum=10, maximum=1000 + ) + deadline = time.monotonic() + SLSKD_SEARCH_TIMEOUT_SECONDS + + releases: list[Release] = [] + seen_ids: set[str] = set() + # Expanding keeps going through every variant even after one produced results. + for index, query in enumerate(queries, start=1): + if releases and not expand_search: + break + if time.monotonic() >= deadline: + logger.warning("slskd search timed out before query %d/%d", index, len(queries)) + break + logger.debug("slskd query %d/%d: '%s'", index, len(queries), query) + try: + responses = client.search( + query, + wait_seconds=wait_seconds, + response_limit=response_limit, + stop_after=deadline, + ) + except Exception: + logger.exception("slskd search failed for '%s'", query) + continue + + for release in build_releases(responses, content_type=content_type, formats=formats): + if release.source_id in seen_ids: + continue + seen_ids.add(release.source_id) + releases.append(release) + + releases.sort(key=rank_key) + if releases: + peers = len({r.indexer for r in releases}) + logger.info("slskd: %d results from %d peers", len(releases), peers) + else: + logger.debug("slskd: no results found") + return releases + + def is_available(self) -> bool: + if not config.get("SLSKD_ENABLED", False): + return False + return bool(normalize_http_url(str(config.get("SLSKD_URL", "") or ""))) diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 9ee4ea8e7..2c5f29316 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -447,7 +447,7 @@ export interface Release { size_bytes?: number; // Size in bytes for sorting download_url?: string; info_url?: string; // Link to release info page (e.g., tracker page) - makes title clickable - protocol?: 'http' | 'torrent' | 'nzb' | 'dcc'; + protocol?: 'http' | 'torrent' | 'nzb' | 'dcc' | 'soulseek'; indexer?: string; // Display name for the source/indexer seeders?: number; // For torrents peers?: string; // For torrents: "seeders/leechers" display string diff --git a/src/frontend/src/utils/colorMaps.ts b/src/frontend/src/utils/colorMaps.ts index f4c5fabcd..f5bf18607 100644 --- a/src/frontend/src/utils/colorMaps.ts +++ b/src/frontend/src/utils/colorMaps.ts @@ -117,6 +117,7 @@ export function getProtocolDotColor(protocol?: string): string { const p = protocol.toLowerCase(); if (p === 'torrent') return 'bg-orange-500'; if (p === 'nzb' || p === 'usenet') return 'bg-sky-500'; + if (p === 'soulseek') return 'bg-emerald-500'; return 'bg-gray-400'; } diff --git a/tests/slskd/__init__.py b/tests/slskd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slskd/conftest.py b/tests/slskd/conftest.py new file mode 100644 index 000000000..d53f5f1af --- /dev/null +++ b/tests/slskd/conftest.py @@ -0,0 +1,24 @@ +""" +slskd test configuration. + +Stubs out optional heavy dependencies (flask_socketio) that are not available +in lightweight dev/CI environments. These are only needed by the IRC source, +which is unrelated to the slskd plugin under test. +""" + +import sys +import types + + +def _stub_module(name: str) -> None: + """Insert a minimal stub module into sys.modules if not already present.""" + if name not in sys.modules: + stub = types.ModuleType(name) + # Provide no-op stand-ins for the symbols IRC source imports. + stub.SocketIO = object + stub.join_room = lambda *a, **kw: None + stub.leave_room = lambda *a, **kw: None + sys.modules[name] = stub + + +_stub_module("flask_socketio") diff --git a/tests/slskd/test_api.py b/tests/slskd/test_api.py new file mode 100644 index 000000000..300041519 --- /dev/null +++ b/tests/slskd/test_api.py @@ -0,0 +1,391 @@ +"""Unit tests for the slskd API client.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from shelfmark.release_sources.slskd.api import ( + SlskdAuthError, + SlskdClient, + SlskdError, + _flatten_transfers, + transfer_is_complete, + transfer_succeeded, +) + +# ── helpers ──────────────────────────────────────────────────────────────────── + + +def _make_response(payload=None, status: int = 200, *, raw: bytes | None = None) -> MagicMock: + r = MagicMock(spec=requests.Response) + r.status_code = status + r.ok = status < 400 + if raw is not None: + r.content = raw + r.json.side_effect = ValueError("not json") + elif payload is None: + r.content = b"" + r.json.side_effect = ValueError("no content") + else: + r.content = b"{}" + r.json.return_value = payload + r.raise_for_status = MagicMock() + if status >= 400: + r.raise_for_status.side_effect = requests.exceptions.HTTPError(response=r) + return r + + +def _app_payload(*, logged_in: bool = True, state: str = "Connected, LoggedIn") -> dict: + return { + "version": {"full": "0.26.0.0 (0.26.0.0+e42a525d)", "current": "0.26.0.0"}, + "server": {"state": state, "isConnected": True, "isLoggedIn": logged_in}, + } + + +TRANSFERS_PAYLOAD = { + "username": "peer", + "directories": [ + { + "directory": "@@share\\Books\\Author", + "fileCount": 2, + "files": [ + {"id": "t1", "filename": "@@share\\Books\\Author\\one.epub", "state": "InProgress"}, + { + "id": "t2", + "filename": "@@share\\Books\\Author\\two.epub", + "state": "Completed, Succeeded", + }, + ], + } + ], +} + + +# ── URL construction / auth ──────────────────────────────────────────────────── + + +class TestApiUrl: + def test_appends_api_prefix(self): + client = SlskdClient("http://slskd:5030", "key") + assert client._api_url("application") == "http://slskd:5030/api/v0/application" + + def test_strips_trailing_slash(self): + client = SlskdClient("http://slskd:5030/", "key") + assert client._api_url("/searches") == "http://slskd:5030/api/v0/searches" + + def test_does_not_double_prefix(self): + client = SlskdClient("http://slskd:5030/api/v0", "key") + assert client._api_url("options") == "http://slskd:5030/api/v0/options" + + def test_adds_scheme_when_missing(self): + client = SlskdClient("slskd:5030", "key") + assert client.base_url.startswith("http") + + +class TestRequest: + def test_sends_api_key_header(self): + client = SlskdClient("http://slskd:5030", "secret") + with patch.object( + client._session, "request", return_value=_make_response(_app_payload()) + ) as req: + client.get_application() + kwargs = req.call_args.kwargs + assert kwargs["headers"]["X-API-Key"] == "secret" + assert kwargs["method"] == "GET" if "method" in kwargs else req.call_args.args[0] == "GET" + + def test_omits_header_without_key(self): + client = SlskdClient("http://slskd:5030", "") + with patch.object( + client._session, "request", return_value=_make_response(_app_payload()) + ) as req: + client.get_application() + assert "X-API-Key" not in req.call_args.kwargs["headers"] + + def test_401_raises_auth_error(self): + client = SlskdClient("http://slskd:5030", "bad") + with ( + patch.object(client._session, "request", return_value=_make_response(status=401)), + pytest.raises(SlskdAuthError), + ): + client.get_application() + + def test_500_raises_http_error(self): + client = SlskdClient("http://slskd:5030", "key") + with ( + patch.object(client._session, "request", return_value=_make_response(status=500)), + pytest.raises(requests.exceptions.HTTPError), + ): + client.get_application() + + def test_non_json_body_raises_slskd_error(self): + client = SlskdClient("http://slskd:5030", "key") + with ( + patch.object(client._session, "request", return_value=_make_response(raw=b"")), + pytest.raises(SlskdError), + ): + client.get_application() + + +# ── test_connection ──────────────────────────────────────────────────────────── + + +class TestTestConnection: + def test_success_when_logged_in(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object(client, "get_application", return_value=_app_payload()): + ok, msg = client.test_connection() + assert ok is True + assert "0.26.0" in msg + assert "logged in" in msg.lower() + + def test_failure_when_not_logged_in(self): + client = SlskdClient("http://slskd:5030", "key") + payload = _app_payload(logged_in=False, state="Disconnected") + with patch.object(client, "get_application", return_value=payload): + ok, msg = client.test_connection() + assert ok is False + assert "Disconnected" in msg + assert "not logged in" in msg + + def test_invalid_api_key(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object(client, "get_application", side_effect=SlskdAuthError("no")): + ok, msg = client.test_connection() + assert ok is False + assert msg == "Invalid API key" + + def test_connection_error(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object( + client, "get_application", side_effect=requests.exceptions.ConnectionError() + ): + ok, msg = client.test_connection() + assert ok is False + assert "Could not connect" in msg + + def test_http_error(self): + client = SlskdClient("http://slskd:5030", "key") + err = requests.exceptions.HTTPError(response=_make_response(status=502)) + with patch.object(client, "get_application", side_effect=err): + ok, msg = client.test_connection() + assert ok is False + assert "502" in msg + + +# ── options ──────────────────────────────────────────────────────────────────── + + +class TestGetDownloadDirectory: + def test_reads_directories_downloads(self): + client = SlskdClient("http://slskd:5030", "key") + payload = {"directories": {"downloads": "/downloads/complete", "incomplete": "/x"}} + with patch.object(client, "_request", return_value=_make_response(payload)): + assert client.get_download_directory() == "/downloads/complete" + + def test_returns_none_when_missing(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object(client, "_request", return_value=_make_response({"web": {}})): + assert client.get_download_directory() is None + + +# ── searches ─────────────────────────────────────────────────────────────────── + + +class TestSearch: + def test_start_search_posts_expected_payload(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object( + client, "_request", return_value=_make_response({"id": "abc", "state": "InProgress"}) + ) as req: + search_id = client.start_search("dune herbert", timeout_ms=5000, response_limit=50) + assert search_id == "abc" + method, path = req.call_args.args + assert (method, path) == ("POST", "searches") + body = req.call_args.kwargs["json"] + assert body["searchText"] == "dune herbert" + assert body["searchTimeout"] == 5000 + assert body["responseLimit"] == 50 + + def test_start_search_without_id_raises(self): + client = SlskdClient("http://slskd:5030", "key") + with ( + patch.object(client, "_request", return_value=_make_response({})), + pytest.raises(SlskdError), + ): + client.start_search("x", timeout_ms=1000, response_limit=10) + + def test_search_polls_until_complete_then_deletes(self): + client = SlskdClient("http://slskd:5030", "key") + states = iter( + [ + {"id": "abc", "isComplete": False, "state": "InProgress"}, + {"id": "abc", "isComplete": True, "state": "Completed, TimedOut"}, + ] + ) + responses = [{"username": "peer", "files": []}] + with ( + patch.object(client, "start_search", return_value="abc"), + patch.object(client, "get_search", side_effect=lambda _id: next(states)), + patch.object(client, "get_search_responses", return_value=responses) as get_resp, + patch.object(client, "delete_search") as delete, + patch("shelfmark.release_sources.slskd.api.time.sleep"), + ): + result = client.search("dune", wait_seconds=5, response_limit=10, poll_interval=0) + assert result == responses + get_resp.assert_called_once_with("abc") + delete.assert_called_once_with("abc") + + def test_search_stops_at_deadline_and_still_collects(self): + client = SlskdClient("http://slskd:5030", "key") + with ( + patch.object(client, "start_search", return_value="abc"), + patch.object( + client, "get_search", return_value={"isComplete": False, "state": "InProgress"} + ), + patch.object(client, "get_search_responses", return_value=[{"username": "p"}]), + patch.object(client, "delete_search") as delete, + patch("shelfmark.release_sources.slskd.api.time.sleep"), + patch("shelfmark.release_sources.slskd.api.time.monotonic", side_effect=[0, 0.1, 100]), + ): + result = client.search("dune", wait_seconds=1, response_limit=10, poll_interval=0) + assert result == [{"username": "p"}] + delete.assert_called_once() + + def test_search_deletes_even_when_polling_fails(self): + client = SlskdClient("http://slskd:5030", "key") + with ( + patch.object(client, "start_search", return_value="abc"), + patch.object(client, "get_search", side_effect=requests.exceptions.Timeout()), + patch.object(client, "delete_search") as delete, + pytest.raises(requests.exceptions.Timeout), + ): + client.search("dune", wait_seconds=1, response_limit=10) + delete.assert_called_once_with("abc") + + def test_empty_query_short_circuits(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object(client, "start_search") as start: + assert client.search(" ", wait_seconds=1, response_limit=10) == [] + start.assert_not_called() + + def test_get_search_responses_filters_non_dicts(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object( + client, "_request", return_value=_make_response([{"username": "a"}, "junk", 3]) + ): + assert client.get_search_responses("abc") == [{"username": "a"}] + + def test_delete_search_swallows_errors(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object(client, "_request", side_effect=requests.exceptions.ConnectionError()): + client.delete_search("abc") # must not raise + + +# ── transfers ────────────────────────────────────────────────────────────────── + + +class TestTransfers: + def test_enqueue_returns_enqueued_records(self): + client = SlskdClient("http://slskd:5030", "key") + payload = { + "enqueued": [{"id": "t1", "filename": "a\\b.epub", "state": "Queued, Locally"}], + "failed": [], + } + with patch.object(client, "_request", return_value=_make_response(payload)) as req: + result = client.enqueue_downloads("peer name", [{"filename": "a\\b.epub", "size": 5}]) + assert result == payload["enqueued"] + method, path = req.call_args.args + assert method == "POST" + assert path == "transfers/downloads/peer%20name" + assert req.call_args.kwargs["json"] == [{"filename": "a\\b.epub", "size": 5}] + + def test_enqueue_raises_when_slskd_reports_failures(self): + client = SlskdClient("http://slskd:5030", "key") + payload = {"enqueued": [], "failed": [{"filename": "x", "reason": "Not shared"}]} + with ( + patch.object(client, "_request", return_value=_make_response(payload)), + pytest.raises(SlskdError, match="Not shared"), + ): + client.enqueue_downloads("peer", [{"filename": "x", "size": 1}]) + + def test_enqueue_falls_back_to_listing_on_empty_body(self): + # Older slskd versions answer 201 with no body. + client = SlskdClient("http://slskd:5030", "key") + with ( + patch.object(client, "_request", return_value=_make_response(None, status=201)), + patch.object( + client, "list_downloads", return_value=_flatten_transfers(TRANSFERS_PAYLOAD) + ), + ): + result = client.enqueue_downloads( + "peer", [{"filename": "@@share\\Books\\Author\\two.epub", "size": 1}] + ) + assert [t["id"] for t in result] == ["t2"] + + def test_list_downloads_flattens_directories(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object(client, "_request", return_value=_make_response(TRANSFERS_PAYLOAD)): + transfers = client.list_downloads("peer") + assert [t["id"] for t in transfers] == ["t1", "t2"] + + def test_list_downloads_404_is_empty(self): + client = SlskdClient("http://slskd:5030", "key") + err = requests.exceptions.HTTPError(response=_make_response(status=404)) + with patch.object(client, "_request", side_effect=err): + assert client.list_downloads("peer") == [] + + def test_get_transfer_404_is_none(self): + client = SlskdClient("http://slskd:5030", "key") + err = requests.exceptions.HTTPError(response=_make_response(status=404)) + with patch.object(client, "_request", side_effect=err): + assert client.get_transfer("peer", "t1") is None + + def test_get_transfer_returns_record(self): + client = SlskdClient("http://slskd:5030", "key") + record = {"id": "t1", "state": "InProgress"} + with patch.object(client, "_request", return_value=_make_response(record)) as req: + assert client.get_transfer("peer", "t1") == record + assert req.call_args.args == ("GET", "transfers/downloads/peer/t1") + + def test_cancel_transfer_sends_remove_flag(self): + client = SlskdClient("http://slskd:5030", "key") + with patch.object(client, "_request", return_value=_make_response(None, 204)) as req: + assert client.cancel_transfer("peer", "t1", remove=True) is True + assert req.call_args.args == ("DELETE", "transfers/downloads/peer/t1") + assert req.call_args.kwargs["params"] == {"remove": "true"} + + def test_cancel_transfer_404_is_false(self): + client = SlskdClient("http://slskd:5030", "key") + err = requests.exceptions.HTTPError(response=_make_response(status=404)) + with patch.object(client, "_request", side_effect=err): + assert client.cancel_transfer("peer", "t1", remove=False) is False + + +# ── helpers ──────────────────────────────────────────────────────────────────── + + +class TestHelpers: + def test_flatten_handles_list_of_users(self): + data = [TRANSFERS_PAYLOAD, {"username": "other", "directories": []}] + assert [t["id"] for t in _flatten_transfers(data)] == ["t1", "t2"] + + def test_flatten_ignores_garbage(self): + assert _flatten_transfers(None) == [] + assert _flatten_transfers({"directories": "nope"}) == [] + assert _flatten_transfers({"directories": [{"files": "nope"}, 3]}) == [] + + @pytest.mark.parametrize( + ("state", "complete", "succeeded"), + [ + ("Completed, Succeeded", True, True), + ("Completed, Errored", True, False), + ("Completed, Cancelled", True, False), + ("InProgress", False, False), + ("Queued, Remotely", False, False), + (None, False, False), + ], + ) + def test_state_predicates(self, state, complete, succeeded): + assert transfer_is_complete(state) is complete + assert transfer_succeeded(state) is succeeded diff --git a/tests/slskd/test_handler.py b/tests/slskd/test_handler.py new file mode 100644 index 000000000..3b714cb20 --- /dev/null +++ b/tests/slskd/test_handler.py @@ -0,0 +1,686 @@ +"""Unit tests for the slskd download handler.""" + +from pathlib import Path +from threading import Event +from unittest.mock import MagicMock + +import pytest + +from shelfmark.core.models import DownloadTask +from shelfmark.release_sources import get_handler +from shelfmark.release_sources.slskd.api import SlskdError +from shelfmark.release_sources.slskd.handler import ( + SlskdDownloadSpec, + SlskdHandler, + _snapshot, + local_path_for, + resolve_local_download_root, + spec_from_release_data, + spec_from_task, +) + +# ── helpers ──────────────────────────────────────────────────────────────────── + +REMOTE_DIR = "@@share\\Books\\Author" +FILE_A = f"{REMOTE_DIR}\\Book.epub" +FILE_B = f"{REMOTE_DIR}\\Book.mobi" + + +class ProgressRecorder: + def __init__(self): + self.progress_values: list[float] = [] + self.status_updates: list[tuple[str, str | None]] = [] + + def progress_callback(self, v: float): + self.progress_values.append(v) + + def status_callback(self, status: str, message: str | None): + self.status_updates.append((status, message)) + + @property + def last_status(self) -> str | None: + return self.status_updates[-1][0] if self.status_updates else None + + @property + def last_message(self) -> str | None: + return self.status_updates[-1][1] if self.status_updates else None + + @property + def statuses(self) -> list[str]: + return [s[0] for s in self.status_updates] + + +class FakeSlskd: + """In-memory stand-in for SlskdClient that scripts transfer state per poll.""" + + def __init__(self, *, download_dir="/downloads/complete", polls=None): + self.download_dir = download_dir + # Each poll returns the next list of transfer dicts; the last repeats forever. + self.polls = list(polls or []) + self.enqueued: list[tuple[str, list[dict]]] = [] + self.cancelled: list[tuple[str, str, bool]] = [] + self.enqueue_error: Exception | None = None + self.poll_count = 0 + + def enqueue_downloads(self, username, files): + if self.enqueue_error: + raise self.enqueue_error + self.enqueued.append((username, files)) + return [ + {"id": f"t{i}", "filename": f["filename"], "state": "Queued, Locally"} + for i, f in enumerate(files) + ] + + def list_downloads(self, username): + self.poll_count += 1 + if not self.polls: + return [] + index = min(self.poll_count - 1, len(self.polls) - 1) + return list(self.polls[index]) + + def cancel_transfer(self, username, transfer_id, *, remove): + """Mimic slskd: a live transfer is cancelled (record kept), a complete one removed.""" + self.cancelled.append((username, transfer_id, remove)) + for poll in self.polls: + for transfer in poll: + if transfer.get("id") != transfer_id: + continue + if transfer["state"].startswith("Completed"): + if remove: + transfer["removed"] = True + else: + transfer["state"] = "Completed, Cancelled" + return True + + def get_download_directory(self): + return self.download_dir + + +def _transfer(filename, state, *, transferred=0, size=1000, tid=None, speed=0, **extra): + return { + "id": tid or f"id-{filename}", + "filename": filename, + "state": state, + "bytesTransferred": transferred, + "size": size, + "averageSpeed": speed, + "removed": False, + **extra, + } + + +def _release_data(files=None, username="peer", directory=REMOTE_DIR): + files = files if files is not None else [{"filename": FILE_A, "size": 1000}] + return { + "source": "slskd", + "source_id": "slskd:abc", + "title": "Book", + "extra": {"username": username, "directory": directory, "files": files}, + } + + +def _task(files=None, task_id="slskd:abc", username="peer"): + files = files if files is not None else [{"filename": FILE_A, "size": 1000}] + return DownloadTask( + task_id=task_id, + source="slskd", + title="Book", + retry_source_context={"username": username, "directory": REMOTE_DIR, "files": files}, + ) + + +@pytest.fixture +def fast_handler(monkeypatch): + """Handler with no sleeps between polls / path checks.""" + monkeypatch.setattr(SlskdHandler, "_poll_interval", lambda self: 0) + monkeypatch.setattr(SlskdHandler, "_completed_path_retry_interval", lambda self: 0) + monkeypatch.setattr(SlskdHandler, "_completed_path_timeout_seconds", lambda self: 0) + monkeypatch.setattr(SlskdHandler, "_queue_timeout_seconds", lambda self: 0) + monkeypatch.setattr(SlskdHandler, "_cancel_remove_wait", lambda self: None) + return SlskdHandler() + + +@pytest.fixture +def config_values(monkeypatch): + import shelfmark.release_sources.slskd.handler as mod + + values: dict = {} + monkeypatch.setattr(mod.config, "get", lambda k, d=None: values.get(k, d)) + return values + + +def _mount(fast_handler, fake): + fast_handler._get_client = lambda: fake # type: ignore[method-assign] + + +def _write_completed(root: Path, *filenames: str) -> list[Path]: + paths = [] + for filename in filenames: + path = local_path_for(root, filename) + assert path is not None + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * 10) + paths.append(path) + return paths + + +# ── spec parsing ─────────────────────────────────────────────────────────────── + + +class TestSpec: + def test_from_release_data(self): + spec = spec_from_release_data(_release_data()) + assert spec == SlskdDownloadSpec( + username="peer", directory=REMOTE_DIR, files=[{"filename": FILE_A, "size": 1000}] + ) + assert spec.filenames == [FILE_A] + assert spec.total_size == 1000 + + def test_from_release_data_without_extra(self): + assert spec_from_release_data({"source": "slskd"}) is None + assert spec_from_release_data({"extra": {"username": "peer", "files": []}}) is None + assert ( + spec_from_release_data({"extra": {"username": "", "files": [{"filename": "x"}]}}) + is None + ) + + def test_directory_derived_when_missing(self): + spec = spec_from_release_data(_release_data(directory="")) + assert spec is not None + assert spec.directory == REMOTE_DIR + + def test_from_task_round_trip(self): + handler = SlskdHandler() + fields = handler.build_retry_resolution_fields(_release_data()) + assert fields["retry_download_protocol"] == "soulseek" + task = DownloadTask(task_id="x", source="slskd", title="t", **fields) + assert spec_from_task(task) == spec_from_release_data(_release_data()) + + def test_from_task_without_context(self): + assert spec_from_task(DownloadTask(task_id="x", source="slskd", title="t")) is None + + def test_bad_sizes_become_zero(self): + spec = spec_from_release_data(_release_data(files=[{"filename": FILE_A, "size": "n/a"}])) + assert spec is not None + assert spec.files[0]["size"] == 0 + + def test_build_retry_fields_empty_without_spec(self): + assert SlskdHandler().build_retry_resolution_fields({"extra": {}}) == {} + + def test_list_files(self): + files = SlskdHandler().list_files( + _release_data(files=[{"filename": FILE_A, "size": 10}, {"filename": FILE_B, "size": 0}]) + ) + assert files is not None + assert [(f.path, f.size) for f in files] == [("Book.epub", 10), ("Book.mobi", None)] + + def test_list_files_none_without_spec(self): + assert SlskdHandler().list_files({"extra": {}}) is None + + +# ── local path resolution ────────────────────────────────────────────────────── + + +class TestLocalPaths: + def test_local_path_uses_last_folder_only(self): + assert local_path_for(Path("/dl"), FILE_A) == Path("/dl/Author/Book.epub") + + def test_local_path_forward_slashes(self): + assert local_path_for(Path("/dl"), "share/Music/Album/01.mp3") == Path("/dl/Album/01.mp3") + + def test_local_path_bare_file(self): + assert local_path_for(Path("/dl"), "Book.epub") == Path("/dl/Book.epub") + + def test_local_path_refuses_traversal(self): + assert local_path_for(Path("/dl"), "a\\..\\Book.epub") is None + assert local_path_for(Path("/dl"), "a\\Author\\..") is None + assert local_path_for(Path("/dl"), "a\\Author\\") is None + + def test_root_override_wins(self, config_values): + config_values["SLSKD_DOWNLOAD_PATH"] = "/mnt/slskd " + client = MagicMock() + root, error = resolve_local_download_root(client) + assert (root, error) == (Path("/mnt/slskd"), None) + client.get_download_directory.assert_not_called() + + def test_root_from_slskd_options(self, config_values): + client = MagicMock() + client.get_download_directory.return_value = "/downloads/complete" + assert resolve_local_download_root(client) == (Path("/downloads/complete"), None) + + def test_root_remapped_by_path_mapping(self, config_values): + config_values["PROWLARR_REMOTE_PATH_MAPPINGS"] = [ + {"host": "slskd", "remotePath": "/downloads", "localPath": "/mnt/slskd"}, + {"host": "qbittorrent", "remotePath": "/downloads", "localPath": "/mnt/qb"}, + ] + client = MagicMock() + client.get_download_directory.return_value = "/downloads/complete" + assert resolve_local_download_root(client) == (Path("/mnt/slskd/complete"), None) + + def test_root_error_when_slskd_reports_nothing(self, config_values): + client = MagicMock() + client.get_download_directory.return_value = None + root, error = resolve_local_download_root(client) + assert root is None + assert error and "Downloads Path" in error + + def test_root_error_when_slskd_unreachable(self, config_values): + client = MagicMock() + client.get_download_directory.side_effect = SlskdError("down") + root, error = resolve_local_download_root(client) + assert root is None + assert error and "down" in error + + +# ── snapshot aggregation ─────────────────────────────────────────────────────── + + +class TestSnapshot: + def _spec(self): + return SlskdDownloadSpec( + username="peer", + directory=REMOTE_DIR, + files=[{"filename": FILE_A, "size": 1000}, {"filename": FILE_B, "size": 1000}], + ) + + def test_all_succeeded(self): + snap = _snapshot( + [ + _transfer(FILE_A, "Completed, Succeeded", transferred=1000), + _transfer(FILE_B, "Completed, Succeeded", transferred=1000), + ], + self._spec(), + ) + assert snap.all_succeeded + assert snap.bytes_transferred == 2000 + assert snap.failed_state is None + + def test_partial_progress(self): + snap = _snapshot( + [ + _transfer(FILE_A, "InProgress", transferred=500, speed=100.0), + _transfer(FILE_B, "Queued, Remotely", placeInQueue=3), + ], + self._spec(), + ) + assert not snap.all_succeeded + assert snap.bytes_transferred == 500 + assert snap.in_progress == 1 + assert snap.queued_remotely == 1 + assert snap.queue_positions == [3] + assert snap.speed == 100.0 + + def test_failure_detected(self): + snap = _snapshot( + [ + _transfer(FILE_A, "Completed, Succeeded", transferred=1000), + _transfer(FILE_B, "Completed, Errored"), + ], + self._spec(), + ) + assert snap.failed_state == "Completed, Errored" + assert snap.failed_filename == FILE_B + + def test_ignores_unrelated_and_removed(self): + snap = _snapshot( + [ + _transfer("other\\thing.epub", "InProgress"), + _transfer(FILE_A, "InProgress", removed=True), + ], + self._spec(), + ) + assert snap.found == 0 + + def test_prefers_live_record_over_old_failure(self): + # slskd keeps a failed attempt around after a retry re-enqueues the file. + snap = _snapshot( + [ + _transfer(FILE_A, "Completed, Errored", requestedAt="2026-01-01T00:00:00"), + _transfer(FILE_A, "InProgress", transferred=10, requestedAt="2026-01-01T00:01:00"), + _transfer(FILE_B, "InProgress"), + ], + self._spec(), + ) + assert snap.failed_state is None + assert snap.in_progress == 2 + + +# ── download lifecycle ───────────────────────────────────────────────────────── + + +class TestDownload: + def test_registered(self): + assert isinstance(get_handler("slskd"), SlskdHandler) + + def test_single_file_success_returns_client_path(self, fast_handler, config_values, tmp_path): + config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path) + fake = FakeSlskd( + polls=[ + [_transfer(FILE_A, "Queued, Remotely")], + [_transfer(FILE_A, "InProgress", transferred=400, speed=2 * 1024 * 1024)], + [_transfer(FILE_A, "Completed, Succeeded", transferred=1000, tid="done")], + ] + ) + _mount(fast_handler, fake) + (expected,) = _write_completed(tmp_path, FILE_A) + rec = ProgressRecorder() + + result = fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + + assert result == str(expected) + assert fake.enqueued == [("peer", [{"filename": FILE_A, "size": 1000}])] + assert rec.statuses[0] == "resolving" + assert ("downloading", "Queued at peer") in rec.status_updates + assert any(m and m.startswith("40% (2.0 MB/s)") for _, m in rec.status_updates) + assert rec.progress_values[-1] == 100.0 + assert "error" not in rec.statuses + assert fast_handler._cleanup_refs[_task().task_id][2] == ["done"] + + def test_multi_file_success_stages_into_tmp( + self, fast_handler, config_values, tmp_path, monkeypatch + ): + import shelfmark.download.staging as staging + + root = tmp_path / "slskd" + staging_root = tmp_path / "staging" + staging_root.mkdir() + monkeypatch.setattr(staging, "get_staging_dir", lambda: staging_root) + config_values["SLSKD_DOWNLOAD_PATH"] = str(root) + files = [{"filename": FILE_A, "size": 10}, {"filename": FILE_B, "size": 10}] + fake = FakeSlskd( + polls=[ + [ + _transfer(FILE_A, "Completed, Succeeded", transferred=10), + _transfer(FILE_B, "Completed, Succeeded", transferred=10), + ] + ] + ) + _mount(fast_handler, fake) + originals = _write_completed(root, FILE_A, FILE_B) + # A stranger's file in the same per-folder directory must not come along. + stranger = originals[0].parent / "stranger.epub" + stranger.write_bytes(b"nope") + rec = ProgressRecorder() + + result = fast_handler.download( + _task(files=files), Event(), rec.progress_callback, rec.status_callback + ) + + assert result is not None + staged = Path(result) + assert staged.parent == staging_root + assert sorted(p.name for p in staged.iterdir()) == ["Book.epub", "Book.mobi"] + assert all(not p.exists() for p in originals) + assert stranger.exists() + + def test_failed_transfer_reports_outcome_and_removes(self, fast_handler, config_values): + fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Rejected", tid="x")]]) + _mount(fast_handler, fake) + rec = ProgressRecorder() + + result = fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + + assert result is None + assert rec.last_status == "error" + assert rec.last_message == "Peer transfer failed: Rejected" + assert fake.cancelled == [("peer", "x", True)] + + def test_failed_file_named_in_multi_file_release(self, fast_handler, config_values): + files = [{"filename": FILE_A, "size": 10}, {"filename": FILE_B, "size": 10}] + fake = FakeSlskd( + polls=[ + [ + _transfer(FILE_A, "Completed, Succeeded", transferred=10), + _transfer(FILE_B, "Completed, TimedOut"), + ] + ] + ) + _mount(fast_handler, fake) + rec = ProgressRecorder() + + result = fast_handler.download( + _task(files=files), Event(), rec.progress_callback, rec.status_callback + ) + + assert result is None + assert rec.last_message == "Peer transfer failed: TimedOut (Book.mobi)" + + def test_cancel_flag_cancels_transfers(self, fast_handler, config_values): + fake = FakeSlskd(polls=[[_transfer(FILE_A, "InProgress", transferred=10, tid="live")]]) + _mount(fast_handler, fake) + cancel = Event() + rec = ProgressRecorder() + + def status_callback(status, message): + rec.status_callback(status, message) + if status == "downloading": + cancel.set() + + result = fast_handler.download(_task(), cancel, rec.progress_callback, status_callback) + + assert result is None + assert rec.last_status == "cancelled" + # First pass cancels the live transfer, second pass removes the leftover record. + assert fake.cancelled == [("peer", "live", True), ("peer", "live", True)] + assert all(t["removed"] for poll in fake.polls for t in poll) + + def test_cancelled_before_start(self, fast_handler, config_values): + fake = FakeSlskd() + _mount(fast_handler, fake) + cancel = Event() + cancel.set() + rec = ProgressRecorder() + + assert ( + fast_handler.download(_task(), cancel, rec.progress_callback, rec.status_callback) + is None + ) + assert rec.last_status == "cancelled" + assert fake.enqueued == [] + + def test_missing_spec_errors(self, fast_handler, config_values): + _mount(fast_handler, FakeSlskd()) + rec = ProgressRecorder() + task = DownloadTask(task_id="slskd:none", source="slskd", title="t") + + assert ( + fast_handler.download(task, Event(), rec.progress_callback, rec.status_callback) is None + ) + assert rec.last_status == "error" + assert "missing" in (rec.last_message or "") + + def test_unconfigured_client_errors(self, fast_handler, config_values): + fast_handler._get_client = lambda: None # type: ignore[method-assign] + rec = ProgressRecorder() + + assert ( + fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + is None + ) + assert rec.last_status == "error" + assert rec.last_message == "slskd is not configured" + + def test_enqueue_failure_errors(self, fast_handler, config_values): + fake = FakeSlskd() + fake.enqueue_error = SlskdError("slskd refused 1 file(s): Not shared") + _mount(fast_handler, fake) + rec = ProgressRecorder() + + assert ( + fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + is None + ) + assert rec.last_status == "error" + assert "Not shared" in (rec.last_message or "") + + def test_transfer_vanishing_errors_after_grace(self, fast_handler, config_values, monkeypatch): + import shelfmark.release_sources.slskd.handler as mod + + monkeypatch.setattr(mod, "MAX_MISSING_POLLS", 3) + fake = FakeSlskd(polls=[[]]) + _mount(fast_handler, fake) + rec = ProgressRecorder() + + assert ( + fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + is None + ) + assert rec.last_status == "error" + assert rec.last_message == "Transfer disappeared from slskd" + assert fake.poll_count == 3 + + def test_queue_timeout_errors(self, fast_handler, config_values, monkeypatch): + import shelfmark.release_sources.slskd.handler as mod + + monkeypatch.setattr(SlskdHandler, "_queue_timeout_seconds", lambda self: 120) + clock = iter([0, 0, 200, 200, 200]) + monkeypatch.setattr(mod.time, "monotonic", lambda: next(clock)) + fake = FakeSlskd(polls=[[_transfer(FILE_A, "Queued, Remotely", tid="q")]]) + _mount(fast_handler, fake) + rec = ProgressRecorder() + + assert ( + fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + is None + ) + assert rec.last_status == "error" + assert rec.last_message == "Peer did not start the transfer within 2 minutes" + assert fake.cancelled == [("peer", "q", True), ("peer", "q", True)] + assert all(t["removed"] for poll in fake.polls for t in poll) + + def test_completed_file_missing_errors(self, fast_handler, config_values, tmp_path): + config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path) + fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000)]]) + _mount(fast_handler, fake) + rec = ProgressRecorder() + + assert ( + fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + is None + ) + assert rec.last_status == "error" + assert "Completed file not found" in (rec.last_message or "") + assert str(tmp_path / "Author" / "Book.epub") in (rec.last_message or "") + + def test_completed_file_appears_after_wait( + self, fast_handler, config_values, tmp_path, monkeypatch + ): + config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path) + monkeypatch.setattr(SlskdHandler, "_completed_path_retry_interval", lambda self: 1) + monkeypatch.setattr(SlskdHandler, "_completed_path_timeout_seconds", lambda self: 2) + fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000)]]) + _mount(fast_handler, fake) + rec = ProgressRecorder() + expected = local_path_for(tmp_path, FILE_A) + assert expected is not None + + cancel = MagicMock(spec=Event) + cancel.is_set.return_value = False + + def wait(timeout=None): + _write_completed(tmp_path, FILE_A) + return False + + cancel.wait.side_effect = wait + + result = fast_handler.download(_task(), cancel, rec.progress_callback, rec.status_callback) + + assert result == str(expected) + assert ("locating", "Waiting for completed files...") in rec.status_updates + + def test_unsafe_remote_name_errors(self, fast_handler, config_values, tmp_path): + config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path) + evil = "a\\..\\Book.epub" + fake = FakeSlskd(polls=[[_transfer(evil, "Completed, Succeeded", transferred=10)]]) + _mount(fast_handler, fake) + rec = ProgressRecorder() + + task = _task(files=[{"filename": evil, "size": 10}]) + assert ( + fast_handler.download(task, Event(), rec.progress_callback, rec.status_callback) is None + ) + assert rec.last_status == "error" + assert "unsafe" in (rec.last_message or "") + + def test_status_check_errors_are_retried(self, fast_handler, config_values, tmp_path): + config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path) + fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000)]]) + calls = {"n": 0} + original = fake.list_downloads + + def flaky(username): + calls["n"] += 1 + if calls["n"] == 1: + raise SlskdError("hiccup") + return original(username) + + fake.list_downloads = flaky + _mount(fast_handler, fake) + _write_completed(tmp_path, FILE_A) + rec = ProgressRecorder() + + result = fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback) + + assert result is not None + assert ("resolving", "Waiting for slskd...") in rec.status_updates + + +# ── post-processing cleanup ──────────────────────────────────────────────────── + + +class TestCleanup: + def _completed(self, fast_handler, config_values, tmp_path): + config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path) + fake = FakeSlskd( + polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000, tid="t")]] + ) + _mount(fast_handler, fake) + (path,) = _write_completed(tmp_path, FILE_A) + rec = ProgressRecorder() + task = _task() + assert fast_handler.download(task, Event(), rec.progress_callback, rec.status_callback) + return fake, task, path + + def test_success_removes_transfer_and_empty_folder(self, fast_handler, config_values, tmp_path): + fake, task, path = self._completed(fast_handler, config_values, tmp_path) + path.unlink() # orchestrator moved it into the library + + fast_handler.post_process_cleanup(task, success=True) + + assert fake.cancelled == [("peer", "t", True)] + assert not path.parent.exists() + assert task.task_id not in fast_handler._cleanup_refs + + def test_success_keeps_folder_with_other_files(self, fast_handler, config_values, tmp_path): + _fake, task, path = self._completed(fast_handler, config_values, tmp_path) + path.unlink() + (path.parent / "other.epub").write_bytes(b"x") + + fast_handler.post_process_cleanup(task, success=True) + + assert path.parent.exists() + + def test_success_respects_remove_setting(self, fast_handler, config_values, tmp_path): + fake, task, _path = self._completed(fast_handler, config_values, tmp_path) + config_values["SLSKD_REMOVE_COMPLETED"] = False + + fast_handler.post_process_cleanup(task, success=True) + + assert fake.cancelled == [] + + def test_failure_leaves_everything(self, fast_handler, config_values, tmp_path): + fake, task, path = self._completed(fast_handler, config_values, tmp_path) + + fast_handler.post_process_cleanup(task, success=False) + + assert fake.cancelled == [] + assert path.exists() + assert task.task_id not in fast_handler._cleanup_refs + + def test_cleanup_without_refs_is_noop(self, fast_handler): + fast_handler.post_process_cleanup(_task(), success=True) + + def test_cancel_drops_refs(self, fast_handler, config_values, tmp_path): + _fake, task, _path = self._completed(fast_handler, config_values, tmp_path) + assert fast_handler.cancel(task.task_id) is True + assert task.task_id not in fast_handler._cleanup_refs diff --git a/tests/slskd/test_live_slskd.py b/tests/slskd/test_live_slskd.py new file mode 100644 index 000000000..5fdfb6054 --- /dev/null +++ b/tests/slskd/test_live_slskd.py @@ -0,0 +1,80 @@ +"""Live checks against a real slskd instance. + +Skipped unless ``SLSKD_TEST_URL`` and ``SLSKD_TEST_API_KEY`` are set, e.g. after +``docker compose -f docker-compose.test-clients.yml up -d slskd``. +""" + +import os + +import pytest + +from shelfmark.release_sources.slskd.api import SlskdAuthError, SlskdClient + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def live_client() -> SlskdClient: + url = os.environ.get("SLSKD_TEST_URL", "").strip() + api_key = os.environ.get("SLSKD_TEST_API_KEY", "").strip() + if not url or not api_key: + pytest.skip("SLSKD_TEST_URL / SLSKD_TEST_API_KEY not set") + client = SlskdClient(url, api_key, timeout=15) + try: + client.get_application() + except Exception as e: # pragma: no cover - environment dependent + pytest.skip(f"slskd not reachable: {e}") + return client + + +def test_connection_reports_login_state(live_client): + ok, message = live_client.test_connection() + assert "slskd" in message + if ok: + assert "logged in" in message.lower() + else: + assert "not logged in" in message + + +def test_bad_api_key_is_rejected(live_client): + bad = SlskdClient(live_client.base_url, "definitely-not-the-key", timeout=15) + with pytest.raises(SlskdAuthError): + bad.get_application() + + +def test_download_directory_is_reported(live_client): + directory = live_client.get_download_directory() + assert directory + assert directory.startswith("/") + + +def test_search_lifecycle(live_client): + search_id = live_client.start_search( + "zzzz-shelfmark-nonexistent-qqqq", timeout_ms=1000, response_limit=5 + ) + state = live_client.get_search(search_id) + assert state["id"] == search_id + assert isinstance(live_client.get_search_responses(search_id), list) + live_client.delete_search(search_id) + + +def test_missing_transfer_is_none(live_client): + assert ( + live_client.get_transfer("nobody-shelfmark-test", "00000000-0000-0000-0000-000000000000") + is None + ) + assert live_client.list_downloads("nobody-shelfmark-test") == [] + + +def test_network_search_returns_ebooks_when_logged_in(live_client): + if not live_client.is_logged_in(): + pytest.skip("slskd is not logged in to Soulseek") + from shelfmark.release_sources.slskd.source import build_releases + + responses = live_client.search("sherlock holmes epub", wait_seconds=10, response_limit=20) + releases = build_releases(responses, content_type="ebook", formats={"epub"}) + # The network is live, so only assert on shape when something answered. + for release in releases[:5]: + assert release.source == "slskd" + assert release.format == "epub" + assert release.extra["files"][0]["filename"].lower().endswith(".epub") diff --git a/tests/slskd/test_orchestrator_integration.py b/tests/slskd/test_orchestrator_integration.py new file mode 100644 index 000000000..2752f59c4 --- /dev/null +++ b/tests/slskd/test_orchestrator_integration.py @@ -0,0 +1,115 @@ +"""slskd releases survive queueing, retry serialization and a full handler round-trip.""" + +from pathlib import Path +from threading import Event + +from shelfmark.core.models import DownloadTask +from shelfmark.download import orchestrator +from shelfmark.release_sources import get_handler +from shelfmark.release_sources.slskd.handler import SlskdHandler, spec_from_task + +FILE = "@@share\\Books\\Author\\Book.epub" + + +def _release_data(): + return { + "source": "slskd", + "source_id": "slskd:deadbeef", + "title": "Book", + "format": "epub", + "protocol": "soulseek", + "content_type": "ebook", + "extra": { + "username": "peer", + "directory": "@@share\\Books\\Author", + "files": [{"filename": FILE, "size": 1000}], + "upload_speed": 1, + }, + } + + +def test_queue_release_persists_soulseek_spec(monkeypatch): + captured: dict[str, DownloadTask] = {} + + def fake_add(task: DownloadTask) -> bool: + captured["task"] = task + return True + + monkeypatch.setattr(orchestrator.config, "get", lambda _key, default=None, **_kw: default) + monkeypatch.setattr(orchestrator, "_source_unavailable_message", lambda _source: None) + monkeypatch.setattr(orchestrator.book_queue, "add", fake_add) + monkeypatch.setattr(orchestrator, "ws_manager", None) + + ok, error = orchestrator.queue_release(_release_data(), 0) + + assert ok, error + task = captured["task"] + assert task.source == "slskd" + assert task.task_id == "slskd:deadbeef" + assert task.retry_download_protocol == "soulseek" + assert task.retry_download_url is None + spec = spec_from_task(task) + assert spec is not None + assert spec.username == "peer" + assert spec.filenames == [FILE] + + +def test_retry_payload_round_trips_soulseek_spec(): + handler = get_handler("slskd") + fields = handler.build_retry_resolution_fields(_release_data()) + task = DownloadTask(task_id="slskd:deadbeef", source="slskd", title="Book", **fields) + + payload = orchestrator.serialize_task_for_retry(task) + restored = orchestrator._restore_task_from_retry_payload(payload) + + assert restored is not None + assert spec_from_task(restored) == spec_from_task(task) + + +def test_handler_round_trip_from_queued_task(monkeypatch, tmp_path): + """Queue → task → handler download using the persisted context only (no cache).""" + import shelfmark.release_sources.slskd.handler as mod + + handler = get_handler("slskd") + assert isinstance(handler, SlskdHandler) + fields = handler.build_retry_resolution_fields(_release_data()) + task = DownloadTask(task_id="slskd:deadbeef", source="slskd", title="Book", **fields) + + completed = tmp_path / "Author" / "Book.epub" + completed.parent.mkdir(parents=True) + completed.write_bytes(b"epub") + + class Client: + def enqueue_downloads(self, username, files): + assert username == "peer" + return [{"id": "t1", "filename": files[0]["filename"]}] + + def list_downloads(self, username): + return [ + { + "id": "t1", + "filename": FILE, + "state": "Completed, Succeeded", + "bytesTransferred": 1000, + "removed": False, + } + ] + + def cancel_transfer(self, username, transfer_id, *, remove): + return True + + def get_download_directory(self): + return str(tmp_path) + + monkeypatch.setattr(mod.config, "get", lambda k, d=None: d) + monkeypatch.setattr(SlskdHandler, "_get_client", lambda self: Client()) + monkeypatch.setattr(SlskdHandler, "_poll_interval", lambda self: 0) + monkeypatch.setattr(SlskdHandler, "_completed_path_timeout_seconds", lambda self: 0) + + statuses: list[tuple[str, str | None]] = [] + result = handler.download(task, Event(), lambda p: None, lambda s, m: statuses.append((s, m))) + + assert result == str(completed) + assert Path(result).exists() + assert "error" not in {s for s, _ in statuses} + assert statuses[0] == ("resolving", "Sending to slskd") diff --git a/tests/slskd/test_settings.py b/tests/slskd/test_settings.py new file mode 100644 index 000000000..8061d506a --- /dev/null +++ b/tests/slskd/test_settings.py @@ -0,0 +1,122 @@ +"""Tests for the slskd settings tab and its Test Connection action.""" + +from shelfmark.core.settings_registry import ( + ActionButton, + CheckboxField, + NumberField, + PasswordField, + TextField, +) +from shelfmark.release_sources.slskd.settings import ( + _test_slskd_connection, + slskd_config_settings, +) + + +def _field(key: str): + return next(f for f in slskd_config_settings() if getattr(f, "key", None) == key) + + +def test_settings_expose_expected_fields(): + assert isinstance(_field("SLSKD_ENABLED"), CheckboxField) + assert isinstance(_field("SLSKD_URL"), TextField) + assert isinstance(_field("SLSKD_API_KEY"), PasswordField) + assert isinstance(_field("test_slskd"), ActionButton) + assert isinstance(_field("SLSKD_DOWNLOAD_PATH"), TextField) + assert isinstance(_field("SLSKD_SEARCH_TIMEOUT"), NumberField) + assert isinstance(_field("SLSKD_RESPONSE_LIMIT"), NumberField) + assert isinstance(_field("SLSKD_QUEUE_TIMEOUT_MINUTES"), NumberField) + assert isinstance(_field("SLSKD_REMOVE_COMPLETED"), CheckboxField) + + +def test_dependent_fields_hide_until_enabled(): + for key in ("SLSKD_URL", "SLSKD_API_KEY", "test_slskd", "SLSKD_DOWNLOAD_PATH"): + assert _field(key).show_when == {"field": "SLSKD_ENABLED", "value": True} + + +def test_defaults(): + assert _field("SLSKD_ENABLED").default is False + assert _field("SLSKD_SEARCH_TIMEOUT").default == 15 + assert _field("SLSKD_RESPONSE_LIMIT").default == 100 + assert _field("SLSKD_QUEUE_TIMEOUT_MINUTES").default == 60 + assert _field("SLSKD_REMOVE_COMPLETED").default is True + + +def test_connection_action_uses_form_values(monkeypatch): + import shelfmark.release_sources.slskd.api as api_module + + created: list[tuple[str, str]] = [] + + class FakeClient: + def __init__(self, url, api_key): + created.append((url, api_key)) + + def test_connection(self): + return True, "Connected to slskd 0.26.0 (logged in to Soulseek)" + + monkeypatch.setattr(api_module, "SlskdClient", FakeClient) + + result = _test_slskd_connection({"SLSKD_URL": "slskd:5030/", "SLSKD_API_KEY": " key "}) + + assert result == { + "success": True, + "message": "Connected to slskd 0.26.0 (logged in to Soulseek)", + } + assert created == [("http://slskd:5030", "key")] + + +def test_connection_action_requires_url(monkeypatch): + import shelfmark.release_sources.slskd.settings as mod + + monkeypatch.setattr(mod, "normalize_http_url", lambda url: url) + from shelfmark.core.config import config + + monkeypatch.setattr(config, "get", lambda k, d=None: d) + result = _test_slskd_connection({"SLSKD_URL": "", "SLSKD_API_KEY": "k"}) + assert result == {"success": False, "message": "slskd URL is required"} + + +def test_connection_action_requires_api_key(monkeypatch): + from shelfmark.core.config import config + + monkeypatch.setattr(config, "get", lambda k, d=None: d) + result = _test_slskd_connection({"SLSKD_URL": "http://slskd:5030", "SLSKD_API_KEY": ""}) + assert result == {"success": False, "message": "slskd API key is required"} + + +def test_connection_action_reports_failure(monkeypatch): + import shelfmark.release_sources.slskd.api as api_module + + class FakeClient: + def __init__(self, url, api_key): + pass + + def test_connection(self): + return False, "Invalid API key" + + monkeypatch.setattr(api_module, "SlskdClient", FakeClient) + result = _test_slskd_connection({"SLSKD_URL": "http://slskd:5030", "SLSKD_API_KEY": "bad"}) + assert result == {"success": False, "message": "Invalid API key"} + + +def test_connection_action_wraps_unexpected_errors(monkeypatch): + import shelfmark.release_sources.slskd.api as api_module + + class FakeClient: + def __init__(self, url, api_key): + raise RuntimeError("kaboom") + + monkeypatch.setattr(api_module, "SlskdClient", FakeClient) + result = _test_slskd_connection({"SLSKD_URL": "http://slskd:5030", "SLSKD_API_KEY": "k"}) + assert result["success"] is False + assert "kaboom" in result["message"] + + +def test_remote_path_mapping_offers_slskd_host(): + from shelfmark.config.settings import advanced_settings + + table = next( + f for f in advanced_settings() if getattr(f, "key", None) == "PROWLARR_REMOTE_PATH_MAPPINGS" + ) + host_column = next(c for c in table.columns if c["key"] == "host") + assert {"value": "slskd", "label": "slskd"} in host_column["options"] diff --git a/tests/slskd/test_source.py b/tests/slskd/test_source.py new file mode 100644 index 000000000..abd645f3a --- /dev/null +++ b/tests/slskd/test_source.py @@ -0,0 +1,486 @@ +"""Unit tests for the slskd release source.""" + +from unittest.mock import MagicMock + +import pytest + +from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant +from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources import ReleaseProtocol, get_source +from shelfmark.release_sources.slskd.source import ( + SlskdSource, + _plan_queries, + build_releases, + file_extension, + get_supported_formats, + iter_peer_files, + make_source_id, + rank_key, + remote_directory_name, + split_remote_path, +) + +# ── fixtures / helpers ───────────────────────────────────────────────────────── + + +def _make_book(**kwargs) -> BookMetadata: + defaults = { + "provider": "hardcover", + "provider_id": "1", + "title": "Dune", + "authors": ["Frank Herbert"], + } + defaults.update(kwargs) + return BookMetadata(**defaults) + + +def _make_plan( + *, + variants: list[tuple[str, str]] | None = None, + manual_query: str | None = None, +) -> ReleaseSearchPlan: + variants = variants if variants is not None else [("Dune", "Frank Herbert")] + title_variants = [ReleaseSearchVariant(title=t, author=a) for t, a in variants] + return ReleaseSearchPlan( + languages=["en"], + isbn_candidates=[], + author="Frank Herbert", + title_variants=title_variants, + grouped_title_variants=title_variants, + manual_query=manual_query, + ) + + +def _file(filename: str, size: int = 1000, **extra) -> dict: + return {"filename": filename, "size": size, "isLocked": False, **extra} + + +def _response(username: str = "peer", files: list[dict] | None = None, **extra) -> dict: + base = { + "username": username, + "hasFreeUploadSlot": True, + "queueLength": 0, + "uploadSpeed": 500000, + "fileCount": len(files or []), + "files": files or [], + } + base.update(extra) + return base + + +EBOOK_FORMATS = {"epub", "mobi", "azw3", "pdf"} +AUDIO_FORMATS = {"mp3", "m4b", "flac", "zip"} + + +# ── path helpers ─────────────────────────────────────────────────────────────── + + +class TestPathHelpers: + def test_split_backslash_path(self): + assert split_remote_path("@@a\\Books\\Author\\Dune.epub") == ( + "@@a\\Books\\Author", + "Dune.epub", + ) + + def test_split_forward_slash_path(self): + assert split_remote_path("share/Books/Dune.epub") == ("share/Books", "Dune.epub") + + def test_split_bare_filename(self): + assert split_remote_path("Dune.epub") == ("", "Dune.epub") + + def test_split_mixed_separators_uses_last(self): + assert split_remote_path("a\\b/c\\d.epub") == ("a\\b/c", "d.epub") + + def test_remote_directory_name(self): + assert remote_directory_name("@@a\\Books\\Author") == "Author" + assert remote_directory_name("Author") == "Author" + assert remote_directory_name("") == "" + + @pytest.mark.parametrize( + ("name", "ext"), + [ + ("Dune.EPUB", "epub"), + ("Dune.part1.mp3", "mp3"), + ("no-extension", ""), + ("trailing.", ""), + ], + ) + def test_file_extension(self, name, ext): + assert file_extension(name) == ext + + def test_source_id_is_stable_and_namespaced(self): + a = make_source_id("peer", "x\\y.epub") + assert a == make_source_id("peer", "x\\y.epub") + assert a.startswith("slskd:") + assert a != make_source_id("other", "x\\y.epub") + + +# ── iter_peer_files ──────────────────────────────────────────────────────────── + + +class TestIterPeerFiles: + def test_extracts_files(self): + response = _response(files=[_file("@@a\\Books\\Dune.epub", 2048)]) + (pf,) = iter_peer_files(response) + assert pf.username == "peer" + assert pf.filename == "@@a\\Books\\Dune.epub" + assert pf.directory == "@@a\\Books" + assert pf.basename == "Dune.epub" + assert pf.extension == "epub" + assert pf.size == 2048 + + def test_skips_locked_files(self): + response = _response(files=[_file("a\\locked.epub", isLocked=True), _file("a\\ok.epub")]) + assert [pf.basename for pf in iter_peer_files(response)] == ["ok.epub"] + + def test_skips_garbage(self): + response = _response(files=[None, {"filename": ""}, {"filename": "dir\\"}, "junk"]) + assert iter_peer_files(response) == [] + + def test_missing_username_yields_nothing(self): + assert iter_peer_files({"files": [_file("a.epub")]}) == [] + + def test_size_coercion(self): + response = _response(files=[_file("a.epub", size="12"), _file("b.epub", size=None)]) + assert [pf.size for pf in iter_peer_files(response)] == [12, 0] + + +# ── build_releases ───────────────────────────────────────────────────────────── + + +class TestBuildReleases: + def test_ebook_one_release_per_file(self): + response = _response( + files=[ + _file("@@a\\Books\\Dune.epub", 1000), + _file("@@a\\Books\\Dune.mobi", 2000), + _file("@@a\\Books\\cover.jpg", 50), + ] + ) + releases = build_releases([response], content_type="ebook", formats=EBOOK_FORMATS) + assert [r.title for r in releases] == ["Dune", "Dune"] + assert [r.format for r in releases] == ["epub", "mobi"] + assert releases[0].source == "slskd" + assert releases[0].protocol == ReleaseProtocol.SOULSEEK + assert releases[0].indexer == "peer" + assert releases[0].content_type == "ebook" + assert releases[0].size_bytes == 1000 + assert releases[0].size == "1000 B" + assert releases[0].extra["username"] == "peer" + assert releases[0].extra["files"] == [{"filename": "@@a\\Books\\Dune.epub", "size": 1000}] + assert releases[0].extra["directory"] == "@@a\\Books" + assert releases[0].extra["directory_name"] == "Books" + assert releases[0].extra["availability"] == "Free slot" + assert releases[0].extra["formats"] == ["epub"] + + def test_format_filter_respected(self): + response = _response(files=[_file("a\\x.pdf"), _file("a\\x.epub")]) + releases = build_releases([response], content_type="ebook", formats={"epub"}) + assert [r.format for r in releases] == ["epub"] + + def test_audiobook_groups_directory(self): + response = _response( + files=[ + _file("@@a\\Audio\\Dune\\01.mp3", 100), + _file("@@a\\Audio\\Dune\\02.mp3", 200), + _file("@@a\\Audio\\Dune\\03.mp3", 300), + _file("@@a\\Audio\\Dune\\cover.jpg", 5), + _file("@@a\\Audio\\Other\\single.m4b", 999), + ] + ) + releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS) + assert len(releases) == 2 + grouped = next(r for r in releases if r.extra["file_count"] == 3) + single = next(r for r in releases if r.extra["file_count"] == 1) + assert grouped.title == "Dune (3 files)" + assert grouped.format == "mp3" + assert grouped.size_bytes == 600 + assert grouped.content_type == "audiobook" + assert [f["filename"] for f in grouped.extra["files"]] == [ + "@@a\\Audio\\Dune\\01.mp3", + "@@a\\Audio\\Dune\\02.mp3", + "@@a\\Audio\\Dune\\03.mp3", + ] + assert single.title == "single" + assert single.format == "m4b" + + def test_audiobook_archives_stay_separate(self): + response = _response( + files=[ + _file("@@a\\Dune\\Dune.zip", 5000), + _file("@@a\\Dune\\01.mp3", 100), + _file("@@a\\Dune\\02.mp3", 100), + ] + ) + releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS) + formats = sorted((r.format, r.extra["file_count"]) for r in releases) + assert formats == [("mp3", 2), ("zip", 1)] + + def test_dominant_format_wins_for_group(self): + response = _response( + files=[ + _file("@@a\\Dune\\01.flac"), + _file("@@a\\Dune\\02.mp3"), + _file("@@a\\Dune\\03.mp3"), + ] + ) + (release,) = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS) + assert release.format == "mp3" + assert release.extra["formats"] == ["mp3", "flac"] + + def test_dedupes_identical_files_across_responses(self): + response = _response(files=[_file("a\\x.epub")]) + releases = build_releases([response, response], content_type="ebook", formats=EBOOK_FORMATS) + assert len(releases) == 1 + + def test_same_file_from_two_peers_is_two_releases(self): + a = _response(username="alice", files=[_file("a\\x.epub")]) + b = _response(username="bob", files=[_file("a\\x.epub")]) + releases = build_releases([a, b], content_type="ebook", formats=EBOOK_FORMATS) + assert {r.indexer for r in releases} == {"alice", "bob"} + assert len({r.source_id for r in releases}) == 2 + + def test_availability_labels(self): + busy = _response(hasFreeUploadSlot=False, queueLength=0, files=[_file("a\\x.epub")]) + queued = _response(hasFreeUploadSlot=False, queueLength=4, files=[_file("a\\x.epub")]) + (busy_release,) = build_releases([busy], content_type="ebook", formats=EBOOK_FORMATS) + (queued_release,) = build_releases([queued], content_type="ebook", formats=EBOOK_FORMATS) + assert busy_release.extra["availability"] == "Busy" + assert queued_release.extra["availability"] == "Queue 4" + + +# ── ranking ──────────────────────────────────────────────────────────────────── + + +class TestRanking: + def test_free_slot_then_queue_then_speed(self): + responses = [ + _response(username="slow-free", uploadSpeed=10, files=[_file("a\\x.epub")]), + _response( + username="fast-queued", + hasFreeUploadSlot=False, + queueLength=2, + uploadSpeed=10**7, + files=[_file("a\\x.epub")], + ), + _response(username="fast-free", uploadSpeed=10**6, files=[_file("a\\x.epub")]), + _response( + username="short-queue", + hasFreeUploadSlot=False, + queueLength=1, + uploadSpeed=1, + files=[_file("a\\x.epub")], + ), + ] + releases = build_releases(responses, content_type="ebook", formats=EBOOK_FORMATS) + releases.sort(key=rank_key) + assert [r.indexer for r in releases] == [ + "fast-free", + "slow-free", + "short-queue", + "fast-queued", + ] + + +# ── supported formats ────────────────────────────────────────────────────────── + + +class TestSupportedFormats: + def test_reads_ebook_formats(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr( + mod.config, + "get", + lambda k, d=None: ["EPUB", ".mobi"] if k == "SUPPORTED_FORMATS" else d, + ) + assert get_supported_formats("ebook") == {"epub", "mobi"} + + def test_reads_audiobook_formats(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr( + mod.config, + "get", + lambda k, d=None: ["m4b"] if k == "SUPPORTED_AUDIOBOOK_FORMATS" else d, + ) + assert get_supported_formats("audiobook") == {"m4b"} + + def test_falls_back_to_defaults(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", lambda k, d=None: d) + assert "epub" in get_supported_formats("ebook") + assert "mp3" in get_supported_formats("audiobook") + assert "zip" in get_supported_formats("audiobook") + + def test_accepts_comma_string(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", lambda k, d=None: "epub, pdf") + assert get_supported_formats("ebook") == {"epub", "pdf"} + + +# ── query planning ───────────────────────────────────────────────────────────── + + +class TestPlanQueries: + def test_title_author_then_bare_title(self): + assert _plan_queries(_make_plan()) == ["Dune Frank Herbert", "Dune"] + + def test_manual_query_first(self): + plan = _make_plan(manual_query="dune messiah herbert") + assert _plan_queries(plan)[0] == "dune messiah herbert" + + def test_dedupes_and_caps(self): + plan = _make_plan( + variants=[("Dune", "Frank Herbert"), ("dune", "frank herbert"), ("Dune Messiah", "X")] + ) + queries = _plan_queries(plan) + assert queries == ["Dune Frank Herbert", "Dune", "Dune Messiah X"] + + def test_empty_plan(self): + assert _plan_queries(_make_plan(variants=[])) == [] + + +# ── SlskdSource ──────────────────────────────────────────────────────────────── + + +class TestSlskdSource: + def _config(self, **overrides): + values = {"SLSKD_ENABLED": True, "SLSKD_URL": "http://slskd:5030", "SLSKD_API_KEY": "k"} + values.update(overrides) + return lambda k, default=None: values.get(k, default) + + def test_registered(self): + assert isinstance(get_source("slskd"), SlskdSource) + + def test_available_when_enabled_and_url_set(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config()) + assert SlskdSource().is_available() is True + + def test_unavailable_when_disabled(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config(SLSKD_ENABLED=False)) + assert SlskdSource().is_available() is False + + def test_unavailable_without_url(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config(SLSKD_URL="")) + assert SlskdSource().is_available() is False + + def test_column_config_serializes(self): + from shelfmark.release_sources import serialize_column_config + + data = serialize_column_config(SlskdSource().get_column_config()) + assert [c["key"] for c in data["columns"]] == [ + "extra.username", + "extra.availability", + "format", + "size", + ] + assert data["supported_filters"] == ["format"] + assert data["leading_cell"]["type"] == "none" + assert {o["sort_key"] for o in data["extra_sort_options"]} == { + "extra.upload_speed", + "extra.queue_length", + } + + def test_search_without_client_returns_empty(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config(SLSKD_URL="")) + assert SlskdSource().search(_make_book(), _make_plan()) == [] + + def test_search_stops_after_first_query_with_results(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config()) + client = MagicMock() + client.search.return_value = [_response(files=[_file("a\\Dune.epub")])] + monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client) + + releases = SlskdSource().search(_make_book(), _make_plan()) + + assert len(releases) == 1 + client.search.assert_called_once() + assert client.search.call_args.args == ("Dune Frank Herbert",) + kwargs = client.search.call_args.kwargs + assert kwargs["wait_seconds"] == 15 + assert kwargs["response_limit"] == 100 + + def test_search_falls_back_to_bare_title(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config()) + client = MagicMock() + client.search.side_effect = [[], [_response(files=[_file("a\\Dune.epub")])]] + monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client) + + releases = SlskdSource().search(_make_book(), _make_plan()) + + assert len(releases) == 1 + assert [c.args[0] for c in client.search.call_args_list] == ["Dune Frank Herbert", "Dune"] + + def test_expand_search_runs_every_query(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config()) + client = MagicMock() + client.search.side_effect = [ + [_response(files=[_file("a\\Dune.epub")])], + [_response(username="other", files=[_file("b\\Dune.epub")])], + ] + monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client) + + releases = SlskdSource().search(_make_book(), _make_plan(), expand_search=True) + + assert len(releases) == 2 + assert client.search.call_count == 2 + + def test_search_survives_client_errors(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config()) + client = MagicMock() + client.search.side_effect = [RuntimeError("boom"), [_response(files=[_file("a\\x.epub")])]] + monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client) + + assert len(SlskdSource().search(_make_book(), _make_plan())) == 1 + + def test_search_uses_configured_limits(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr( + mod.config, "get", self._config(SLSKD_SEARCH_TIMEOUT="30", SLSKD_RESPONSE_LIMIT=5000) + ) + client = MagicMock() + client.search.return_value = [] + monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client) + + SlskdSource().search(_make_book(), _make_plan()) + + kwargs = client.search.call_args_list[0].kwargs + assert kwargs["wait_seconds"] == 30 + assert kwargs["response_limit"] == 1000 # clamped + + def test_search_audiobook_uses_audio_formats(self, monkeypatch): + import shelfmark.release_sources.slskd.source as mod + + monkeypatch.setattr(mod.config, "get", self._config()) + client = MagicMock() + client.search.return_value = [ + _response( + files=[_file("a\\Dune\\01.mp3"), _file("a\\Dune\\02.mp3"), _file("a\\Dune.epub")] + ) + ] + monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client) + + releases = SlskdSource().search(_make_book(), _make_plan(), content_type="audiobook") + + assert len(releases) == 1 + assert releases[0].extra["file_count"] == 2 From 2fdca9a7cda9009d27bd6228d57645218e1254f8 Mon Sep 17 00:00:00 2001 From: sandwich <299465+dskvr@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:42:48 +0200 Subject: [PATCH 2/2] Combine exception handling for size conversion Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- shelfmark/release_sources/slskd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shelfmark/release_sources/slskd/handler.py b/shelfmark/release_sources/slskd/handler.py index fa088cd3a..78c3a4a3f 100644 --- a/shelfmark/release_sources/slskd/handler.py +++ b/shelfmark/release_sources/slskd/handler.py @@ -128,7 +128,7 @@ def _parse_spec(username: object, directory: object, raw_files: object) -> Slskd continue try: size = int(raw.get("size") or 0) - except TypeError, ValueError: + except (TypeError, ValueError): size = 0 files.append({"filename": filename, "size": max(size, 0)}) if not files: