soulsync/ui/pages/downloads.py
Broque Thomas 11a1343217 fix
2025-07-09 21:44:21 -07:00

1342 lines
No EOL
48 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QPushButton, QProgressBar, QListWidget,
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)
self.title = title
self.artist = artist
self.status = status
self.progress = progress
self.setup_ui()
def setup_ui(self):
self.setFixedHeight(80)
self.setStyleSheet("""
DownloadItem {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
margin: 2px;
}
DownloadItem:hover {
background: #333333;
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(20, 15, 20, 15)
layout.setSpacing(15)
# Status icon
status_icon = QLabel()
status_icon.setFixedSize(32, 32)
status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
if self.status == "downloading":
status_icon.setText("📥")
status_icon.setStyleSheet("""
QLabel {
color: #1db954;
font-size: 18px;
background: rgba(29, 185, 84, 0.1);
border-radius: 16px;
}
""")
elif self.status == "completed":
status_icon.setText("")
status_icon.setStyleSheet("""
QLabel {
color: #1db954;
font-size: 18px;
background: rgba(29, 185, 84, 0.1);
border-radius: 16px;
}
""")
elif self.status == "failed":
status_icon.setText("")
status_icon.setStyleSheet("""
QLabel {
color: #e22134;
font-size: 18px;
background: rgba(226, 33, 52, 0.1);
border-radius: 16px;
}
""")
else:
status_icon.setText("")
status_icon.setStyleSheet("""
QLabel {
color: #ffa500;
font-size: 18px;
background: rgba(255, 165, 0, 0.1);
border-radius: 16px;
}
""")
# Content
content_layout = QVBoxLayout()
content_layout.setSpacing(5)
# Title and artist
title_label = QLabel(self.title)
title_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
title_label.setStyleSheet("color: #ffffff;")
artist_label = QLabel(f"by {self.artist}")
artist_label.setFont(QFont("Arial", 10))
artist_label.setStyleSheet("color: #b3b3b3;")
content_layout.addWidget(title_label)
content_layout.addWidget(artist_label)
# Progress section
progress_layout = QVBoxLayout()
progress_layout.setSpacing(5)
# Progress bar
progress_bar = QProgressBar()
progress_bar.setFixedHeight(6)
progress_bar.setValue(self.progress)
progress_bar.setStyleSheet("""
QProgressBar {
border: none;
border-radius: 3px;
background: #404040;
}
QProgressBar::chunk {
background: #1db954;
border-radius: 3px;
}
""")
# Status text
status_text = f"{self.status.title()}"
if self.status == "downloading":
status_text += f" - {self.progress}%"
status_label = QLabel(status_text)
status_label.setFont(QFont("Arial", 9))
status_label.setStyleSheet("color: #b3b3b3;")
progress_layout.addWidget(progress_bar)
progress_layout.addWidget(status_label)
# Action button
action_btn = QPushButton()
action_btn.setFixedSize(80, 30)
if self.status == "downloading":
action_btn.setText("Cancel")
action_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #e22134;
border-radius: 15px;
color: #e22134;
font-size: 10px;
font-weight: bold;
}
QPushButton:hover {
background: #e22134;
color: #ffffff;
}
""")
elif self.status == "failed":
action_btn.setText("Retry")
action_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #1db954;
border-radius: 15px;
color: #1db954;
font-size: 10px;
font-weight: bold;
}
QPushButton:hover {
background: #1db954;
color: #000000;
}
""")
else:
action_btn.setText("Details")
action_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #b3b3b3;
border-radius: 15px;
color: #b3b3b3;
font-size: 10px;
font-weight: bold;
}
QPushButton:hover {
background: #b3b3b3;
color: #000000;
}
""")
layout.addWidget(status_icon)
layout.addLayout(content_layout)
layout.addStretch()
layout.addLayout(progress_layout)
layout.addWidget(action_btn)
class DownloadQueue(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.setup_ui()
def setup_ui(self):
self.setStyleSheet("""
DownloadQueue {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
# Header
header_layout = QHBoxLayout()
title_label = QLabel("Download Queue")
title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
title_label.setStyleSheet("color: #ffffff;")
queue_count = QLabel("5 items")
queue_count.setFont(QFont("Arial", 11))
queue_count.setStyleSheet("color: #b3b3b3;")
header_layout.addWidget(title_label)
header_layout.addStretch()
header_layout.addWidget(queue_count)
# Queue list
queue_scroll = QScrollArea()
queue_scroll.setWidgetResizable(True)
queue_scroll.setFixedHeight(300)
queue_scroll.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:vertical {
background: #404040;
width: 8px;
border-radius: 4px;
}
QScrollBar::handle:vertical {
background: #1db954;
border-radius: 4px;
}
""")
queue_widget = QWidget()
queue_layout = QVBoxLayout(queue_widget)
queue_layout.setSpacing(8)
# Sample download items
downloads = [
("Song Title 1", "Artist Name 1", "downloading", 75),
("Song Title 2", "Artist Name 2", "downloading", 45),
("Song Title 3", "Artist Name 3", "queued", 0),
("Song Title 4", "Artist Name 4", "completed", 100),
("Song Title 5", "Artist Name 5", "failed", 0)
]
for title, artist, status, progress in downloads:
item = DownloadItem(title, artist, status, progress)
queue_layout.addWidget(item)
queue_layout.addStretch()
queue_scroll.setWidget(queue_widget)
layout.addLayout(header_layout)
layout.addWidget(queue_scroll)
class DownloadsPage(QWidget):
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):
self.setStyleSheet("""
DownloadsPage {
background: #191414;
}
""")
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 30, 30, 30)
main_layout.setSpacing(25)
# Header
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)
# Left side - Download queue
queue_section = DownloadQueue()
content_layout.addWidget(queue_section, 2)
# Right side - Controls and stats
controls_section = self.create_controls_section()
content_layout.addWidget(controls_section, 1)
main_layout.addLayout(content_layout)
# Bottom section - Missing tracks
missing_section = self.create_missing_tracks_section()
main_layout.addWidget(missing_section)
def create_header(self):
header = QWidget()
layout = QVBoxLayout(header)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
# Title
title_label = QLabel("Downloads")
title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold))
title_label.setStyleSheet("color: #ffffff;")
# Subtitle
subtitle_label = QLabel("Manage your music downloads from Soulseek")
subtitle_label.setFont(QFont("Arial", 14))
subtitle_label.setStyleSheet("color: #b3b3b3;")
layout.addWidget(title_label)
layout.addWidget(subtitle_label)
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)
layout.setSpacing(20)
# Download controls
controls_frame = QFrame()
controls_frame.setStyleSheet("""
QFrame {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
controls_layout = QVBoxLayout(controls_frame)
controls_layout.setContentsMargins(20, 20, 20, 20)
controls_layout.setSpacing(15)
# Controls title
controls_title = QLabel("Download Controls")
controls_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
controls_title.setStyleSheet("color: #ffffff;")
# Pause/Resume button
pause_btn = QPushButton("⏸️ Pause Downloads")
pause_btn.setFixedHeight(40)
pause_btn.setStyleSheet("""
QPushButton {
background: #1db954;
border: none;
border-radius: 20px;
color: #000000;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background: #1ed760;
}
""")
# Clear completed button
clear_btn = QPushButton("🗑️ Clear Completed")
clear_btn.setFixedHeight(35)
clear_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #e22134;
border-radius: 17px;
color: #e22134;
font-size: 11px;
font-weight: bold;
}
QPushButton:hover {
background: rgba(226, 33, 52, 0.1);
}
""")
# 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()
stats_frame.setStyleSheet("""
QFrame {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
stats_layout = QVBoxLayout(stats_frame)
stats_layout.setContentsMargins(20, 20, 20, 20)
stats_layout.setSpacing(15)
# Stats title
stats_title = QLabel("Download Statistics")
stats_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
stats_title.setStyleSheet("color: #ffffff;")
# Stats items
stats_items = [
("Total Downloads", "247"),
("Completed", "238"),
("Failed", "4"),
("In Progress", "2"),
("Queued", "3")
]
stats_layout.addWidget(stats_title)
for label, value in stats_items:
item_layout = QHBoxLayout()
label_widget = QLabel(label)
label_widget.setFont(QFont("Arial", 11))
label_widget.setStyleSheet("color: #b3b3b3;")
value_widget = QLabel(value)
value_widget.setFont(QFont("Arial", 11, QFont.Weight.Bold))
value_widget.setStyleSheet("color: #ffffff;")
item_layout.addWidget(label_widget)
item_layout.addStretch()
item_layout.addWidget(value_widget)
stats_layout.addLayout(item_layout)
layout.addWidget(controls_frame)
layout.addWidget(stats_frame)
layout.addStretch()
return section
def create_missing_tracks_section(self):
section = QFrame()
section.setFixedHeight(250)
section.setStyleSheet("""
QFrame {
background: #282828;
border-radius: 8px;
border: 1px solid #404040;
}
""")
layout = QVBoxLayout(section)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
# Header
header_layout = QHBoxLayout()
title_label = QLabel("Missing Tracks")
title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
title_label.setStyleSheet("color: #ffffff;")
count_label = QLabel("23 tracks")
count_label.setFont(QFont("Arial", 11))
count_label.setStyleSheet("color: #b3b3b3;")
download_all_btn = QPushButton("📥 Download All")
download_all_btn.setFixedSize(120, 35)
download_all_btn.setStyleSheet("""
QPushButton {
background: #1db954;
border: none;
border-radius: 17px;
color: #000000;
font-size: 11px;
font-weight: bold;
}
QPushButton:hover {
background: #1ed760;
}
""")
header_layout.addWidget(title_label)
header_layout.addWidget(count_label)
header_layout.addStretch()
header_layout.addWidget(download_all_btn)
# Missing tracks scroll area
missing_scroll = QScrollArea()
missing_scroll.setWidgetResizable(True)
missing_scroll.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:vertical {
background: #404040;
width: 6px;
border-radius: 3px;
}
QScrollBar::handle:vertical {
background: #1db954;
border-radius: 3px;
}
""")
missing_widget = QWidget()
missing_layout = QVBoxLayout(missing_widget)
missing_layout.setSpacing(8)
missing_layout.setContentsMargins(0, 0, 0, 0)
# Sample missing tracks with playlist info
missing_tracks = [
("Song Title 1", "Artist Name 1", "Liked Songs"),
("Another Track", "Different Artist", "Road Trip Mix"),
("Cool Song", "Band Name", "Workout Playlist"),
("Missing Hit", "Popular Artist", "Discover Weekly"),
("Rare Track", "Indie Artist", "Chill Vibes")
]
for track_title, artist, playlist in missing_tracks:
track_item = self.create_missing_track_item(track_title, artist, playlist)
missing_layout.addWidget(track_item)
missing_layout.addStretch()
missing_scroll.setWidget(missing_widget)
layout.addLayout(header_layout)
layout.addWidget(missing_scroll)
return section
def create_missing_track_item(self, track_title: str, artist: str, playlist: str):
item = QFrame()
item.setFixedHeight(45)
item.setStyleSheet("""
QFrame {
background: #333333;
border-radius: 6px;
border: 1px solid #404040;
}
QFrame:hover {
background: #3a3a3a;
border: 1px solid #1db954;
}
""")
layout = QHBoxLayout(item)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(10)
# Track info
info_layout = QVBoxLayout()
info_layout.setSpacing(2)
track_label = QLabel(f"{track_title} - {artist}")
track_label.setFont(QFont("Arial", 10, QFont.Weight.Medium))
track_label.setStyleSheet("color: #ffffff;")
playlist_label = QLabel(f"from: {playlist}")
playlist_label.setFont(QFont("Arial", 9))
playlist_label.setStyleSheet("color: #1db954;")
info_layout.addWidget(track_label)
info_layout.addWidget(playlist_label)
# Download button
download_btn = QPushButton("📥")
download_btn.setFixedSize(30, 30)
download_btn.setStyleSheet("""
QPushButton {
background: rgba(29, 185, 84, 0.2);
border: 1px solid #1db954;
border-radius: 15px;
color: #1db954;
font-size: 12px;
}
QPushButton:hover {
background: #1db954;
color: #000000;
}
""")
layout.addLayout(info_layout)
layout.addStretch()
layout.addWidget(download_btn)
return item