soulsync/core/video/slskd_download.py
BoulderBadgeDad 0bb77bf782 video downloads Phase A: cancelled state, cancel/retry, monitor robustness
Fixes the 'cancelled but still shows running' stuck bug and adds real depth:
- classify_state now distinguishes 'cancelled' from 'failed'.
- Monitor is robust to slskd forgetting a transfer: if it's gone, it first tries to
  complete from the FILE on disk (survives the music 'Clean Completed Downloads'
  auto-clear), else counts misses and fails the row after ~8 polls instead of hanging
  on 'downloading' forever. Cancelled transfers → cancelled status.
- POST /downloads/cancel (slskd DELETE transfer + mark cancelled) and /downloads/retry
  (re-grab the same release). get_video_download(id) added; clear includes cancelled.
process_download stays pure (fs/slskd injected); 15 tests, ruff + guard clean.
Phase B (page: tabs/queue/history/cancel+retry buttons) next.
2026-06-19 15:31:51 -07:00

127 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Start + track Soulseek (slskd) downloads for the video pipeline.
Thin wrapper over slskd's transfer API (the same shared instance the music side uses):
- ``start_download(username, filename, size)`` → POST /transfers/downloads/{username}
- ``list_downloads()`` → GET /transfers/downloads (flattened)
The flatten + state-classification helpers are pure (unit-tested); the HTTP is glue.
Isolated: stdlib + requests + shared ``config_manager``; no music imports.
"""
from __future__ import annotations
from typing import Any
from urllib.parse import quote
import requests
def _conn():
from config.settings import config_manager
base = str(config_manager.get("soulseek.slskd_url", "") or "").rstrip("/")
key = config_manager.get("soulseek.api_key", "") or ""
return base, ({"X-API-Key": key} if key else {})
def start_download(username: str, filename: str, size_bytes: int = 0) -> dict:
"""Ask slskd to download one file from a user. Returns {ok[, error]}."""
base, headers = _conn()
if not base:
return {"ok": False, "error": "slskd isn't configured"}
try:
r = requests.post(base + "/api/v0/transfers/downloads/" + quote(str(username or "")),
json=[{"filename": filename, "size": int(size_bytes or 0)}],
headers=headers, timeout=15)
r.raise_for_status()
return {"ok": True}
except Exception as e: # noqa: BLE001 - surface any slskd/network failure to the caller
return {"ok": False, "error": str(e)}
def list_downloads() -> list:
"""Current slskd downloads, flattened to one dict per file."""
base, headers = _conn()
if not base:
return []
try:
r = requests.get(base + "/api/v0/transfers/downloads", headers=headers, timeout=15)
if not r.ok:
return []
data = r.json()
except Exception: # noqa: BLE001, S110 - transient slskd error → no downloads this tick
return []
return flatten_downloads(data)
def flatten_downloads(data: Any) -> list:
"""slskd's nested users→directories→files → a flat list of file transfers. Pure."""
out = []
for user in (data if isinstance(data, list) else []):
if not isinstance(user, dict):
continue
un = user.get("username", "")
for d in (user.get("directories") or []):
for f in (d.get("files") or []):
out.append({
"username": un, "filename": f.get("filename", ""), "id": f.get("id", ""),
"state": f.get("state", ""), "size": f.get("size", 0) or 0,
"transferred": f.get("bytesTransferred", 0) or 0,
})
return out
def classify_state(state: Any) -> str:
"""slskd state string → 'completed' | 'cancelled' | 'failed' | 'active'. Pure."""
s = str(state or "").lower()
if "completed" in s and "succeed" in s:
return "completed"
if "cancel" in s:
return "cancelled"
if any(x in s for x in ("error", "timed", "failed", "reject")):
return "failed"
if "completed" in s: # completed but not succeeded → treat as failed
return "failed"
return "active"
def cancel_download(username: str, filename: str) -> dict:
"""Cancel (and remove) a slskd transfer matching username+filename. Returns
{ok[, gone][, error]}. 'gone' = the transfer was already absent."""
base, headers = _conn()
if not base:
return {"ok": False, "error": "slskd isn't configured"}
tid = None
for t in list_downloads():
if t.get("username") == username and t.get("filename") == filename:
tid = t.get("id")
break
if not tid:
return {"ok": True, "gone": True}
try:
requests.delete(base + "/api/v0/transfers/downloads/%s/%s" % (quote(str(username or "")), tid),
headers=headers, params={"remove": "true"}, timeout=10)
return {"ok": True}
except Exception as e: # noqa: BLE001 - surface the failure; the row is marked cancelled anyway
return {"ok": False, "error": str(e)}
def progress_pct(transfer: dict) -> float:
"""0100 from bytesTransferred/size (100 once completed). Pure."""
transfer = transfer or {}
if classify_state(transfer.get("state")) == "completed":
return 100.0
size = transfer.get("size", 0) or 0
tr = transfer.get("transferred", 0) or 0
return round(min(99.0, (tr / size * 100.0) if size else 0.0), 1)
def find_transfer(transfers: list, username: str, filename: str) -> dict:
"""The transfer matching a started download (by username + filename). Pure."""
for t in (transfers or []):
if t.get("username") == username and t.get("filename") == filename:
return t
return {}
__all__ = ["start_download", "list_downloads", "flatten_downloads", "classify_state",
"progress_pct", "find_transfer", "cancel_download"]