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": {
|
||||
"slskd_url": "http://localhost:5030",
|
||||
"api_key": "123456789123456789",
|
||||
"download_path": "./downloads"
|
||||
"api_key": "1234567891234567",
|
||||
"download_path": "E:\\Broque Projects\\newMusic\\downloads"
|
||||
},
|
||||
"database": {
|
||||
"path": "database/artists.db"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -69,7 +69,6 @@ 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()
|
||||
|
||||
|
|
@ -90,6 +89,7 @@ class SoulseekClient:
|
|||
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
|
||||
|
||||
|
|
@ -100,60 +100,170 @@ class SoulseekClient:
|
|||
|
||||
url = f"{self.base_url}/api/v0/{endpoint}"
|
||||
|
||||
if not self.session:
|
||||
self.session = aiohttp.ClientSession()
|
||||
|
||||
# Create a fresh session for each thread/event loop to avoid conflicts
|
||||
session = None
|
||||
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,
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
headers=headers,
|
||||
**kwargs
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
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]: # 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:
|
||||
logger.error(f"API request failed: {response.status} - {await response.text()}")
|
||||
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 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:
|
||||
logger.error("Soulseek client not configured")
|
||||
return []
|
||||
|
||||
try:
|
||||
logger.info(f"Starting search for: '{query}'")
|
||||
|
||||
search_data = {
|
||||
'searchText': query,
|
||||
'timeout': timeout * 1000,
|
||||
'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")
|
||||
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}")
|
||||
|
||||
# Wait for search to complete (reduced timeout for testing)
|
||||
await asyncio.sleep(timeout)
|
||||
|
||||
results_response = await self._make_request('GET', f'searches/{search_id}')
|
||||
if not results_response:
|
||||
logger.debug(f"Getting results for search ID: {search_id}")
|
||||
|
||||
# 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 []
|
||||
|
||||
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 = []
|
||||
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', '')
|
||||
size = file_data.get('size', 0)
|
||||
|
||||
|
|
@ -181,22 +291,93 @@ class SoulseekClient:
|
|||
logger.error(f"Error searching: {e}")
|
||||
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:
|
||||
logger.error("Soulseek client not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
download_data = {
|
||||
'username': username,
|
||||
'files': [filename]
|
||||
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
|
||||
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)
|
||||
if response:
|
||||
logger.info(f"Started download: {filename} from {username}")
|
||||
return filename
|
||||
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}")
|
||||
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:
|
||||
|
|
@ -233,7 +414,12 @@ class SoulseekClient:
|
|||
return []
|
||||
|
||||
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:
|
||||
return []
|
||||
|
||||
|
|
@ -286,7 +472,7 @@ class SoulseekClient:
|
|||
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)
|
||||
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"""
|
||||
|
|
@ -300,17 +486,116 @@ class SoulseekClient:
|
|||
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):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
# No persistent session to close - each request creates its own session
|
||||
pass
|
||||
|
||||
def __del__(self):
|
||||
if self.session and not self.session.closed:
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(self.session.close())
|
||||
except:
|
||||
pass
|
||||
# No persistent session to clean up
|
||||
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
|
||||
self.dashboard_page = DashboardPage()
|
||||
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.settings_page = SettingsPage()
|
||||
|
||||
|
|
@ -151,18 +151,32 @@ class MainWindow(QMainWindow):
|
|||
def closeEvent(self, event):
|
||||
logger.info("Closing application...")
|
||||
|
||||
# Stop status monitoring thread
|
||||
if self.status_thread:
|
||||
self.status_thread.stop()
|
||||
|
||||
# Close Soulseek client
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(self.soulseek_client.close())
|
||||
# Stop all page threads first
|
||||
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:
|
||||
logger.error(f"Error closing Soulseek client: {e}")
|
||||
|
||||
event.accept()
|
||||
logger.error(f"Error during application shutdown: {e}")
|
||||
# Force accept the event to prevent hanging
|
||||
event.accept()
|
||||
|
||||
def main():
|
||||
logging_config = config_manager.get_logging_config()
|
||||
|
|
|
|||
|
|
@ -189,8 +189,8 @@
|
|||
# soulseek:
|
||||
# address: vps.slsknet.org
|
||||
# port: 2271
|
||||
# username: ~
|
||||
# password: ~
|
||||
# username: bipidiboop
|
||||
# password: Thomasclan123@
|
||||
# description: |
|
||||
# A slskd user. https://github.com/slskd/slskd
|
||||
# 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,
|
||||
QFrame, QPushButton, QProgressBar, QListWidget,
|
||||
QListWidgetItem, QComboBox, QLineEdit, QScrollArea)
|
||||
from PyQt6.QtCore import Qt
|
||||
QListWidgetItem, QComboBox, QLineEdit, QScrollArea, QMessageBox)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||
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):
|
||||
def __init__(self, title: str, artist: str, status: str, progress: int = 0, parent=None):
|
||||
super().__init__(parent)
|
||||
|
|
@ -261,8 +580,14 @@ class DownloadQueue(QFrame):
|
|||
layout.addWidget(queue_scroll)
|
||||
|
||||
class DownloadsPage(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, soulseek_client=None, parent=None):
|
||||
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()
|
||||
|
||||
def setup_ui(self):
|
||||
|
|
@ -280,6 +605,10 @@ class DownloadsPage(QWidget):
|
|||
header = self.create_header()
|
||||
main_layout.addWidget(header)
|
||||
|
||||
# Search section
|
||||
search_section = self.create_search_section()
|
||||
main_layout.addWidget(search_section)
|
||||
|
||||
# Content area
|
||||
content_layout = QHBoxLayout()
|
||||
content_layout.setSpacing(25)
|
||||
|
|
@ -319,6 +648,397 @@ class DownloadsPage(QWidget):
|
|||
|
||||
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):
|
||||
section = QWidget()
|
||||
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(pause_btn)
|
||||
controls_layout.addWidget(clear_btn)
|
||||
controls_layout.addWidget(explore_btn)
|
||||
controls_layout.addWidget(session_btn)
|
||||
|
||||
# Download stats
|
||||
stats_frame = QFrame()
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class SettingsPage(QWidget):
|
|||
soulseek_config = config_manager.get_soulseek_config()
|
||||
self.slskd_url_input.setText(soulseek_config.get('slskd_url', ''))
|
||||
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:
|
||||
QMessageBox.warning(self, "Error", f"Failed to load configuration: {e}")
|
||||
|
|
@ -119,6 +120,7 @@ class SettingsPage(QWidget):
|
|||
# Save Soulseek settings
|
||||
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.download_path', self.download_path_input.text())
|
||||
|
||||
# Show success message
|
||||
QMessageBox.information(self, "Success", "Settings saved successfully!")
|
||||
|
|
@ -274,6 +276,21 @@ class SettingsPage(QWidget):
|
|||
except Exception as 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):
|
||||
header = QWidget()
|
||||
layout = QVBoxLayout(header)
|
||||
|
|
@ -465,15 +482,16 @@ class SettingsPage(QWidget):
|
|||
path_label = QLabel("Download Path:")
|
||||
path_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
|
||||
path_input = QLineEdit("./downloads")
|
||||
path_input.setStyleSheet(self.get_input_style())
|
||||
self.download_path_input = QLineEdit("./downloads")
|
||||
self.download_path_input.setStyleSheet(self.get_input_style())
|
||||
|
||||
browse_btn = QPushButton("Browse")
|
||||
browse_btn.setFixedSize(70, 30)
|
||||
browse_btn.clicked.connect(self.browse_download_path)
|
||||
browse_btn.setStyleSheet(self.get_test_button_style())
|
||||
|
||||
path_layout.addWidget(path_label)
|
||||
path_layout.addWidget(path_input)
|
||||
path_layout.addWidget(self.download_path_input)
|
||||
path_layout.addWidget(browse_btn)
|
||||
|
||||
download_layout.addLayout(quality_layout)
|
||||
|
|
|
|||
Loading…
Reference in a new issue