soulsync/ui/pages/downloads.py
2025-07-09 22:06:24 -07:00

1373 lines
No EOL
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.is_downloading = False
self.setup_ui()
def setup_ui(self):
self.setFixedHeight(120) # Increased height for better spacing
self.setStyleSheet("""
SearchResultItem {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(40, 40, 40, 0.95),
stop:1 rgba(30, 30, 30, 0.95));
border-radius: 12px;
border: 1px solid rgba(64, 64, 64, 0.8);
margin: 8px;
}
SearchResultItem:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(51, 51, 51, 0.95),
stop:1 rgba(40, 40, 40, 0.95));
border: 1px solid rgba(29, 185, 84, 0.6);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(20, 15, 20, 15)
layout.setSpacing(20)
# Album art placeholder
album_art = QLabel()
album_art.setFixedSize(80, 80)
album_art.setStyleSheet("""
QLabel {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 rgba(29, 185, 84, 0.3),
stop:1 rgba(29, 185, 84, 0.1));
border-radius: 8px;
border: 2px solid rgba(29, 185, 84, 0.2);
}
""")
album_art.setAlignment(Qt.AlignmentFlag.AlignCenter)
album_art.setText("🎵")
album_art.setFont(QFont("Arial", 24))
# Main content area
content_layout = QVBoxLayout()
content_layout.setSpacing(8)
# Primary info (song/artist extracted from filename)
primary_info = self._extract_song_info()
# Song title
song_title = QLabel(primary_info['title'])
song_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
song_title.setStyleSheet("color: #ffffff; margin-bottom: 2px;")
song_title.setWordWrap(True)
# Artist/Album info
artist_info = QLabel(primary_info['artist'])
artist_info.setFont(QFont("Arial", 12, QFont.Weight.Normal))
artist_info.setStyleSheet("color: #b3b3b3; margin-bottom: 6px;")
# Technical details
tech_layout = QHBoxLayout()
tech_layout.setSpacing(15)
# Quality badge
quality_badge = self._create_quality_badge()
tech_layout.addWidget(quality_badge)
# File size
size_mb = self.search_result.size // (1024*1024)
size_label = QLabel(f"{size_mb} MB")
size_label.setFont(QFont("Arial", 10, QFont.Weight.Medium))
size_label.setStyleSheet("color: #888888;")
tech_layout.addWidget(size_label)
# Duration if available
if self.search_result.duration:
duration_mins = self.search_result.duration // 60
duration_secs = self.search_result.duration % 60
duration_label = QLabel(f"{duration_mins}:{duration_secs:02d}")
duration_label.setFont(QFont("Arial", 10, QFont.Weight.Medium))
duration_label.setStyleSheet("color: #888888;")
tech_layout.addWidget(duration_label)
tech_layout.addStretch()
# User info
user_layout = QHBoxLayout()
user_layout.setSpacing(10)
# User avatar placeholder
user_avatar = QLabel("👤")
user_avatar.setFont(QFont("Arial", 14))
user_avatar.setStyleSheet("color: #1db954;")
# Username
username_label = QLabel(self.search_result.username)
username_label.setFont(QFont("Arial", 11, QFont.Weight.Medium))
username_label.setStyleSheet("color: #1db954;")
# Upload speed indicator
speed_indicator = self._create_speed_indicator()
user_layout.addWidget(user_avatar)
user_layout.addWidget(username_label)
user_layout.addWidget(speed_indicator)
user_layout.addStretch()
content_layout.addWidget(song_title)
content_layout.addWidget(artist_info)
content_layout.addLayout(tech_layout)
content_layout.addLayout(user_layout)
content_layout.addStretch()
# Action area
action_layout = QVBoxLayout()
action_layout.setSpacing(10)
action_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Quality score
quality_score = QLabel(f"{self.search_result.quality_score:.1f}")
quality_score.setAlignment(Qt.AlignmentFlag.AlignCenter)
quality_score.setFont(QFont("Arial", 12, QFont.Weight.Bold))
if self.search_result.quality_score >= 0.9:
quality_score.setStyleSheet("color: #1db954; background: rgba(29, 185, 84, 0.1); padding: 4px 8px; border-radius: 6px;")
elif self.search_result.quality_score >= 0.7:
quality_score.setStyleSheet("color: #ffa500; background: rgba(255, 165, 0, 0.1); padding: 4px 8px; border-radius: 6px;")
else:
quality_score.setStyleSheet("color: #e22134; background: rgba(226, 33, 52, 0.1); padding: 4px 8px; border-radius: 6px;")
# Download button
self.download_btn = QPushButton("⬇️ Download")
self.download_btn.setFixedSize(120, 40)
self.download_btn.clicked.connect(self.request_download)
self.download_btn.setStyleSheet("""
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(29, 185, 84, 0.9),
stop:1 rgba(24, 156, 71, 0.9));
border: none;
border-radius: 20px;
color: #000000;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(30, 215, 96, 1.0),
stop:1 rgba(25, 180, 80, 1.0));
transform: translateY(-1px);
}
QPushButton:pressed {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(24, 156, 71, 1.0),
stop:1 rgba(20, 130, 60, 1.0));
}
""")
action_layout.addWidget(quality_score)
action_layout.addWidget(self.download_btn)
action_layout.addStretch()
layout.addWidget(album_art)
layout.addLayout(content_layout)
layout.addLayout(action_layout)
def _extract_song_info(self):
"""Extract song title and artist from filename"""
filename = self.search_result.filename
# Remove file extension
name_without_ext = filename.rsplit('.', 1)[0]
# Common patterns for artist - title separation
separators = [' - ', ' ', '', '_-_', ' | ']
for sep in separators:
if sep in name_without_ext:
parts = name_without_ext.split(sep, 1)
return {
'title': parts[1].strip(),
'artist': parts[0].strip()
}
# If no separator found, use filename as title
return {
'title': name_without_ext,
'artist': 'Unknown Artist'
}
def _create_quality_badge(self):
"""Create a quality indicator badge"""
quality = self.search_result.quality.upper()
bitrate = self.search_result.bitrate
if quality == 'FLAC':
badge_text = "FLAC"
badge_color = "#1db954"
elif bitrate and bitrate >= 320:
badge_text = f"{bitrate}k"
badge_color = "#1db954"
elif bitrate and bitrate >= 256:
badge_text = f"{bitrate}k"
badge_color = "#ffa500"
elif bitrate and bitrate >= 192:
badge_text = f"{bitrate}k"
badge_color = "#ffaa00"
else:
badge_text = quality
badge_color = "#e22134"
badge = QLabel(badge_text)
badge.setFont(QFont("Arial", 9, QFont.Weight.Bold))
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
badge.setFixedSize(60, 24)
badge.setStyleSheet(f"""
QLabel {{
background: {badge_color};
color: #000000;
border-radius: 12px;
padding: 2px 8px;
}}
""")
return badge
def _create_speed_indicator(self):
"""Create upload speed indicator"""
speed = self.search_result.upload_speed
slots = self.search_result.free_upload_slots
if slots > 0 and speed > 100:
indicator_color = "#1db954"
speed_text = "🚀 Fast"
elif slots > 0:
indicator_color = "#ffa500"
speed_text = "⚡ Available"
else:
indicator_color = "#e22134"
speed_text = "⏳ Queued"
indicator = QLabel(speed_text)
indicator.setFont(QFont("Arial", 9, QFont.Weight.Medium))
indicator.setStyleSheet(f"color: {indicator_color};")
return indicator
def request_download(self):
if not self.is_downloading:
self.is_downloading = True
self.download_btn.setText("⏳ Downloading...")
self.download_btn.setEnabled(False)
self.download_requested.emit(self.search_result)
def reset_download_state(self):
"""Reset the download button state"""
self.is_downloading = False
self.download_btn.setText("⬇️ Download")
self.download_btn.setEnabled(True)
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 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);
}
""")
controls_layout.addWidget(controls_title)
controls_layout.addWidget(pause_btn)
controls_layout.addWidget(clear_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