from __future__ import annotations import asyncio import re import sys import time from pathlib import Path from typing import Any, Dict, List, Optional from Provider._base import SearchProvider, SearchResult from SYS.logger import log, debug 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 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.""" import os 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) 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: 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 return True except ImportError: return False async def download_soulseek_file( username: str, filename: str, output_dir: Path = Path("./downloads"), timeout: int = 1200, ) -> 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() settings = Settings(credentials=CredentialsSettings(username=Soulseek.USERNAME, password=Soulseek.PASSWORD)) client = SoulSeekClient(settings) try: await client.start() await client.login() debug(f"[soulseek] Logged in as {Soulseek.USERNAME}") 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_log_time = 0.0 while not transfer.is_finalized(): if time.time() - start_time > timeout: log(f"[soulseek] Download timeout after {timeout}s", file=sys.stderr) return None if time.time() - last_log_time >= 5.0 and transfer.bytes_transfered > 0: progress = (transfer.bytes_transfered / transfer.filesize * 100) if transfer.filesize else 0 debug( f"[soulseek] Progress: {progress:.1f}% " f"({transfer.bytes_transfered}/{transfer.filesize})" ) last_log_time = time.time() await asyncio.sleep(1) if transfer.state.VALUE == TransferState.COMPLETE and transfer.local_path: downloaded_path = Path(transfer.local_path) if downloaded_path.exists(): debug(f"[soulseek] Download complete: {downloaded_path}") return downloaded_path log(f"[soulseek] Transfer completed but file missing: {downloaded_path}", file=sys.stderr) return None log( f"[soulseek] Download failed: state={transfer.state.VALUE} " f"bytes={transfer.bytes_transfered}/{transfer.filesize}", file=sys.stderr, ) return None finally: try: await client.stop() except Exception: pass 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