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"] wanted = {f["filename"] for f in 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) completed = False 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", []): if f.get("filename") in wanted: # only the files we enqueued states.append(str(f.get("state", ""))) if states: if any(bad in s for s in states for bad in ("Errored", "Rejected", "Cancelled")): raise RuntimeError("soulseek transfer failed") done = sum(1 for s in states if "Completed" in s) on_progress(min(1.0, done / max(total, 1))) if done >= total: completed = True break if not completed: raise TimeoutError(f"soulseek download did not complete for {username}") return {"track_count": total, "path": dest}