This commit is contained in:
Broque Thomas 2025-07-09 21:44:21 -07:00
parent 8dd433fc3b
commit 11a1343217
11 changed files with 4650 additions and 56 deletions

View file

@ -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"}

View file

@ -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"

View file

@ -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

File diff suppressed because it is too large Load diff

36
main.py
View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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)