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..78c3a4a3f --- /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