feat: add real slskd Soulseek client and opt-in live test
This commit is contained in:
@@ -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}
|
||||||
@@ -3,3 +3,4 @@ pytest>=8.3,<9
|
|||||||
cryptography>=42,<46
|
cryptography>=42,<46
|
||||||
yt-dlp>=2024.1
|
yt-dlp>=2024.1
|
||||||
streamrip>=2.0
|
streamrip>=2.0
|
||||||
|
requests>=2.31
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user