soulsync/core/soulseek_client.py
Broque Thomas 8dd433fc3b fix
2025-07-09 13:55:18 -07:00

316 lines
No EOL
11 KiB
Python

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:
username: str
filename: str
size: int
bitrate: Optional[int]
duration: Optional[int]
quality: str
free_upload_slots: int
upload_speed: int
queue_length: int
@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 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.session: Optional[aiohttp.ClientSession] = 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:
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}"
if not self.session:
self.session = aiohttp.ClientSession()
try:
async with self.session.request(
method,
url,
headers=self._get_headers(),
**kwargs
) as response:
if response.status == 200:
return await response.json()
else:
logger.error(f"API request failed: {response.status} - {await response.text()}")
return None
except Exception as e:
logger.error(f"Error making API request: {e}")
return None
async def search(self, query: str, timeout: int = 30) -> List[SearchResult]:
if not self.base_url:
logger.error("Soulseek client not configured")
return []
try:
search_data = {
'searchText': query,
'timeout': timeout * 1000,
'filterResponses': True,
'minimumResponseFileCount': 1,
'minimumPeerUploadSpeed': 0
}
response = await self._make_request('POST', 'searches', json=search_data)
if not response:
return []
search_id = response.get('id')
if not search_id:
logger.error("No search ID returned")
return []
await asyncio.sleep(timeout)
results_response = await self._make_request('GET', f'searches/{search_id}')
if not results_response:
return []
search_results = []
for response_data in results_response.get('responses', []):
username = response_data.get('username', '')
for file_data in response_data.get('files', []):
filename = file_data.get('filename', '')
size = file_data.get('size', 0)
file_ext = Path(filename).suffix.lower().lstrip('.')
quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown'
result = SearchResult(
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)
)
search_results.append(result)
search_results.sort(key=lambda x: x.quality_score, reverse=True)
logger.info(f"Found {len(search_results)} results for query: {query}")
return search_results
except Exception as e:
logger.error(f"Error searching: {e}")
return []
async def download(self, username: str, filename: str) -> Optional[str]:
if not self.base_url:
logger.error("Soulseek client not configured")
return None
try:
download_data = {
'username': username,
'files': [filename]
}
response = await self._make_request('POST', 'transfers/downloads', json=download_data)
if response:
logger.info(f"Started download: {filename} from {username}")
return filename
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:
response = await self._make_request('GET', 'transfers/downloads')
if not response:
return []
downloads = []
for download_data in response:
status = DownloadStatus(
id=download_data.get('id', ''),
filename=download_data.get('filename', ''),
username=download_data.get('username', ''),
state=download_data.get('state', ''),
progress=download_data.get('percentComplete', 0.0),
size=download_data.get('size', 0),
transferred=download_data.get('bytesTransferred', 0),
speed=download_data.get('averageSpeed', 0),
time_remaining=download_data.get('timeRemaining')
)
downloads.append(status)
return downloads
except Exception as e:
logger.error(f"Error getting downloads: {e}")
return []
async def cancel_download(self, download_id: str) -> bool:
if not self.base_url:
return False
try:
response = await self._make_request('DELETE', f'transfers/downloads/{download_id}')
return response is not None
except Exception as e:
logger.error(f"Error cancelling download: {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)
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
def is_configured(self) -> bool:
"""Check if slskd is configured (has base_url)"""
return self.base_url is not None
async def close(self):
if self.session:
await self.session.close()
def __del__(self):
if self.session and not self.session.closed:
try:
asyncio.get_event_loop().run_until_complete(self.session.close())
except:
pass