import requests import asyncio import aiohttp from typing import List, Optional, Dict, Any from dataclasses import dataclass import time from pathlib import Path from utils.logging_config import get_logger from config.settings import config_manager logger = get_logger("soulseek_client") @dataclass class SearchResult: """Base class for search results""" username: str filename: str size: int bitrate: Optional[int] duration: Optional[int] quality: str free_upload_slots: int upload_speed: int queue_length: int result_type: str = "track" # "track" or "album" @property def quality_score(self) -> float: quality_weights = { 'flac': 1.0, 'mp3': 0.8, 'ogg': 0.7, 'aac': 0.6, 'wma': 0.5 } base_score = quality_weights.get(self.quality.lower(), 0.3) if self.bitrate: if self.bitrate >= 320: base_score += 0.2 elif self.bitrate >= 256: base_score += 0.1 elif self.bitrate < 128: base_score -= 0.2 if self.free_upload_slots > 0: base_score += 0.1 if self.upload_speed > 100: base_score += 0.05 if self.queue_length > 10: base_score -= 0.1 return min(base_score, 1.0) @dataclass class TrackResult(SearchResult): """Individual track search result""" artist: Optional[str] = None title: Optional[str] = None album: Optional[str] = None track_number: Optional[int] = None def __post_init__(self): self.result_type = "track" # Try to extract metadata from filename if not provided if not self.title or not self.artist: self._parse_filename_metadata() def _parse_filename_metadata(self): """Extract artist, title, album from filename patterns""" import re import os # Get just the filename without extension and path base_name = os.path.splitext(os.path.basename(self.filename))[0] # Common patterns for track naming patterns = [ r'^(\d+)\s*[-\.]\s*(.+?)\s*[-–]\s*(.+)$', # "01 - Artist - Title" or "01. Artist - Title" r'^(.+?)\s*[-–]\s*(.+)$', # "Artist - Title" r'^(\d+)\s*[-\.]\s*(.+)$', # "01 - Title" or "01. Title" ] for pattern in patterns: match = re.match(pattern, base_name) if match: groups = match.groups() if len(groups) == 3: # Track number, artist, title try: self.track_number = int(groups[0]) self.artist = self.artist or groups[1].strip() self.title = self.title or groups[2].strip() except ValueError: # First group might not be a number self.artist = self.artist or groups[0].strip() self.title = self.title or f"{groups[1]} - {groups[2]}".strip() elif len(groups) == 2: if groups[0].isdigit(): # Track number and title try: self.track_number = int(groups[0]) self.title = self.title or groups[1].strip() except ValueError: pass else: # Artist and title self.artist = self.artist or groups[0].strip() self.title = self.title or groups[1].strip() break # Fallback: use filename as title if nothing was extracted if not self.title: self.title = base_name # Try to extract album from directory path if not self.album and '/' in self.filename: path_parts = self.filename.split('/') if len(path_parts) >= 2: # Look for album-like directory names for part in reversed(path_parts[:-1]): # Exclude filename if part and not part.startswith('@'): # Skip system directories # Clean up common patterns cleaned = re.sub(r'^\d+\s*[-\.]\s*', '', part) # Remove leading numbers if len(cleaned) > 3: # Must be substantial self.album = cleaned break @dataclass class AlbumResult: """Album/folder search result containing multiple tracks""" username: str album_path: str # Directory path album_title: str artist: Optional[str] track_count: int total_size: int tracks: List[TrackResult] dominant_quality: str # Most common quality in album year: Optional[str] = None free_upload_slots: int = 0 upload_speed: int = 0 queue_length: int = 0 result_type: str = "album" @property def quality_score(self) -> float: """Calculate album quality score based on dominant quality and track count""" quality_weights = { 'flac': 1.0, 'mp3': 0.8, 'ogg': 0.7, 'aac': 0.6, 'wma': 0.5 } base_score = quality_weights.get(self.dominant_quality.lower(), 0.3) # Bonus for complete albums (typically 8-15 tracks) if 8 <= self.track_count <= 20: base_score += 0.1 elif self.track_count > 20: base_score += 0.05 # User metrics (same as individual tracks) if self.free_upload_slots > 0: base_score += 0.1 if self.upload_speed > 100: base_score += 0.05 if self.queue_length > 10: base_score -= 0.1 return min(base_score, 1.0) @property def size_mb(self) -> int: """Album size in MB""" return self.total_size // (1024 * 1024) @property def average_track_size_mb(self) -> float: """Average track size in MB""" if self.track_count > 0: return self.size_mb / self.track_count return 0 @dataclass class DownloadStatus: id: str filename: str username: str state: str progress: float size: int transferred: int speed: int time_remaining: Optional[int] = None class SoulseekClient: def __init__(self): self.base_url: Optional[str] = None self.api_key: Optional[str] = None self.download_path: Path = Path("./downloads") self._setup_client() def _setup_client(self): config = config_manager.get_soulseek_config() if not config.get('slskd_url'): logger.warning("Soulseek slskd URL not configured") return self.base_url = config['slskd_url'].rstrip('/') self.api_key = config.get('api_key', '') self.download_path = Path(config.get('download_path', './downloads')) self.download_path.mkdir(parents=True, exist_ok=True) logger.info(f"Soulseek client configured with slskd at {self.base_url}") def _get_headers(self) -> Dict[str, str]: headers = {'Content-Type': 'application/json'} if self.api_key: # Use X-API-Key authentication (Bearer tokens are session-based JWT tokens) headers['X-API-Key'] = self.api_key return headers async def _make_request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict[str, Any]]: if not self.base_url: logger.error("Soulseek client not configured") return None url = f"{self.base_url}/api/v0/{endpoint}" # Create a fresh session for each thread/event loop to avoid conflicts session = None try: session = aiohttp.ClientSession() headers = self._get_headers() logger.debug(f"Making {method} request to: {url}") logger.debug(f"Headers: {headers}") if 'json' in kwargs: logger.debug(f"JSON payload: {kwargs['json']}") async with session.request( method, url, headers=headers, **kwargs ) as response: response_text = await response.text() logger.debug(f"Response status: {response.status}") logger.debug(f"Response text: {response_text[:500]}...") # First 500 chars if response.status in [200, 201, 204]: # Accept 200 OK, 201 Created, and 204 No Content try: if response_text.strip(): # Only parse if there's content return await response.json() else: # Return empty dict for successful requests with no content (like 201 Created) return {} except: # If response_text was already consumed, parse it manually import json if response_text.strip(): return json.loads(response_text) else: return {} else: logger.error(f"API request failed: {response.status} - {response_text}") return None except Exception as e: logger.error(f"Error making API request: {e}") return None finally: # Always clean up the session if session: try: await session.close() except: pass async def _make_direct_request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict[str, Any]]: """Make a direct request to slskd without /api/v0/ prefix (for endpoints that work directly)""" if not self.base_url: logger.error("Soulseek client not configured") return None url = f"{self.base_url}/{endpoint}" # Create a fresh session for each thread/event loop to avoid conflicts session = None try: session = aiohttp.ClientSession() headers = self._get_headers() logger.debug(f"Making direct {method} request to: {url}") logger.debug(f"Headers: {headers}") if 'json' in kwargs: logger.debug(f"JSON payload: {kwargs['json']}") async with session.request( method, url, headers=headers, **kwargs ) as response: response_text = await response.text() logger.debug(f"Response status: {response.status}") logger.debug(f"Response text: {response_text[:500]}...") # First 500 chars if response.status == 200: try: return await response.json() except: # If response_text was already consumed, parse it manually import json return json.loads(response_text) else: logger.error(f"Direct API request failed: {response.status} - {response_text}") return None except Exception as e: logger.error(f"Error making direct API request: {e}") return None finally: # Always clean up the session if session: try: await session.close() except: pass def _process_search_responses(self, responses_data: List[Dict[str, Any]]) -> tuple[List[TrackResult], List[AlbumResult]]: """Process search response data into TrackResult and AlbumResult objects""" from collections import defaultdict import re all_tracks = [] albums_by_path = defaultdict(list) logger.debug(f"Processing {len(responses_data)} user responses") # Audio file extensions to filter for audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} for response_data in responses_data: username = response_data.get('username', '') files = response_data.get('files', []) logger.debug(f"User {username} has {len(files)} files") for file_data in files: filename = file_data.get('filename', '') size = file_data.get('size', 0) file_ext = Path(filename).suffix.lower().lstrip('.') # Only process audio files if f'.{file_ext}' not in audio_extensions: continue quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' # Create TrackResult track = TrackResult( username=username, filename=filename, size=size, bitrate=file_data.get('bitRate'), duration=file_data.get('length'), quality=quality, free_upload_slots=response_data.get('freeUploadSlots', 0), upload_speed=response_data.get('uploadSpeed', 0), queue_length=response_data.get('queueLength', 0) ) all_tracks.append(track) # Group tracks by album path for album detection album_path = self._extract_album_path(filename) if album_path: albums_by_path[(username, album_path)].append(track) # Create AlbumResults from grouped tracks album_results = self._create_album_results(albums_by_path) # Keep individual tracks that weren't grouped into albums album_track_filenames = set() for album in album_results: for track in album.tracks: album_track_filenames.add(track.filename) # Individual tracks are those not part of any album individual_tracks = [track for track in all_tracks if track.filename not in album_track_filenames] logger.info(f"Found {len(individual_tracks)} individual tracks and {len(album_results)} albums") logger.debug(f"Album detection details: {len(albums_by_path)} potential albums processed") for (username, album_path), tracks in list(albums_by_path.items())[:3]: # Log first 3 for debugging logger.debug(f"Album: {username}/{album_path} -> {len(tracks)} tracks") return individual_tracks, album_results def _extract_album_path(self, filename: str) -> Optional[str]: """Extract potential album directory path from filename""" # Handle both Windows (\) and Unix (/) path separators if '/' not in filename and '\\' not in filename: return None # Normalize path separators to forward slashes for consistent processing normalized_path = filename.replace('\\', '/') path_parts = normalized_path.split('/') if len(path_parts) < 2: return None # Take the directory containing the file as potential album path album_dir = path_parts[-2] # Directory containing the file # Skip system directories that start with @ or are too short if album_dir.startswith('@') or len(album_dir) < 2: return None # Return the full path up to the album directory (keeping forward slashes) return '/'.join(path_parts[:-1]) def _create_album_results(self, albums_by_path: dict) -> List[AlbumResult]: """Create AlbumResult objects from grouped tracks""" import re from collections import Counter album_results = [] for (username, album_path), tracks in albums_by_path.items(): # Only create albums for paths with multiple tracks (2+ tracks) if len(tracks) < 2: continue # Calculate album metadata total_size = sum(track.size for track in tracks) quality_counts = Counter(track.quality for track in tracks) dominant_quality = quality_counts.most_common(1)[0][0] # Extract album title from path album_title = self._extract_album_title(album_path) # Try to determine artist from tracks or path artist = self._determine_album_artist(tracks, album_path) # Extract year if present year = self._extract_year(album_path, album_title) # Use user metrics from first track (they should be the same for all tracks from same user) first_track = tracks[0] album = AlbumResult( username=username, album_path=album_path, album_title=album_title, artist=artist, track_count=len(tracks), total_size=total_size, tracks=sorted(tracks, key=lambda t: t.track_number or 0), # Sort by track number dominant_quality=dominant_quality, year=year, free_upload_slots=first_track.free_upload_slots, upload_speed=first_track.upload_speed, queue_length=first_track.queue_length ) album_results.append(album) return album_results def _extract_album_title(self, album_path: str) -> str: """Extract album title from directory path""" import re # Get the last directory name as album title album_dir = album_path.split('/')[-1] # Clean up common patterns # Remove leading numbers and separators cleaned = re.sub(r'^\d+\s*[-\.\s]+', '', album_dir) # Remove year patterns at the end: (2023), [2023], - 2023 cleaned = re.sub(r'\s*[-\(\[]?\d{4}[-\)\]]?\s*$', '', cleaned) # Remove common separators and extra spaces cleaned = re.sub(r'\s+', ' ', cleaned).strip() return cleaned if cleaned else album_dir def _determine_album_artist(self, tracks: List[TrackResult], album_path: str) -> Optional[str]: """Determine album artist from track artists or path""" from collections import Counter # Get artist from tracks track_artists = [track.artist for track in tracks if track.artist] if track_artists: # Use most common artist artist_counts = Counter(track_artists) return artist_counts.most_common(1)[0][0] # Try to extract from path import re album_dir = album_path.split('/')[-1] # Look for "Artist - Album" pattern artist_match = re.match(r'^(.+?)\s*[-–]\s*(.+)$', album_dir) if artist_match: potential_artist = artist_match.group(1).strip() if len(potential_artist) > 1: return potential_artist return None def _extract_year(self, album_path: str, album_title: str) -> Optional[str]: """Extract year from album path or title""" import re # Look for 4-digit year in parentheses, brackets, or after dash text_to_search = f"{album_path} {album_title}" year_patterns = [ r'\((\d{4})\)', # (2023) r'\[(\d{4})\]', # [2023] r'\s-(\d{4})$', # - 2023 at end r'\s(\d{4})\s', # 2023 with spaces r'\s(\d{4})$' # 2023 at end ] for pattern in year_patterns: match = re.search(pattern, text_to_search) if match: year = match.group(1) # Validate year range (1900-2030) if 1900 <= int(year) <= 2030: return year return None async def search(self, query: str, timeout: int = 35, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]: if not self.base_url: logger.error("Soulseek client not configured") return [], [] try: logger.info(f"Starting search for: '{query}'") search_data = { 'searchText': query, 'timeout': timeout * 1000, # slskd expects milliseconds 'filterResponses': True, 'minimumResponseFileCount': 1, 'minimumPeerUploadSpeed': 0 } logger.debug(f"Search data: {search_data}") logger.debug(f"Making POST request to: {self.base_url}/api/v0/searches") response = await self._make_request('POST', 'searches', json=search_data) if not response: logger.error("No response from search POST request") return [], [] search_id = response.get('id') if not search_id: logger.error("No search ID returned from POST request") logger.debug(f"Full response: {response}") return [], [] logger.info(f"Search initiated with ID: {search_id}") # Poll for results - process and emit results immediately when found all_responses = [] all_tracks = [] all_albums = [] poll_interval = 1 # Check every 1.5 seconds for more responsive updates max_polls = int(timeout / poll_interval) # 20 attempts over 30 seconds for poll_count in range(max_polls): logger.debug(f"Polling for results (attempt {poll_count + 1}/{max_polls}) - elapsed: {poll_count * poll_interval:.1f}s") # Get current search responses responses_data = await self._make_request('GET', f'searches/{search_id}/responses') if responses_data and isinstance(responses_data, list): # Check if we got new responses new_response_count = len(responses_data) - len(all_responses) if new_response_count > 0: # Process only the new responses new_responses = responses_data[len(all_responses):] all_responses = responses_data logger.info(f"Found {new_response_count} new responses ({len(all_responses)} total) at {poll_count * poll_interval:.1f}s") # Process new responses immediately new_tracks, new_albums = self._process_search_responses(new_responses) # Add to cumulative results all_tracks.extend(new_tracks) all_albums.extend(new_albums) # Sort by quality score for better display order all_tracks.sort(key=lambda x: x.quality_score, reverse=True) all_albums.sort(key=lambda x: x.quality_score, reverse=True) # Call progress callback with processed results immediately if progress_callback: try: progress_callback(all_tracks, all_albums, len(all_responses)) except Exception as e: logger.error(f"Error in progress callback: {e}") logger.info(f"Processed results: {len(all_tracks)} tracks, {len(all_albums)} albums") # Early termination if we have enough responses if len(all_responses) >= 30: # Stop after 30 responses for better performance logger.info(f"Early termination: Found {len(all_responses)} responses, stopping search") break elif len(all_responses) > 0: logger.debug(f"No new responses, total still: {len(all_responses)}") else: logger.debug(f"Still waiting for responses... ({poll_count * poll_interval:.1f}s elapsed)") # Wait before next poll (unless this is the last attempt) if poll_count < max_polls - 1: await asyncio.sleep(poll_interval) logger.info(f"Search completed. Final results: {len(all_tracks)} tracks and {len(all_albums)} albums for query: {query}") return all_tracks, all_albums except Exception as e: logger.error(f"Error searching: {e}") return [], [] async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]: if not self.base_url: logger.error("Soulseek client not configured") return None try: logger.debug(f"Attempting to download: {filename} from {username} (size: {file_size})") # Use the exact format observed in the web interface # Payload: [{filename: "...", size: 123}] - array of files # Try adding path parameter to see if slskd supports custom download paths download_data = [ { "filename": filename, "size": file_size, "path": str(self.download_path) # Try custom download path } ] logger.debug(f"Using web interface API format: {download_data}") # Use the correct endpoint pattern from web interface: /api/v0/transfers/downloads/{username} endpoint = f'transfers/downloads/{username}' logger.debug(f"Trying web interface endpoint: {endpoint}") try: response = await self._make_request('POST', endpoint, json=download_data) if response is not None: # 201 Created might return download info logger.info(f"[SUCCESS] Started download: {filename} from {username}") # Try to extract download ID from response if available if isinstance(response, dict) and 'id' in response: logger.debug(f"Got download ID from response: {response['id']}") return response['id'] elif isinstance(response, list) and len(response) > 0 and 'id' in response[0]: logger.debug(f"Got download ID from response list: {response[0]['id']}") return response[0]['id'] else: # Fallback to filename if no ID in response logger.debug(f"No ID in response, using filename as fallback: {response}") return filename else: logger.debug(f"Web interface endpoint returned no response") except Exception as e: logger.debug(f"Web interface endpoint failed: {e}") # Fallback: Try alternative patterns if the main one fails logger.debug("Web interface endpoint failed, trying alternatives...") # Try different username-based endpoint patterns username_endpoints_to_try = [ f'transfers/{username}/enqueue', f'users/{username}/downloads', f'users/{username}/enqueue' ] # Try with array format first for endpoint in username_endpoints_to_try: logger.debug(f"Trying endpoint: {endpoint} with array format") try: response = await self._make_request('POST', endpoint, json=download_data) if response is not None: logger.info(f"[SUCCESS] Started download: {filename} from {username} using endpoint: {endpoint}") # Try to extract download ID from response if available if isinstance(response, dict) and 'id' in response: logger.debug(f"Got download ID from response: {response['id']}") return response['id'] elif isinstance(response, list) and len(response) > 0 and 'id' in response[0]: logger.debug(f"Got download ID from response list: {response[0]['id']}") return response[0]['id'] else: # Fallback to filename if no ID in response logger.debug(f"No ID in response, using filename as fallback: {response}") return filename else: logger.debug(f"Endpoint {endpoint} returned no response") except Exception as e: logger.debug(f"Endpoint {endpoint} failed: {e}") continue # Try with old format as final fallback logger.debug("Array format failed, trying old object format") fallback_data = { "files": [ { "filename": filename, "size": file_size } ] } for endpoint in username_endpoints_to_try: logger.debug(f"Trying endpoint: {endpoint} with object format") try: response = await self._make_request('POST', endpoint, json=fallback_data) if response is not None: logger.info(f"[SUCCESS] Started download: {filename} from {username} using fallback endpoint: {endpoint}") # Try to extract download ID from response if available if isinstance(response, dict) and 'id' in response: logger.debug(f"Got download ID from response: {response['id']}") return response['id'] elif isinstance(response, list) and len(response) > 0 and 'id' in response[0]: logger.debug(f"Got download ID from response list: {response[0]['id']}") return response[0]['id'] else: # Fallback to filename if no ID in response logger.debug(f"No ID in response, using filename as fallback: {response}") return filename else: logger.debug(f"Fallback endpoint {endpoint} returned no response") except Exception as e: logger.debug(f"Fallback endpoint {endpoint} failed: {e}") continue logger.error(f"All download endpoints failed for {filename} from {username}") return None except Exception as e: logger.error(f"Error starting download: {e}") return None async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: if not self.base_url: return None try: response = await self._make_request('GET', f'transfers/downloads/{download_id}') if not response: return None return DownloadStatus( id=response.get('id', ''), filename=response.get('filename', ''), username=response.get('username', ''), state=response.get('state', ''), progress=response.get('percentComplete', 0.0), size=response.get('size', 0), transferred=response.get('bytesTransferred', 0), speed=response.get('averageSpeed', 0), time_remaining=response.get('timeRemaining') ) except Exception as e: logger.error(f"Error getting download status: {e}") return None async def get_all_downloads(self) -> List[DownloadStatus]: if not self.base_url: return [] try: # FIXED: Skip the 404 endpoint and go straight to the working one response = await self._make_request('GET', 'transfers/downloads') if not response: return [] downloads = [] # FIXED: Parse the nested response structure correctly # Response format: [{"username": "user", "directories": [{"files": [...]}]}] for user_data in response: username = user_data.get('username', '') directories = user_data.get('directories', []) for directory in directories: files = directory.get('files', []) for file_data in files: # Parse progress from the state if available progress = 0.0 if file_data.get('state', '').lower().startswith('completed'): progress = 100.0 elif 'progress' in file_data: progress = float(file_data.get('progress', 0.0)) status = DownloadStatus( id=file_data.get('id', ''), filename=file_data.get('filename', ''), username=username, state=file_data.get('state', ''), progress=progress, size=file_data.get('size', 0), transferred=file_data.get('bytesTransferred', 0), # May not exist in API speed=file_data.get('averageSpeed', 0), # May not exist in API time_remaining=file_data.get('timeRemaining') ) downloads.append(status) logger.debug(f"Parsed {len(downloads)} downloads from API response") return downloads except Exception as e: logger.error(f"Error getting downloads: {e}") return [] async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: if not self.base_url: return False # If username is not provided, try to extract it from stored transfer data if not username: logger.debug(f"No username provided for download_id {download_id}, attempting to find it") try: downloads = await self.get_all_downloads() for download in downloads: if download.id == download_id: username = download.username logger.debug(f"Found username {username} for download_id {download_id}") break if not username: logger.error(f"Could not find username for download_id {download_id}") return False except Exception as e: logger.error(f"Error finding username for download: {e}") return False try: # Use correct slskd API format: DELETE /transfers/downloads/{username}/{download_id}?remove={true/false} # remove=false: Cancel download but keep in transfer list # remove=true: Remove download completely from transfer list endpoint = f'transfers/downloads/{username}/{download_id}?remove={str(remove).lower()}' action = "Removing" if remove else "Cancelling" logger.debug(f"{action} download with endpoint: {endpoint}") response = await self._make_request('DELETE', endpoint) return response is not None except Exception as e: logger.error(f"Error cancelling download: {e}") return False async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool: """Signal the Soulseek API that a download has completed or been cancelled Args: download_id: The ID of the download username: The uploader username remove: True to remove from transfer list (completion), False to just cancel Returns: bool: True if signal was successful, False otherwise """ if not self.base_url: logger.error("Soulseek client not configured") return False try: # Use the API endpoint format: /transfers/downloads/{username}/{download_id}?remove={true/false} endpoint = f'transfers/downloads/{username}/{download_id}?remove={str(remove).lower()}' action = "Signaling completion" if remove else "Signaling cancellation" logger.debug(f"{action} for download {download_id} from {username}") response = await self._make_request('DELETE', endpoint) success = response is not None if success: logger.info(f"Successfully signaled download {action.lower()}: {download_id}") else: logger.warning(f"Failed to signal download {action.lower()}: {download_id}") return success except Exception as e: logger.error(f"Error signaling download completion: {e}") return False async def clear_all_completed_downloads(self) -> bool: """Clear all completed/finished downloads from slskd backend Uses the /api/v0/transfers/downloads/all/completed endpoint to remove all downloads with completed, cancelled, or failed status from slskd. Returns: bool: True if clearing was successful, False otherwise """ if not self.base_url: logger.error("Soulseek client not configured") return False try: endpoint = 'transfers/downloads/all/completed' logger.debug(f"Clearing all completed downloads with endpoint: {endpoint}") response = await self._make_request('DELETE', endpoint) success = response is not None if success: logger.info("Successfully cleared all completed downloads from slskd") else: logger.error("Failed to clear completed downloads from slskd") return success except Exception as e: logger.error(f"Error clearing completed downloads: {e}") return False async def search_and_download_best(self, query: str, preferred_quality: str = 'flac') -> Optional[str]: results = await self.search(query) if not results: logger.warning(f"No results found for: {query}") return None preferred_results = [r for r in results if r.quality.lower() == preferred_quality.lower()] if preferred_results: best_result = preferred_results[0] else: best_result = results[0] logger.info(f"Preferred quality {preferred_quality} not found, using {best_result.quality}") logger.info(f"Downloading: {best_result.filename} ({best_result.quality}) from {best_result.username}") return await self.download(best_result.username, best_result.filename, best_result.size) async def check_connection(self) -> bool: """Check if slskd is running and accessible""" if not self.base_url: return False try: response = await self._make_request('GET', 'session') return response is not None except Exception as e: logger.debug(f"Connection check failed: {e}") return False async def get_session_info(self) -> Optional[Dict[str, Any]]: """Get slskd session information including version""" if not self.base_url: return None try: response = await self._make_request('GET', 'session') if response: logger.info(f"slskd session info: {response}") return response return None except Exception as e: logger.error(f"Error getting session info: {e}") return None async def explore_api_endpoints(self) -> Dict[str, Any]: """Explore available API endpoints to find the correct download endpoint""" if not self.base_url: return {} try: logger.info("Exploring slskd API endpoints...") # Try to get Swagger/OpenAPI documentation swagger_url = f"{self.base_url}/swagger/v1/swagger.json" session = aiohttp.ClientSession() try: headers = self._get_headers() async with session.get(swagger_url, headers=headers) as response: if response.status == 200: swagger_data = await response.json() logger.info("✓ Found Swagger documentation") # Look for download/transfer related endpoints paths = swagger_data.get('paths', {}) download_endpoints = {} for path, methods in paths.items(): if any(keyword in path.lower() for keyword in ['download', 'transfer', 'enqueue']): download_endpoints[path] = methods logger.info(f"Found endpoint: {path} with methods: {list(methods.keys())}") return { 'swagger_available': True, 'download_endpoints': download_endpoints, 'base_url': self.base_url } else: logger.debug(f"Swagger endpoint returned {response.status}") except Exception as e: logger.debug(f"Could not access Swagger docs: {e}") finally: await session.close() # If Swagger is not available, try common endpoints manually logger.info("Swagger not available, testing common endpoints...") common_endpoints = [ 'transfers', 'downloads', 'transfers/downloads', 'api/transfers', 'api/downloads' ] available_endpoints = {} for endpoint in common_endpoints: try: response = await self._make_request('GET', endpoint) if response is not None: available_endpoints[endpoint] = 'GET available' logger.info(f"[OK] Endpoint available: {endpoint}") else: # Try different endpoints without /api/v0 prefix simple_url = f"{self.base_url}/{endpoint}" session = aiohttp.ClientSession() try: headers = self._get_headers() async with session.get(simple_url, headers=headers) as resp: if resp.status in [200, 405]: # 405 means endpoint exists but wrong method available_endpoints[f"direct_{endpoint}"] = f"Status: {resp.status}" logger.info(f"[OK] Direct endpoint available: {simple_url} (Status: {resp.status})") except: pass finally: await session.close() except Exception as e: logger.debug(f"Endpoint {endpoint} failed: {e}") return { 'swagger_available': False, 'available_endpoints': available_endpoints, 'base_url': self.base_url } except Exception as e: logger.error(f"Error exploring API endpoints: {e}") return {'error': str(e)} def is_configured(self) -> bool: """Check if slskd is configured (has base_url)""" return self.base_url is not None async def close(self): # No persistent session to close - each request creates its own session pass def __del__(self): # No persistent session to clean up pass