from __future__ import annotations import asyncio import contextlib import io import logging import os import re import sys import time from pathlib import Path from typing import Any, Dict, List, Optional from ProviderCore.base import SearchProvider, SearchResult from SYS.logger import log, debug from models import ProgressBar _SOULSEEK_NOISE_SUBSTRINGS = ( "search reply ticket does not match any search request", "failed to receive transfer ticket on file connection", "aioslsk.exceptions.ConnectionReadError", ) def _configure_aioslsk_logging() -> None: """Reduce aioslsk internal log noise. 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", ): logger = logging.getLogger(name) logger.setLevel(logging.ERROR) logger.propagate = False class _LineFilterStream(io.TextIOBase): """A minimal stream wrapper that filters known noisy lines. It also suppresses entire traceback blocks when they contain known non-fatal aioslsk noise (e.g. ConnectionReadError during peer init). """ 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 def writable(self) -> bool: # pragma: no cover return True def _should_suppress_line(self, line: str) -> bool: return any(sub in line for sub in self._suppress) 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 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) def _handle_line(self, line: str) -> None: # 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 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() return # Non-traceback line if self._should_suppress_line(line): return try: self._underlying.write(line + "\n") except Exception: pass 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._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 @contextlib.contextmanager def _suppress_aioslsk_noise() -> Any: """Temporarily suppress known aioslsk noise printed to stdout/stderr. Opt out by setting DOWNLOW_SOULSEEK_VERBOSE=1. """ if os.environ.get("DOWNLOW_SOULSEEK_VERBOSE"): yield return _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 class Soulseek(SearchProvider): """Search provider for Soulseek P2P network.""" MUSIC_EXTENSIONS = { ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".alac", ".wma", ".ape", ".aiff", ".dsf", ".dff", ".wv", ".tta", ".tak", ".ac3", ".dts", } # NOTE: These defaults preserve existing behavior. USERNAME = "asjhkjljhkjfdsd334" PASSWORD = "khhhg" DOWNLOAD_DIR = "./downloads" MAX_WAIT_TRANSFER = 1200 def __init__(self, config: Optional[Dict[str, Any]] = None): super().__init__(config) try: from config import get_soulseek_username, get_soulseek_password 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 def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]: """Download file from Soulseek.""" try: full_metadata = result.full_metadata or {} username = full_metadata.get("username") filename = full_metadata.get("filename") or result.path if not username or not filename: log(f"[soulseek] Missing metadata for download: {result.title}", file=sys.stderr) return None # This cmdlet stack is synchronous; use asyncio.run for clarity. return asyncio.run( download_soulseek_file( username=username, filename=filename, output_dir=output_dir, timeout=self.MAX_WAIT_TRANSFER, ) ) 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: asyncio.set_event_loop(loop) return loop.run_until_complete( download_soulseek_file( username=username, filename=filename, output_dir=output_dir, timeout=self.MAX_WAIT_TRANSFER, ) ) finally: try: loop.close() except Exception: pass except Exception as exc: log(f"[soulseek] Download error: {exc}", file=sys.stderr) return None async def perform_search(self, query: str, timeout: float = 9.0, limit: int = 50) -> List[Dict[str, Any]]: """Perform async Soulseek search.""" from aioslsk.client import SoulSeekClient from aioslsk.settings import CredentialsSettings, Settings os.makedirs(self.DOWNLOAD_DIR, exist_ok=True) settings = Settings(credentials=CredentialsSettings(username=self.USERNAME, password=self.PASSWORD)) client = SoulSeekClient(settings) with _suppress_aioslsk_noise(): try: await client.start() await client.login() except Exception as exc: log(f"[soulseek] Login failed: {type(exc).__name__}: {exc}", file=sys.stderr) return [] try: search_request = await client.searches.search(query) await self._collect_results(search_request, timeout=timeout) return self._flatten_results(search_request)[:limit] except Exception as exc: log(f"[soulseek] Search error: {type(exc).__name__}: {exc}", file=sys.stderr) return [] 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 def _flatten_results(self, search_request: Any) -> List[dict]: flat: List[dict] = [] for result in getattr(search_request, "results", []): username = getattr(result, "username", "?") for file_data in getattr(result, "shared_items", []): flat.append( { "file": file_data, "username": username, "filename": getattr(file_data, "filename", "?"), "size": getattr(file_data, "filesize", 0), } ) for file_data in getattr(result, "locked_results", []): flat.append( { "file": file_data, "username": username, "filename": getattr(file_data, "filename", "?"), "size": getattr(file_data, "filesize", 0), } ) return flat async def _collect_results(self, search_request: Any, timeout: float = 75.0) -> None: end = time.time() + timeout last_count = 0 while time.time() < end: current_count = len(getattr(search_request, "results", [])) if current_count > last_count: debug(f"[soulseek] Got {current_count} result(s)...") last_count = current_count await asyncio.sleep(0.5) def search( self, query: str, limit: int = 50, filters: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> List[SearchResult]: filters = filters or {} try: flat_results = asyncio.run(self.perform_search(query, timeout=9.0, limit=limit)) if not flat_results: return [] music_results: List[dict] = [] for item in flat_results: filename = item["filename"] ext = ("." + filename.rsplit(".", 1)[-1].lower()) if "." in filename else "" if ext in self.MUSIC_EXTENSIONS: music_results.append(item) if not music_results: return [] enriched_results: List[dict] = [] for item in music_results: filename = item["filename"] ext = ("." + filename.rsplit(".", 1)[-1].lower()) if "." in filename else "" 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 "") base_name = display_name.rsplit(".", 1)[0] if "." in display_name else display_name track_num = "" title = base_name filename_artist = "" 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 if filename_artist: artist = filename_artist enriched_results.append( { **item, "artist": artist, "album": album, "title": title, "track_num": track_num, "ext": ext, } ) if filters: artist_filter = (filters.get("artist", "") or "").lower() album_filter = (filters.get("album", "") or "").lower() track_filter = (filters.get("track", "") or "").lower() 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(): 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 enriched_results.sort(key=lambda item: (item["ext"].lower() != ".flac", -item["size"])) 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) columns = [ ("Track", item["track_num"] or "?"), ("Title", item["title"][:40]), ("Artist", artist_display[:32]), ("Album", album_display[:32]), ("Size", f"{size_mb} MB"), ] 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()], media_kind="audio", size_bytes=item["size"], columns=columns, full_metadata={ "username": item["username"], "filename": item["filename"], "artist": item["artist"], "album": item["album"], "track_num": item["track_num"], "ext": item["ext"], }, ) ) return results except Exception as exc: log(f"[soulseek] Search error: {exc}", file=sys.stderr) return [] def validate(self) -> bool: try: from aioslsk.client import SoulSeekClient # noqa: F401 # Require configured credentials. try: from config import get_soulseek_username, get_soulseek_password 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 async def download_soulseek_file( username: str, filename: str, output_dir: Path = Path("./downloads"), timeout: int = 1200, *, client_username: Optional[str] = None, client_password: Optional[str] = None, ) -> Optional[Path]: """Download a file from a Soulseek peer.""" 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 output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) 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) 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 output_path = output_path.resolve() 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)") settings = Settings(credentials=CredentialsSettings(username=login_user, password=login_pass)) async def _attempt_once(attempt_num: int) -> tuple[Optional[Path], Any, int, float]: client = SoulSeekClient(settings) with _suppress_aioslsk_noise(): try: await client.start() await client.login() debug(f"[soulseek] Logged in as {login_user}") log( f"[soulseek] Download attempt {attempt_num}: {username} :: {local_filename}", file=sys.stderr, ) debug(f"[soulseek] Requesting download from {username}: {filename}") transfer = await client.transfers.add(Transfer(username, filename, TransferDirection.DOWNLOAD)) transfer.local_path = str(output_path) await client.transfers.queue(transfer) start_time = time.time() last_progress_time = start_time progress_bar = ProgressBar() 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) try: if getattr(sys.stderr, "isatty", lambda: False)(): sys.stderr.write("\r" + (" " * 140) + "\r") sys.stderr.flush() except Exception: pass return None, state_val, bytes_done, elapsed bytes_done = int(getattr(transfer, "bytes_transfered", 0) or 0) total_bytes = int(getattr(transfer, "filesize", 0) or 0) now = time.time() if now - last_progress_time >= 0.5: percent = (bytes_done / total_bytes) * 100.0 if total_bytes > 0 else 0.0 speed = bytes_done / elapsed if elapsed > 0 else 0.0 eta_str: Optional[str] = None if total_bytes > 0 and speed > 0: try: eta_seconds = max(0.0, float(total_bytes - bytes_done) / float(speed)) minutes, seconds = divmod(int(eta_seconds), 60) hours, minutes = divmod(minutes, 60) eta_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}" except Exception: eta_str = None speed_str = progress_bar.format_bytes(speed) + "/s" progress_line = progress_bar.format_progress( percent_str=f"{percent:.1f}%", downloaded=bytes_done, total=total_bytes if total_bytes > 0 else None, speed_str=speed_str, eta_str=eta_str, ) try: if getattr(sys.stderr, "isatty", lambda: False)(): sys.stderr.write("\r" + progress_line + " ") sys.stderr.flush() else: log(progress_line, file=sys.stderr) except Exception: pass last_progress_time = now await asyncio.sleep(1) final_state = getattr(getattr(transfer, "state", None), "VALUE", None) downloaded_path = Path(transfer.local_path) if getattr(transfer, "local_path", None) else output_path final_elapsed = time.time() - start_time # Clear in-place progress bar. try: if getattr(sys.stderr, "isatty", lambda: False)(): sys.stderr.write("\r" + (" " * 140) + "\r") sys.stderr.flush() except Exception: pass # 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: 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 if final_state == TransferState.COMPLETE and downloaded_path.exists(): debug(f"[soulseek] Download complete: {downloaded_path}") return downloaded_path, final_state, int(downloaded_path.stat().st_size), final_elapsed 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, ) # Clean up 0-byte placeholder. try: if downloaded_path.exists() and downloaded_path.stat().st_size == 0: 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 # 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 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) await asyncio.sleep(2) continue break return None except ImportError: log("[soulseek] aioslsk not installed. Install with: pip install aioslsk", file=sys.stderr) return None except Exception as exc: log(f"[soulseek] Download failed: {type(exc).__name__}: {exc}", file=sys.stderr) return None