Files
Medios-Macina/plugins/soulseek/__init__.py
T

966 lines
35 KiB
Python
Raw Normal View History

2025-12-11 19:04:02 -08:00
from __future__ import annotations
import asyncio
2025-12-11 23:21:45 -08:00
import contextlib
import io
import logging
import os
2025-12-11 19:04:02 -08:00
import re
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
2026-05-21 16:19:17 -07:00
from PluginCore.base import Provider, SearchResult
from SYS.logger import log, debug, debug_panel
from SYS.models import ProgressBar
2025-12-11 19:04:02 -08:00
2025-12-11 23:21:45 -08:00
_SOULSEEK_NOISE_SUBSTRINGS = (
"unhandled exception on loop",
"Task exception was never retrieved",
"future: <Task finished",
"ConnectionFailedError",
"PeerConnectionError",
"indirect connection failed",
"indirect connection timed out",
"failed to connect",
2025-12-29 17:05:03 -08:00
"search reply ticket does not match any search request",
"failed to receive transfer ticket on file connection",
"aioslsk.exceptions.ConnectionReadError",
2025-12-11 23:21:45 -08:00
)
2025-12-20 02:12:45 -08:00
@contextlib.asynccontextmanager
async def _suppress_aioslsk_asyncio_task_noise() -> Any:
2025-12-29 17:05:03 -08:00
"""Suppress non-fatal aioslsk task exceptions emitted via asyncio's loop handler.
2025-12-20 02:12:45 -08:00
2025-12-29 17:05:03 -08:00
aioslsk may spawn background tasks (e.g. direct peer connection attempts) that
can fail with ConnectionFailedError. These are often expected and should not
end a successful download with a scary "Task exception was never retrieved"
traceback.
2025-12-20 02:12:45 -08:00
2025-12-29 17:05:03 -08:00
We only swallow those specific cases and delegate everything else to the
previous/default handler.
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# Not in an event loop.
yield
return
2025-12-20 02:12:45 -08:00
2025-12-29 17:05:03 -08:00
previous_handler = loop.get_exception_handler()
2025-12-20 02:12:45 -08:00
2025-12-29 17:05:03 -08:00
def _handler(loop: asyncio.AbstractEventLoop, context: Dict[str, Any]) -> None:
try:
exc = context.get("exception")
msg = str(context.get("message") or "")
2026-01-11 04:54:27 -08:00
if exc is not None:
# Suppress internal asyncio AssertionError on Windows teardown (Proactor loop)
if isinstance(exc, AssertionError):
m_lower = msg.lower()
if "proactor" in m_lower or "_start_serving" in m_lower or "self._sockets is not None" in str(exc):
return
# Only suppress un-retrieved task exceptions from aioslsk connection failures.
if msg == "Task exception was never retrieved":
cls = getattr(exc, "__class__", None)
name = getattr(cls, "__name__", "")
exc_text = str(exc or "").lower()
2026-01-11 04:54:27 -08:00
# Suppress expected peer direct-connect failures from aioslsk.
if name == "ConnectionFailedError" or "failed to connect" in exc_text:
2026-01-11 04:54:27 -08:00
return
2025-12-29 17:05:03 -08:00
except Exception:
# If our filter logic fails, fall through to default handling.
pass
2025-12-20 02:12:45 -08:00
2025-12-29 17:05:03 -08:00
if previous_handler is not None:
previous_handler(loop, context)
else:
loop.default_exception_handler(context)
2025-12-20 02:12:45 -08:00
2025-12-29 17:05:03 -08:00
loop.set_exception_handler(_handler)
try:
yield
finally:
try:
loop.set_exception_handler(previous_handler)
except Exception:
pass
2025-12-20 02:12:45 -08:00
2025-12-11 23:21:45 -08:00
def _configure_aioslsk_logging() -> None:
2025-12-29 17:05:03 -08:00
"""Reduce aioslsk internal log noise.
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
Some aioslsk components emit non-fatal warnings/errors during high churn
(search + download + disconnect). We keep our own debug output, but push
aioslsk to ERROR and stop propagation so it doesn't spam the CLI.
"""
for name in (
"aioslsk",
"aioslsk.network",
"aioslsk.search",
"aioslsk.transfer",
"aioslsk.transfer.manager",
2025-12-29 17:05:03 -08:00
):
logger = logging.getLogger(name)
logger.setLevel(logging.ERROR)
logger.propagate = False
2025-12-11 23:21:45 -08:00
class _LineFilterStream(io.TextIOBase):
2025-12-29 17:05:03 -08:00
"""A minimal stream wrapper that filters known noisy lines.
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
It also suppresses entire traceback blocks when they contain known non-fatal
aioslsk noise (e.g. ConnectionReadError during peer init).
"""
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
def __init__(self, underlying: Any, suppress_substrings: tuple[str, ...]):
super().__init__()
self._underlying = underlying
self._suppress = suppress_substrings
self._buf = ""
self._in_tb = False
self._tb_lines: list[str] = []
self._tb_suppress = False
self._in_task_block = False
self._task_lines: list[str] = []
self._task_suppress = False
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
def writable(self) -> bool: # pragma: no cover
return True
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
def _should_suppress_line(self, line: str) -> bool:
return any(sub in line for sub in self._suppress)
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
def _flush_tb(self) -> None:
if not self._tb_lines:
return
if not self._tb_suppress:
for l in self._tb_lines:
try:
self._underlying.write(l + "\n")
except Exception:
pass
self._tb_lines = []
self._tb_suppress = False
self._in_tb = False
2025-12-11 23:21:45 -08:00
def _flush_task_block(self) -> None:
if not self._task_lines:
return
if not self._task_suppress:
for l in self._task_lines:
try:
self._underlying.write(l + "\n")
except Exception:
pass
self._task_lines = []
self._task_suppress = False
self._in_task_block = False
2025-12-29 17:05:03 -08:00
def write(self, s: str) -> int:
self._buf += str(s)
while "\n" in self._buf:
line, self._buf = self._buf.split("\n", 1)
self._handle_line(line)
return len(s)
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
def _handle_line(self, line: str) -> None:
if not self._in_task_block and self._should_suppress_line(line) and (
line.startswith("Task exception was never retrieved")
or line.startswith("future: <Task finished")
or line.startswith("unhandled exception on loop")
):
self._in_task_block = True
self._task_lines = [line]
self._task_suppress = True
return
if self._in_task_block:
if line.startswith("Traceback (most recent call last):"):
self._in_tb = True
self._tb_lines = [line]
self._tb_suppress = True
return
self._task_lines.append(line)
if self._should_suppress_line(line):
self._task_suppress = True
if line.strip() == "":
self._flush_task_block()
return
2025-12-29 17:05:03 -08:00
# Start capturing tracebacks so we can suppress the whole block if it matches.
if not self._in_tb and line.startswith("Traceback (most recent call last):"):
self._in_tb = True
self._tb_lines = [line]
self._tb_suppress = False
return
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
if self._in_tb:
self._tb_lines.append(line)
if self._should_suppress_line(line):
self._tb_suppress = True
# End traceback block on blank line.
if line.strip() == "":
self._flush_tb()
if self._in_task_block:
self._flush_task_block()
2025-12-29 17:05:03 -08:00
return
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
# Non-traceback line
if self._should_suppress_line(line):
return
try:
self._underlying.write(line + "\n")
except Exception:
pass
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
def flush(self) -> None:
# Flush any pending traceback block.
if self._in_tb:
# If the traceback ends without a trailing blank line, decide here.
self._flush_tb()
if self._in_task_block:
self._flush_task_block()
2025-12-29 17:05:03 -08:00
if self._buf:
line = self._buf
self._buf = ""
if not self._should_suppress_line(line):
try:
self._underlying.write(line)
except Exception:
pass
try:
self._underlying.flush()
except Exception:
pass
2025-12-11 23:21:45 -08:00
@contextlib.contextmanager
def _suppress_aioslsk_noise() -> Any:
2025-12-29 17:05:03 -08:00
"""Temporarily suppress known aioslsk noise printed to stdout/stderr.
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
Opt out by setting DOWNLOW_SOULSEEK_VERBOSE=1.
"""
if os.environ.get("DOWNLOW_SOULSEEK_VERBOSE"):
yield
return
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
_configure_aioslsk_logging()
old_out, old_err = sys.stdout, sys.stderr
sys.stdout = _LineFilterStream(old_out, _SOULSEEK_NOISE_SUBSTRINGS)
sys.stderr = _LineFilterStream(old_err, _SOULSEEK_NOISE_SUBSTRINGS)
try:
yield
finally:
try:
sys.stdout.flush()
sys.stderr.flush()
except Exception:
pass
sys.stdout, sys.stderr = old_out, old_err
2025-12-11 23:21:45 -08:00
2025-12-19 02:29:42 -08:00
class Soulseek(Provider):
2026-01-03 03:37:48 -08:00
2026-05-26 19:00:04 -07:00
SUPPORTED_CMDLETS = frozenset({"download-file", "search-file"})
2026-01-03 03:37:48 -08:00
TABLE_AUTO_STAGES = {
"soulseek": ["download-file", "-plugin", "soulseek"],
2026-01-03 03:37:48 -08:00
}
2025-12-29 17:05:03 -08:00
"""Search provider for Soulseek P2P network."""
2025-12-11 19:04:02 -08:00
2026-01-11 04:54:27 -08:00
def selector(
self,
selected_items: List[Any],
*,
ctx: Any,
stage_is_last: bool = True,
**_kwargs: Any,
) -> bool:
"""Handle Soulseek selection.
Currently defaults to download-file via TABLE_AUTO_STAGES, but this
hook allows for future 'Browse User' or 'Browse Folder' drill-down.
"""
if not stage_is_last:
return False
2026-01-11 14:46:41 -08:00
# If we wanted to handle drill-down (like Tidal.py) we would:
2026-01-11 04:54:27 -08:00
# 1. Fetch more data (e.g. user shares)
# 2. Create a new ResultTable
# 3. ctx.set_current_stage_table(new_table)
# 4. return True
return False
2026-01-11 03:24:49 -08:00
@classmethod
2026-01-19 06:24:09 -08:00
def config_schema(cls) -> List[Dict[str, Any]]:
2026-01-11 03:24:49 -08:00
return [
{
"key": "username",
"label": "Soulseek Username",
"default": "",
"required": True
},
{
"key": "password",
"label": "Soulseek Password",
"default": "",
"required": True,
"secret": True
}
]
2025-12-29 17:05:03 -08:00
MUSIC_EXTENSIONS = {
".flac",
".mp3",
".m4a",
".aac",
".ogg",
".opus",
".wav",
".alac",
".wma",
".ape",
".aiff",
".dsf",
".dff",
".wv",
".tta",
".tak",
".ac3",
".dts",
}
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
# NOTE: These defaults preserve existing behavior.
USERNAME = "asjhkjljhkjfdsd334"
PASSWORD = "khhhg"
2026-01-11 10:59:50 -08:00
DOWNLOAD_DIR = None
2025-12-29 17:05:03 -08:00
MAX_WAIT_TRANSFER = 1200
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
try:
from SYS.config import get_soulseek_username, get_soulseek_password
2025-12-16 01:45:01 -08:00
2025-12-29 17:05:03 -08:00
user = get_soulseek_username(self.config)
pwd = get_soulseek_password(self.config)
if user:
Soulseek.USERNAME = user
if pwd:
Soulseek.PASSWORD = pwd
except Exception:
pass
2025-12-16 01:45:01 -08:00
2025-12-29 17:05:03 -08:00
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
"""Download file from Soulseek."""
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
try:
full_metadata = result.full_metadata or {}
username = full_metadata.get("username")
filename = full_metadata.get("filename") or result.path
2025-12-11 19:04:02 -08:00
2026-01-11 04:54:27 -08:00
if not username or not filename:
# If we were invoked via generic download-file on a SearchResult
# that has minimal data (e.g. from table selection), try to rescue it.
if isinstance(result, SearchResult) and result.full_metadata:
username = result.full_metadata.get("username")
filename = result.full_metadata.get("filename")
2025-12-29 17:05:03 -08:00
if not username or not filename:
log(
f"[soulseek] Missing metadata for download: {result.title}",
file=sys.stderr
)
2025-12-29 17:05:03 -08:00
return None
2026-01-11 04:54:27 -08:00
2026-01-19 06:24:09 -08:00
# Cast to str for Mypy
username = str(username)
filename = str(filename)
2026-01-11 10:59:50 -08:00
# Use tempfile directory as default if generic path elements were passed or None.
if output_dir is None:
2026-01-11 04:54:27 -08:00
import tempfile
target_dir = Path(tempfile.gettempdir()) / "Medios" / "Soulseek"
2026-01-11 10:59:50 -08:00
else:
target_dir = Path(output_dir)
if str(target_dir) in (".", "downloads", "Downloads"):
import tempfile
target_dir = Path(tempfile.gettempdir()) / "Medios" / "Soulseek"
target_dir.mkdir(parents=True, exist_ok=True)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
# This cmdlet stack is synchronous; use asyncio.run for clarity.
return asyncio.run(
download_soulseek_file(
username=username,
filename=filename,
2026-01-11 04:54:27 -08:00
output_dir=target_dir,
2025-12-29 17:05:03 -08:00
timeout=self.MAX_WAIT_TRANSFER,
)
)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
except RuntimeError:
# If we're already inside an event loop (e.g., TUI), fall back to a
# dedicated loop in this thread.
loop = asyncio.new_event_loop()
try:
2026-01-11 10:59:50 -08:00
# Re-resolve target_dir inside rescue block just in case
if output_dir is None:
import tempfile
target_dir = Path(tempfile.gettempdir()) / "Medios" / "Soulseek"
else:
target_dir = Path(output_dir)
if str(target_dir) in (".", "downloads", "Downloads"):
import tempfile
target_dir = Path(tempfile.gettempdir()) / "Medios" / "Soulseek"
2025-12-29 17:05:03 -08:00
asyncio.set_event_loop(loop)
2026-01-19 06:24:09 -08:00
# Cast to str for Mypy
username_str = str(username)
filename_str = str(filename)
2025-12-29 17:05:03 -08:00
return loop.run_until_complete(
download_soulseek_file(
2026-01-19 06:24:09 -08:00
username=username_str,
filename=filename_str,
2026-01-11 10:59:50 -08:00
output_dir=target_dir,
2025-12-29 17:05:03 -08:00
timeout=self.MAX_WAIT_TRANSFER,
)
)
finally:
try:
loop.close()
except Exception:
pass
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
except Exception as exc:
log(f"[soulseek] Download error: {exc}", file=sys.stderr)
return None
2025-12-11 19:04:02 -08:00
async def perform_search(self,
query: str,
timeout: float = 9.0,
limit: int = 50) -> List[Dict[str,
Any]]:
2025-12-29 17:05:03 -08:00
"""Perform async Soulseek search."""
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
from aioslsk.client import SoulSeekClient
from aioslsk.settings import CredentialsSettings, Settings
2025-12-11 19:04:02 -08:00
2026-01-11 04:54:27 -08:00
# Removed legacy os.makedirs(self.DOWNLOAD_DIR) - specific commands handle output dirs.
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
settings = Settings(
credentials=CredentialsSettings(
username=self.USERNAME,
password=self.PASSWORD
)
2025-12-29 17:05:03 -08:00
)
client = SoulSeekClient(settings)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
with _suppress_aioslsk_noise():
async with _suppress_aioslsk_asyncio_task_noise():
try:
await client.start()
await client.login()
except Exception as exc:
log(
f"[soulseek] Login failed: {type(exc).__name__}: {exc}",
file=sys.stderr
)
2025-12-29 17:05:03 -08:00
return []
2025-12-11 23:21:45 -08:00
2025-12-29 17:05:03 -08:00
try:
search_request = await client.searches.search(query)
summary = await self._collect_results(search_request, timeout=timeout)
return self._flatten_results(search_request)[:limit], summary
2025-12-29 17:05:03 -08:00
except Exception as exc:
log(
f"[soulseek] Search error: {type(exc).__name__}: {exc}",
file=sys.stderr
)
return [], {}
2025-12-29 17:05:03 -08:00
finally:
# Best-effort: try to cancel/close the search request before stopping
# the client to reduce stray reply spam.
try:
if "search_request" in locals() and search_request is not None:
cancel = getattr(search_request, "cancel", None)
if callable(cancel):
maybe = cancel()
if asyncio.iscoroutine(maybe):
await maybe
except Exception:
pass
try:
await client.stop()
except Exception:
pass
2026-01-11 04:54:27 -08:00
# Give Proactor/Windows loop a moment to drain internal buffers after stop.
2025-12-29 17:05:03 -08:00
try:
2026-01-11 04:54:27 -08:00
await asyncio.sleep(0.2)
2025-12-29 17:05:03 -08:00
except Exception:
pass
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
def _flatten_results(self, search_request: Any) -> List[dict]:
flat: List[dict] = []
for result in getattr(search_request, "results", []):
username = getattr(result, "username", "?")
2025-12-11 19:04:02 -08:00
2026-01-11 04:54:27 -08:00
def _add(file_data: Any) -> None:
flat.append({
"file": file_data,
"username": username,
"filename": getattr(file_data, "filename", "?"),
"size": getattr(file_data, "filesize", 0)
})
2025-12-29 17:05:03 -08:00
for file_data in getattr(result, "shared_items", []):
2026-01-11 04:54:27 -08:00
_add(file_data)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
for file_data in getattr(result, "locked_results", []):
2026-01-11 04:54:27 -08:00
_add(file_data)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
return flat
2025-12-11 19:04:02 -08:00
async def _collect_results(
self,
search_request: Any,
timeout: float = 75.0
) -> Dict[str, Any]:
start = time.time()
end = start + timeout
2025-12-29 17:05:03 -08:00
last_count = 0
update_count = 0
2025-12-29 17:05:03 -08:00
while time.time() < end:
current_count = len(getattr(search_request, "results", []))
if current_count > last_count:
last_count = current_count
update_count += 1
2025-12-29 17:05:03 -08:00
await asyncio.sleep(0.5)
2025-12-11 19:04:02 -08:00
return {
"peer_hits": last_count,
"count_updates": update_count,
"elapsed_seconds": round(max(0.0, time.time() - start), 1),
}
2025-12-29 17:05:03 -08:00
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str,
Any]] = None,
2025-12-29 17:05:03 -08:00
**kwargs: Any,
) -> List[SearchResult]:
filters = filters or {}
2025-12-11 19:04:02 -08:00
2026-01-11 04:54:27 -08:00
# Ensure temp download dir structure exists, but don't create legacy ./downloads here.
import tempfile
base_tmp = Path(tempfile.gettempdir()) / "Medios" / "Soulseek"
base_tmp.mkdir(parents=True, exist_ok=True)
2025-12-29 17:05:03 -08:00
try:
flat_results, search_summary = asyncio.run(
self.perform_search(query,
timeout=9.0,
limit=limit)
)
2025-12-29 17:05:03 -08:00
if not flat_results:
return []
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
music_results: List[dict] = []
for item in flat_results:
filename = item["filename"]
ext = (
"." + filename.rsplit(".",
1)[-1].lower()
) if "." in filename else ""
2025-12-29 17:05:03 -08:00
if ext in self.MUSIC_EXTENSIONS:
music_results.append(item)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
if not music_results:
return []
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
enriched_results: List[dict] = []
for item in music_results:
filename = item["filename"]
ext = (
"." + filename.rsplit(".",
1)[-1].lower()
) if "." in filename else ""
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
display_name = filename.replace("\\", "/").split("/")[-1]
path_parts = filename.replace("\\", "/").split("/")
artist = path_parts[-3] if len(path_parts) >= 3 else ""
album = (
path_parts[-2] if len(path_parts) >= 3 else
(path_parts[-2] if len(path_parts) == 2 else "")
2025-12-29 17:05:03 -08:00
)
2025-12-11 19:04:02 -08:00
base_name = display_name.rsplit(
".",
1
)[0] if "." in display_name else display_name
2025-12-29 17:05:03 -08:00
track_num = ""
title = base_name
filename_artist = ""
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
match = re.match(r"^(\d{1,3})\s*[\.\-]?\s+(.+)$", base_name)
if match:
track_num = match.group(1)
rest = match.group(2)
if " - " in rest:
filename_artist, title = rest.split(" - ", 1)
else:
title = rest
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
if filename_artist:
artist = filename_artist
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
enriched_results.append(
{
**item,
"artist": artist,
"album": album,
"title": title,
"track_num": track_num,
"ext": ext,
}
)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
if filters:
artist_filter = (filters.get("artist", "") or "").lower()
album_filter = (filters.get("album", "") or "").lower()
track_filter = (filters.get("track", "") or "").lower()
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
if artist_filter or album_filter or track_filter:
filtered: List[dict] = []
for item in enriched_results:
if artist_filter and artist_filter not in item["artist"].lower(
):
2025-12-29 17:05:03 -08:00
continue
if album_filter and album_filter not in item["album"].lower():
continue
if track_filter and track_filter not in item["title"].lower():
continue
filtered.append(item)
enriched_results = filtered
2025-12-11 19:04:02 -08:00
enriched_results.sort(
key=lambda item: (item["ext"].lower() != ".flac", -item["size"])
)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
results: List[SearchResult] = []
for item in enriched_results:
artist_display = item["artist"] if item["artist"] else "(no artist)"
album_display = item["album"] if item["album"] else "(no album)"
size_mb = int(item["size"] / 1024 / 1024)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
columns = [
("Track",
item["track_num"] or "?"),
("Title",
item["title"][:40]),
("Artist",
artist_display[:32]),
("Album",
album_display[:32]),
("Size",
f"{size_mb} MB"),
2025-12-29 17:05:03 -08:00
]
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
results.append(
SearchResult(
table="soulseek",
title=item["title"],
path=item["filename"],
detail=f"{artist_display} - {album_display}",
annotations=[f"{size_mb} MB",
item["ext"].lstrip(".").upper()],
2025-12-29 17:05:03 -08:00
media_kind="audio",
size_bytes=item["size"],
columns=columns,
selection_action=["download-file", "-plugin", "soulseek"],
2025-12-29 17:05:03 -08:00
full_metadata={
"username": item["username"],
"filename": item["filename"],
"artist": item["artist"],
"album": item["album"],
"track_num": item["track_num"],
"ext": item["ext"],
2026-05-26 15:32:01 -07:00
"plugin": "soulseek"
2025-12-29 17:05:03 -08:00
},
)
)
2025-12-11 19:04:02 -08:00
try:
debug_panel(
"soulseek search",
[
("query", query),
("peer_hits", search_summary.get("peer_hits", 0)),
("file_hits", len(flat_results)),
("audio_hits", len(music_results)),
("results", len(results)),
("poll_updates", search_summary.get("count_updates", 0)),
("elapsed_s", search_summary.get("elapsed_seconds", 0.0)),
],
border_style="magenta",
)
except Exception:
pass
2025-12-29 17:05:03 -08:00
return results
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
except Exception as exc:
log(f"[soulseek] Search error: {exc}", file=sys.stderr)
return []
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
def validate(self) -> bool:
try:
from aioslsk.client import SoulSeekClient # noqa: F401
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
# Require configured credentials.
try:
from SYS.config import get_soulseek_username, get_soulseek_password
2025-12-29 17:05:03 -08:00
user = get_soulseek_username(self.config)
pwd = get_soulseek_password(self.config)
return bool(user and pwd)
except Exception:
# Fall back to legacy class defaults if config helpers aren't available.
return bool(Soulseek.USERNAME and Soulseek.PASSWORD)
except ImportError:
return False
2025-12-11 19:04:02 -08:00
async def download_soulseek_file(
2025-12-29 17:05:03 -08:00
username: str,
filename: str,
2026-01-11 10:59:50 -08:00
output_dir: Optional[Path] = None,
2025-12-29 17:05:03 -08:00
timeout: int = 1200,
*,
client_username: Optional[str] = None,
client_password: Optional[str] = None,
2025-12-11 19:04:02 -08:00
) -> Optional[Path]:
2025-12-29 17:05:03 -08:00
"""Download a file from a Soulseek peer."""
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
try:
from aioslsk.client import SoulSeekClient
from aioslsk.settings import CredentialsSettings, Settings
from aioslsk.transfer.model import Transfer, TransferDirection
from aioslsk.transfer.state import TransferState
2025-12-11 19:04:02 -08:00
2026-01-11 10:59:50 -08:00
if output_dir is None:
import tempfile
output_dir = Path(tempfile.gettempdir()) / "Medios" / "Soulseek"
2025-12-29 17:05:03 -08:00
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
local_filename = filename.replace("\\", "/").split("/")[-1]
output_user_dir = output_dir / username
output_user_dir.mkdir(parents=True, exist_ok=True)
output_path = output_user_dir / local_filename
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
if output_path.exists():
base = output_path.stem
ext = output_path.suffix
counter = 1
while output_path.exists():
output_path = output_user_dir / f"{base}_{counter}{ext}"
counter += 1
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
output_path = output_path.resolve()
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
login_user = (client_username or Soulseek.USERNAME or "").strip()
login_pass = (client_password or Soulseek.PASSWORD or "").strip()
if not login_user or not login_pass:
raise RuntimeError(
"Soulseek credentials not configured (set provider=soulseek username/password)"
)
2025-12-16 01:45:01 -08:00
2025-12-29 17:05:03 -08:00
settings = Settings(
credentials=CredentialsSettings(username=login_user,
password=login_pass)
2025-12-29 17:05:03 -08:00
)
2025-12-11 19:04:02 -08:00
async def _attempt_once(
attempt_num: int
) -> tuple[Optional[Path],
Any,
int,
float]:
2025-12-29 17:05:03 -08:00
client = SoulSeekClient(settings)
with _suppress_aioslsk_noise():
async with _suppress_aioslsk_asyncio_task_noise():
try:
await client.start()
await client.login()
debug(f"[soulseek] Logged in as {login_user}")
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
log(
f"[soulseek] Download attempt {attempt_num}: {username} :: {local_filename}",
file=sys.stderr,
)
debug(
f"[soulseek] Requesting download from {username}: {filename}"
)
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
transfer = await client.transfers.add(
Transfer(username,
filename,
TransferDirection.DOWNLOAD)
2025-12-29 17:05:03 -08:00
)
transfer.local_path = str(output_path)
await client.transfers.queue(transfer)
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
start_time = time.time()
last_progress_time = start_time
progress_bar = ProgressBar()
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
while not transfer.is_finalized():
elapsed = time.time() - start_time
if elapsed > timeout:
log(
f"[soulseek] Download timeout after {timeout}s",
file=sys.stderr
)
bytes_done = int(
getattr(transfer,
"bytes_transfered",
0) or 0
)
state_val = getattr(
getattr(transfer,
"state",
None),
"VALUE",
None
2025-12-29 17:05:03 -08:00
)
progress_bar.finish()
return None, state_val, bytes_done, elapsed
2025-12-25 04:49:22 -08:00
bytes_done = int(
getattr(transfer,
"bytes_transfered",
0) or 0
)
2025-12-29 17:05:03 -08:00
total_bytes = int(getattr(transfer, "filesize", 0) or 0)
now = time.time()
if now - last_progress_time >= 0.5:
progress_bar.update(
downloaded=bytes_done,
total=total_bytes if total_bytes > 0 else None,
label="download",
file=sys.stderr,
)
last_progress_time = now
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
await asyncio.sleep(1)
2025-12-25 04:49:22 -08:00
final_state = getattr(
getattr(transfer,
"state",
None),
"VALUE",
None
)
2025-12-29 17:05:03 -08:00
downloaded_path = (
Path(transfer.local_path)
if getattr(transfer,
"local_path",
None) else output_path
2025-12-29 17:05:03 -08:00
)
final_elapsed = time.time() - start_time
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
# Clear in-place progress bar.
progress_bar.finish()
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
# If a file was written, treat it as success even if state is odd.
try:
if downloaded_path.exists() and downloaded_path.stat(
).st_size > 0:
2025-12-29 17:05:03 -08:00
if final_state != TransferState.COMPLETE:
log(
f"[soulseek] Transfer finalized as {final_state}, but file exists ({downloaded_path.stat().st_size} bytes). Keeping file.",
file=sys.stderr,
)
return (
downloaded_path,
final_state,
int(downloaded_path.stat().st_size),
final_elapsed,
)
except Exception:
pass
2025-12-25 04:49:22 -08:00
if final_state == TransferState.COMPLETE and downloaded_path.exists(
):
2025-12-29 17:05:03 -08:00
debug(f"[soulseek] Download complete: {downloaded_path}")
return (
downloaded_path,
final_state,
int(downloaded_path.stat().st_size),
final_elapsed,
)
2025-12-18 22:50:21 -08:00
2025-12-29 17:05:03 -08:00
fail_bytes = int(getattr(transfer, "bytes_transfered", 0) or 0)
fail_total = int(getattr(transfer, "filesize", 0) or 0)
reason = getattr(transfer, "reason", None)
log(
f"[soulseek] Download failed: state={final_state} bytes={fail_bytes}/{fail_total} reason={reason}",
file=sys.stderr,
)
2025-12-18 22:50:21 -08:00
2025-12-29 17:05:03 -08:00
# Clean up 0-byte placeholder.
try:
if downloaded_path.exists() and downloaded_path.stat(
).st_size == 0:
2025-12-29 17:05:03 -08:00
downloaded_path.unlink(missing_ok=True)
except Exception:
pass
return None, final_state, fail_bytes, final_elapsed
finally:
try:
await client.stop()
except Exception:
pass
# Let cancellation/cleanup callbacks run while our exception handler is still installed.
2026-01-11 04:54:27 -08:00
# Increased to 0.2s for Windows Proactor loop stability.
2025-12-29 17:05:03 -08:00
try:
2026-01-11 04:54:27 -08:00
await asyncio.sleep(0.2)
2025-12-29 17:05:03 -08:00
except Exception:
pass
2025-12-18 22:50:21 -08:00
2025-12-29 17:05:03 -08:00
# Retry a couple times only for fast 0-byte failures (common transient case).
max_attempts = 3
for attempt in range(1, max_attempts + 1):
result_path, final_state, bytes_done, elapsed = await _attempt_once(attempt)
if result_path:
return result_path
2025-12-18 22:50:21 -08:00
2025-12-29 17:05:03 -08:00
should_retry = (bytes_done == 0) and (elapsed < 15.0)
if attempt < max_attempts and should_retry:
log(
f"[soulseek] Retrying after fast failure (state={final_state})",
file=sys.stderr
2025-12-29 17:05:03 -08:00
)
await asyncio.sleep(2)
continue
break
return None
2025-12-11 19:04:02 -08:00
2025-12-29 17:05:03 -08:00
except ImportError:
log(
"[soulseek] aioslsk not installed. Install with: pip install aioslsk",
file=sys.stderr
)
2025-12-29 17:05:03 -08:00
return None
except Exception as exc:
log(f"[soulseek] Download failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return None