good
This commit is contained in:
parent
f138824c03
commit
347eccf762
5 changed files with 10398 additions and 326 deletions
|
|
@ -1 +1 @@
|
||||||
{"access_token": "BQCCZ_QN3sFdtefCigaIuPuMYIdP2K25HRJ-NAQs1yLwHQkj9YxyvN3zAUEj6A3kANHI5yqH3DGN7oRzAO778PTcxQwcFFbpOWAKgi80owbwXVva_SwcjBL3MyLEAQJh2TN3iolRTWycnfHx0LSvT2Ddkm6o09xjQfxIfI14t5dEwummt4Ceqb0AykByuhtEpSCzjh6Z0cfTayvOYPv_1lgm2JSLalKSaz3osh9PhT-PRLAmbA-F1yCaTs73bnhc", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1753495023, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
|
{"access_token": "BQD6bPKD5klEqF4yItt47hgMBJ-wwIDl_zcqZhkU3IHgT8FD-qDv5QnKYFrnaBOogR4UvC0C-j23K7TfC06cKrIXNnwgV5M2uqh3gfCSF1joHk9pc0xeeIcLCTwP4Y9MeZ9yPiTAmvDal3Bo_tySvWmUdWuh8GJhx3c24Q1xx-vN3luBn_OP2jJpUBbJnF7IVnnd9JV7IFPcofYpl1SfXuOKLTDfo8CEsJ0FvCIeFSVVEJLjkaXYQOEnYDPGZIPI", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1753507025, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
|
||||||
10119
logs/app.log
10119
logs/app.log
File diff suppressed because it is too large
Load diff
2
main.py
2
main.py
|
|
@ -101,7 +101,7 @@ class MainWindow(QMainWindow):
|
||||||
self.dashboard_page = DashboardPage()
|
self.dashboard_page = DashboardPage()
|
||||||
self.downloads_page = DownloadsPage(self.soulseek_client)
|
self.downloads_page = DownloadsPage(self.soulseek_client)
|
||||||
self.sync_page = SyncPage(self.spotify_client, self.plex_client, self.soulseek_client, self.downloads_page)
|
self.sync_page = SyncPage(self.spotify_client, self.plex_client, self.soulseek_client, self.downloads_page)
|
||||||
self.artists_page = ArtistsPage()
|
self.artists_page = ArtistsPage(downloads_page=self.downloads_page)
|
||||||
self.settings_page = SettingsPage()
|
self.settings_page = SettingsPage()
|
||||||
|
|
||||||
self.stacked_widget.addWidget(self.dashboard_page)
|
self.stacked_widget.addWidget(self.dashboard_page)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -16,6 +16,8 @@ from core.spotify_client import SpotifyClient, Artist, Album
|
||||||
from core.plex_client import PlexClient
|
from core.plex_client import PlexClient
|
||||||
from core.soulseek_client import SoulseekClient, AlbumResult
|
from core.soulseek_client import SoulseekClient, AlbumResult
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ArtistMatch:
|
class ArtistMatch:
|
||||||
|
|
@ -24,6 +26,46 @@ class ArtistMatch:
|
||||||
confidence: float
|
confidence: float
|
||||||
match_reason: str = ""
|
match_reason: str = ""
|
||||||
|
|
||||||
|
class DownloadCompletionWorkerSignals(QObject):
|
||||||
|
"""Signals for the download completion worker"""
|
||||||
|
completed = pyqtSignal(object, str) # download_item, organized_path
|
||||||
|
error = pyqtSignal(object, str) # download_item, error_message
|
||||||
|
|
||||||
|
class DownloadCompletionWorker(QRunnable):
|
||||||
|
"""Background worker to handle download completion processing without blocking UI"""
|
||||||
|
|
||||||
|
def __init__(self, download_item, absolute_file_path, organize_func):
|
||||||
|
super().__init__()
|
||||||
|
self.download_item = download_item
|
||||||
|
self.absolute_file_path = absolute_file_path
|
||||||
|
self.organize_func = organize_func
|
||||||
|
self.signals = DownloadCompletionWorkerSignals()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Process download completion in background thread"""
|
||||||
|
try:
|
||||||
|
print(f"🧵 Background worker processing download...")
|
||||||
|
|
||||||
|
# Add a small delay to ensure file is fully written
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Organize the file into Transfer folder structure
|
||||||
|
organized_path = self.organize_func(self.download_item, self.absolute_file_path)
|
||||||
|
|
||||||
|
# Emit completion signal
|
||||||
|
self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error in background worker: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# Emit error signal
|
||||||
|
self.signals.error.emit(self.download_item, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ImageDownloaderSignals(QObject):
|
class ImageDownloaderSignals(QObject):
|
||||||
"""Signals for the ImageDownloader worker."""
|
"""Signals for the ImageDownloader worker."""
|
||||||
finished = pyqtSignal(QLabel, QPixmap)
|
finished = pyqtSignal(QLabel, QPixmap)
|
||||||
|
|
@ -146,9 +188,9 @@ class AlbumSearchWorker(QThread):
|
||||||
search_failed = pyqtSignal(str)
|
search_failed = pyqtSignal(str)
|
||||||
search_progress = pyqtSignal(str) # Progress messages
|
search_progress = pyqtSignal(str) # Progress messages
|
||||||
|
|
||||||
def __init__(self, album: Album, soulseek_client: SoulseekClient):
|
def __init__(self, query: str, soulseek_client: SoulseekClient):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.album = album
|
self.query = query
|
||||||
self.soulseek_client = soulseek_client
|
self.soulseek_client = soulseek_client
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
|
|
||||||
|
|
@ -157,38 +199,48 @@ class AlbumSearchWorker(QThread):
|
||||||
self._stop_requested = True
|
self._stop_requested = True
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
"""Executes the album search asynchronously."""
|
||||||
|
loop = None
|
||||||
try:
|
try:
|
||||||
if not self.soulseek_client:
|
if not self.soulseek_client:
|
||||||
self.search_failed.emit("Soulseek client not available")
|
self.search_failed.emit("Soulseek client not available")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create search query for the album
|
# Create a new event loop for this thread to run async operations
|
||||||
search_query = f'"{self.album.name}"'
|
loop = asyncio.new_event_loop()
|
||||||
if self.album.artists:
|
asyncio.set_event_loop(loop)
|
||||||
search_query = f'"{self.album.artists[0]}" "{self.album.name}"'
|
|
||||||
|
|
||||||
self.search_progress.emit(f"Searching for: {search_query}")
|
self.search_progress.emit(f"Searching for: {self.query}")
|
||||||
|
|
||||||
# Perform the search
|
# Perform the async search using the provided query
|
||||||
results = self.soulseek_client.search(search_query)
|
results = loop.run_until_complete(self.soulseek_client.search(self.query))
|
||||||
|
|
||||||
if self._stop_requested:
|
if self._stop_requested:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Filter to album results only
|
# The search method returns a tuple of (tracks, albums)
|
||||||
album_results = []
|
tracks, albums = results if results else ([], [])
|
||||||
if results:
|
album_results = albums if albums else []
|
||||||
tracks, albums = results # Unpack the tuple returned by search
|
|
||||||
album_results = albums if albums else []
|
|
||||||
|
|
||||||
# Sort by quality/size
|
# Sort by a combination of track count and total size for relevance
|
||||||
album_results.sort(key=lambda x: (x.total_size, x.track_count), reverse=True)
|
album_results.sort(key=lambda x: (x.track_count, x.total_size), reverse=True)
|
||||||
|
|
||||||
self.search_results.emit(album_results)
|
self.search_results.emit(album_results)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not self._stop_requested:
|
if not self._stop_requested:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
self.search_failed.emit(str(e))
|
self.search_failed.emit(str(e))
|
||||||
|
finally:
|
||||||
|
# Ensure the event loop is properly closed
|
||||||
|
if loop:
|
||||||
|
try:
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error closing event loop in AlbumSearchWorker: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PlexLibraryWorker(QThread):
|
class PlexLibraryWorker(QThread):
|
||||||
"""Background worker for checking Plex library"""
|
"""Background worker for checking Plex library"""
|
||||||
|
|
@ -310,337 +362,284 @@ class AlbumSearchDialog(QDialog):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.album = album
|
self.album = album
|
||||||
self.selected_album_result = None
|
self.selected_album_result = None
|
||||||
|
self.selected_widget = None
|
||||||
self.search_worker = None
|
self.search_worker = None
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
self.start_search()
|
self.start_search() # Start automatic search on open
|
||||||
|
|
||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
self.setWindowTitle(f"Download Album: {self.album.name}")
|
self.setWindowTitle(f"Download Source for: {self.album.name}")
|
||||||
self.setFixedSize(600, 500)
|
self.setFixedSize(800, 700)
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
QDialog {
|
QDialog { background: #191414; color: #ffffff; }
|
||||||
background: #191414;
|
QScrollArea { border: 1px solid #404040; border-radius: 8px; background: #282828; }
|
||||||
color: #ffffff;
|
QLineEdit {
|
||||||
|
background: #333; border: 1px solid #555; border-radius: 4px;
|
||||||
|
padding: 8px; font-size: 12px;
|
||||||
}
|
}
|
||||||
|
QPushButton {
|
||||||
|
background-color: #444; border: 1px solid #666; border-radius: 4px;
|
||||||
|
padding: 8px 12px; font-size: 12px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #555; }
|
||||||
""")
|
""")
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(20, 20, 20, 20)
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
layout.setSpacing(16)
|
layout.setSpacing(15)
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
header_label = QLabel(f"Searching for: {self.album.name}")
|
header_label = QLabel(f"Searching for: {self.album.name} by {', '.join(self.album.artists)}")
|
||||||
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||||||
header_label.setStyleSheet("color: #ffffff; padding: 8px;")
|
|
||||||
|
|
||||||
artist_label = QLabel(f"By: {', '.join(self.album.artists)}")
|
# Manual Search Section
|
||||||
artist_label.setFont(QFont("Arial", 11))
|
search_layout = QHBoxLayout()
|
||||||
artist_label.setStyleSheet("color: #b3b3b3; padding: 4px;")
|
self.manual_search_input = QLineEdit()
|
||||||
|
self.manual_search_input.setPlaceholderText("Refine search: Artist Album Title...")
|
||||||
|
self.manual_search_input.returnPressed.connect(self.trigger_manual_search)
|
||||||
|
|
||||||
# Progress bar
|
self.search_cancel_btn = QPushButton("Search")
|
||||||
self.progress_bar = QProgressBar()
|
self.search_cancel_btn.setFixedWidth(120)
|
||||||
self.progress_bar.setRange(0, 0) # Indeterminate progress
|
self.search_cancel_btn.clicked.connect(self.handle_search_cancel_click)
|
||||||
self.progress_bar.setStyleSheet("""
|
|
||||||
QProgressBar {
|
|
||||||
border: 1px solid #404040;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #282828;
|
|
||||||
text-align: center;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
QProgressBar::chunk {
|
|
||||||
background: #1db954;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Status label
|
search_layout.addWidget(self.manual_search_input, 1)
|
||||||
|
search_layout.addWidget(self.search_cancel_btn)
|
||||||
|
|
||||||
|
# Status
|
||||||
self.status_label = QLabel("Initializing search...")
|
self.status_label = QLabel("Initializing search...")
|
||||||
self.status_label.setFont(QFont("Arial", 10))
|
self.status_label.setStyleSheet("color: #b3b3b3;")
|
||||||
self.status_label.setStyleSheet("color: #b3b3b3; padding: 4px;")
|
|
||||||
|
|
||||||
# Results area
|
# Results Area
|
||||||
self.results_scroll = QScrollArea()
|
self.results_scroll = QScrollArea()
|
||||||
self.results_scroll.setWidgetResizable(True)
|
self.results_scroll.setWidgetResizable(True)
|
||||||
self.results_scroll.setStyleSheet("""
|
|
||||||
QScrollArea {
|
|
||||||
border: 1px solid #404040;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #282828;
|
|
||||||
}
|
|
||||||
QScrollBar:vertical {
|
|
||||||
background: #404040;
|
|
||||||
width: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
QScrollBar::handle:vertical {
|
|
||||||
background: #1db954;
|
|
||||||
border-radius: 6px;
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
self.results_widget = QWidget()
|
self.results_widget = QWidget()
|
||||||
self.results_layout = QVBoxLayout(self.results_widget)
|
self.results_layout = QVBoxLayout(self.results_widget)
|
||||||
self.results_layout.setSpacing(8)
|
self.results_layout.setSpacing(8)
|
||||||
self.results_layout.setContentsMargins(12, 12, 12, 12)
|
self.results_layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
self.results_layout.addStretch(1)
|
||||||
self.results_scroll.setWidget(self.results_widget)
|
self.results_scroll.setWidget(self.results_widget)
|
||||||
|
|
||||||
# Buttons
|
# Bottom Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.cancel_search_btn = QPushButton("Cancel Search")
|
|
||||||
self.cancel_search_btn.setStyleSheet("""
|
|
||||||
QPushButton {
|
|
||||||
background: #ff6b6b;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
QPushButton:hover {
|
|
||||||
background: #ff5252;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
self.cancel_search_btn.clicked.connect(self.cancel_search)
|
|
||||||
|
|
||||||
self.download_btn = QPushButton("Download Selected")
|
self.download_btn = QPushButton("Download Selected")
|
||||||
self.download_btn.setEnabled(False)
|
self.download_btn.setEnabled(False) # Initially disabled
|
||||||
self.download_btn.setStyleSheet("""
|
self.download_btn.setStyleSheet("background-color: #1db954; color: black;")
|
||||||
QPushButton {
|
|
||||||
background: #1db954;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
QPushButton:hover {
|
|
||||||
background: #1ed760;
|
|
||||||
}
|
|
||||||
QPushButton:disabled {
|
|
||||||
background: #404040;
|
|
||||||
color: #808080;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
self.download_btn.clicked.connect(self.download_selected)
|
|
||||||
|
|
||||||
close_btn = QPushButton("Close")
|
close_btn = QPushButton("Close")
|
||||||
close_btn.setStyleSheet("""
|
|
||||||
QPushButton {
|
self.download_btn.clicked.connect(self.download_selected)
|
||||||
background: #404040;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
QPushButton:hover {
|
|
||||||
background: #505050;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
close_btn.clicked.connect(self.reject)
|
close_btn.clicked.connect(self.reject)
|
||||||
|
|
||||||
button_layout.addWidget(self.cancel_search_btn)
|
button_layout.addStretch(1)
|
||||||
button_layout.addStretch()
|
|
||||||
button_layout.addWidget(self.download_btn)
|
button_layout.addWidget(self.download_btn)
|
||||||
button_layout.addWidget(close_btn)
|
button_layout.addWidget(close_btn)
|
||||||
|
|
||||||
# Add to main layout
|
|
||||||
layout.addWidget(header_label)
|
layout.addWidget(header_label)
|
||||||
layout.addWidget(artist_label)
|
layout.addLayout(search_layout)
|
||||||
layout.addWidget(self.progress_bar)
|
|
||||||
layout.addWidget(self.status_label)
|
layout.addWidget(self.status_label)
|
||||||
layout.addWidget(self.results_scroll, 1)
|
layout.addWidget(self.results_scroll, 1)
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
def start_search(self):
|
def handle_search_cancel_click(self):
|
||||||
"""Start the album search"""
|
"""Toggles between starting a search and cancelling an active one."""
|
||||||
# We need to get the soulseek client from the parent
|
if self.search_worker and self.search_worker.isRunning():
|
||||||
|
self.cancel_search()
|
||||||
|
else:
|
||||||
|
self.trigger_manual_search()
|
||||||
|
|
||||||
|
def trigger_manual_search(self):
|
||||||
|
"""Starts a new search using the text from the manual search input."""
|
||||||
|
query = self.manual_search_input.text().strip()
|
||||||
|
if query:
|
||||||
|
self.start_search(query)
|
||||||
|
|
||||||
|
def start_search(self, query: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Starts the album search. If a query is provided, it's a manual search.
|
||||||
|
Otherwise, it constructs an automatic query.
|
||||||
|
"""
|
||||||
|
if self.search_worker and self.search_worker.isRunning():
|
||||||
|
self.search_worker.stop()
|
||||||
|
self.search_worker.wait()
|
||||||
|
|
||||||
|
self.clear_results()
|
||||||
|
self.download_btn.setEnabled(False)
|
||||||
|
self.status_label.setText("Searching...")
|
||||||
|
self.set_search_button_to_cancel(True)
|
||||||
|
|
||||||
|
if query is None:
|
||||||
|
artist_part = self.album.artists[0] if self.album.artists else ""
|
||||||
|
query = f"{artist_part} {self.album.name}".strip()
|
||||||
|
|
||||||
|
self.manual_search_input.setText(query)
|
||||||
|
|
||||||
parent_page = self.parent()
|
parent_page = self.parent()
|
||||||
if hasattr(parent_page, 'soulseek_client') and parent_page.soulseek_client:
|
if hasattr(parent_page, 'soulseek_client') and parent_page.soulseek_client:
|
||||||
self.search_worker = AlbumSearchWorker(self.album, parent_page.soulseek_client)
|
self.search_worker = AlbumSearchWorker(query, parent_page.soulseek_client)
|
||||||
self.search_worker.search_results.connect(self.on_search_results)
|
self.search_worker.search_results.connect(self.on_search_results)
|
||||||
self.search_worker.search_failed.connect(self.on_search_failed)
|
self.search_worker.search_failed.connect(self.on_search_failed)
|
||||||
self.search_worker.search_progress.connect(self.on_search_progress)
|
self.search_worker.search_progress.connect(self.on_search_progress)
|
||||||
self.search_worker.finished.connect(self.on_search_finished)
|
|
||||||
self.search_worker.start()
|
self.search_worker.start()
|
||||||
else:
|
else:
|
||||||
self.on_search_failed("Soulseek client not available")
|
self.on_search_failed("Soulseek client not available")
|
||||||
|
|
||||||
def cancel_search(self):
|
def cancel_search(self):
|
||||||
"""Cancel the current search"""
|
if self.search_worker and self.search_worker.isRunning():
|
||||||
if self.search_worker:
|
|
||||||
self.search_worker.stop()
|
self.search_worker.stop()
|
||||||
self.search_worker.terminate()
|
self.status_label.setText("Search cancelled.")
|
||||||
self.search_worker.wait()
|
self.set_search_button_to_cancel(False)
|
||||||
self.reject()
|
|
||||||
|
|
||||||
def on_search_progress(self, message):
|
def on_search_progress(self, message):
|
||||||
"""Handle search progress updates"""
|
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
|
|
||||||
def on_search_results(self, album_results):
|
def on_search_results(self, album_results):
|
||||||
"""Handle search results"""
|
self.set_search_button_to_cancel(False)
|
||||||
|
self.clear_results()
|
||||||
if not album_results:
|
if not album_results:
|
||||||
self.status_label.setText("No albums found")
|
self.status_label.setText("No albums found for this query.")
|
||||||
self.progress_bar.setRange(0, 1)
|
|
||||||
self.progress_bar.setValue(1)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.status_label.setText(f"Found {len(album_results)} potential albums. Click one to select.")
|
||||||
|
|
||||||
self.status_label.setText(f"Found {len(album_results)} albums")
|
for album_result in album_results[:25]: # Show top 25
|
||||||
self.progress_bar.setRange(0, 1)
|
result_item = self.create_result_item(album_result)
|
||||||
self.progress_bar.setValue(1)
|
self.results_layout.insertWidget(self.results_layout.count() - 1, result_item)
|
||||||
|
|
||||||
# Display results
|
|
||||||
for i, album_result in enumerate(album_results[:10]): # Show top 10 results
|
|
||||||
result_item = self.create_result_item(album_result, i)
|
|
||||||
self.results_layout.addWidget(result_item)
|
|
||||||
|
|
||||||
self.results_layout.addStretch()
|
|
||||||
self.cancel_search_btn.setText("Cancel")
|
|
||||||
|
|
||||||
def on_search_failed(self, error):
|
def on_search_failed(self, error):
|
||||||
"""Handle search failure"""
|
self.set_search_button_to_cancel(False)
|
||||||
self.status_label.setText(f"Search failed: {error}")
|
self.status_label.setText(f"Search failed: {error}")
|
||||||
self.progress_bar.setRange(0, 1)
|
|
||||||
self.progress_bar.setValue(1)
|
def create_result_item(self, album_result: AlbumResult):
|
||||||
self.cancel_search_btn.setText("Close")
|
"""Creates a larger, more informative, and clickable result item widget."""
|
||||||
|
|
||||||
def on_search_finished(self):
|
|
||||||
"""Handle search completion"""
|
|
||||||
self.cancel_search_btn.setText("Close")
|
|
||||||
|
|
||||||
def create_result_item(self, album_result: AlbumResult, index: int):
|
|
||||||
"""Create a result item widget"""
|
|
||||||
item = QFrame()
|
item = QFrame()
|
||||||
|
item.setFixedHeight(75) # Increased height for better readability
|
||||||
|
item.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
item.setStyleSheet("""
|
item.setStyleSheet("""
|
||||||
QFrame {
|
QFrame {
|
||||||
background: rgba(40, 40, 40, 0.8);
|
background: rgba(40, 40, 40, 0.8);
|
||||||
border: 1px solid #606060;
|
border: 1px solid #555;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
QFrame:hover {
|
|
||||||
background: rgba(50, 50, 50, 0.9);
|
|
||||||
border: 1px solid #1db954;
|
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
item.setCursor(Qt.CursorShape.PointingHandCursor)
|
# Connect the click event for the whole frame
|
||||||
|
item.mousePressEvent = lambda event: self.select_result(album_result, item)
|
||||||
|
|
||||||
layout = QHBoxLayout(item)
|
layout = QHBoxLayout(item)
|
||||||
layout.setContentsMargins(12, 8, 12, 8)
|
layout.setContentsMargins(15, 10, 15, 10)
|
||||||
layout.setSpacing(12)
|
layout.setSpacing(15)
|
||||||
|
|
||||||
# Album info
|
|
||||||
info_layout = QVBoxLayout()
|
info_layout = QVBoxLayout()
|
||||||
|
info_layout.setSpacing(4)
|
||||||
|
|
||||||
title_label = QLabel(album_result.album_title)
|
title_label = QLabel(f"{album_result.album_title} by {album_result.artist}")
|
||||||
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||||||
title_label.setStyleSheet("color: #ffffff;")
|
|
||||||
|
|
||||||
details_label = QLabel(f"By: {album_result.artist} • {album_result.track_count} tracks • {self.format_size(album_result.total_size)}")
|
details_text = (f"{album_result.track_count} tracks | "
|
||||||
|
f"{self.format_size(album_result.total_size)} | "
|
||||||
|
f"Uploader: {album_result.username}")
|
||||||
|
details_label = QLabel(details_text)
|
||||||
details_label.setFont(QFont("Arial", 9))
|
details_label.setFont(QFont("Arial", 9))
|
||||||
details_label.setStyleSheet("color: #b3b3b3;")
|
details_label.setStyleSheet("color: #b3b3b3;")
|
||||||
|
|
||||||
uploader_label = QLabel(f"Uploader: {album_result.username}")
|
|
||||||
uploader_label.setFont(QFont("Arial", 8))
|
|
||||||
uploader_label.setStyleSheet("color: #888888;")
|
|
||||||
|
|
||||||
info_layout.addWidget(title_label)
|
info_layout.addWidget(title_label)
|
||||||
info_layout.addWidget(details_label)
|
info_layout.addWidget(details_label)
|
||||||
info_layout.addWidget(uploader_label)
|
|
||||||
|
|
||||||
# Select button
|
quality_badge = self.create_quality_badge(album_result)
|
||||||
select_btn = QPushButton("Select")
|
|
||||||
select_btn.setFixedWidth(80)
|
|
||||||
select_btn.setStyleSheet("""
|
|
||||||
QPushButton {
|
|
||||||
background: #1db954;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 6px 12px;
|
|
||||||
}
|
|
||||||
QPushButton:hover {
|
|
||||||
background: #1ed760;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
select_btn.clicked.connect(lambda: self.select_result(album_result))
|
|
||||||
|
|
||||||
layout.addLayout(info_layout)
|
layout.addLayout(info_layout, 1)
|
||||||
layout.addStretch()
|
layout.addWidget(quality_badge)
|
||||||
layout.addWidget(select_btn)
|
|
||||||
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
def format_size(self, size_bytes):
|
def create_quality_badge(self, album_result: AlbumResult):
|
||||||
"""Format file size in human readable format"""
|
"""Creates a styled badge for displaying audio quality."""
|
||||||
if size_bytes >= 1024 * 1024 * 1024:
|
quality = album_result.dominant_quality.upper()
|
||||||
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
|
||||||
elif size_bytes >= 1024 * 1024:
|
# Safely calculate average bitrate from the album's tracks
|
||||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
bitrate = 0
|
||||||
elif size_bytes >= 1024:
|
if hasattr(album_result, 'tracks') and album_result.tracks:
|
||||||
return f"{size_bytes / 1024:.1f} KB"
|
valid_bitrates = [
|
||||||
|
track.bitrate for track in album_result.tracks
|
||||||
|
if hasattr(track, 'bitrate') and track.bitrate
|
||||||
|
]
|
||||||
|
if valid_bitrates:
|
||||||
|
bitrate = sum(valid_bitrates) // len(valid_bitrates)
|
||||||
|
|
||||||
|
badge_text = quality
|
||||||
|
if quality == 'MP3' and bitrate > 0:
|
||||||
|
badge_text = f"MP3 {bitrate}k"
|
||||||
|
elif quality == 'VBR':
|
||||||
|
badge_text = "MP3 VBR"
|
||||||
|
|
||||||
|
badge = QLabel(badge_text)
|
||||||
|
badge.setFixedWidth(80)
|
||||||
|
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
badge.setFont(QFont("Arial", 9, QFont.Weight.Bold))
|
||||||
|
|
||||||
|
if quality == 'FLAC':
|
||||||
|
style = "background-color: #4CAF50; color: white; border-radius: 4px; padding: 5px;"
|
||||||
|
elif bitrate >= 320:
|
||||||
|
style = "background-color: #2196F3; color: white; border-radius: 4px; padding: 5px;"
|
||||||
|
elif bitrate >= 192 or quality == 'VBR':
|
||||||
|
style = "background-color: #FFC107; color: black; border-radius: 4px; padding: 5px;"
|
||||||
else:
|
else:
|
||||||
return f"{size_bytes} B"
|
style = "background-color: #F44336; color: white; border-radius: 4px; padding: 5px;"
|
||||||
|
|
||||||
|
badge.setStyleSheet(style)
|
||||||
|
return badge
|
||||||
|
|
||||||
|
def clear_results(self):
|
||||||
|
"""Removes all result widgets from the layout, preserving the stretch item."""
|
||||||
|
self.selected_widget = None # Clear selection
|
||||||
|
# Iterate backwards to safely remove items while preserving the stretch
|
||||||
|
for i in reversed(range(self.results_layout.count())):
|
||||||
|
item = self.results_layout.itemAt(i)
|
||||||
|
if item.widget():
|
||||||
|
widget = item.widget()
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
|
def format_size(self, size_bytes):
|
||||||
|
if size_bytes >= 1024**3: return f"{size_bytes / 1024**3:.1f} GB"
|
||||||
|
if size_bytes >= 1024**2: return f"{size_bytes / 1024**2:.1f} MB"
|
||||||
|
return f"{size_bytes / 1024:.1f} KB"
|
||||||
|
|
||||||
def select_result(self, album_result):
|
def select_result(self, album_result, selected_item_widget):
|
||||||
"""Select an album result"""
|
"""Handles the selection of a result and provides visual feedback."""
|
||||||
self.selected_album_result = album_result
|
self.selected_album_result = album_result
|
||||||
self.download_btn.setEnabled(True)
|
self.download_btn.setEnabled(True)
|
||||||
|
|
||||||
|
# Deselect previous widget
|
||||||
|
if self.selected_widget:
|
||||||
|
self.selected_widget.setStyleSheet("""
|
||||||
|
QFrame { background: rgba(40, 40, 40, 0.8); border: 1px solid #555; border-radius: 6px; }
|
||||||
|
""")
|
||||||
|
|
||||||
# Visual feedback - highlight selected item
|
# Apply selected style to the new widget
|
||||||
for i in range(self.results_layout.count()):
|
selected_item_widget.setStyleSheet("""
|
||||||
item = self.results_layout.itemAt(i)
|
QFrame { background: rgba(29, 185, 84, 0.2); border: 1px solid #1db954; border-radius: 6px; }
|
||||||
if item and item.widget():
|
""")
|
||||||
widget = item.widget()
|
self.selected_widget = selected_item_widget
|
||||||
if hasattr(widget, 'setStyleSheet'):
|
|
||||||
if widget.layout() and widget.layout().itemAt(2) and widget.layout().itemAt(2).widget():
|
|
||||||
btn = widget.layout().itemAt(2).widget()
|
|
||||||
if btn.text() == "Select":
|
|
||||||
widget.setStyleSheet("""
|
|
||||||
QFrame {
|
|
||||||
background: rgba(40, 40, 40, 0.8);
|
|
||||||
border: 1px solid #606060;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
btn.setText("Select")
|
|
||||||
|
|
||||||
# Highlight the selected item
|
|
||||||
sender = self.sender()
|
|
||||||
if sender:
|
|
||||||
parent_frame = sender.parent()
|
|
||||||
if parent_frame:
|
|
||||||
parent_frame.setStyleSheet("""
|
|
||||||
QFrame {
|
|
||||||
background: rgba(29, 185, 84, 0.3);
|
|
||||||
border: 2px solid #1db954;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
sender.setText("Selected ✓")
|
|
||||||
|
|
||||||
def download_selected(self):
|
def download_selected(self):
|
||||||
"""Download the selected album"""
|
|
||||||
if self.selected_album_result:
|
if self.selected_album_result:
|
||||||
self.album_selected.emit(self.selected_album_result)
|
self.album_selected.emit(self.selected_album_result)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
def set_search_button_to_cancel(self, is_searching: bool):
|
||||||
|
"""Changes the search button's text and style."""
|
||||||
|
if is_searching:
|
||||||
|
self.search_cancel_btn.setText("Cancel Search")
|
||||||
|
self.search_cancel_btn.setStyleSheet("background-color: #F44336; color: white;")
|
||||||
|
else:
|
||||||
|
self.search_cancel_btn.setText("Search")
|
||||||
|
self.search_cancel_btn.setStyleSheet("background-color: #1db954; color: black;")
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
"""Handle dialog close"""
|
self.cancel_search()
|
||||||
if self.search_worker:
|
|
||||||
self.search_worker.stop()
|
|
||||||
self.search_worker.terminate()
|
|
||||||
self.search_worker.wait()
|
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ArtistResultCard(QFrame):
|
class ArtistResultCard(QFrame):
|
||||||
"""Card widget for displaying artist search results"""
|
"""Card widget for displaying artist search results"""
|
||||||
artist_selected = pyqtSignal(object) # Artist object
|
artist_selected = pyqtSignal(object) # Artist object
|
||||||
|
|
@ -998,13 +997,14 @@ class AlbumCard(QFrame):
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
class ArtistsPage(QWidget):
|
class ArtistsPage(QWidget):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, downloads_page=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
# Core clients
|
# Core clients
|
||||||
self.spotify_client = None
|
self.spotify_client = None
|
||||||
self.plex_client = None
|
self.plex_client = None
|
||||||
self.soulseek_client = None
|
self.soulseek_client = None
|
||||||
|
self.downloads_page = downloads_page # Store reference to DownloadsPage
|
||||||
self.matching_engine = MusicMatchingEngine()
|
self.matching_engine = MusicMatchingEngine()
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
|
|
@ -1500,7 +1500,6 @@ class ArtistsPage(QWidget):
|
||||||
col = 0
|
col = 0
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
|
|
||||||
def start_plex_library_check(self, albums):
|
def start_plex_library_check(self, albums):
|
||||||
"""Start Plex library check in background"""
|
"""Start Plex library check in background"""
|
||||||
# Stop any existing Plex worker
|
# Stop any existing Plex worker
|
||||||
|
|
@ -1568,82 +1567,33 @@ class ArtistsPage(QWidget):
|
||||||
"""Handle album fetch failure"""
|
"""Handle album fetch failure"""
|
||||||
self.albums_status.setText(f"Failed to load albums: {error}")
|
self.albums_status.setText(f"Failed to load albums: {error}")
|
||||||
|
|
||||||
|
def on_album_download_requested(self, album: Album):
|
||||||
def on_album_download_requested(self, album):
|
"""Handle album download request from an AlbumCard."""
|
||||||
"""Handle album download request"""
|
|
||||||
print(f"Download requested for album: {album.name} by {', '.join(album.artists)}")
|
print(f"Download requested for album: {album.name} by {', '.join(album.artists)}")
|
||||||
|
|
||||||
# Open the album search dialog
|
# Store the album object that needs to be downloaded
|
||||||
|
self.album_to_download = album
|
||||||
|
|
||||||
|
# Open the album search dialog to find a source on Soulseek
|
||||||
dialog = AlbumSearchDialog(album, self)
|
dialog = AlbumSearchDialog(album, self)
|
||||||
dialog.album_selected.connect(self.on_album_selected_for_download)
|
dialog.album_selected.connect(self.on_album_selected_for_download)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
||||||
def on_album_selected_for_download(self, album_result: AlbumResult):
|
def on_album_selected_for_download(self, album_result: AlbumResult):
|
||||||
"""Handle album selection from search dialog"""
|
"""
|
||||||
|
Handles album selection from the search dialog and delegates the
|
||||||
|
matched album download process to the main DownloadsPage.
|
||||||
|
"""
|
||||||
print(f"Selected album for download: {album_result.album_title} by {album_result.artist}")
|
print(f"Selected album for download: {album_result.album_title} by {album_result.artist}")
|
||||||
|
|
||||||
# Start download process for the selected album
|
if self.downloads_page:
|
||||||
self.start_album_download(album_result)
|
# Delegate to the DownloadsPage to handle the matched download
|
||||||
|
# This will open the Spotify matching modal and add to the central queue
|
||||||
def start_album_download(self, album_result: AlbumResult):
|
print("🚀 Delegating to DownloadsPage to start matched album download...")
|
||||||
"""Start downloading the selected album"""
|
self.downloads_page.start_matched_album_download(album_result)
|
||||||
try:
|
else:
|
||||||
if not self.soulseek_client:
|
QMessageBox.critical(self, "Error", "Downloads page is not connected. Cannot start download.")
|
||||||
QMessageBox.warning(self, "Error", "Soulseek client not available!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create download items for each track in the album
|
|
||||||
for track in album_result.tracks:
|
|
||||||
# Create a unique download ID
|
|
||||||
download_id = f"{album_result.username}_{track.filename}"
|
|
||||||
|
|
||||||
# Add to download queue (we need to integrate with the main app's download queue)
|
|
||||||
self.add_to_download_queue(
|
|
||||||
title=track.title or track.filename,
|
|
||||||
artist=track.artist or album_result.artist,
|
|
||||||
album=album_result.album_title,
|
|
||||||
username=album_result.username,
|
|
||||||
filename=track.filename,
|
|
||||||
file_size=track.size,
|
|
||||||
download_id=download_id,
|
|
||||||
track_result=track
|
|
||||||
)
|
|
||||||
|
|
||||||
# Show confirmation
|
|
||||||
QMessageBox.information(
|
|
||||||
self,
|
|
||||||
"Download Started",
|
|
||||||
f"Started downloading {len(album_result.tracks)} tracks from '{album_result.album_title}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error starting album download: {e}")
|
|
||||||
QMessageBox.critical(self, "Download Error", f"Failed to start download: {str(e)}")
|
|
||||||
|
|
||||||
def add_to_download_queue(self, title, artist, album, username, filename, file_size, download_id, track_result):
|
|
||||||
"""Add a track to the download queue - needs integration with main app"""
|
|
||||||
# TODO: This needs to integrate with the main application's download system
|
|
||||||
# For now, we'll simulate starting the download
|
|
||||||
|
|
||||||
print(f"Adding to download queue: {title} by {artist}")
|
|
||||||
|
|
||||||
# We need to access the main app's download manager
|
|
||||||
# This would typically be done by emitting a signal that the main app listens to
|
|
||||||
# or by accessing the download manager through a parent reference
|
|
||||||
|
|
||||||
# For now, just start the individual track download
|
|
||||||
if self.soulseek_client:
|
|
||||||
try:
|
|
||||||
# Start the download using the soulseek client
|
|
||||||
self.soulseek_client.download(
|
|
||||||
username=username,
|
|
||||||
filename=filename,
|
|
||||||
download_id=download_id
|
|
||||||
)
|
|
||||||
print(f"Started download: {filename}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to start download for {filename}: {e}")
|
|
||||||
|
|
||||||
def return_to_search(self):
|
def return_to_search(self):
|
||||||
"""Return to search interface"""
|
"""Return to search interface"""
|
||||||
# Stop any running workers
|
# Stop any running workers
|
||||||
|
|
@ -1699,4 +1649,7 @@ class ArtistsPage(QWidget):
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
"""Handle page close/cleanup"""
|
"""Handle page close/cleanup"""
|
||||||
self.stop_all_workers()
|
self.stop_all_workers()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue