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