Files
Lyra/worker/lyra_worker/adapters/_slskd.py
T
Jonathan 1fc61a0f5a fix(worker): cancel abandoned slskd transfers + cap download fall-through
Two compounding causes of 'many downloads of one album from different sources' in
slskd: (1) the slskd client raised on timeout/error WITHOUT cancelling the transfer
it enqueued, so as the pipeline fell through to other peers each left a transfer
running; (2) the incomplete-download fall-through was uncapped, grinding through
every candidate of a popular album (~200 Soulseek peers), amplified now that Qobuz
pacing pushes most jobs to Soulseek.

- SlskdClient.download now cancels its enqueued transfers (DELETE) on any abandon.
- The download loop caps at _MAX_DOWNLOAD_ATTEMPTS (6) candidates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:03:05 +02:00

240 lines
9.7 KiB
Python

import json
import os
import shutil
import time
from typing import Callable
import requests
_DEFAULT_DOWNLOADS_ROOT = "/slskd-downloads"
_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", "")
# Where slskd (an external daemon) writes finished downloads. slskd saves into ITS
# OWN directory, so this path must be a share/mount both slskd and this worker see.
# Precedence: the slskd.downloadsDir Config row (Settings-overridable) > the
# SLSKD_DOWNLOADS_ROOT env (the container mount point) > a sensible default.
self._downloads_dir = (
config.get("slskd.downloadsDir")
or os.environ.get("SLSKD_DOWNLOADS_ROOT")
or _DEFAULT_DOWNLOADS_ROOT
)
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)
xfer_ids: set[str] = set() # slskd transfer ids we started, for cancel-on-abandon
try:
return self._download(username, files, wanted, dest, on_progress, xfer_ids)
except Exception:
# Abandoning this peer (timeout / error / the pipeline moving on): cancel the transfers
# we enqueued so they don't keep running and pile up in slskd as the job falls through
# to other peers/sources for the same album.
self._cancel_transfers(username, xfer_ids)
raise
def _download(self, username, files, wanted, dest, on_progress, xfer_ids: set) -> dict:
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 f.get("id"): # remember the transfer so we can cancel it if abandoned
xfer_ids.add(str(f["id"]))
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}")
# slskd saved the files into its OWN downloads dir — retrieve them into `dest` so the
# pipeline (which verifies + imports against `dest`/staging) actually sees the audio.
moved = self._retrieve(files, dest)
if moved == 0:
raise RuntimeError(
f"soulseek transfer completed but no files were found under "
f"{self._downloads_dir!r} to move into staging — check that the slskd "
f"downloads dir is shared/mounted (SLSKD_DOWNLOADS_ROOT / slskd.downloadsDir)"
)
return {"track_count": moved, "path": dest}
def _cancel_transfers(self, username: str, xfer_ids: set) -> None:
"""Cancel the given in-flight slskd transfers for a peer. Best-effort: a cleanup failure
must never mask the original download failure."""
for tid in xfer_ids:
try:
requests.delete(
f"{self._url}/api/v0/transfers/downloads/{username}/{tid}",
headers=self._headers(),
timeout=15,
)
except Exception:
pass
def _retrieve(self, files: list[dict], dest: str) -> int:
"""Move the just-finished slskd files out of its downloads dir into `dest`.
slskd's on-disk layout can vary by version/config, so match tolerantly: for each
wanted file, find on-disk candidates by basename anywhere under the downloads root,
then prefer the one whose parent-folder name matches the remote directory and whose
size matches. Returns the number of files actually moved. Offline-testable.
"""
root = self._downloads_dir
if not root or not os.path.isdir(root):
raise RuntimeError(
f"slskd downloads dir not found: {root!r} — mount slskd's downloads "
f"directory into the worker (SLSKD_DOWNLOADS_ROOT / slskd.downloadsDir)"
)
os.makedirs(dest, exist_ok=True)
wanted: dict[str, dict] = {} # basename(lower) -> {parent, size}
for f in files:
name = f.get("filename", "")
wanted[_basename(name).lower()] = {
"parent": _basename(_dirname(name)).lower(),
"size": f.get("size"),
}
found: dict[str, list[str]] = {b: [] for b in wanted}
for cur_root, _dirs, fnames in os.walk(root):
for fn in fnames:
key = fn.lower()
if key in found:
found[key].append(os.path.join(cur_root, fn))
moved = 0
for basename, paths in found.items():
if not paths:
continue
want = wanted[basename]
def _score(p: str) -> tuple[int, int]:
parent_ok = int(os.path.basename(os.path.dirname(p)).lower() == want["parent"])
size_ok = 0
try:
size_ok = int(bool(want["size"]) and os.path.getsize(p) == want["size"])
except OSError:
pass
return (parent_ok, size_ok)
best = max(paths, key=_score)
shutil.move(best, os.path.join(dest, os.path.basename(best)))
moved += 1
return moved