diff --git a/worker/lyra_worker/adapters/_slskd.py b/worker/lyra_worker/adapters/_slskd.py new file mode 100644 index 0000000..8090f96 --- /dev/null +++ b/worker/lyra_worker/adapters/_slskd.py @@ -0,0 +1,135 @@ +import json +import os +import time +from typing import Callable + +import requests + +_AUDIO_EXT = {"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac"} +_LOSSLESS_EXT = {"flac", "wav"} +_SEARCH_POLLS = 40 # * 3s ≈ 2 min +_XFER_POLLS = 200 # * 3s ≈ 10 min +_POLL_SECONDS = 3 + + +def _basename(path: str) -> str: + return path.rsplit("\\", 1)[-1].rsplit("/", 1)[-1] + + +def _dirname(path: str) -> str: + if "\\" in path: + return path.rsplit("\\", 1)[0] + if "/" in path: + return path.rsplit("/", 1)[0] + return "" + + +class SlskdClient: + """Real Soulseek client talking to an slskd daemon's REST API. + + NOT unit-tested offline; see test_soulseek_live.py. slskd response/transfer + JSON shapes may vary by version — validate with the live test. + """ + + def __init__(self, config: dict): + self._url = (config.get("slskd.url", "") or "").rstrip("/") + self._key = config.get("slskd.api_key", "") + + def is_configured(self) -> bool: + return bool(self._url and self._key) + + def _headers(self) -> dict: + return {"X-API-Key": self._key, "Content-Type": "application/json"} + + def _get(self, path: str) -> dict | list: + r = requests.get(f"{self._url}{path}", headers=self._headers(), timeout=30) + r.raise_for_status() + return r.json() + + def search_album(self, artist: str, album: str) -> list[dict]: + r = requests.post( + f"{self._url}/api/v0/searches", + headers=self._headers(), + json={"searchText": f"{artist} {album}"}, + timeout=30, + ) + r.raise_for_status() + search_id = r.json()["id"] + + for _ in range(_SEARCH_POLLS): + time.sleep(_POLL_SECONDS) + state = self._get(f"/api/v0/searches/{search_id}") + if state.get("isComplete") or "Completed" in str(state.get("state", "")): + break + + responses = self._get(f"/api/v0/searches/{search_id}/responses") + candidates: list[dict] = [] + for resp in responses: + username = resp.get("username", "") + by_dir: dict[str, list] = {} + for f in resp.get("files", []) or []: + name = f.get("filename", "") + ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" + if ext not in _AUDIO_EXT: + continue + by_dir.setdefault(_dirname(name), []).append( + {"filename": name, "size": f.get("size", 0), "ext": ext} + ) + for directory, dfiles in by_dir.items(): + if not dfiles: + continue + lossless = all(f["ext"] in _LOSSLESS_EXT for f in dfiles) + dirname = _basename(directory) + if " - " in dirname: + guess_artist, guess_album = (p.strip() for p in dirname.split(" - ", 1)) + else: + guess_artist, guess_album = "", dirname + candidates.append( + { + "source_ref": json.dumps( + { + "username": username, + "files": [{"filename": f["filename"], "size": f["size"]} for f in dfiles], + } + ), + "title": guess_album, + "artist": guess_artist, + "track_count": len(dfiles), + "format": "FLAC" if lossless else "MP3", + "bitrate": None, + } + ) + return candidates + + def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict: + ref = json.loads(source_ref) + username = ref["username"] + files = ref["files"] + os.makedirs(dest, exist_ok=True) + + r = requests.post( + f"{self._url}/api/v0/transfers/downloads/{username}", + headers=self._headers(), + json=[{"filename": f["filename"], "size": f["size"]} for f in files], + timeout=30, + ) + r.raise_for_status() + on_progress(0.0) + + total = len(files) + for _ in range(_XFER_POLLS): + time.sleep(_POLL_SECONDS) + data = self._get(f"/api/v0/transfers/downloads/{username}") + states: list[str] = [] + for directory in data.get("directories", []) if isinstance(data, dict) else []: + for f in directory.get("files", []): + states.append(str(f.get("state", ""))) + if states: + done = sum(1 for s in states if "Completed" in s) + on_progress(min(1.0, done / max(total, 1))) + if any(bad in s for s in states for bad in ("Errored", "Rejected", "Cancelled")): + raise RuntimeError("soulseek transfer failed") + if done >= total: + break + + return {"track_count": total, "path": dest} diff --git a/worker/requirements.txt b/worker/requirements.txt index 73e268a..bd020c8 100644 --- a/worker/requirements.txt +++ b/worker/requirements.txt @@ -3,3 +3,4 @@ pytest>=8.3,<9 cryptography>=42,<46 yt-dlp>=2024.1 streamrip>=2.0 +requests>=2.31 diff --git a/worker/tests/test_soulseek_live.py b/worker/tests/test_soulseek_live.py new file mode 100644 index 0000000..259f67e --- /dev/null +++ b/worker/tests/test_soulseek_live.py @@ -0,0 +1,20 @@ +import os + +import pytest + +from lyra_worker.adapters._slskd import SlskdClient + +pytestmark = pytest.mark.skipif( + not (os.environ.get("LYRA_LIVE_TESTS") and os.environ.get("SLSKD_URL") and os.environ.get("SLSKD_API_KEY")), + reason="live slskd test; set LYRA_LIVE_TESTS=1 + SLSKD_URL + SLSKD_API_KEY to run", +) + + +def test_live_search_returns_results(): + client = SlskdClient({"slskd.url": os.environ["SLSKD_URL"], "slskd.api_key": os.environ["SLSKD_API_KEY"]}) + results = client.search_album("John Mayer", "Continuum") + assert isinstance(results, list) + # Soulseek results depend on who's online; assert shape, not count. + for r in results: + assert r["source_ref"] and r["track_count"] >= 1 + assert r["format"] in ("FLAC", "MP3")