fix
This commit is contained in:
parent
8dd433fc3b
commit
11a1343217
11 changed files with 4650 additions and 56 deletions
|
|
@ -1 +1 @@
|
||||||
{"access_token": "BQA7NSoqYKl8-HgT1MaTChCe-DbSBPXUZG9_4AzQYIqZsO4MgnVjJyl35gsDKR36HejqrJK8DziDO-HIecF2nhX4OSro0ROIY71CCtjCboE_-BAHk7w-x1XtUyINP7ZNBARToEaketFUiguJAMXIH7NALut2AtQelLyuT7X_NZdINBNpUY6St3BoEAiOIJe5dkxy7UFdHgLZdBLDUZhXLs40zB73qONG4Ie3WjkCAH_RTa4owXn7wvSMLToXd3VH", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752094286, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
|
{"access_token": "BQCXjLyK_OHaggFHfb5wR0JXJv3sWABpjx_UDSIHBXYZRaoQCZmWRaa1QMoNYt2UWMzISQoM91ei1TYyLuT9p0a8XV0NhmctOTQX1sGAnw-wCI2LYTnH5ZVj0DFSrosUqca14vTVLTTlhBZP3gjdgCXfoXpymZUMpHWHMf_aFYr27NfhwQyRQffze3bPzuHTmln2BAEM0BlpHBQl97pI8XoHhOtyvLq95RCZrZZcYHcKPL4dSHG6Bn_gQJLjiv2y", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752125167, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
},
|
},
|
||||||
"soulseek": {
|
"soulseek": {
|
||||||
"slskd_url": "http://localhost:5030",
|
"slskd_url": "http://localhost:5030",
|
||||||
"api_key": "123456789123456789",
|
"api_key": "1234567891234567",
|
||||||
"download_path": "./downloads"
|
"download_path": "E:\\Broque Projects\\newMusic\\downloads"
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"path": "database/artists.db"
|
"path": "database/artists.db"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -69,7 +69,6 @@ class SoulseekClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_url: Optional[str] = None
|
self.base_url: Optional[str] = None
|
||||||
self.api_key: Optional[str] = None
|
self.api_key: Optional[str] = None
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
|
||||||
self.download_path: Path = Path("./downloads")
|
self.download_path: Path = Path("./downloads")
|
||||||
self._setup_client()
|
self._setup_client()
|
||||||
|
|
||||||
|
|
@ -90,6 +89,7 @@ class SoulseekClient:
|
||||||
def _get_headers(self) -> Dict[str, str]:
|
def _get_headers(self) -> Dict[str, str]:
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
if self.api_key:
|
if self.api_key:
|
||||||
|
# Use X-API-Key authentication (Bearer tokens are session-based JWT tokens)
|
||||||
headers['X-API-Key'] = self.api_key
|
headers['X-API-Key'] = self.api_key
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
@ -100,60 +100,170 @@ class SoulseekClient:
|
||||||
|
|
||||||
url = f"{self.base_url}/api/v0/{endpoint}"
|
url = f"{self.base_url}/api/v0/{endpoint}"
|
||||||
|
|
||||||
if not self.session:
|
# Create a fresh session for each thread/event loop to avoid conflicts
|
||||||
self.session = aiohttp.ClientSession()
|
session = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self.session.request(
|
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,
|
method,
|
||||||
url,
|
url,
|
||||||
headers=self._get_headers(),
|
headers=headers,
|
||||||
**kwargs
|
**kwargs
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
response_text = await response.text()
|
||||||
return await response.json()
|
logger.debug(f"Response status: {response.status}")
|
||||||
|
logger.debug(f"Response text: {response_text[:500]}...") # First 500 chars
|
||||||
|
|
||||||
|
if response.status in [200, 201]: # Accept both 200 OK and 201 Created
|
||||||
|
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:
|
else:
|
||||||
logger.error(f"API request failed: {response.status} - {await response.text()}")
|
logger.error(f"API request failed: {response.status} - {response_text}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error making API request: {e}")
|
logger.error(f"Error making API request: {e}")
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
# Always clean up the session
|
||||||
|
if session:
|
||||||
|
try:
|
||||||
|
await session.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
async def search(self, query: str, timeout: int = 30) -> List[SearchResult]:
|
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
|
||||||
|
|
||||||
|
async def search(self, query: str, timeout: int = 10) -> List[SearchResult]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
logger.error("Soulseek client not configured")
|
logger.error("Soulseek client not configured")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"Starting search for: '{query}'")
|
||||||
|
|
||||||
search_data = {
|
search_data = {
|
||||||
'searchText': query,
|
'searchText': query,
|
||||||
'timeout': timeout * 1000,
|
'timeout': timeout * 1000, # slskd expects milliseconds
|
||||||
'filterResponses': True,
|
'filterResponses': True,
|
||||||
'minimumResponseFileCount': 1,
|
'minimumResponseFileCount': 1,
|
||||||
'minimumPeerUploadSpeed': 0
|
'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)
|
response = await self._make_request('POST', 'searches', json=search_data)
|
||||||
if not response:
|
if not response:
|
||||||
|
logger.error("No response from search POST request")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
search_id = response.get('id')
|
search_id = response.get('id')
|
||||||
if not search_id:
|
if not search_id:
|
||||||
logger.error("No search ID returned")
|
logger.error("No search ID returned from POST request")
|
||||||
|
logger.debug(f"Full response: {response}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Search initiated with ID: {search_id}")
|
||||||
|
|
||||||
|
# Wait for search to complete (reduced timeout for testing)
|
||||||
await asyncio.sleep(timeout)
|
await asyncio.sleep(timeout)
|
||||||
|
|
||||||
results_response = await self._make_request('GET', f'searches/{search_id}')
|
logger.debug(f"Getting results for search ID: {search_id}")
|
||||||
if not results_response:
|
|
||||||
|
# Use the correct endpoint to get search responses (actual files)
|
||||||
|
responses_data = await self._make_request('GET', f'searches/{search_id}/responses')
|
||||||
|
if not responses_data:
|
||||||
|
logger.error("No response from search responses GET request")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
logger.debug(f"Responses data type: {type(responses_data)}")
|
||||||
|
logger.debug(f"Responses data length: {len(responses_data) if isinstance(responses_data, list) else 'Not a list'}")
|
||||||
|
|
||||||
search_results = []
|
search_results = []
|
||||||
for response_data in results_response.get('responses', []):
|
|
||||||
username = response_data.get('username', '')
|
# responses_data should be a list of user responses
|
||||||
|
if isinstance(responses_data, list):
|
||||||
|
responses = responses_data
|
||||||
|
else:
|
||||||
|
logger.error(f"Expected list of responses, got: {type(responses_data)}")
|
||||||
|
return []
|
||||||
|
|
||||||
for file_data in response_data.get('files', []):
|
logger.info(f"Processing {len(responses)} user responses")
|
||||||
|
|
||||||
|
for response_data in responses:
|
||||||
|
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', '')
|
filename = file_data.get('filename', '')
|
||||||
size = file_data.get('size', 0)
|
size = file_data.get('size', 0)
|
||||||
|
|
||||||
|
|
@ -181,22 +291,93 @@ class SoulseekClient:
|
||||||
logger.error(f"Error searching: {e}")
|
logger.error(f"Error searching: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def download(self, username: str, filename: str) -> Optional[str]:
|
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
logger.error("Soulseek client not configured")
|
logger.error("Soulseek client not configured")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
download_data = {
|
logger.debug(f"Attempting to download: {filename} from {username} (size: {file_size})")
|
||||||
'username': username,
|
|
||||||
'files': [filename]
|
# Use the exact format observed in the web interface
|
||||||
|
# Payload: [{filename: "...", size: 123}] - array of files
|
||||||
|
download_data = [
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"size": file_size
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
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 returns empty dict {} but status 201
|
||||||
|
logger.info(f"[SUCCESS] Started download: {filename} from {username}")
|
||||||
|
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}")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await self._make_request('POST', 'transfers/downloads', json=download_data)
|
for endpoint in username_endpoints_to_try:
|
||||||
if response:
|
logger.debug(f"Trying endpoint: {endpoint} with object format")
|
||||||
logger.info(f"Started download: {filename} from {username}")
|
|
||||||
return filename
|
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}")
|
||||||
|
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
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -233,7 +414,12 @@ class SoulseekClient:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self._make_request('GET', 'transfers/downloads')
|
# Try different endpoints for getting downloads
|
||||||
|
response = await self._make_request('GET', 'downloads')
|
||||||
|
if not response:
|
||||||
|
# Fallback to the old endpoint
|
||||||
|
response = await self._make_request('GET', 'transfers/downloads')
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -286,7 +472,7 @@ class SoulseekClient:
|
||||||
logger.info(f"Preferred quality {preferred_quality} not found, using {best_result.quality}")
|
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}")
|
logger.info(f"Downloading: {best_result.filename} ({best_result.quality}) from {best_result.username}")
|
||||||
return await self.download(best_result.username, best_result.filename)
|
return await self.download(best_result.username, best_result.filename, best_result.size)
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
async def check_connection(self) -> bool:
|
||||||
"""Check if slskd is running and accessible"""
|
"""Check if slskd is running and accessible"""
|
||||||
|
|
@ -300,17 +486,116 @@ class SoulseekClient:
|
||||||
logger.debug(f"Connection check failed: {e}")
|
logger.debug(f"Connection check failed: {e}")
|
||||||
return False
|
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:
|
def is_configured(self) -> bool:
|
||||||
"""Check if slskd is configured (has base_url)"""
|
"""Check if slskd is configured (has base_url)"""
|
||||||
return self.base_url is not None
|
return self.base_url is not None
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
if self.session:
|
# No persistent session to close - each request creates its own session
|
||||||
await self.session.close()
|
pass
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
if self.session and not self.session.closed:
|
# No persistent session to clean up
|
||||||
try:
|
pass
|
||||||
asyncio.get_event_loop().run_until_complete(self.session.close())
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
3519
logs/app.log
3519
logs/app.log
File diff suppressed because it is too large
Load diff
36
main.py
36
main.py
|
|
@ -100,7 +100,7 @@ class MainWindow(QMainWindow):
|
||||||
# Create and add pages
|
# Create and add pages
|
||||||
self.dashboard_page = DashboardPage()
|
self.dashboard_page = DashboardPage()
|
||||||
self.sync_page = SyncPage(self.spotify_client, self.plex_client)
|
self.sync_page = SyncPage(self.spotify_client, self.plex_client)
|
||||||
self.downloads_page = DownloadsPage()
|
self.downloads_page = DownloadsPage(self.soulseek_client)
|
||||||
self.artists_page = ArtistsPage()
|
self.artists_page = ArtistsPage()
|
||||||
self.settings_page = SettingsPage()
|
self.settings_page = SettingsPage()
|
||||||
|
|
||||||
|
|
@ -151,18 +151,32 @@ class MainWindow(QMainWindow):
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
logger.info("Closing application...")
|
logger.info("Closing application...")
|
||||||
|
|
||||||
# Stop status monitoring thread
|
|
||||||
if self.status_thread:
|
|
||||||
self.status_thread.stop()
|
|
||||||
|
|
||||||
# Close Soulseek client
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
# Stop all page threads first
|
||||||
loop.run_until_complete(self.soulseek_client.close())
|
if hasattr(self, 'downloads_page') and self.downloads_page:
|
||||||
|
logger.info("Cleaning up Downloads page threads...")
|
||||||
|
self.downloads_page.cleanup_all_threads()
|
||||||
|
|
||||||
|
# Stop status monitoring thread
|
||||||
|
if self.status_thread:
|
||||||
|
logger.info("Stopping status monitoring thread...")
|
||||||
|
self.status_thread.stop()
|
||||||
|
|
||||||
|
# Close Soulseek client
|
||||||
|
try:
|
||||||
|
logger.info("Closing Soulseek client...")
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.run_until_complete(self.soulseek_client.close())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error closing Soulseek client: {e}")
|
||||||
|
|
||||||
|
logger.info("Application closed successfully")
|
||||||
|
event.accept()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error closing Soulseek client: {e}")
|
logger.error(f"Error during application shutdown: {e}")
|
||||||
|
# Force accept the event to prevent hanging
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging_config = config_manager.get_logging_config()
|
logging_config = config_manager.get_logging_config()
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,8 @@
|
||||||
# soulseek:
|
# soulseek:
|
||||||
# address: vps.slsknet.org
|
# address: vps.slsknet.org
|
||||||
# port: 2271
|
# port: 2271
|
||||||
# username: ~
|
# username: bipidiboop
|
||||||
# password: ~
|
# password: Thomasclan123@
|
||||||
# description: |
|
# description: |
|
||||||
# A slskd user. https://github.com/slskd/slskd
|
# A slskd user. https://github.com/slskd/slskd
|
||||||
# picture: path/to/slsk-profile-picture.jpg
|
# picture: path/to/slsk-profile-picture.jpg
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,9 +1,328 @@
|
||||||
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
QFrame, QPushButton, QProgressBar, QListWidget,
|
QFrame, QPushButton, QProgressBar, QListWidget,
|
||||||
QListWidgetItem, QComboBox, QLineEdit, QScrollArea)
|
QListWidgetItem, QComboBox, QLineEdit, QScrollArea, QMessageBox)
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||||
from PyQt6.QtGui import QFont
|
from PyQt6.QtGui import QFont
|
||||||
|
|
||||||
|
class DownloadThread(QThread):
|
||||||
|
download_completed = pyqtSignal(str) # Download ID or success message
|
||||||
|
download_failed = pyqtSignal(str) # Error message
|
||||||
|
download_progress = pyqtSignal(str) # Progress message
|
||||||
|
|
||||||
|
def __init__(self, soulseek_client, search_result):
|
||||||
|
super().__init__()
|
||||||
|
self.soulseek_client = soulseek_client
|
||||||
|
self.search_result = search_result
|
||||||
|
self._stop_requested = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
loop = None
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
self.download_progress.emit(f"Starting download: {self.search_result.filename}")
|
||||||
|
|
||||||
|
# Create a completely fresh event loop for this thread
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# Perform download with proper error handling
|
||||||
|
download_id = loop.run_until_complete(self._do_download())
|
||||||
|
|
||||||
|
if not self._stop_requested:
|
||||||
|
if download_id:
|
||||||
|
self.download_completed.emit(f"Download started: {download_id}")
|
||||||
|
else:
|
||||||
|
self.download_failed.emit("Download failed to start")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.download_failed.emit(str(e))
|
||||||
|
finally:
|
||||||
|
# Ensure proper cleanup
|
||||||
|
if loop:
|
||||||
|
try:
|
||||||
|
# Close any remaining tasks
|
||||||
|
pending = asyncio.all_tasks(loop)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||||
|
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error cleaning up download event loop: {e}")
|
||||||
|
|
||||||
|
async def _do_download(self):
|
||||||
|
"""Perform the actual download with proper async handling"""
|
||||||
|
return await self.soulseek_client.download(
|
||||||
|
self.search_result.username,
|
||||||
|
self.search_result.filename,
|
||||||
|
self.search_result.size
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the download gracefully"""
|
||||||
|
self._stop_requested = True
|
||||||
|
|
||||||
|
class SessionInfoThread(QThread):
|
||||||
|
session_info_completed = pyqtSignal(dict) # Session info dict
|
||||||
|
session_info_failed = pyqtSignal(str) # Error message
|
||||||
|
|
||||||
|
def __init__(self, soulseek_client):
|
||||||
|
super().__init__()
|
||||||
|
self.soulseek_client = soulseek_client
|
||||||
|
self._stop_requested = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
loop = None
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Create a completely fresh event loop for this thread
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# Check if stop was requested before starting
|
||||||
|
if self._stop_requested:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get session info
|
||||||
|
session_info = loop.run_until_complete(self._get_session_info())
|
||||||
|
|
||||||
|
# Only emit if not stopped
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.session_info_completed.emit(session_info or {})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.session_info_failed.emit(str(e))
|
||||||
|
finally:
|
||||||
|
# Ensure proper cleanup
|
||||||
|
if loop:
|
||||||
|
try:
|
||||||
|
# Close any remaining tasks
|
||||||
|
pending = asyncio.all_tasks(loop)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||||
|
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error cleaning up session info event loop: {e}")
|
||||||
|
|
||||||
|
async def _get_session_info(self):
|
||||||
|
"""Get the session information"""
|
||||||
|
return await self.soulseek_client.get_session_info()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the session info gathering gracefully"""
|
||||||
|
self._stop_requested = True
|
||||||
|
|
||||||
|
class ExploreApiThread(QThread):
|
||||||
|
exploration_completed = pyqtSignal(dict) # API info dict
|
||||||
|
exploration_failed = pyqtSignal(str) # Error message
|
||||||
|
|
||||||
|
def __init__(self, soulseek_client):
|
||||||
|
super().__init__()
|
||||||
|
self.soulseek_client = soulseek_client
|
||||||
|
self._stop_requested = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
loop = None
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Create a completely fresh event loop for this thread
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# Check if stop was requested before starting
|
||||||
|
if self._stop_requested:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Explore the API
|
||||||
|
api_info = loop.run_until_complete(self._explore_api())
|
||||||
|
|
||||||
|
# Only emit if not stopped
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.exploration_completed.emit(api_info)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.exploration_failed.emit(str(e))
|
||||||
|
finally:
|
||||||
|
# Ensure proper cleanup
|
||||||
|
if loop:
|
||||||
|
try:
|
||||||
|
# Close any remaining tasks
|
||||||
|
pending = asyncio.all_tasks(loop)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||||
|
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error cleaning up exploration event loop: {e}")
|
||||||
|
|
||||||
|
async def _explore_api(self):
|
||||||
|
"""Perform the actual API exploration"""
|
||||||
|
return await self.soulseek_client.explore_api_endpoints()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the exploration gracefully"""
|
||||||
|
self._stop_requested = True
|
||||||
|
|
||||||
|
class SearchThread(QThread):
|
||||||
|
search_completed = pyqtSignal(list) # List of search results
|
||||||
|
search_failed = pyqtSignal(str) # Error message
|
||||||
|
search_progress = pyqtSignal(str) # Progress message
|
||||||
|
|
||||||
|
def __init__(self, soulseek_client, query):
|
||||||
|
super().__init__()
|
||||||
|
self.soulseek_client = soulseek_client
|
||||||
|
self.query = query
|
||||||
|
self._stop_requested = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
loop = None
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
self.search_progress.emit(f"Searching for: {self.query}")
|
||||||
|
|
||||||
|
# Create a completely fresh event loop for this thread
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# Perform search with proper error handling
|
||||||
|
results = loop.run_until_complete(self._do_search())
|
||||||
|
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.search_completed.emit(results)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.search_failed.emit(str(e))
|
||||||
|
finally:
|
||||||
|
# Ensure proper cleanup
|
||||||
|
if loop:
|
||||||
|
try:
|
||||||
|
# Close any remaining tasks
|
||||||
|
pending = asyncio.all_tasks(loop)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||||
|
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error cleaning up event loop: {e}")
|
||||||
|
|
||||||
|
async def _do_search(self):
|
||||||
|
"""Perform the actual search with proper async handling"""
|
||||||
|
return await self.soulseek_client.search(self.query)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the search gracefully"""
|
||||||
|
self._stop_requested = True
|
||||||
|
|
||||||
|
class SearchResultItem(QFrame):
|
||||||
|
download_requested = pyqtSignal(object) # SearchResult object
|
||||||
|
|
||||||
|
def __init__(self, search_result, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.search_result = search_result
|
||||||
|
self.setup_ui()
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
self.setFixedHeight(60)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
SearchResultItem {
|
||||||
|
background: #282828;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
SearchResultItem:hover {
|
||||||
|
background: #333333;
|
||||||
|
border: 1px solid #1db954;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(15, 10, 15, 10)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
# File info
|
||||||
|
info_layout = QVBoxLayout()
|
||||||
|
info_layout.setSpacing(3)
|
||||||
|
|
||||||
|
# Filename
|
||||||
|
filename_label = QLabel(self.search_result.filename)
|
||||||
|
filename_label.setFont(QFont("Arial", 10, QFont.Weight.Medium))
|
||||||
|
filename_label.setStyleSheet("color: #ffffff;")
|
||||||
|
filename_label.setWordWrap(True)
|
||||||
|
|
||||||
|
# Details
|
||||||
|
details = []
|
||||||
|
if self.search_result.bitrate:
|
||||||
|
details.append(f"{self.search_result.bitrate}kbps")
|
||||||
|
details.append(self.search_result.quality.upper())
|
||||||
|
details.append(f"{self.search_result.size // (1024*1024)}MB")
|
||||||
|
details.append(f"User: {self.search_result.username}")
|
||||||
|
|
||||||
|
details_label = QLabel(" • ".join(details))
|
||||||
|
details_label.setFont(QFont("Arial", 9))
|
||||||
|
details_label.setStyleSheet("color: #b3b3b3;")
|
||||||
|
|
||||||
|
info_layout.addWidget(filename_label)
|
||||||
|
info_layout.addWidget(details_label)
|
||||||
|
|
||||||
|
# Quality indicator
|
||||||
|
quality_label = QLabel(f"★ {self.search_result.quality_score:.1f}")
|
||||||
|
quality_label.setFixedWidth(50)
|
||||||
|
quality_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
quality_label.setFont(QFont("Arial", 9, QFont.Weight.Bold))
|
||||||
|
|
||||||
|
if self.search_result.quality_score >= 0.9:
|
||||||
|
quality_label.setStyleSheet("color: #1db954;")
|
||||||
|
elif self.search_result.quality_score >= 0.7:
|
||||||
|
quality_label.setStyleSheet("color: #ffa500;")
|
||||||
|
else:
|
||||||
|
quality_label.setStyleSheet("color: #e22134;")
|
||||||
|
|
||||||
|
# Download button
|
||||||
|
download_btn = QPushButton("📥 Download")
|
||||||
|
download_btn.setFixedSize(90, 30)
|
||||||
|
download_btn.clicked.connect(self.request_download)
|
||||||
|
download_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background: rgba(29, 185, 84, 0.2);
|
||||||
|
border: 1px solid #1db954;
|
||||||
|
border-radius: 15px;
|
||||||
|
color: #1db954;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background: #1db954;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout.addLayout(info_layout)
|
||||||
|
layout.addStretch()
|
||||||
|
layout.addWidget(quality_label)
|
||||||
|
layout.addWidget(download_btn)
|
||||||
|
|
||||||
|
def request_download(self):
|
||||||
|
self.download_requested.emit(self.search_result)
|
||||||
|
|
||||||
class DownloadItem(QFrame):
|
class DownloadItem(QFrame):
|
||||||
def __init__(self, title: str, artist: str, status: str, progress: int = 0, parent=None):
|
def __init__(self, title: str, artist: str, status: str, progress: int = 0, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -261,8 +580,14 @@ class DownloadQueue(QFrame):
|
||||||
layout.addWidget(queue_scroll)
|
layout.addWidget(queue_scroll)
|
||||||
|
|
||||||
class DownloadsPage(QWidget):
|
class DownloadsPage(QWidget):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, soulseek_client=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.soulseek_client = soulseek_client
|
||||||
|
self.search_thread = None
|
||||||
|
self.explore_thread = None # Track API exploration thread
|
||||||
|
self.session_thread = None # Track session info thread
|
||||||
|
self.download_threads = [] # Track active download threads
|
||||||
|
self.search_results = []
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
|
|
||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
|
|
@ -280,6 +605,10 @@ class DownloadsPage(QWidget):
|
||||||
header = self.create_header()
|
header = self.create_header()
|
||||||
main_layout.addWidget(header)
|
main_layout.addWidget(header)
|
||||||
|
|
||||||
|
# Search section
|
||||||
|
search_section = self.create_search_section()
|
||||||
|
main_layout.addWidget(search_section)
|
||||||
|
|
||||||
# Content area
|
# Content area
|
||||||
content_layout = QHBoxLayout()
|
content_layout = QHBoxLayout()
|
||||||
content_layout.setSpacing(25)
|
content_layout.setSpacing(25)
|
||||||
|
|
@ -319,6 +648,397 @@ class DownloadsPage(QWidget):
|
||||||
|
|
||||||
return header
|
return header
|
||||||
|
|
||||||
|
def create_search_section(self):
|
||||||
|
section = QFrame()
|
||||||
|
section.setFixedHeight(350)
|
||||||
|
section.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background: #282828;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(section)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
|
||||||
|
# Search header
|
||||||
|
search_header = QLabel("Search & Download")
|
||||||
|
search_header.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||||||
|
search_header.setStyleSheet("color: #ffffff;")
|
||||||
|
|
||||||
|
# Search input and button
|
||||||
|
search_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.search_input = QLineEdit()
|
||||||
|
self.search_input.setPlaceholderText("Search for music (e.g., 'Artist - Song Title')")
|
||||||
|
self.search_input.setFixedHeight(40)
|
||||||
|
self.search_input.returnPressed.connect(self.perform_search)
|
||||||
|
self.search_input.setStyleSheet("""
|
||||||
|
QLineEdit {
|
||||||
|
background: #404040;
|
||||||
|
border: 1px solid #606060;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 15px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
QLineEdit:focus {
|
||||||
|
border: 1px solid #1db954;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.search_btn = QPushButton("🔍 Search")
|
||||||
|
self.search_btn.setFixedSize(100, 40)
|
||||||
|
self.search_btn.clicked.connect(self.perform_search)
|
||||||
|
self.search_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background: #1db954;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
}
|
||||||
|
QPushButton:disabled {
|
||||||
|
background: #404040;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
search_layout.addWidget(self.search_input)
|
||||||
|
search_layout.addWidget(self.search_btn)
|
||||||
|
|
||||||
|
# Search status
|
||||||
|
self.search_status = QLabel("Enter a search term and click Search")
|
||||||
|
self.search_status.setFont(QFont("Arial", 10))
|
||||||
|
self.search_status.setStyleSheet("color: #b3b3b3;")
|
||||||
|
|
||||||
|
# Search results
|
||||||
|
self.search_results_scroll = QScrollArea()
|
||||||
|
self.search_results_scroll.setWidgetResizable(True)
|
||||||
|
self.search_results_scroll.setStyleSheet("""
|
||||||
|
QScrollArea {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #404040;
|
||||||
|
width: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #1db954;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.search_results_widget = QWidget()
|
||||||
|
self.search_results_layout = QVBoxLayout(self.search_results_widget)
|
||||||
|
self.search_results_layout.setSpacing(5)
|
||||||
|
self.search_results_layout.addStretch()
|
||||||
|
self.search_results_scroll.setWidget(self.search_results_widget)
|
||||||
|
|
||||||
|
layout.addWidget(search_header)
|
||||||
|
layout.addLayout(search_layout)
|
||||||
|
layout.addWidget(self.search_status)
|
||||||
|
layout.addWidget(self.search_results_scroll)
|
||||||
|
|
||||||
|
return section
|
||||||
|
|
||||||
|
def perform_search(self):
|
||||||
|
query = self.search_input.text().strip()
|
||||||
|
if not query:
|
||||||
|
QMessageBox.warning(self, "Search Error", "Please enter a search term")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.soulseek_client:
|
||||||
|
QMessageBox.warning(self, "Connection Error", "Soulseek client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stop any existing search
|
||||||
|
if self.search_thread and self.search_thread.isRunning():
|
||||||
|
self.search_thread.stop()
|
||||||
|
self.search_thread.wait(1000) # Wait up to 1 second
|
||||||
|
if self.search_thread.isRunning():
|
||||||
|
self.search_thread.terminate()
|
||||||
|
|
||||||
|
# Clear previous results
|
||||||
|
self.clear_search_results()
|
||||||
|
|
||||||
|
# Update UI
|
||||||
|
self.search_btn.setText("🔄 Searching...")
|
||||||
|
self.search_btn.setEnabled(False)
|
||||||
|
self.search_status.setText(f"Searching for: {query}")
|
||||||
|
|
||||||
|
# Start new search thread
|
||||||
|
self.search_thread = SearchThread(self.soulseek_client, query)
|
||||||
|
self.search_thread.search_completed.connect(self.on_search_completed)
|
||||||
|
self.search_thread.search_failed.connect(self.on_search_failed)
|
||||||
|
self.search_thread.search_progress.connect(self.on_search_progress)
|
||||||
|
self.search_thread.finished.connect(self.on_search_thread_finished)
|
||||||
|
self.search_thread.start()
|
||||||
|
|
||||||
|
def on_search_thread_finished(self):
|
||||||
|
"""Clean up when search thread finishes"""
|
||||||
|
if self.search_thread:
|
||||||
|
self.search_thread.deleteLater()
|
||||||
|
self.search_thread = None
|
||||||
|
|
||||||
|
def clear_search_results(self):
|
||||||
|
# Remove all result items except the stretch
|
||||||
|
for i in reversed(range(self.search_results_layout.count())):
|
||||||
|
item = self.search_results_layout.itemAt(i)
|
||||||
|
if item.widget():
|
||||||
|
item.widget().deleteLater()
|
||||||
|
elif item.spacerItem():
|
||||||
|
continue # Keep the stretch spacer
|
||||||
|
else:
|
||||||
|
self.search_results_layout.removeItem(item)
|
||||||
|
|
||||||
|
def on_search_completed(self, results):
|
||||||
|
self.search_btn.setText("🔍 Search")
|
||||||
|
self.search_btn.setEnabled(True)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
self.search_status.setText("No results found. Try a different search term.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sort results by quality score (best first)
|
||||||
|
results.sort(key=lambda x: x.quality_score, reverse=True)
|
||||||
|
|
||||||
|
# Take top 10 results to avoid overwhelming the UI
|
||||||
|
top_results = results[:10]
|
||||||
|
|
||||||
|
self.search_status.setText(f"Found {len(results)} results (showing top {len(top_results)})")
|
||||||
|
|
||||||
|
# Add result items to UI
|
||||||
|
for result in top_results:
|
||||||
|
result_item = SearchResultItem(result)
|
||||||
|
result_item.download_requested.connect(self.start_download)
|
||||||
|
# Insert before the stretch item
|
||||||
|
self.search_results_layout.insertWidget(self.search_results_layout.count() - 1, result_item)
|
||||||
|
|
||||||
|
def on_search_failed(self, error_msg):
|
||||||
|
self.search_btn.setText("🔍 Search")
|
||||||
|
self.search_btn.setEnabled(True)
|
||||||
|
self.search_status.setText(f"Search failed: {error_msg}")
|
||||||
|
QMessageBox.critical(self, "Search Error", f"Search failed: {error_msg}")
|
||||||
|
|
||||||
|
def on_search_progress(self, message):
|
||||||
|
self.search_status.setText(message)
|
||||||
|
|
||||||
|
def start_download(self, search_result):
|
||||||
|
"""Start downloading a search result using threaded approach"""
|
||||||
|
try:
|
||||||
|
# Create and start download thread
|
||||||
|
download_thread = DownloadThread(self.soulseek_client, search_result)
|
||||||
|
download_thread.download_completed.connect(self.on_download_completed)
|
||||||
|
download_thread.download_failed.connect(self.on_download_failed)
|
||||||
|
download_thread.download_progress.connect(self.on_download_progress)
|
||||||
|
download_thread.finished.connect(lambda: self.on_download_thread_finished(download_thread))
|
||||||
|
|
||||||
|
# Track the thread
|
||||||
|
self.download_threads.append(download_thread)
|
||||||
|
|
||||||
|
# Start the download
|
||||||
|
download_thread.start()
|
||||||
|
|
||||||
|
# Show immediate feedback
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Download Started",
|
||||||
|
f"Starting download: {search_result.filename}\n"
|
||||||
|
f"From user: {search_result.username}\n\n"
|
||||||
|
f"The download will be queued in slskd.\n"
|
||||||
|
f"Check the slskd web interface or Downloads page for progress."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Download Error", f"Failed to start download: {str(e)}")
|
||||||
|
|
||||||
|
def on_download_completed(self, message):
|
||||||
|
"""Handle successful download start"""
|
||||||
|
# Could update download queue UI here
|
||||||
|
print(f"Download success: {message}")
|
||||||
|
|
||||||
|
def on_download_failed(self, error_msg):
|
||||||
|
"""Handle download failure"""
|
||||||
|
QMessageBox.critical(self, "Download Failed", f"Download failed: {error_msg}")
|
||||||
|
|
||||||
|
def on_download_progress(self, message):
|
||||||
|
"""Handle download progress updates"""
|
||||||
|
# Could update status or progress UI here
|
||||||
|
print(f"Download progress: {message}")
|
||||||
|
|
||||||
|
def on_download_thread_finished(self, thread):
|
||||||
|
"""Clean up when download thread finishes"""
|
||||||
|
if thread in self.download_threads:
|
||||||
|
self.download_threads.remove(thread)
|
||||||
|
thread.deleteLater()
|
||||||
|
|
||||||
|
def explore_slskd_api(self):
|
||||||
|
"""Explore slskd API endpoints to find the correct download endpoint"""
|
||||||
|
if not self.soulseek_client:
|
||||||
|
QMessageBox.warning(self, "Error", "Soulseek client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stop any existing exploration thread
|
||||||
|
if self.explore_thread and self.explore_thread.isRunning():
|
||||||
|
self.explore_thread.stop()
|
||||||
|
self.explore_thread.wait(1000) # Wait up to 1 second
|
||||||
|
if self.explore_thread.isRunning():
|
||||||
|
self.explore_thread.terminate()
|
||||||
|
|
||||||
|
# Create and track new exploration thread
|
||||||
|
self.explore_thread = ExploreApiThread(self.soulseek_client)
|
||||||
|
self.explore_thread.exploration_completed.connect(self.on_api_exploration_completed)
|
||||||
|
self.explore_thread.exploration_failed.connect(self.on_api_exploration_failed)
|
||||||
|
self.explore_thread.finished.connect(self.on_explore_thread_finished)
|
||||||
|
self.explore_thread.start()
|
||||||
|
|
||||||
|
def on_api_exploration_completed(self, api_info):
|
||||||
|
"""Handle API exploration results"""
|
||||||
|
message = "slskd API Exploration Results:\n\n"
|
||||||
|
|
||||||
|
if api_info.get('swagger_available'):
|
||||||
|
message += "✓ Swagger Documentation Available\n\n"
|
||||||
|
download_endpoints = api_info.get('download_endpoints', {})
|
||||||
|
if download_endpoints:
|
||||||
|
message += "Download/Transfer Endpoints Found:\n"
|
||||||
|
for path, methods in download_endpoints.items():
|
||||||
|
message += f" {path}: {list(methods.keys())}\n"
|
||||||
|
else:
|
||||||
|
message += "No download/transfer endpoints found in Swagger docs\n"
|
||||||
|
else:
|
||||||
|
message += "✗ Swagger Documentation Not Available\n\n"
|
||||||
|
available_endpoints = api_info.get('available_endpoints', {})
|
||||||
|
if available_endpoints:
|
||||||
|
message += "Available Endpoints Found:\n"
|
||||||
|
for endpoint, status in available_endpoints.items():
|
||||||
|
message += f" {endpoint}: {status}\n"
|
||||||
|
else:
|
||||||
|
message += "No endpoints found\n"
|
||||||
|
|
||||||
|
message += f"\nBase URL: {api_info.get('base_url', 'Unknown')}"
|
||||||
|
message += f"\n\nYou can also check: {api_info.get('base_url', 'http://localhost:5030')}/swagger"
|
||||||
|
|
||||||
|
QMessageBox.information(self, "API Exploration Results", message)
|
||||||
|
|
||||||
|
def on_api_exploration_failed(self, error_msg):
|
||||||
|
"""Handle API exploration failure"""
|
||||||
|
QMessageBox.critical(self, "API Exploration Failed", f"Failed to explore API: {error_msg}")
|
||||||
|
|
||||||
|
def on_explore_thread_finished(self):
|
||||||
|
"""Clean up when explore thread finishes"""
|
||||||
|
if self.explore_thread:
|
||||||
|
self.explore_thread.deleteLater()
|
||||||
|
self.explore_thread = None
|
||||||
|
|
||||||
|
def get_session_info(self):
|
||||||
|
"""Get slskd session info including version"""
|
||||||
|
if not self.soulseek_client:
|
||||||
|
QMessageBox.warning(self, "Error", "Soulseek client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stop any existing session thread
|
||||||
|
if self.session_thread and self.session_thread.isRunning():
|
||||||
|
self.session_thread.stop()
|
||||||
|
self.session_thread.wait(1000) # Wait up to 1 second
|
||||||
|
if self.session_thread.isRunning():
|
||||||
|
self.session_thread.terminate()
|
||||||
|
|
||||||
|
# Create and track new session thread
|
||||||
|
self.session_thread = SessionInfoThread(self.soulseek_client)
|
||||||
|
self.session_thread.session_info_completed.connect(self.on_session_info_completed)
|
||||||
|
self.session_thread.session_info_failed.connect(self.on_session_info_failed)
|
||||||
|
self.session_thread.finished.connect(self.on_session_thread_finished)
|
||||||
|
self.session_thread.start()
|
||||||
|
|
||||||
|
def on_session_info_completed(self, session_info):
|
||||||
|
"""Handle session info results"""
|
||||||
|
message = "slskd Session Information:\n\n"
|
||||||
|
|
||||||
|
if session_info:
|
||||||
|
for key, value in session_info.items():
|
||||||
|
message += f"{key}: {value}\n"
|
||||||
|
else:
|
||||||
|
message += "No session information available"
|
||||||
|
|
||||||
|
message += f"\n\nManual Steps:\n"
|
||||||
|
message += f"1. Open: http://localhost:5030\n"
|
||||||
|
message += f"2. Press F12 → Network tab\n"
|
||||||
|
message += f"3. Search for a file\n"
|
||||||
|
message += f"4. Click Download\n"
|
||||||
|
message += f"5. Check the HTTP request in Network tab\n"
|
||||||
|
message += f"6. Note the endpoint, method, and payload"
|
||||||
|
|
||||||
|
QMessageBox.information(self, "slskd Session Info", message)
|
||||||
|
|
||||||
|
def on_session_info_failed(self, error_msg):
|
||||||
|
"""Handle session info failure"""
|
||||||
|
QMessageBox.critical(self, "Session Info Failed", f"Failed to get session info: {error_msg}")
|
||||||
|
|
||||||
|
def on_session_thread_finished(self):
|
||||||
|
"""Clean up when session thread finishes"""
|
||||||
|
if self.session_thread:
|
||||||
|
self.session_thread.deleteLater()
|
||||||
|
self.session_thread = None
|
||||||
|
|
||||||
|
def cleanup_all_threads(self):
|
||||||
|
"""Stop and cleanup all active threads"""
|
||||||
|
try:
|
||||||
|
# Stop search thread
|
||||||
|
if self.search_thread and self.search_thread.isRunning():
|
||||||
|
self.search_thread.stop()
|
||||||
|
self.search_thread.wait(2000) # Wait up to 2 seconds
|
||||||
|
if self.search_thread.isRunning():
|
||||||
|
self.search_thread.terminate()
|
||||||
|
self.search_thread.wait(1000)
|
||||||
|
self.search_thread = None
|
||||||
|
|
||||||
|
# Stop explore thread
|
||||||
|
if self.explore_thread and self.explore_thread.isRunning():
|
||||||
|
self.explore_thread.stop()
|
||||||
|
self.explore_thread.wait(2000) # Wait up to 2 seconds
|
||||||
|
if self.explore_thread.isRunning():
|
||||||
|
self.explore_thread.terminate()
|
||||||
|
self.explore_thread.wait(1000)
|
||||||
|
self.explore_thread = None
|
||||||
|
|
||||||
|
# Stop session thread
|
||||||
|
if self.session_thread and self.session_thread.isRunning():
|
||||||
|
self.session_thread.stop()
|
||||||
|
self.session_thread.wait(2000) # Wait up to 2 seconds
|
||||||
|
if self.session_thread.isRunning():
|
||||||
|
self.session_thread.terminate()
|
||||||
|
self.session_thread.wait(1000)
|
||||||
|
self.session_thread = None
|
||||||
|
|
||||||
|
# Stop all download threads
|
||||||
|
for download_thread in self.download_threads[:]: # Copy list to avoid modification during iteration
|
||||||
|
if download_thread.isRunning():
|
||||||
|
download_thread.stop()
|
||||||
|
download_thread.wait(2000) # Wait up to 2 seconds
|
||||||
|
if download_thread.isRunning():
|
||||||
|
download_thread.terminate()
|
||||||
|
download_thread.wait(1000)
|
||||||
|
download_thread.deleteLater()
|
||||||
|
|
||||||
|
self.download_threads.clear()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during thread cleanup: {e}")
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
"""Handle widget close event"""
|
||||||
|
self.cleanup_all_threads()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
def create_controls_section(self):
|
def create_controls_section(self):
|
||||||
section = QWidget()
|
section = QWidget()
|
||||||
layout = QVBoxLayout(section)
|
layout = QVBoxLayout(section)
|
||||||
|
|
@ -377,9 +1097,47 @@ class DownloadsPage(QWidget):
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# API Explorer button (for debugging)
|
||||||
|
explore_btn = QPushButton("🔍 Explore API")
|
||||||
|
explore_btn.setFixedHeight(35)
|
||||||
|
explore_btn.clicked.connect(self.explore_slskd_api)
|
||||||
|
explore_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #1db954;
|
||||||
|
border-radius: 17px;
|
||||||
|
color: #1db954;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background: rgba(29, 185, 84, 0.1);
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Session Info button (for debugging)
|
||||||
|
session_btn = QPushButton("ℹ️ Session Info")
|
||||||
|
session_btn.setFixedHeight(35)
|
||||||
|
session_btn.clicked.connect(self.get_session_info)
|
||||||
|
session_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #ffa500;
|
||||||
|
border-radius: 17px;
|
||||||
|
color: #ffa500;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background: rgba(255, 165, 0, 0.1);
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
controls_layout.addWidget(controls_title)
|
controls_layout.addWidget(controls_title)
|
||||||
controls_layout.addWidget(pause_btn)
|
controls_layout.addWidget(pause_btn)
|
||||||
controls_layout.addWidget(clear_btn)
|
controls_layout.addWidget(clear_btn)
|
||||||
|
controls_layout.addWidget(explore_btn)
|
||||||
|
controls_layout.addWidget(session_btn)
|
||||||
|
|
||||||
# Download stats
|
# Download stats
|
||||||
stats_frame = QFrame()
|
stats_frame = QFrame()
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ class SettingsPage(QWidget):
|
||||||
soulseek_config = config_manager.get_soulseek_config()
|
soulseek_config = config_manager.get_soulseek_config()
|
||||||
self.slskd_url_input.setText(soulseek_config.get('slskd_url', ''))
|
self.slskd_url_input.setText(soulseek_config.get('slskd_url', ''))
|
||||||
self.api_key_input.setText(soulseek_config.get('api_key', ''))
|
self.api_key_input.setText(soulseek_config.get('api_key', ''))
|
||||||
|
self.download_path_input.setText(soulseek_config.get('download_path', './downloads'))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, "Error", f"Failed to load configuration: {e}")
|
QMessageBox.warning(self, "Error", f"Failed to load configuration: {e}")
|
||||||
|
|
@ -119,6 +120,7 @@ class SettingsPage(QWidget):
|
||||||
# Save Soulseek settings
|
# Save Soulseek settings
|
||||||
config_manager.set('soulseek.slskd_url', self.slskd_url_input.text())
|
config_manager.set('soulseek.slskd_url', self.slskd_url_input.text())
|
||||||
config_manager.set('soulseek.api_key', self.api_key_input.text())
|
config_manager.set('soulseek.api_key', self.api_key_input.text())
|
||||||
|
config_manager.set('soulseek.download_path', self.download_path_input.text())
|
||||||
|
|
||||||
# Show success message
|
# Show success message
|
||||||
QMessageBox.information(self, "Success", "Settings saved successfully!")
|
QMessageBox.information(self, "Success", "Settings saved successfully!")
|
||||||
|
|
@ -274,6 +276,21 @@ class SettingsPage(QWidget):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", f"✗ Unexpected error:\n{str(e)}")
|
QMessageBox.critical(self, "Error", f"✗ Unexpected error:\n{str(e)}")
|
||||||
|
|
||||||
|
def browse_download_path(self):
|
||||||
|
"""Open a directory dialog to select download path"""
|
||||||
|
from PyQt6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
|
current_path = self.download_path_input.text()
|
||||||
|
selected_path = QFileDialog.getExistingDirectory(
|
||||||
|
self,
|
||||||
|
"Select Download Directory",
|
||||||
|
current_path if current_path else ".",
|
||||||
|
QFileDialog.Option.ShowDirsOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_path:
|
||||||
|
self.download_path_input.setText(selected_path)
|
||||||
|
|
||||||
def create_header(self):
|
def create_header(self):
|
||||||
header = QWidget()
|
header = QWidget()
|
||||||
layout = QVBoxLayout(header)
|
layout = QVBoxLayout(header)
|
||||||
|
|
@ -465,15 +482,16 @@ class SettingsPage(QWidget):
|
||||||
path_label = QLabel("Download Path:")
|
path_label = QLabel("Download Path:")
|
||||||
path_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
path_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||||
|
|
||||||
path_input = QLineEdit("./downloads")
|
self.download_path_input = QLineEdit("./downloads")
|
||||||
path_input.setStyleSheet(self.get_input_style())
|
self.download_path_input.setStyleSheet(self.get_input_style())
|
||||||
|
|
||||||
browse_btn = QPushButton("Browse")
|
browse_btn = QPushButton("Browse")
|
||||||
browse_btn.setFixedSize(70, 30)
|
browse_btn.setFixedSize(70, 30)
|
||||||
|
browse_btn.clicked.connect(self.browse_download_path)
|
||||||
browse_btn.setStyleSheet(self.get_test_button_style())
|
browse_btn.setStyleSheet(self.get_test_button_style())
|
||||||
|
|
||||||
path_layout.addWidget(path_label)
|
path_layout.addWidget(path_label)
|
||||||
path_layout.addWidget(path_input)
|
path_layout.addWidget(self.download_path_input)
|
||||||
path_layout.addWidget(browse_btn)
|
path_layout.addWidget(browse_btn)
|
||||||
|
|
||||||
download_layout.addLayout(quality_layout)
|
download_layout.addLayout(quality_layout)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue