1342 lines
No EOL
48 KiB
Python
1342 lines
No EOL
48 KiB
Python
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 |