from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton, QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QMessageBox, QApplication) from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject, QRunnable, QThreadPool from PyQt6.QtGui import QFont, QPalette, QColor import time import re import asyncio import threading from concurrent.futures import ThreadPoolExecutor, as_completed try: import resource HAS_RESOURCE = True except ImportError: HAS_RESOURCE = False import os from typing import Optional, Dict, Any, List from datetime import datetime from dataclasses import dataclass import requests from PIL import Image import io from core.matching_engine import MusicMatchingEngine from ui.components.database_updater_widget import DatabaseUpdaterWidget from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker from core.wishlist_service import get_wishlist_service from utils.logging_config import get_logger from core.soulseek_client import TrackResult from database.music_database import get_database from core.plex_scan_manager import PlexScanManager # dashboard.py - Add these helper classes logger = get_logger("dashboard") from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton, QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QMessageBox, QApplication) from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject, QRunnable, QThreadPool from PyQt6.QtGui import QFont, QPalette, QColor import time import re import asyncio import threading from concurrent.futures import ThreadPoolExecutor, as_completed try: import resource HAS_RESOURCE = True except ImportError: HAS_RESOURCE = False import os from typing import Optional, Dict, Any, List from datetime import datetime from dataclasses import dataclass import requests from PIL import Image import io from core.matching_engine import MusicMatchingEngine from ui.components.database_updater_widget import DatabaseUpdaterWidget from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker from core.wishlist_service import get_wishlist_service from utils.logging_config import get_logger from core.soulseek_client import TrackResult from database.music_database import get_database from core.plex_scan_manager import PlexScanManager # dashboard.py - Add these helper classes logger = get_logger("dashboard") @dataclass class TrackAnalysisResult: """Result of analyzing a track for Plex existence""" spotify_track: object # Spotify track object exists_in_plex: bool plex_match: Optional[object] = None # Plex track if found confidence: float = 0.0 error_message: Optional[str] = None class PlaylistTrackAnalysisWorkerSignals(QObject): """Signals for playlist track analysis worker""" analysis_started = pyqtSignal(int) track_analyzed = pyqtSignal(int, object) analysis_completed = pyqtSignal(list) analysis_failed = pyqtSignal(str) class PlaylistTrackAnalysisWorker(QRunnable): """Background worker to analyze playlist tracks against the local database""" def __init__(self, playlist_tracks, plex_client): super().__init__() self.playlist_tracks = playlist_tracks self.plex_client = plex_client # Still needed for connection check self.signals = PlaylistTrackAnalysisWorkerSignals() self._cancelled = False self.matching_engine = MusicMatchingEngine() def cancel(self): self._cancelled = True def run(self): try: if self._cancelled: return self.signals.analysis_started.emit(len(self.playlist_tracks)) results = [] db = get_database() for i, track in enumerate(self.playlist_tracks): if self._cancelled: return result = TrackAnalysisResult(spotify_track=track, exists_in_plex=False) try: plex_match, confidence = self._check_track_in_db(track, db) if plex_match and confidence >= 0.8: result.exists_in_plex = True result.plex_match = plex_match result.confidence = confidence except Exception as e: result.error_message = f"DB check failed: {str(e)}" results.append(result) self.signals.track_analyzed.emit(i + 1, result) if not self._cancelled: self.signals.analysis_completed.emit(results) except Exception as e: if not self._cancelled: self.signals.analysis_failed.emit(str(e)) def _check_track_in_db(self, spotify_track, db): """ Checks if a Spotify track exists in the database. This logic now relies solely on the central MusicMatchingEngine for consistency. """ try: original_title = spotify_track.name # The matching engine's clean_title now handles "(Original Mix)" and other noise. # We create variations to be safe. title_variations = [original_title] cleaned_title = self.matching_engine.clean_title(original_title) if cleaned_title.lower() != original_title.lower(): title_variations.append(cleaned_title) unique_title_variations = list(dict.fromkeys(title_variations)) artists_to_search = spotify_track.artists if spotify_track.artists else [""] for artist_name in artists_to_search: if self._cancelled: return None, 0.0 for query_title in unique_title_variations: if self._cancelled: return None, 0.0 db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7) if db_track and confidence >= 0.7: class MockPlexTrack: def __init__(self, db_track): self.id = str(db_track.id) self.title = db_track.title self.artist_name = db_track.artist_name self.album_title = db_track.album_title self.track_number = db_track.track_number self.duration = db_track.duration self.file_path = db_track.file_path mock_track = MockPlexTrack(db_track) return mock_track, confidence return None, 0.0 except Exception as e: import traceback print(f"Error checking track in database: {e}") traceback.print_exc() return None, 0.0 class SyncStatusProcessingWorkerSignals(QObject): completed = pyqtSignal(list) error = pyqtSignal(str) class SyncStatusProcessingWorker(QRunnable): """Background worker for processing download status updates.""" def __init__(self, soulseek_client, download_items_data): super().__init__() self.signals = SyncStatusProcessingWorkerSignals() self.soulseek_client = soulseek_client self.download_items_data = download_items_data def run(self): try: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) transfers_data = loop.run_until_complete( self.soulseek_client._make_request('GET', 'transfers/downloads') ) loop.close() results = [] if not transfers_data: transfers_data = [] all_transfers = [] for user_data in transfers_data: if 'files' in user_data and isinstance(user_data['files'], list): all_transfers.extend(user_data['files']) if 'directories' in user_data and isinstance(user_data['directories'], list): for directory in user_data['directories']: if 'files' in directory and isinstance(directory['files'], list): all_transfers.extend(directory['files']) transfers_by_id = {t['id']: t for t in all_transfers} for item_data in self.download_items_data: matching_transfer = None if item_data.get('download_id'): matching_transfer = transfers_by_id.get(item_data['download_id']) if not matching_transfer: expected_basename = os.path.basename(item_data['file_path']).lower() for t in all_transfers: api_basename = os.path.basename(t.get('filename', '')).lower() if api_basename == expected_basename: matching_transfer = t break if matching_transfer: state = matching_transfer.get('state', 'Unknown') progress = matching_transfer.get('percentComplete', 0) if 'Cancelled' in state or 'Canceled' in state: new_status = 'cancelled' elif 'Failed' in state or 'Errored' in state: new_status = 'failed' elif 'Completed' in state or 'Succeeded' in state: new_status = 'completed' elif 'InProgress' in state: new_status = 'downloading' else: new_status = 'queued' payload = { 'widget_id': item_data['widget_id'], 'status': new_status, 'progress': int(progress), 'transfer_id': matching_transfer.get('id'), 'username': matching_transfer.get('username') } results.append(payload) else: item_data['api_missing_count'] = item_data.get('api_missing_count', 0) + 1 if item_data['api_missing_count'] >= 3: payload = {'widget_id': item_data['widget_id'], 'status': 'failed'} results.append(payload) self.signals.completed.emit(results) except Exception as e: self.signals.error.emit(str(e)) # dashboard.py - Replace the old modal class with this new one class DownloadMissingWishlistTracksModal(QDialog): """ Enhanced modal for downloading missing wishlist tracks with live progress tracking. Functionality is extended from the modals in sync.py and artists.py. """ process_finished = pyqtSignal() def __init__(self, wishlist_service, parent_dashboard, downloads_page, spotify_client, plex_client, soulseek_client): super().__init__(parent_dashboard) self.wishlist_service = wishlist_service self.parent_dashboard = parent_dashboard self.downloads_page = downloads_page self.spotify_client = spotify_client self.plex_client = plex_client self.soulseek_client = soulseek_client self.matching_engine = MusicMatchingEngine() # State tracking self.wishlist_tracks = [] self.total_tracks = 0 self.matched_tracks_count = 0 self.tracks_to_download_count = 0 self.downloaded_tracks_count = 0 self.analysis_complete = False self.download_in_progress = False self.cancel_requested = False self.permanently_failed_tracks = [] self.analysis_results = [] self.missing_tracks = [] self.active_workers = [] self.fallback_pools = [] self.active_downloads = [] # Status Polling self.download_status_pool = QThreadPool() self.download_status_pool.setMaxThreadCount(1) self._is_status_update_running = False self.download_status_timer = QTimer(self) self.download_status_timer.timeout.connect(self.poll_all_download_statuses) self.download_status_timer.start(2000) self.setup_ui() self.load_and_populate_tracks() def start_search(self): """ Public method to start the search process. Can be called externally. This will trigger the same action as clicking the 'Begin Search' button. """ if not self.download_in_progress: self.on_begin_search_clicked() def load_and_populate_tracks(self): """Fetches tracks from the wishlist service and prepares them for the modal.""" # A simple dataclass to mimic the structure of a Spotify track object # that the rest of the modal logic expects. @dataclass class MockSpotifyTrack: id: str name: str artists: List[str] album: str duration_ms: int = 0 try: wishlist_data = self.wishlist_service.get_wishlist_tracks_for_download() self.wishlist_tracks = [] for track_data in wishlist_data: # Convert artist dicts like [{'name': 'Artist'}] to a simple list ['Artist'] artist_list = [artist['name'] for artist in track_data.get('artists', []) if 'name' in artist] mock_track = MockSpotifyTrack( id=track_data.get('spotify_track_id', ''), name=track_data.get('name', 'Unknown Track'), artists=artist_list, album=track_data.get('album_name', 'Unknown Album') ) self.wishlist_tracks.append(mock_track) self.total_tracks = len(self.wishlist_tracks) self.total_count_label.setText(str(self.total_tracks)) self.populate_track_table() except Exception as e: logger.error(f"Failed to load wishlist tracks: {e}") QMessageBox.critical(self, "Error", f"Could not load wishlist tracks: {e}") def setup_ui(self): self.setWindowTitle("Download Wishlist Tracks") self.resize(1200, 900) self.setWindowFlags(Qt.WindowType.Window) self.setStyleSheet(""" QDialog { background-color: #1e1e1e; color: #ffffff; } QLabel { color: #ffffff; } QPushButton { background-color: #1db954; color: #000000; border: none; border-radius: 6px; font-size: 13px; font-weight: bold; padding: 10px 20px; min-width: 100px; } QPushButton:hover { background-color: #1ed760; } QPushButton:disabled { background-color: #404040; color: #888888; } """) main_layout = QVBoxLayout(self) main_layout.setContentsMargins(25, 25, 25, 25) main_layout.setSpacing(15) top_section = self.create_compact_top_section() main_layout.addWidget(top_section) progress_section = self.create_progress_section() main_layout.addWidget(progress_section) table_section = self.create_track_table() main_layout.addWidget(table_section, stretch=1) button_section = self.create_buttons() main_layout.addWidget(button_section) def create_compact_top_section(self): top_frame = QFrame() top_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 15px;") layout = QVBoxLayout(top_frame) header_layout = QHBoxLayout() title_section = QVBoxLayout() title = QLabel("Download Wishlist Tracks") title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) title.setStyleSheet("color: #1db954;") subtitle = QLabel("Processing tracks from your wishlist") subtitle.setFont(QFont("Arial", 11)) subtitle.setStyleSheet("color: #aaaaaa;") title_section.addWidget(title) title_section.addWidget(subtitle) dashboard_layout = QHBoxLayout() self.total_card = self.create_compact_counter_card("📀 Total", "0", "#1db954") self.matched_card = self.create_compact_counter_card("✅ Found", "0", "#4CAF50") self.download_card = self.create_compact_counter_card("âŦ‡ī¸ Missing", "0", "#ff6b6b") self.downloaded_card = self.create_compact_counter_card("✅ Downloaded", "0", "#4CAF50") dashboard_layout.addWidget(self.total_card) dashboard_layout.addWidget(self.matched_card) dashboard_layout.addWidget(self.download_card) dashboard_layout.addWidget(self.downloaded_card) header_layout.addLayout(title_section) header_layout.addStretch() header_layout.addLayout(dashboard_layout) layout.addLayout(header_layout) return top_frame def create_compact_counter_card(self, title, count, color): card = QFrame() card.setStyleSheet(f"background-color: #3a3a3a; border: 2px solid {color}; border-radius: 6px; padding: 8px 12px; min-width: 80px;") layout = QVBoxLayout(card) count_label = QLabel(count) count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) count_label.setStyleSheet(f"color: {color}; background: transparent;") count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) title_label = QLabel(title) title_label.setFont(QFont("Arial", 9)) title_label.setStyleSheet("color: #cccccc; background: transparent;") title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(count_label) layout.addWidget(title_label) if "Total" in title: self.total_count_label = count_label elif "Found" in title: self.matched_count_label = count_label elif "Missing" in title: self.download_count_label = count_label elif "Downloaded" in title: self.downloaded_count_label = count_label return card def create_progress_section(self): progress_frame = QFrame() progress_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 12px;") layout = QVBoxLayout(progress_frame) analysis_container = QVBoxLayout() analysis_label = QLabel("🔍 Library Analysis") analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) self.analysis_progress = QProgressBar() self.analysis_progress.setFixedHeight(20) self.analysis_progress.setStyleSheet("QProgressBar { border: 1px solid #555; border-radius: 10px; text-align: center; background-color: #444; color: #fff; font-size: 11px; } QProgressBar::chunk { background-color: #1db954; border-radius: 9px; }") self.analysis_progress.setVisible(False) analysis_container.addWidget(analysis_label) analysis_container.addWidget(self.analysis_progress) download_container = QVBoxLayout() download_label = QLabel("âŦ‡ī¸ Download Progress") download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) self.download_progress = QProgressBar() self.download_progress.setFixedHeight(20) self.download_progress.setStyleSheet("QProgressBar { border: 1px solid #555; border-radius: 10px; text-align: center; background-color: #444; color: #fff; font-size: 11px; } QProgressBar::chunk { background-color: #ff6b6b; border-radius: 9px; }") self.download_progress.setVisible(False) download_container.addWidget(download_label) download_container.addWidget(self.download_progress) layout.addLayout(analysis_container) layout.addLayout(download_container) return progress_frame def create_track_table(self): """Create enhanced track table without the Duration column.""" table_frame = QFrame() table_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 0px;") layout = QVBoxLayout(table_frame) layout.setContentsMargins(15, 15, 15, 15) self.track_table = QTableWidget() # Change column count from 5 to 4 self.track_table.setColumnCount(4) # Remove "Duration" from the labels self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Matched", "Status"]) # Adjust resize modes for new column indices self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # "Matched" is now column 2 self.track_table.setColumnWidth(2, 140) # Set width for "Matched" column self.track_table.setStyleSheet("QTableWidget { background-color: #3a3a3a; alternate-background-color: #424242; selection-background-color: #1db954; gridline-color: #555; color: #fff; border: 1px solid #555; font-size: 12px; } QHeaderView::section { background-color: #1db954; color: #000; font-weight: bold; font-size: 13px; padding: 12px 8px; border: none; } QTableWidget::item { padding: 12px 8px; border-bottom: 1px solid #4a4a4a; }") self.track_table.setAlternatingRowColors(True) self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.track_table.verticalHeader().setVisible(False) layout.addWidget(self.track_table) return table_frame def populate_track_table(self): """Populate track table with wishlist tracks, omitting the duration.""" self.track_table.setRowCount(len(self.wishlist_tracks)) for i, track in enumerate(self.wishlist_tracks): self.track_table.setItem(i, 0, QTableWidgetItem(track.name)) artist_name = track.artists[0] if track.artists else "Unknown" self.track_table.setItem(i, 1, QTableWidgetItem(artist_name)) # --- DURATION LOGIC REMOVED --- # "Matched" is now column 2 matched_item = QTableWidgetItem("âŗ Pending") matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.track_table.setItem(i, 2, matched_item) # "Status" is now column 3 status_item = QTableWidgetItem("—") status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.track_table.setItem(i, 3, status_item) # Loop over 4 columns instead of 5 for col in range(4): item = self.track_table.item(i, col) if item: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) def format_duration(self, duration_ms): if not duration_ms: return "0:00" seconds = duration_ms // 1000 return f"{seconds // 60}:{seconds % 60:02d}" def create_buttons(self): button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;") layout = QHBoxLayout(button_frame) self.correct_failed_btn = QPushButton("🔧 Correct Failed Matches") self.correct_failed_btn.setFixedWidth(220) self.correct_failed_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: #000; border-radius: 20px; font-weight: bold; }") self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked) self.correct_failed_btn.hide() self.begin_search_btn = QPushButton("Begin Search") self.begin_search_btn.setFixedSize(160, 40) self.begin_search_btn.setStyleSheet("QPushButton { background-color: #1db954; color: #000; border: none; border-radius: 20px; font-size: 14px; font-weight: bold; }") self.begin_search_btn.clicked.connect(self.on_begin_search_clicked) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.setFixedSize(110, 40) self.cancel_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px;}") self.cancel_btn.clicked.connect(self.on_cancel_clicked) self.cancel_btn.hide() self.close_btn = QPushButton("Close") self.close_btn.setFixedSize(110, 40) self.close_btn.setStyleSheet("QPushButton { background-color: #616161; color: #fff; border-radius: 20px;}") self.close_btn.clicked.connect(self.on_close_clicked) layout.addStretch() layout.addWidget(self.begin_search_btn) layout.addWidget(self.cancel_btn) layout.addWidget(self.correct_failed_btn) layout.addWidget(self.close_btn) return button_frame # --- All the logic methods from sync.py's modal --- # (on_begin_search_clicked, start_plex_analysis, on_analysis_completed, etc.) # are copied here without change, except for the modifications noted below. def on_begin_search_clicked(self): self.parent_dashboard.auto_processing_wishlist = True self.begin_search_btn.hide() self.cancel_btn.show() self.analysis_progress.setVisible(True) self.analysis_progress.setMaximum(self.total_tracks) self.analysis_progress.setValue(0) self.download_in_progress = True self.start_plex_analysis() def start_plex_analysis(self): # This now uses the mock track objects from the wishlist worker = PlaylistTrackAnalysisWorker(self.wishlist_tracks, self.plex_client) worker.signals.analysis_started.connect(self.on_analysis_started) worker.signals.track_analyzed.connect(self.on_track_analyzed) worker.signals.analysis_completed.connect(self.on_analysis_completed) worker.signals.analysis_failed.connect(self.on_analysis_failed) self.active_workers.append(worker) QThreadPool.globalInstance().start(worker) def find_track_index_in_playlist(self, spotify_track): """Finds the table row index for a given track from the wishlist.""" for i, track in enumerate(self.wishlist_tracks): if track.id == spotify_track.id: return i return -1 # Return -1 if not found # ... Paste the rest of the methods from DownloadMissingTracksModal in sync.py here ... # (on_analysis_started, on_track_analyzed, on_analysis_completed, on_analysis_failed, # start_download_progress, start_parallel_downloads, start_next_batch_of_downloads, # search_and_download_track_parallel, start_track_search_with_queries_parallel, # start_search_worker_parallel, on_search_query_completed_parallel, # start_validated_download_parallel, start_matched_download_via_infrastructure_parallel, # poll_all_download_statuses, _handle_processed_status_updates, # cancel_download_before_retry, retry_parallel_download_with_fallback, # on_parallel_track_completed, on_parallel_track_failed, # update_failed_matches_button, on_correct_failed_matches_clicked, # on_manual_match_resolved, on_all_downloads_complete, on_cancel_clicked, # on_close_clicked, cancel_operations, closeEvent, ParallelSearchWorker, # get_valid_candidates, create_spotify_based_search_result_from_validation, # generate_smart_search_queries) # NOTE: I am pasting all the required methods below for completeness. def on_analysis_started(self, total_tracks): logger.debug(f"Analysis started for {total_tracks} tracks") def on_track_analyzed(self, track_index, result): self.analysis_progress.setValue(track_index) if result.exists_in_plex: matched_text = f"✅ Found ({result.confidence:.1f})" self.matched_tracks_count += 1 self.matched_count_label.setText(str(self.matched_tracks_count)) track_id_to_remove = result.spotify_track.id if self.wishlist_service.remove_track_from_wishlist(track_id_to_remove): logger.info(f"Removed pre-existing track '{result.spotify_track.name}' from wishlist during analysis.") else: logger.warning(f"Could not remove pre-existing track '{track_id_to_remove}' from wishlist.") else: matched_text = "❌ Missing" self.tracks_to_download_count += 1 self.download_count_label.setText(str(self.tracks_to_download_count)) self.track_table.setItem(track_index - 1, 2, QTableWidgetItem(matched_text)) def on_analysis_completed(self, results): self.analysis_complete = True self.analysis_results = results self.missing_tracks = [r for r in results if not r.exists_in_plex] logger.info(f"Analysis complete: {len(self.missing_tracks)} to download") if self.missing_tracks: self.start_download_progress() else: self.download_in_progress = False self.cancel_btn.hide() self.process_finished.emit() QMessageBox.information(self, "Analysis Complete", "All wishlist tracks already exist in your library!") def on_analysis_failed(self, error_message): logger.error(f"Analysis failed: {error_message}") QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}") self.cancel_btn.hide() self.begin_search_btn.show() def start_download_progress(self): self.download_progress.setVisible(True) self.download_progress.setMaximum(len(self.missing_tracks)) self.download_progress.setValue(0) self.start_parallel_downloads() def start_parallel_downloads(self): self.active_parallel_downloads = 0 self.download_queue_index = 0 self.failed_downloads = 0 self.completed_downloads = 0 self.successful_downloads = 0 self.start_next_batch_of_downloads() def start_next_batch_of_downloads(self, max_concurrent=3): while (self.active_parallel_downloads < max_concurrent and self.download_queue_index < len(self.missing_tracks)): track_result = self.missing_tracks[self.download_queue_index] track = track_result.spotify_track track_index = self.find_track_index_in_playlist(track) if track_index != -1: # FIX: Changed column index from 4 to 3 to target the "Status" column. self.track_table.setItem(track_index, 3, QTableWidgetItem("🔍 Searching...")) self.search_and_download_track_parallel(track, self.download_queue_index, track_index) self.active_parallel_downloads += 1 self.download_queue_index += 1 if (self.download_queue_index >= len(self.missing_tracks) and self.active_parallel_downloads == 0): self.on_all_downloads_complete() def search_and_download_track_parallel(self, spotify_track, download_index, track_index): artist_name = spotify_track.artists[0] if spotify_track.artists else "" search_queries = self.generate_smart_search_queries(artist_name, spotify_track.name) self.start_track_search_with_queries_parallel(spotify_track, search_queries, track_index, track_index, download_index) def start_track_search_with_queries_parallel(self, spotify_track, search_queries, track_index, table_index, download_index): if not hasattr(self, 'parallel_search_tracking'): self.parallel_search_tracking = {} self.parallel_search_tracking[download_index] = { 'spotify_track': spotify_track, 'track_index': track_index, 'table_index': table_index, 'download_index': download_index, 'completed': False, 'used_sources': set(), 'candidates': [], 'retry_count': 0 } self.start_search_worker_parallel(search_queries, spotify_track, track_index, table_index, 0, download_index) def start_search_worker_parallel(self, queries, spotify_track, track_index, table_index, query_index, download_index): if query_index >= len(queries): self.on_parallel_track_failed(download_index, "All search strategies failed") return query = queries[query_index] worker = self.ParallelSearchWorker(self.soulseek_client, query) worker.signals.search_completed.connect(lambda r, q: self.on_search_query_completed_parallel(r, queries, spotify_track, track_index, table_index, query_index, q, download_index)) worker.signals.search_failed.connect(lambda q, e: self.on_search_query_completed_parallel([], queries, spotify_track, track_index, table_index, query_index, q, download_index)) QThreadPool.globalInstance().start(worker) def on_search_query_completed_parallel(self, results, queries, spotify_track, track_index, table_index, query_index, query, download_index): if self.cancel_requested: return valid_candidates = self.get_valid_candidates(results, spotify_track, query) if valid_candidates: self.parallel_search_tracking[download_index]['candidates'] = valid_candidates best_match = valid_candidates[0] self.start_validated_download_parallel(best_match, spotify_track, track_index, table_index, download_index) return next_query_index = query_index + 1 if next_query_index < len(queries): self.start_search_worker_parallel(queries, spotify_track, track_index, table_index, next_query_index, download_index) else: self.on_parallel_track_failed(download_index, f"No valid results after trying all {len(queries)} queries.") def start_validated_download_parallel(self, slskd_result, spotify_metadata, track_index, table_index, download_index): track_info = self.parallel_search_tracking[download_index] if track_info.get('completed', False): track_info['completed'] = False if self.failed_downloads > 0: self.failed_downloads -= 1 self.active_parallel_downloads += 1 if self.completed_downloads > 0: self.completed_downloads -= 1 source_key = f"{getattr(slskd_result, 'username', 'unknown')}_{slskd_result.filename}" track_info['used_sources'].add(source_key) spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata) self.track_table.setItem(table_index, 3, QTableWidgetItem("... Queued")) self.start_matched_download_via_infrastructure_parallel(spotify_based_result, track_index, table_index, download_index) def start_matched_download_via_infrastructure_parallel(self, spotify_based_result, track_index, table_index, download_index): try: artist = type('Artist', (), {'name': spotify_based_result.artist})() download_item = self.downloads_page._start_download_with_artist(spotify_based_result, artist) if download_item: self.active_downloads.append({ 'download_index': download_index, 'track_index': track_index, 'table_index': table_index, 'download_id': download_item.download_id, 'slskd_result': spotify_based_result, 'candidates': self.parallel_search_tracking[download_index]['candidates'] }) else: self.on_parallel_track_failed(download_index, "Failed to start download") except Exception as e: self.on_parallel_track_failed(download_index, str(e)) def poll_all_download_statuses(self): if self._is_status_update_running or not self.active_downloads: return self._is_status_update_running = True items_to_check = [] for d in self.active_downloads: if d.get('slskd_result') and hasattr(d['slskd_result'], 'filename'): items_to_check.append({ 'widget_id': d['download_index'], 'download_id': d.get('download_id'), 'file_path': d['slskd_result'].filename, 'api_missing_count': d.get('api_missing_count', 0) }) if not items_to_check: self._is_status_update_running = False return worker = SyncStatusProcessingWorker(self.soulseek_client, items_to_check) worker.signals.completed.connect(self._handle_processed_status_updates) worker.signals.error.connect(lambda e: logger.error(f"Status Worker Error: {e}")) self.download_status_pool.start(worker) def _handle_processed_status_updates(self, results): import time active_downloads_map = {d['download_index']: d for d in self.active_downloads} for result in results: download_index = result['widget_id'] new_status = result['status'] download_info = active_downloads_map.get(download_index) if not download_info: continue if 'api_missing_count' in result: download_info['api_missing_count'] = result['api_missing_count'] if result.get('transfer_id') and download_info.get('download_id') != result['transfer_id']: download_info['download_id'] = result['transfer_id'] if new_status in ['failed', 'cancelled']: if download_info in self.active_downloads: self.active_downloads.remove(download_info) self.retry_parallel_download_with_fallback(download_info) elif new_status == 'completed': if download_info in self.active_downloads: self.active_downloads.remove(download_info) self.on_parallel_track_completed(download_index, success=True) elif new_status == 'downloading': progress = result.get('progress', 0) self.track_table.setItem(download_info['table_index'], 3, QTableWidgetItem(f"âŦ Downloading ({progress}%)")) if 'queued_start_time' in download_info: del download_info['queued_start_time'] if progress < 1: if 'downloading_start_time' not in download_info: download_info['downloading_start_time'] = time.time() elif time.time() - download_info['downloading_start_time'] > 90: self.cancel_download_before_retry(download_info) if download_info in self.active_downloads: self.active_downloads.remove(download_info) self.retry_parallel_download_with_fallback(download_info) else: if 'downloading_start_time' in download_info: del download_info['downloading_start_time'] elif new_status == 'queued': self.track_table.setItem(download_info['table_index'], 3, QTableWidgetItem("... Queued")) if 'queued_start_time' not in download_info: download_info['queued_start_time'] = time.time() elif time.time() - download_info['queued_start_time'] > 90: self.cancel_download_before_retry(download_info) if download_info in self.active_downloads: self.active_downloads.remove(download_info) self.retry_parallel_download_with_fallback(download_info) self._is_status_update_running = False def cancel_download_before_retry(self, download_info): try: slskd_result = download_info.get('slskd_result') if not slskd_result: return download_id = download_info.get('download_id') username = getattr(slskd_result, 'username', None) if download_id and username: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(self.soulseek_client.cancel_download(download_id, username, remove=False)) finally: loop.close() except Exception as e: logger.error(f"Error cancelling download: {e}") def retry_parallel_download_with_fallback(self, failed_download_info): download_index = failed_download_info['download_index'] track_info = self.parallel_search_tracking[download_index] track_info['retry_count'] += 1 if track_info['retry_count'] > 2: self.on_parallel_track_failed(download_index, "All retries failed.") return candidates = failed_download_info.get('candidates', []) used_sources = track_info.get('used_sources', set()) next_candidate = None for candidate in candidates: source_key = f"{getattr(candidate, 'username', 'unknown')}_{candidate.filename}" if source_key not in used_sources: next_candidate = candidate break if not next_candidate: self.on_parallel_track_failed(download_index, "No alternative sources in cache") return self.track_table.setItem(failed_download_info['table_index'], 3, QTableWidgetItem(f"🔄 Retrying ({track_info['retry_count']})...")) self.start_validated_download_parallel(next_candidate, track_info['spotify_track'], track_info['track_index'], track_info['table_index'], download_index) def on_parallel_track_completed(self, download_index, success): track_info = self.parallel_search_tracking.get(download_index) if not track_info or track_info.get('completed', False): return track_info['completed'] = True if success: self.track_table.setItem(track_info['table_index'], 3, QTableWidgetItem("✅ Downloaded")) self.downloaded_tracks_count += 1 self.downloaded_count_label.setText(str(self.downloaded_tracks_count)) self.successful_downloads += 1 self.wishlist_service.remove_track_from_wishlist(track_info['spotify_track'].id) logger.info(f"Successfully downloaded and removed '{track_info['spotify_track'].name}' from wishlist.") else: self.track_table.setItem(track_info['table_index'], 3, QTableWidgetItem("❌ Failed")) self.failed_downloads += 1 if track_info not in self.permanently_failed_tracks: self.permanently_failed_tracks.append(track_info) self.update_failed_matches_button() self.completed_downloads += 1 self.active_parallel_downloads -= 1 self.download_progress.setValue(self.completed_downloads) self.start_next_batch_of_downloads() def on_parallel_track_failed(self, download_index, reason): logger.error(f"Parallel download {download_index + 1} failed: {reason}") self.on_parallel_track_completed(download_index, False) def update_failed_matches_button(self): count = len(self.permanently_failed_tracks) if count > 0: self.correct_failed_btn.setText(f"🔧 Correct {count} Failed Match{'es' if count > 1 else ''}") self.correct_failed_btn.show() else: self.correct_failed_btn.hide() def on_correct_failed_matches_clicked(self): if not self.permanently_failed_tracks: return # This requires ManualMatchModal to be copied or imported from ui.pages.sync import ManualMatchModal manual_modal = ManualMatchModal(self) manual_modal.track_resolved.connect(self.on_manual_match_resolved) manual_modal.exec() def on_manual_match_resolved(self, resolved_track_info): original_failed_track = next((t for t in self.permanently_failed_tracks if t['download_index'] == resolved_track_info['download_index']), None) if original_failed_track: self.permanently_failed_tracks.remove(original_failed_track) self.update_failed_matches_button() def on_all_downloads_complete(self): self.download_in_progress = False self.parent_dashboard.auto_processing_wishlist = False self.cancel_btn.hide() self.process_finished.emit() if self.successful_downloads > 0 and hasattr(self.parent_dashboard, 'scan_manager') and self.parent_dashboard.scan_manager: self.parent_dashboard.scan_manager.request_scan(f"Wishlist download completed ({self.successful_downloads} tracks)") wishlist_added_count = 0 if self.permanently_failed_tracks: source_context = {'added_from': 'wishlist_modal', 'timestamp': datetime.now().isoformat()} for failed_track_info in self.permanently_failed_tracks: if self.wishlist_service.add_failed_track_from_modal(track_info=failed_track_info, source_type='wishlist', source_context=source_context): wishlist_added_count += 1 final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n" if wishlist_added_count > 0: final_message += f"✨ Re-added {wishlist_added_count} failed track{'s' if wishlist_added_count > 1 else ''} to wishlist for future retry.\n\n" if self.permanently_failed_tracks: final_message += "You can also manually correct failed downloads." else: final_message += "All tracks were downloaded successfully!" logger.info("Wishlist processing complete. Scheduling next run in 60 minutes.") self.parent_dashboard.wishlist_retry_timer.start(3600000) # 60 minutes # Removed success modal - users don't need to see completion notification def on_cancel_clicked(self): self.cancel_operations() self.process_finished.emit() self.reject() def on_close_clicked(self): if self.cancel_requested or not self.download_in_progress: self.cancel_operations() self.process_finished.emit() self.reject() def cancel_operations(self): self.cancel_requested = True for worker in self.active_workers: if hasattr(worker, 'cancel'): worker.cancel() self.active_workers.clear() self.download_status_timer.stop() def closeEvent(self, event): """Override close event to hide the modal if a download is in progress.""" if self.download_in_progress and not self.cancel_requested: # If a download is running, just hide the window. # The user can bring it back by clicking the wishlist button again. logger.info("Hiding wishlist modal while download is in progress.") self.hide() event.ignore() else: # If not downloading or cancelled, allow it to close for real. logger.info("Closing wishlist modal.") self.cancel_operations() self.process_finished.emit() event.accept() class ParallelSearchWorker(QRunnable): def __init__(self, soulseek_client, query): super().__init__() self.soulseek_client = soulseek_client self.query = query self.signals = self.create_signals() def create_signals(self): class Signals(QObject): search_completed = pyqtSignal(list, str) search_failed = pyqtSignal(str, str) return Signals() def run(self): loop = None try: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) search_result = loop.run_until_complete(self.soulseek_client.search(self.query)) results_list = search_result[0] if isinstance(search_result, tuple) and search_result else [] # Check if signals object is still valid before emitting try: self.signals.search_completed.emit(results_list, self.query) except RuntimeError: # Qt objects deleted during shutdown, ignore logger.debug(f"Search completed for '{self.query}' but UI already closed") except Exception as e: try: self.signals.search_failed.emit(self.query, str(e)) except RuntimeError: # Qt objects deleted during shutdown, ignore logger.debug(f"Search failed for '{self.query}' but UI already closed: {e}") finally: if loop: loop.close() def get_valid_candidates(self, results, spotify_track, query): if not results: return [] initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results) if not initial_candidates: return [] verified_candidates = [] spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else "" normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower() for candidate in initial_candidates: normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', candidate.filename).lower() if normalized_spotify_artist in normalized_slskd_path: verified_candidates.append(candidate) return verified_candidates def create_spotify_based_search_result_from_validation(self, slskd_result, spotify_metadata): class SpotifyBasedSearchResult: def __init__(self): self.filename = getattr(slskd_result, 'filename', f"{spotify_metadata.name}.flac") self.username = getattr(slskd_result, 'username', 'unknown') self.size = getattr(slskd_result, 'size', 0) self.quality = getattr(slskd_result, 'quality', 'flac') self.artist = spotify_metadata.artists[0] if spotify_metadata.artists else "Unknown" self.title = spotify_metadata.name self.album = spotify_metadata.album return SpotifyBasedSearchResult() def generate_smart_search_queries(self, artist_name, track_name): class MockSpotifyTrack: def __init__(self, name, artists, album=None): self.name = name self.artists = artists if isinstance(artists, list) else [artists] if artists else [] self.album = album mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], None) queries = self.matching_engine.generate_download_queries(mock_track) legacy_queries = [track_name.strip()] if artist_name: artist_words = artist_name.split() if artist_words: first_word = artist_words[0] if first_word.lower() == 'the' and len(artist_words) > 1: first_word = artist_words[1] if len(first_word) > 1: legacy_queries.append(f"{track_name} {first_word}".strip()) all_queries = queries + legacy_queries unique_queries = list(dict.fromkeys(q for q in all_queries if q)) return unique_queries class SimpleWishlistDownloadWorker(QRunnable): """Enhanced worker to download a single wishlist track with detailed status updates""" class Signals(QObject): status_updated = pyqtSignal(int, str) # download_index, status_text download_completed = pyqtSignal(int, str) # download_index, download_id download_failed = pyqtSignal(int, str) # download_index, error_message def __init__(self, soulseek_client, query, track_data, download_index): super().__init__() self.soulseek_client = soulseek_client self.query = query self.track_data = track_data self.download_index = download_index self.signals = self.Signals() def run(self): """Run the download with detailed status updates""" try: # Update status: Starting search self.signals.status_updated.emit(self.download_index, "🔍 Searching...") # Get quality preference from config.settings import config_manager quality_preference = config_manager.get_quality_preference() # Use async method in sync context loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # Update status: Found candidates, analyzing self.signals.status_updated.emit(self.download_index, "🔎 Analyzing results...") # Use the enhanced search method that provides more feedback results = loop.run_until_complete( self._search_with_progress(self.query, quality_preference) ) if results and len(results) > 0: # Update status: Found candidates, starting download self.signals.status_updated.emit(self.download_index, f"📋 Found {len(results)} candidates") time.sleep(0.5) # Brief pause so user can see the status # Get the best result and start download best_result = results[0] # Assuming results are sorted by quality self.signals.status_updated.emit(self.download_index, "âŦ Starting download...") # Start the actual download download_id = loop.run_until_complete( self.soulseek_client.download_track(best_result) ) if download_id: self.signals.download_completed.emit(self.download_index, download_id) else: self.signals.download_failed.emit(self.download_index, "Download failed to start") else: self.signals.download_failed.emit(self.download_index, "No search results found") finally: loop.close() except Exception as e: self.signals.download_failed.emit(self.download_index, str(e)) async def _search_with_progress(self, query, quality_preference): """Search for tracks with progress updates""" try: # Emit search progress self.signals.status_updated.emit(self.download_index, "🌐 Searching network...") # Perform the search (this would ideally use the soulseek client's search methods) # For now, we'll use the existing search_and_download_best method # but in a real implementation, you'd want to separate search from download # This is a simplified version - in practice you'd want to: # 1. Search for candidates # 2. Filter by quality # 3. Return the results for manual download # For now, let's use a direct approach from core.soulseek_client import SoulseekClient if hasattr(self.soulseek_client, 'search_tracks'): results = await self.soulseek_client.search_tracks(query) if results: # Filter by quality preference if needed filtered_results = self.soulseek_client.filter_results_by_quality_preference( results, quality_preference ) return filtered_results return [] except Exception as e: logger.error(f"Error in search with progress: {e}") return [] class MetadataUpdateWorker(QThread): """Worker thread for updating Plex artist metadata using Spotify data""" progress_updated = pyqtSignal(str, int, int, float) # current_artist, processed, total, percentage artist_updated = pyqtSignal(str, bool, str) # artist_name, success, details finished = pyqtSignal(int, int, int) # total_processed, successful, failed error = pyqtSignal(str) # error_message artists_loaded = pyqtSignal(int, int) # total_artists, artists_to_process def __init__(self, artists, plex_client, spotify_client, refresh_interval_days=30): super().__init__() self.artists = artists self.plex_client = plex_client self.spotify_client = spotify_client self.matching_engine = MusicMatchingEngine() self.refresh_interval_days = refresh_interval_days self.should_stop = False self.processed_count = 0 self.successful_count = 0 self.failed_count = 0 self.max_workers = 4 # Same as your previous implementation self.thread_lock = threading.Lock() def stop(self): self.should_stop = True def run(self): """Process all artists one by one""" try: # Load artists in background if not provided if self.artists is None: all_artists = self.plex_client.get_all_artists() if not all_artists: self.error.emit("No artists found in Plex library") return # Filter artists that need processing artists_to_process = [artist for artist in all_artists if self.artist_needs_processing(artist)] self.artists = artists_to_process # Emit loaded signal self.artists_loaded.emit(len(all_artists), len(artists_to_process)) if not artists_to_process: self.finished.emit(0, 0, 0) return total_artists = len(self.artists) # Process artists in parallel using ThreadPoolExecutor def process_single_artist(artist): """Process a single artist and return results""" if self.should_stop: return None artist_name = getattr(artist, 'title', 'Unknown Artist') # Double-check ignore flag right before processing (in case it was added after loading) if self.plex_client.is_artist_ignored(artist): return (artist_name, True, "Skipped (ignored)") try: success, details = self.update_artist_metadata(artist) return (artist_name, success, details) except Exception as e: return (artist_name, False, f"Error: {str(e)}") with ThreadPoolExecutor(max_workers=self.max_workers) as executor: # Submit all tasks future_to_artist = {executor.submit(process_single_artist, artist): artist for artist in self.artists} # Process completed tasks as they finish for future in as_completed(future_to_artist): if self.should_stop: break result = future.result() if result is None: # Task was cancelled continue artist_name, success, details = result with self.thread_lock: self.processed_count += 1 if success: self.successful_count += 1 else: self.failed_count += 1 # Emit progress and result signals progress_percent = (self.processed_count / total_artists) * 100 self.progress_updated.emit(artist_name, self.processed_count, total_artists, progress_percent) self.artist_updated.emit(artist_name, success, details) self.finished.emit(self.processed_count, self.successful_count, self.failed_count) except Exception as e: self.error.emit(f"Metadata update failed: {str(e)}") def artist_needs_processing(self, artist): """Check if an artist needs metadata processing using age-based detection""" try: # Use PlexClient's age-based checking with configured interval # This also handles the ignore flag check internally return self.plex_client.needs_update_by_age(artist, self.refresh_interval_days) except Exception as e: print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}") return True # Process if we can't determine status def update_artist_metadata(self, artist): """ Update a single artist's metadata by finding the best match on Spotify. """ try: artist_name = getattr(artist, 'title', 'Unknown Artist') # --- IMPROVED ARTIST MATCHING --- # 1. Search for top 5 potential artists on Spotify spotify_artists = self.spotify_client.search_artists(artist_name, limit=5) if not spotify_artists: return False, "Not found on Spotify" # 2. Find the best match using the matching engine best_match = None highest_score = 0.0 plex_artist_normalized = self.matching_engine.normalize_string(artist_name) for spotify_artist in spotify_artists: spotify_artist_normalized = self.matching_engine.normalize_string(spotify_artist.name) score = self.matching_engine.similarity_score(plex_artist_normalized, spotify_artist_normalized) if score > highest_score: highest_score = score best_match = spotify_artist # 3. If no suitable match is found, exit if not best_match or highest_score < 0.7: # Confidence threshold return False, f"No confident match found (best: '{getattr(best_match, 'name', 'N/A')}', score: {highest_score:.2f})" spotify_artist = best_match changes_made = [] # Update photo if needed photo_updated = self.update_artist_photo(artist, spotify_artist) if photo_updated: changes_made.append("photo") # Update genres genres_updated = self.update_artist_genres(artist, spotify_artist) if genres_updated: changes_made.append("genres") # Update album artwork albums_updated = self.update_album_artwork(artist, spotify_artist) if albums_updated > 0: changes_made.append(f"{albums_updated} album art") if changes_made: # Update artist biography with timestamp to track last update biography_updated = self.plex_client.update_artist_biography(artist) if biography_updated: changes_made.append("timestamp") details = f"Updated {', '.join(changes_made)} (match: '{spotify_artist.name}', score: {highest_score:.2f})" return True, details else: # Even if no metadata changes, update biography to record we checked this artist self.plex_client.update_artist_biography(artist) return True, "Already up to date" except Exception as e: return False, str(e) def update_artist_photo(self, artist, spotify_artist): """Update artist photo from Spotify""" try: # Check if artist already has a good photo if self.artist_has_valid_photo(artist): return False # Get the image URL from Spotify if not spotify_artist.image_url: return False image_url = spotify_artist.image_url # Download and validate image response = requests.get(image_url, timeout=10) response.raise_for_status() # Validate and convert image image_data = self.validate_and_convert_image(response.content) if not image_data: return False # Upload to Plex return self.upload_artist_poster(artist, image_data) except Exception as e: print(f"Error updating photo for {getattr(artist, 'title', 'Unknown')}: {e}") return False def update_artist_genres(self, artist, spotify_artist): """Update artist genres from Spotify and albums""" try: # Get existing genres existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre) for genre in (artist.genres or [])) # Get Spotify artist genres spotify_genres = set(spotify_artist.genres or []) # Get genres from all albums album_genres = set() try: for album in artist.albums(): if hasattr(album, 'genres') and album.genres: album_genres.update(genre.tag if hasattr(genre, 'tag') else str(genre) for genre in album.genres) except Exception: pass # Albums might not be accessible # Combine all genres (prioritize Spotify genres) all_genres = spotify_genres.union(album_genres) # Filter out empty/invalid genres all_genres = {g for g in all_genres if g and g.strip() and len(g.strip()) > 1} print(f"[DEBUG] Artist '{artist.title}': Existing={existing_genres}, Spotify={spotify_genres}, Albums={album_genres}, Combined={all_genres}") # Only update if we have new genres and they're different if all_genres and (not existing_genres or all_genres != existing_genres): # Convert to list and limit to 10 genres genre_list = list(all_genres)[:10] print(f"[DEBUG] Updating genres for '{artist.title}' to: {genre_list}") # Use Plex API to update genres success = self.plex_client.update_artist_genres(artist, genre_list) if success: print(f"[DEBUG] Successfully updated genres for '{artist.title}'") return True else: print(f"[DEBUG] Failed to update genres for '{artist.title}'") return False else: print(f"[DEBUG] No genre update needed for '{artist.title}' - already has good genres") return False except Exception as e: print(f"Error updating genres for {getattr(artist, 'title', 'Unknown')}: {e}") return False def update_album_artwork(self, artist, spotify_artist): """Update album artwork for all albums by this artist""" try: updated_count = 0 skipped_count = 0 # Get all albums for this artist try: albums = list(artist.albums()) except Exception: print(f"Could not access albums for artist '{artist.title}'") return 0 if not albums: print(f"No albums found for artist '{artist.title}'") return 0 print(f"🎨 Checking artwork for {len(albums)} albums by '{artist.title}'...") for album in albums: try: album_title = getattr(album, 'title', 'Unknown Album') # Check if album already has good artwork (debug=True to see detection logic) if self.album_has_valid_artwork(album, debug=True): skipped_count += 1 continue print(f"Album '{album_title}' needs artwork - searching Spotify...") # Search for this specific album on Spotify album_query = f"album:{album_title} artist:{spotify_artist.name}" spotify_albums = self.spotify_client.search_albums(album_query, limit=3) if not spotify_albums: print(f"No Spotify results for album '{album_title}'") continue # Find the best matching album best_album = None highest_score = 0.0 plex_album_normalized = self.matching_engine.normalize_string(album_title) for spotify_album in spotify_albums: spotify_album_normalized = self.matching_engine.normalize_string(spotify_album.name) score = self.matching_engine.similarity_score(plex_album_normalized, spotify_album_normalized) if score > highest_score: highest_score = score best_album = spotify_album # If we found a good match with artwork, download it if best_album and highest_score > 0.7 and best_album.image_url: print(f"Found Spotify match: '{best_album.name}' (score: {highest_score:.2f})") # Download and upload the artwork if self.download_and_upload_album_artwork(album, best_album.image_url): updated_count += 1 else: print(f"No good Spotify match for album '{album_title}' (best score: {highest_score:.2f})") except Exception as e: print(f"Error processing album '{getattr(album, 'title', 'Unknown')}': {e}") continue total_processed = updated_count + skipped_count print(f"🎨 Artwork summary for '{artist.title}': {updated_count} updated, {skipped_count} skipped (already have good artwork)") if updated_count == 0 and skipped_count == len(albums): print(f" ✅ All albums already have good artwork - no Spotify API calls needed!") return updated_count except Exception as e: print(f"Error updating album artwork for artist '{getattr(artist, 'title', 'Unknown')}': {e}") return 0 def album_has_valid_artwork(self, album, debug=False): """Check if album has valid artwork - conservative approach""" try: album_title = getattr(album, 'title', 'Unknown Album') # Check if album has any thumb at all if not hasattr(album, 'thumb') or not album.thumb: if debug: print(f" 🎨 Album '{album_title}' has NO THUMB - needs update") return False thumb_url = str(album.thumb) if debug: print(f" 🔍 Album '{album_title}' artwork URL: {thumb_url}") # CONSERVATIVE APPROACH: Only mark as "needs update" in very obvious cases # Case 1: Completely empty or None if not thumb_url or thumb_url.strip() == '': if debug: print(f" 🎨 Album '{album_title}' has empty URL - needs update") return False # Case 2: Obvious placeholder text in URL obvious_placeholders = [ 'no-image', 'placeholder', 'missing', 'default-album', 'blank.jpg', 'empty.png' ] thumb_lower = thumb_url.lower() for placeholder in obvious_placeholders: if placeholder in thumb_lower: if debug: print(f" 🎨 Album '{album_title}' has obvious placeholder ({placeholder}) - needs update") return False # Case 3: Extremely short URLs (likely broken) if len(thumb_url) < 20: if debug: print(f" 🎨 Album '{album_title}' has very short URL ({len(thumb_url)} chars) - needs update") return False # OTHERWISE: Assume it has valid artwork and SKIP updating if debug: print(f" ✅ Album '{album_title}' appears to have artwork - SKIPPING (URL: {len(thumb_url)} chars)") return True except Exception as e: if debug: print(f" ❌ Error checking artwork for album '{album_title}': {e}") # If we can't check, be conservative and skip updating return True def download_and_upload_album_artwork(self, album, image_url): """Download artwork from Spotify and upload to Plex""" try: album_title = getattr(album, 'title', 'Unknown Album') # Download image from Spotify response = requests.get(image_url, timeout=10) response.raise_for_status() # Validate and convert image (reuse existing function) image_data = self.validate_and_convert_image(response.content) if not image_data: print(f"Invalid image data for album '{album_title}'") return False # Upload to Plex using our new method success = self.plex_client.update_album_poster(album, image_data) if success: print(f"✅ Updated artwork for album '{album_title}'") else: print(f"❌ Failed to upload artwork for album '{album_title}'") return success except Exception as e: print(f"Error downloading/uploading artwork for album '{getattr(album, 'title', 'Unknown')}': {e}") return False def artist_has_valid_photo(self, artist): """Check if artist has a valid photo""" try: if not hasattr(artist, 'thumb') or not artist.thumb: return False thumb_url = str(artist.thumb) if 'default' in thumb_url.lower() or len(thumb_url) < 50: return False return True except Exception: return False def validate_and_convert_image(self, image_data): """Validate and convert image for Plex compatibility""" try: # Open and validate image image = Image.open(io.BytesIO(image_data)) # Check minimum dimensions width, height = image.size if width < 200 or height < 200: return None # Convert to JPEG for consistency if image.format != 'JPEG': buffer = io.BytesIO() image.convert('RGB').save(buffer, format='JPEG', quality=95) return buffer.getvalue() return image_data except Exception: return None def upload_artist_poster(self, artist, image_data): """Upload poster to Plex""" try: # Use Plex client's update method if available if hasattr(self.plex_client, 'update_artist_poster'): return self.plex_client.update_artist_poster(artist, image_data) # Fallback: direct Plex API call server = self.plex_client.server upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters" headers = { 'X-Plex-Token': server._token, 'Content-Type': 'image/jpeg' } response = requests.post(upload_url, data=image_data, headers=headers) response.raise_for_status() # Refresh artist to see changes artist.refresh() return True except Exception as e: print(f"Error uploading poster: {e}") return False @dataclass class ServiceStatus: name: str connected: bool last_check: datetime response_time: float = 0.0 error: Optional[str] = None @dataclass class DownloadStats: active_count: int = 0 finished_count: int = 0 total_speed: float = 0.0 total_transferred: int = 0 @dataclass class MetadataProgress: is_running: bool = False current_artist: str = "" processed_count: int = 0 total_count: int = 0 progress_percentage: float = 0.0 class DashboardDataProvider(QObject): # Signals for real-time updates service_status_updated = pyqtSignal(str, bool, float, str) # service, connected, response_time, error download_stats_updated = pyqtSignal(int, int, float) # active, finished, speed metadata_progress_updated = pyqtSignal(bool, str, int, int, float) # running, artist, processed, total, percentage sync_progress_updated = pyqtSignal(str, int) # current_playlist, progress system_stats_updated = pyqtSignal(str, str) # uptime, memory activity_item_added = pyqtSignal(str, str, str, str) # icon, title, subtitle, time def __init__(self, parent=None): super().__init__(parent) self.service_clients = {} self.downloads_page = None self.sync_page = None self.app_start_time = None # Data storage self.service_status = { 'spotify': ServiceStatus('Spotify', False, datetime.now()), 'plex': ServiceStatus('Plex', False, datetime.now()), 'soulseek': ServiceStatus('Soulseek', False, datetime.now()) } self.download_stats = DownloadStats() self.metadata_progress = MetadataProgress() # Session-based counters (reset on app restart) self.session_completed_downloads = 0 # Update timers with different frequencies self.download_stats_timer = QTimer() self.download_stats_timer.timeout.connect(self.update_download_stats) self.download_stats_timer.start(2000) # Update every 2 seconds self.system_stats_timer = QTimer() self.system_stats_timer.timeout.connect(self.update_system_stats) self.system_stats_timer.start(10000) # Update every 10 seconds def set_service_clients(self, spotify_client, plex_client, soulseek_client): self.service_clients = { 'spotify_client': spotify_client, 'plex_client': plex_client, 'soulseek_client': soulseek_client } def set_page_references(self, downloads_page, sync_page): self.downloads_page = downloads_page self.sync_page = sync_page def set_app_start_time(self, start_time): self.app_start_time = start_time def increment_completed_downloads(self, title="Unknown Track", artist="Unknown Artist"): """Increment the session completed downloads counter""" self.session_completed_downloads += 1 # Emit signal for activity feed with specific track info self.activity_item_added.emit("đŸ“Ĩ", "Download Complete", f"'{title}' by {artist}", "Now") def update_service_status(self, service: str, connected: bool, response_time: float = 0.0, error: str = ""): if service in self.service_status: self.service_status[service].connected = connected self.service_status[service].last_check = datetime.now() self.service_status[service].response_time = response_time self.service_status[service].error = error self.service_status_updated.emit(service, connected, response_time, error) def update_download_stats(self): if self.downloads_page and hasattr(self.downloads_page, 'download_queue'): try: active_count = len(self.downloads_page.download_queue.active_queue.download_items) finished_count = len(self.downloads_page.download_queue.finished_queue.download_items) # Calculate total speed from active downloads (in bytes/sec) total_speed = 0.0 for item in self.downloads_page.download_queue.active_queue.download_items: if hasattr(item, 'download_speed') and isinstance(item.download_speed, (int, float)) and item.download_speed > 0: # download_speed is already in bytes/sec from slskd API total_speed += float(item.download_speed) self.download_stats.active_count = active_count self.download_stats.finished_count = self.session_completed_downloads # Use session counter self.download_stats.total_speed = total_speed self.download_stats_updated.emit(active_count, self.session_completed_downloads, total_speed) except Exception as e: pass # Silent failure for stats updates # Update sync stats if self.sync_page and hasattr(self.sync_page, 'active_sync_workers'): try: active_syncs = len(self.sync_page.active_sync_workers) self.sync_progress_updated.emit("", active_syncs) except Exception as e: pass # Silent failure for stats updates def update_system_stats(self): """Update system statistics (uptime and memory)""" try: uptime_str = self.get_uptime_string() memory_str = self.get_memory_usage() self.system_stats_updated.emit(uptime_str, memory_str) except Exception as e: pass def get_uptime_string(self): """Get formatted uptime string""" if not self.app_start_time: return "Unknown" try: uptime_seconds = time.time() - self.app_start_time if uptime_seconds < 60: return f"{int(uptime_seconds)}s" elif uptime_seconds < 3600: minutes = int(uptime_seconds / 60) return f"{minutes}m" elif uptime_seconds < 86400: hours = int(uptime_seconds / 3600) minutes = int((uptime_seconds % 3600) / 60) return f"{hours}h {minutes}m" else: days = int(uptime_seconds / 86400) hours = int((uptime_seconds % 86400) / 3600) return f"{days}d {hours}h" except Exception: return "Unknown" def get_memory_usage(self): """Get formatted memory usage string""" try: # Try using resource module first (Unix-like systems) if HAS_RESOURCE and hasattr(resource, 'RUSAGE_SELF'): usage = resource.getrusage(resource.RUSAGE_SELF) # ru_maxrss is in KB on Linux, bytes on macOS max_rss = usage.ru_maxrss # Detect platform and convert accordingly import platform if platform.system() == 'Darwin': # macOS memory_mb = max_rss / (1024 * 1024) else: # Linux memory_mb = max_rss / 1024 return f"~{memory_mb:.0f} MB" # Windows fallback: try psutil if available try: import psutil process = psutil.Process(os.getpid()) memory_mb = process.memory_info().rss / (1024 * 1024) return f"~{memory_mb:.0f} MB" except ImportError: pass # Linux fallback: try reading /proc/self/status if os.path.exists('/proc/self/status'): with open('/proc/self/status', 'r') as f: for line in f: if line.startswith('VmRSS:'): kb = int(line.split()[1]) return f"~{kb / 1024:.0f} MB" return "N/A" except Exception: return "N/A" def test_service_connection(self, service: str): """Test connection to a specific service""" print(f"DEBUG: Testing {service} connection") print(f"DEBUG: Available service clients: {list(self.service_clients.keys())}") # Map service names to client keys service_key_map = { 'spotify': 'spotify_client', 'plex': 'plex_client', 'soulseek': 'soulseek_client' } client_key = service_key_map.get(service, service) if client_key not in self.service_clients: print(f"DEBUG: Service {service} (key: {client_key}) not found in service_clients") return print(f"DEBUG: Service client for {service}: {self.service_clients[client_key]}") # Clean up any existing test thread for this service if hasattr(self, '_test_threads') and service in self._test_threads: old_thread = self._test_threads[service] if old_thread.isRunning(): old_thread.quit() old_thread.wait() old_thread.deleteLater() # Initialize test threads dict if needed if not hasattr(self, '_test_threads'): self._test_threads = {} # Run connection test in background thread test_thread = ServiceTestThread(service, self.service_clients[client_key]) test_thread.test_completed.connect(self.on_service_test_completed) test_thread.finished.connect(lambda: self._cleanup_test_thread(service)) self._test_threads[service] = test_thread print(f"DEBUG: Starting test thread for {service}") test_thread.start() def _cleanup_test_thread(self, service: str): """Clean up completed test thread""" if hasattr(self, '_test_threads') and service in self._test_threads: thread = self._test_threads[service] if thread.isRunning(): thread.quit() thread.wait(1000) # Wait up to 1 second thread.deleteLater() del self._test_threads[service] def on_service_test_completed(self, service: str, connected: bool, response_time: float, error: str): self.update_service_status(service, connected, response_time, error) class ServiceTestThread(QThread): test_completed = pyqtSignal(str, bool, float, str) # service, connected, response_time, error def __init__(self, service: str, client, parent=None): super().__init__(parent) self.service = service self.client = client def run(self): start_time = time.time() connected = False error = "" try: if self.service == 'spotify': connected = self.client.is_authenticated() elif self.service == 'plex': connected = self.client.is_connected() elif self.service == 'soulseek': # Run async method in new event loop loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: connected = loop.run_until_complete(self.client.check_connection()) finally: loop.close() except Exception as e: error = str(e) connected = False response_time = (time.time() - start_time) * 1000 # Convert to milliseconds self.test_completed.emit(self.service, connected, response_time, error) # Ensure thread finishes properly self.quit() class StatCard(QFrame): def __init__(self, title: str, value: str, subtitle: str = "", clickable: bool = False, parent=None): super().__init__(parent) self.clickable = clickable self.title_text = title self.setup_ui(title, value, subtitle) def setup_ui(self, title: str, value: str, subtitle: str): self.setFixedHeight(120) hover_style = "border: 1px solid #1db954;" if self.clickable else "" self.setStyleSheet(f""" StatCard {{ background: #282828; border-radius: 8px; border: 1px solid #404040; }} StatCard:hover {{ background: #333333; {hover_style} }} """) layout = QVBoxLayout(self) layout.setContentsMargins(20, 15, 20, 15) layout.setSpacing(5) # Title self.title_label = QLabel(title) self.title_label.setFont(QFont("Arial", 10)) self.title_label.setStyleSheet("color: #b3b3b3;") # Value self.value_label = QLabel(value) self.value_label.setFont(QFont("Arial", 24, QFont.Weight.Bold)) self.value_label.setStyleSheet("color: #ffffff;") # Subtitle self.subtitle_label = None if subtitle: self.subtitle_label = QLabel(subtitle) self.subtitle_label.setFont(QFont("Arial", 9)) self.subtitle_label.setStyleSheet("color: #b3b3b3;") layout.addWidget(self.subtitle_label) layout.addWidget(self.title_label) layout.addWidget(self.value_label) layout.addStretch() def update_values(self, value: str, subtitle: str = ""): self.value_label.setText(value) if self.subtitle_label and subtitle: self.subtitle_label.setText(subtitle) def mousePressEvent(self, event): if self.clickable: self.parent().on_stat_card_clicked(self.title_text) super().mousePressEvent(event) class ServiceStatusCard(QFrame): def __init__(self, service_name: str, parent=None): super().__init__(parent) self.service_name = service_name self.setup_ui() def setup_ui(self): self.setFixedHeight(140) self.setStyleSheet(""" ServiceStatusCard { background: #282828; border-radius: 8px; border: 1px solid #404040; } ServiceStatusCard:hover { background: #333333; } """) layout = QVBoxLayout(self) layout.setContentsMargins(15, 12, 15, 12) layout.setSpacing(8) # Header with service name and status indicator header_layout = QHBoxLayout() header_layout.setSpacing(10) self.service_label = QLabel(self.service_name) self.service_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) self.service_label.setStyleSheet("color: #ffffff;") self.status_indicator = QLabel("●") self.status_indicator.setFont(QFont("Arial", 16)) self.status_indicator.setStyleSheet("color: #ff4444;") # Red by default header_layout.addWidget(self.service_label) header_layout.addStretch() header_layout.addWidget(self.status_indicator) # Status details self.status_text = QLabel("Disconnected") self.status_text.setFont(QFont("Arial", 9)) self.status_text.setStyleSheet("color: #b3b3b3;") self.response_time_label = QLabel("Response: --") self.response_time_label.setFont(QFont("Arial", 8)) self.response_time_label.setStyleSheet("color: #888888;") # Test connection button self.test_button = QPushButton("Test Connection") self.test_button.setFixedHeight(24) self.test_button.setFont(QFont("Arial", 8)) self.test_button.setStyleSheet(""" QPushButton { background: #1db954; color: white; border: none; border-radius: 4px; padding: 4px 8px; } QPushButton:hover { background: #1ed760; } QPushButton:pressed { background: #169c46; } QPushButton:disabled { background: #555555; color: #999999; } """) layout.addLayout(header_layout) layout.addWidget(self.status_text) layout.addWidget(self.response_time_label) layout.addStretch() layout.addWidget(self.test_button) def update_status(self, connected: bool, response_time: float = 0.0, error: str = ""): if connected: self.status_indicator.setStyleSheet("color: #1db954;") # Green self.status_text.setText("Connected") self.response_time_label.setText(f"Response: {response_time:.0f}ms") else: self.status_indicator.setStyleSheet("color: #ff4444;") # Red self.status_text.setText("Disconnected") if error: self.status_text.setText(f"Error: {error[:30]}..." if len(error) > 30 else f"Error: {error}") self.response_time_label.setText("Response: --") # Brief visual feedback self.test_button.setText("Testing..." if not connected and error == "" else "Test Connection") self.test_button.setEnabled(True) class MetadataUpdaterWidget(QFrame): def __init__(self, parent=None): super().__init__(parent) self.setup_ui() def setup_ui(self): self.setStyleSheet(""" MetadataUpdaterWidget { background: #282828; border-radius: 8px; border: 1px solid #404040; } """) layout = QVBoxLayout(self) layout.setContentsMargins(20, 15, 20, 15) layout.setSpacing(12) # Header header_label = QLabel("Plex Metadata Updater") header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) header_label.setStyleSheet("color: #ffffff;") # Info label info_label = QLabel("(type -IgnoreUpdate into artist summary to ignore metadata updates on this artist)") info_label.setFont(QFont("Arial", 9)) info_label.setStyleSheet("color: #b3b3b3; margin-bottom: 5px;") # Control section - reorganized for better balance control_layout = QVBoxLayout() control_layout.setSpacing(12) # Top row: Button button_layout = QHBoxLayout() self.start_button = QPushButton("Begin Metadata Update") self.start_button.setFixedHeight(36) self.start_button.setFont(QFont("Arial", 10, QFont.Weight.Medium)) self.start_button.setStyleSheet(""" QPushButton { background: #1db954; color: white; border: none; border-radius: 6px; padding: 8px 16px; } QPushButton:hover { background: #1ed760; } QPushButton:pressed { background: #169c46; } QPushButton:disabled { background: #555555; color: #999999; } """) button_layout.addWidget(self.start_button) button_layout.addStretch() # Bottom row: Settings and status settings_layout = QHBoxLayout() settings_layout.setSpacing(25) # Refresh interval dropdown refresh_info_layout = QVBoxLayout() refresh_info_layout.setSpacing(4) refresh_label = QLabel("Refresh Interval:") refresh_label.setFont(QFont("Arial", 9)) refresh_label.setStyleSheet("color: #b3b3b3;") self.refresh_interval_combo = QComboBox() self.refresh_interval_combo.setFixedHeight(32) self.refresh_interval_combo.setFont(QFont("Arial", 10)) self.refresh_interval_combo.addItems([ "6 months", "3 months", "1 month", "2 weeks", "1 week", "Full refresh" ]) self.refresh_interval_combo.setCurrentText("1 month") # Default selection self.refresh_interval_combo.setStyleSheet(""" QComboBox { background: #333333; color: #ffffff; border: 1px solid #555555; border-radius: 4px; padding: 4px 8px; min-width: 120px; } QComboBox:hover { border: 1px solid #1db954; } QComboBox::drop-down { border: none; width: 20px; } QComboBox::down-arrow { image: none; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid #ffffff; margin-right: 5px; } QComboBox QAbstractItemView { background: #333333; color: #ffffff; border: 1px solid #555555; selection-background-color: #1db954; } """) refresh_info_layout.addWidget(refresh_label) refresh_info_layout.addWidget(self.refresh_interval_combo) # Current artist display artist_info_layout = QVBoxLayout() artist_info_layout.setSpacing(4) current_label = QLabel("Current Artist:") current_label.setFont(QFont("Arial", 9)) current_label.setStyleSheet("color: #b3b3b3;") self.current_artist_label = QLabel("Not running") self.current_artist_label.setFont(QFont("Arial", 11, QFont.Weight.Medium)) self.current_artist_label.setStyleSheet("color: #ffffff;") artist_info_layout.addWidget(current_label) artist_info_layout.addWidget(self.current_artist_label) settings_layout.addLayout(refresh_info_layout) settings_layout.addLayout(artist_info_layout) settings_layout.addStretch() control_layout.addLayout(button_layout) control_layout.addLayout(settings_layout) # Progress section progress_layout = QVBoxLayout() progress_layout.setSpacing(8) progress_info_layout = QHBoxLayout() self.progress_label = QLabel("Progress: 0%") self.progress_label.setFont(QFont("Arial", 10)) self.progress_label.setStyleSheet("color: #ffffff;") self.count_label = QLabel("0 / 0 artists") self.count_label.setFont(QFont("Arial", 9)) self.count_label.setStyleSheet("color: #b3b3b3;") progress_info_layout.addWidget(self.progress_label) progress_info_layout.addStretch() progress_info_layout.addWidget(self.count_label) self.progress_bar = QProgressBar() self.progress_bar.setFixedHeight(8) self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) self.progress_bar.setStyleSheet(""" QProgressBar { border: none; border-radius: 4px; background: #555555; } QProgressBar::chunk { background: #1db954; border-radius: 4px; } """) progress_layout.addLayout(progress_info_layout) progress_layout.addWidget(self.progress_bar) layout.addWidget(header_label) layout.addWidget(info_label) layout.addLayout(control_layout) layout.addLayout(progress_layout) def update_progress(self, is_running: bool, current_artist: str, processed: int, total: int, percentage: float): if is_running: self.start_button.setText("Stop Update") self.start_button.setEnabled(True) self.current_artist_label.setText(current_artist if current_artist else "Initializing...") self.progress_label.setText(f"Progress: {percentage:.1f}%") self.count_label.setText(f"{processed} / {total} artists") self.progress_bar.setValue(int(percentage)) else: self.start_button.setText("Begin Metadata Update") self.start_button.setEnabled(True) self.current_artist_label.setText("Not running") self.progress_label.setText("Progress: 0%") self.count_label.setText("0 / 0 artists") self.progress_bar.setValue(0) def get_refresh_interval_days(self) -> int: """Convert dropdown selection to number of days""" interval_map = { "6 months": 180, "3 months": 90, "1 month": 30, "2 weeks": 14, "1 week": 7, "Full refresh": 0 # 0 means update everything } selected = self.refresh_interval_combo.currentText() return interval_map.get(selected, 30) # Default to 1 month class ActivityItem(QWidget): def __init__(self, icon: str, title: str, subtitle: str, time: str, parent=None): super().__init__(parent) self.setup_ui(icon, title, subtitle, time) def setup_ui(self, icon: str, title: str, subtitle: str, time: str): self.setFixedHeight(60) layout = QHBoxLayout(self) layout.setContentsMargins(15, 10, 15, 10) layout.setSpacing(15) # Icon icon_label = QLabel(icon) icon_label.setFixedSize(32, 32) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) icon_label.setStyleSheet(""" QLabel { color: #1db954; font-size: 18px; background: rgba(29, 185, 84, 0.1); border-radius: 16px; } """) # Text content text_layout = QVBoxLayout() text_layout.setSpacing(2) self.title_label = QLabel(title) self.title_label.setFont(QFont("Arial", 10, QFont.Weight.Medium)) self.title_label.setStyleSheet("color: #ffffff;") self.subtitle_label = QLabel(subtitle) self.subtitle_label.setFont(QFont("Arial", 9)) self.subtitle_label.setStyleSheet("color: #b3b3b3;") text_layout.addWidget(self.title_label) text_layout.addWidget(self.subtitle_label) # Time time_label = QLabel(time) time_label.setFont(QFont("Arial", 9)) time_label.setStyleSheet("color: #b3b3b3;") time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop) layout.addWidget(icon_label) layout.addLayout(text_layout) layout.addStretch() layout.addWidget(time_label) class DashboardPage(QWidget): database_updated_externally = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) # Initialize data provider self.data_provider = DashboardDataProvider() self.data_provider.service_status_updated.connect(self.on_service_status_updated) self.data_provider.download_stats_updated.connect(self.on_download_stats_updated) self.data_provider.metadata_progress_updated.connect(self.on_metadata_progress_updated) self.data_provider.sync_progress_updated.connect(self.on_sync_progress_updated) self.data_provider.system_stats_updated.connect(self.on_system_stats_updated) self.data_provider.activity_item_added.connect(self.add_activity_item) # Service status cards self.service_cards = {} # Track previous service status to only show changes in activity self.previous_service_status = {} # Track if placeholder exists self.has_placeholder = True # Stats cards self.stats_cards = {} self.setup_ui() self.database_updated_externally.connect(self.refresh_database_statistics) # Initialize list to track active stats workers self._active_stats_workers = [] # Initialize wishlist service and timers self.wishlist_service = get_wishlist_service() # Timer for updating wishlist button count self.wishlist_update_timer = QTimer() self.wishlist_update_timer.timeout.connect(self.update_wishlist_button_count) self.wishlist_update_timer.start(30000) # Update every 30 seconds # Timer for automatic wishlist retry processing self.wishlist_retry_timer = QTimer() self.wishlist_retry_timer.setSingleShot(True) # Single shot timer, we'll restart it after each completion self.wishlist_retry_timer.timeout.connect(self.process_wishlist_automatically) self.wishlist_retry_timer.start(60000) # Start first processing 1 minute after app launch (60000 ms) # Track if automatic processing is currently running self.auto_processing_wishlist = False self.wishlist_download_modal = None # Load initial database statistics (with delay to avoid startup issues) QTimer.singleShot(1000, self.refresh_database_statistics) # Load initial wishlist count (with slight delay) QTimer.singleShot(1500, self.update_wishlist_button_count) def _ensure_wishlist_modal_exists(self): """Creates the persistent wishlist modal instance if it doesn't exist.""" if self.wishlist_download_modal is None: logger.info("Creating persistent wishlist download modal instance.") spotify_client = self.service_clients.get('spotify_client') plex_client = self.service_clients.get('plex_client') soulseek_client = self.service_clients.get('soulseek_client') downloads_page = self.downloads_page if not all([spotify_client, plex_client, soulseek_client, downloads_page]): QMessageBox.critical(self, "Error", "Required services not available for wishlist search.") return False self.wishlist_download_modal = DownloadMissingWishlistTracksModal( self.wishlist_service, self, downloads_page, spotify_client, plex_client, soulseek_client ) self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished) return True def set_service_clients(self, spotify_client, plex_client, soulseek_client, downloads_page=None): """Called from main window to provide service client references""" self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client) # Store service clients for wishlist modal self.service_clients = { 'spotify_client': spotify_client, 'plex_client': plex_client, 'soulseek_client': soulseek_client, 'downloads_page': downloads_page } # Initialize Plex scan manager for wishlist modal integration self.scan_manager = None if plex_client: self.scan_manager = PlexScanManager(plex_client, delay_seconds=60) # Add automatic incremental database update after Plex scan completion self.scan_manager.add_scan_completion_callback(self._on_plex_scan_completed) logger.info("✅ PlexScanManager initialized for Dashboard wishlist modal") def set_page_references(self, downloads_page, sync_page): """Called from main window to provide page references for live data""" self.downloads_page = downloads_page self.sync_page = sync_page self.data_provider.set_page_references(downloads_page, sync_page) def set_app_start_time(self, start_time): """Called from main window to provide app start time for uptime calculation""" self.data_provider.set_app_start_time(start_time) def set_toast_manager(self, toast_manager): """Set the toast manager for showing notifications""" self.toast_manager = toast_manager def _on_plex_scan_completed(self): """Callback triggered when Plex scan completes - start automatic incremental database update""" try: # Import here to avoid circular imports from database import get_database from core.database_update_worker import DatabaseUpdateWorker # Check if we should run incremental update plex_client = self.service_clients.get('plex_client') if not plex_client or not plex_client.is_connected(): logger.debug("Plex not connected - skipping automatic database update") return # Check if database has a previous full refresh database = get_database() last_full_refresh = database.get_last_full_refresh() if not last_full_refresh: logger.info("No previous full refresh found - skipping automatic incremental update") return # Check if database has sufficient content try: stats = database.get_database_info() track_count = stats.get('tracks', 0) if track_count < 100: logger.info(f"Database has only {track_count} tracks - skipping automatic incremental update") return except Exception as e: logger.warning(f"Could not check database stats - skipping automatic update: {e}") return # All conditions met - start incremental update logger.info("đŸŽĩ Starting automatic incremental database update after Plex scan") self._start_automatic_incremental_update() except Exception as e: logger.error(f"Error in Plex scan completion callback: {e}") def _start_automatic_incremental_update(self): """Start the automatic incremental database update""" try: from core.database_update_worker import DatabaseUpdateWorker # Avoid duplicate workers if hasattr(self, '_auto_database_worker') and self._auto_database_worker and self._auto_database_worker.isRunning(): logger.debug("Automatic database update already running") return # Create worker for incremental update only plex_client = self.service_clients.get('plex_client') self._auto_database_worker = DatabaseUpdateWorker( plex_client, "database/music_library.db", full_refresh=False # Always incremental for automatic updates ) # Connect completion signal to log result self._auto_database_worker.finished.connect(self._on_auto_update_finished) self._auto_database_worker.error.connect(self._on_auto_update_error) # Start the update self._auto_database_worker.start() except Exception as e: logger.error(f"Error starting automatic incremental update: {e}") def _on_auto_update_finished(self, total_artists, total_albums, total_tracks, successful, failed): """Handle completion of automatic database update""" try: if successful > 0: logger.info(f"✅ Automatic database update completed: {successful} items processed successfully") else: logger.info("💡 Automatic database update completed - no new content found") self.refresh_database_statistics() # Clean up the worker if hasattr(self, '_auto_database_worker'): self._auto_database_worker.deleteLater() delattr(self, '_auto_database_worker') except Exception as e: logger.error(f"Error handling automatic update completion: {e}") def _on_auto_update_error(self, error_message): """Handle error in automatic database update""" logger.warning(f"Automatic database update encountered an error: {error_message}") # Clean up the worker if hasattr(self, '_auto_database_worker'): self._auto_database_worker.deleteLater() delattr(self, '_auto_database_worker') def setup_ui(self): self.setStyleSheet(""" DashboardPage { background: #191414; } """) # Main scroll area scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) scroll_area.setStyleSheet(""" QScrollArea { border: none; background: #191414; } QScrollBar:vertical { background: #333333; width: 12px; border-radius: 6px; } QScrollBar::handle:vertical { background: #555555; border-radius: 6px; min-height: 20px; } QScrollBar::handle:vertical:hover { background: #666666; } """) # Scroll content widget scroll_content = QWidget() scroll_area.setWidget(scroll_content) main_layout = QVBoxLayout(scroll_content) main_layout.setContentsMargins(30, 30, 30, 30) main_layout.setSpacing(25) # Header header = self.create_header() main_layout.addWidget(header) # Service Status Section service_section = self.create_service_status_section() main_layout.addWidget(service_section) # System Stats Section stats_section = self.create_stats_section() main_layout.addWidget(stats_section) # Plex Metadata Updater metadata_section = self.create_metadata_section() main_layout.addWidget(metadata_section) # Recent Activity activity_section = self.create_activity_section() main_layout.addWidget(activity_section) main_layout.addStretch() # Set main layout page_layout = QVBoxLayout(self) page_layout.setContentsMargins(0, 0, 0, 0) page_layout.addWidget(scroll_area) def create_header(self): header = QWidget() main_layout = QHBoxLayout(header) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(20) # Left side - Title and subtitle left_widget = QWidget() left_layout = QVBoxLayout(left_widget) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(5) # Welcome message welcome_label = QLabel("System Dashboard") welcome_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) welcome_label.setStyleSheet("color: #ffffff;") # Subtitle subtitle_label = QLabel("Monitor your music system health and manage operations") subtitle_label.setFont(QFont("Arial", 14)) subtitle_label.setStyleSheet("color: #b3b3b3;") left_layout.addWidget(welcome_label) left_layout.addWidget(subtitle_label) # Right side - Wishlist button right_widget = QWidget() right_layout = QVBoxLayout(right_widget) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(0) # Spacer to align button with title right_layout.addStretch() # Wishlist button self.wishlist_button = QPushButton("đŸŽĩ Wishlist (0)") self.wishlist_button.setFixedHeight(45) self.wishlist_button.setFixedWidth(150) self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked) self.wishlist_button.setStyleSheet(""" QPushButton { background: #1db954; border: none; border-radius: 22px; color: #000000; font-size: 12px; font-weight: bold; padding: 8px 16px; } QPushButton:hover { background: #1ed760; } QPushButton:pressed { background: #169c46; } QPushButton:disabled { background: #404040; color: #666666; } """) right_layout.addWidget(self.wishlist_button) right_layout.addStretch() # Add to main layout main_layout.addWidget(left_widget) main_layout.addStretch() # Push button to the right main_layout.addWidget(right_widget) return header def create_service_status_section(self): section = QWidget() layout = QVBoxLayout(section) layout.setSpacing(15) # Section header header_label = QLabel("Service Status") header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) header_label.setStyleSheet("color: #ffffff;") # Service cards grid cards_layout = QHBoxLayout() cards_layout.setSpacing(20) # Create service status cards services = ['Spotify', 'Plex', 'Soulseek'] for service in services: card = ServiceStatusCard(service) card.test_button.clicked.connect(lambda checked, s=service.lower(): self.test_service_connection(s)) self.service_cards[service.lower()] = card cards_layout.addWidget(card) cards_layout.addStretch() layout.addWidget(header_label) layout.addLayout(cards_layout) return section def create_stats_section(self): section = QWidget() layout = QVBoxLayout(section) layout.setSpacing(15) # Section header header_label = QLabel("System Statistics") header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) header_label.setStyleSheet("color: #ffffff;") # Stats grid stats_grid = QGridLayout() stats_grid.setSpacing(20) # Create stats cards stats_data = [ ("Active Downloads", "0", "Currently downloading", "active_downloads"), ("Finished Downloads", "0", "Completed today", "finished_downloads"), ("Download Speed", "0 KB/s", "Combined speed", "download_speed"), ("Active Syncs", "0", "Playlists syncing", "active_syncs"), ("System Uptime", "0m", "Application runtime", "uptime"), ("Memory Usage", "--", "Current usage", "memory") ] for i, (title, value, subtitle, key) in enumerate(stats_data): card = StatCard(title, value, subtitle, clickable=False) self.stats_cards[key] = card stats_grid.addWidget(card, i // 3, i % 3) layout.addWidget(header_label) layout.addLayout(stats_grid) return section def create_metadata_section(self): section = QWidget() layout = QVBoxLayout(section) layout.setSpacing(15) # Section header header_label = QLabel("Tools & Operations") header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) header_label.setStyleSheet("color: #ffffff;") # Database updater widget (FIRST) self.database_widget = DatabaseUpdaterWidget() self.database_widget.start_button.clicked.connect(self.toggle_database_update) # Metadata updater widget (SECOND) self.metadata_widget = MetadataUpdaterWidget() self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update) layout.addWidget(header_label) layout.addWidget(self.database_widget) layout.addWidget(self.metadata_widget) return section def create_activity_section(self): activity_widget = QWidget() layout = QVBoxLayout(activity_widget) layout.setSpacing(15) # Section header header_label = QLabel("Recent Activity") header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) header_label.setStyleSheet("color: #ffffff;") # Activity container activity_container = QFrame() activity_container.setStyleSheet(""" QFrame { background: #282828; border-radius: 8px; border: 1px solid #404040; } """) activity_layout = QVBoxLayout(activity_container) activity_layout.setContentsMargins(0, 0, 0, 0) activity_layout.setSpacing(1) # Activity feed will be populated dynamically self.activity_layout = activity_layout # Add initial placeholder placeholder_item = ActivityItem("📊", "System Started", "Dashboard initialized successfully", "Now") activity_layout.addWidget(placeholder_item) layout.addWidget(header_label) layout.addWidget(activity_container) return activity_widget def test_service_connection(self, service: str): """Test connection to a specific service""" if service in self.service_cards: card = self.service_cards[service] # Prevent multiple simultaneous tests if hasattr(self.data_provider, '_test_threads') and service in self.data_provider._test_threads: if self.data_provider._test_threads[service].isRunning(): return card.test_button.setText("Testing...") card.test_button.setEnabled(False) # Update status to testing state card.status_indicator.setStyleSheet("color: #ffaa00;") # Orange card.status_text.setText("Testing connection...") # Add activity item for test initiation self.add_activity_item("🔍", f"Testing {service.capitalize()}", "Connection test initiated", "Now") # Start test self.data_provider.test_service_connection(service) def toggle_database_update(self): """Toggle database update process""" current_text = self.database_widget.start_button.text() if "Update Database" in current_text: # Start database update self.start_database_update() else: # Stop database update self.stop_database_update() def start_database_update(self): """Start the SoulSync database update process""" logger.debug(f"Starting database update - data_provider exists: {hasattr(self, 'data_provider')}") if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'): logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}") logger.debug(f"Plex client: {self.data_provider.service_clients.get('plex')}") if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'): self.add_activity_item("❌", "Database Update", "Plex client not available", "Now") return try: # Get update type from dropdown full_refresh = self.database_widget.is_full_refresh() # Start the database update worker self.database_worker = DatabaseUpdateWorker( self.data_provider.service_clients['plex_client'], "database/music_library.db", full_refresh ) # Connect signals self.database_worker.progress_updated.connect(self.on_database_progress) self.database_worker.artist_processed.connect(self.on_database_artist_processed) self.database_worker.finished.connect(self.on_database_finished) self.database_worker.error.connect(self.on_database_error) self.database_worker.phase_changed.connect(self.on_database_phase_changed) # Update UI and start self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0) update_type = "Full refresh" if full_refresh else "Incremental update" self.add_activity_item("đŸ—„ī¸", "Database Update", f"Starting {update_type.lower()}...", "Now") self.database_worker.start() # Start a timer to refresh database statistics during update self.start_database_stats_refresh() except Exception as e: self.add_activity_item("❌", "Database Update", f"Failed to start: {str(e)}", "Now") def stop_database_update(self): """Stop the database update process""" if hasattr(self, 'database_worker') and self.database_worker.isRunning(): self.database_worker.stop() self.database_worker.wait(3000) # Wait up to 3 seconds if self.database_worker.isRunning(): self.database_worker.terminate() self.database_widget.update_progress(False, "", 0, 0, 0.0) self.add_activity_item("âšī¸", "Database Update", "Stopped database update process", "Now") # Stop statistics refresh timer self.stop_database_stats_refresh() def on_database_progress(self, current_item: str, processed: int, total: int, percentage: float): """Handle database update progress""" self.database_widget.update_progress(True, current_item, processed, total, percentage) def on_database_artist_processed(self, artist_name: str, success: bool, details: str, album_count: int, track_count: int): """Handle individual artist processing completion""" if success: self.add_activity_item("✅", "Artist Processed", f"'{artist_name}' - {details}", "Now") else: self.add_activity_item("❌", "Artist Failed", f"'{artist_name}' - {details}", "Now") def on_database_finished(self, total_artists: int, total_albums: int, total_tracks: int, successful: int, failed: int): """Handle database update completion""" self.database_widget.update_progress(False, "", 0, 0, 0.0) summary = f"Processed {total_artists} artists, {total_albums} albums, {total_tracks} tracks" self.add_activity_item("đŸ—„ī¸", "Database Complete", summary, "Now") # Stop statistics refresh timer and do final update self.stop_database_stats_refresh() self.refresh_database_statistics() def on_database_error(self, error_message: str): """Handle database update error""" self.database_widget.update_progress(False, "", 0, 0, 0.0) self.add_activity_item("❌", "Database Error", error_message, "Now") # Stop statistics refresh timer self.stop_database_stats_refresh() def on_database_phase_changed(self, phase: str): """Handle database update phase changes""" self.database_widget.update_phase(phase) def start_database_stats_refresh(self): """Start periodic database statistics refresh during update""" # Create timer to refresh stats every 5 seconds during update if not hasattr(self, 'database_stats_timer'): self.database_stats_timer = QTimer() self.database_stats_timer.timeout.connect(self.refresh_database_statistics) self.database_stats_timer.start(5000) # Every 5 seconds def stop_database_stats_refresh(self): """Stop periodic database statistics refresh""" if hasattr(self, 'database_stats_timer'): self.database_stats_timer.stop() def refresh_database_statistics(self): """Refresh database statistics display""" try: # Check if database widget exists first if not hasattr(self, 'database_widget') or self.database_widget is None: return # Get statistics in background thread to avoid blocking UI stats_worker = DatabaseStatsWorker("database/music_library.db") # Track the worker for cleanup if not hasattr(self, '_active_stats_workers'): self._active_stats_workers = [] self._active_stats_workers.append(stats_worker) # Connect signals stats_worker.stats_updated.connect(self.update_database_info) stats_worker.finished.connect(lambda: self._cleanup_stats_worker(stats_worker)) stats_worker.start() except Exception as e: logger.error(f"Error refreshing database statistics: {e}") # Fallback to default stats to prevent crashes if hasattr(self, 'database_widget') and self.database_widget: fallback_info = { 'artists': 0, 'albums': 0, 'tracks': 0, 'database_size_mb': 0.0, 'last_full_refresh': None } self.update_database_info(fallback_info) def update_database_info(self, info: dict): """Update database statistics and last refresh info""" try: # Update basic statistics self.database_widget.update_statistics(info) # Update last refresh information last_refresh_date = info.get('last_full_refresh') self.database_widget.update_last_refresh_info(last_refresh_date) except Exception as e: logger.error(f"Error updating database info: {e}") def on_wishlist_modal_finished(self): """Called when the modal's download process is completely done or cancelled.""" logger.info("Wishlist download process finished. Resetting modal instance.") # We can now safely discard the modal instance. A new one will be created on the next run. self.wishlist_download_modal = None self.update_wishlist_button_count() def start_wishlist_search_process(self): """ Ensures the wishlist modal exists and tells it to start the search process. This is the single entry point for automatic searches. """ if not self._ensure_wishlist_modal_exists(): return # Modal creation failed # Tell the modal to begin its search process self.wishlist_download_modal.start_search() def _cleanup_stats_worker(self, worker): """Clean up a finished stats worker""" try: if hasattr(self, '_active_stats_workers') and worker in self._active_stats_workers: self._active_stats_workers.remove(worker) worker.deleteLater() except Exception as e: logger.error(f"Error cleaning up stats worker: {e}") def toggle_metadata_update(self): """Toggle metadata update process""" current_text = self.metadata_widget.start_button.text() if "Begin" in current_text: # Start metadata update self.start_metadata_update() else: # Stop metadata update self.stop_metadata_update() def start_metadata_update(self): """Start the Plex metadata update process""" logger.debug(f"Starting metadata update - data_provider exists: {hasattr(self, 'data_provider')}") if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'): logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}") if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'): self.add_activity_item("❌", "Metadata Update", "Plex client not available", "Now") return if not self.data_provider.service_clients.get('spotify_client'): self.add_activity_item("❌", "Metadata Update", "Spotify client not available", "Now") return try: # Get refresh interval from dropdown refresh_interval_days = self.metadata_widget.get_refresh_interval_days() # Start the metadata update worker (it will handle artist retrieval in background) self.metadata_worker = MetadataUpdateWorker( None, # Artists will be loaded in the worker thread self.data_provider.service_clients['plex_client'], self.data_provider.service_clients['spotify_client'], refresh_interval_days ) # Connect signals self.metadata_worker.progress_updated.connect(self.on_metadata_progress) self.metadata_worker.artist_updated.connect(self.on_artist_updated) self.metadata_worker.finished.connect(self.on_metadata_finished) self.metadata_worker.error.connect(self.on_metadata_error) self.metadata_worker.artists_loaded.connect(self.on_artists_loaded) # Update UI and start self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0) self.add_activity_item("đŸŽĩ", "Metadata Update", "Loading artists from Plex library...", "Now") self.metadata_worker.start() except Exception as e: self.add_activity_item("❌", "Metadata Update", f"Failed to start: {str(e)}", "Now") def on_artists_loaded(self, total_artists, artists_to_process): """Handle when artists are loaded and filtered""" if artists_to_process == 0: self.add_activity_item("✅", "Metadata Update", "All artists already have good metadata", "Now") else: self.add_activity_item("đŸŽĩ", "Metadata Update", f"Processing {artists_to_process} of {total_artists} artists", "Now") def stop_metadata_update(self): """Stop the metadata update process""" if hasattr(self, 'metadata_worker') and self.metadata_worker.isRunning(): self.metadata_worker.stop() self.metadata_worker.wait(3000) # Wait up to 3 seconds if self.metadata_worker.isRunning(): self.metadata_worker.terminate() self.metadata_widget.update_progress(False, "", 0, 0, 0.0) self.add_activity_item("âšī¸", "Metadata Update", "Stopped metadata update process", "Now") def artist_needs_processing(self, artist): """Check if an artist needs metadata processing using smart detection""" try: # Check if artist has a valid photo has_valid_photo = self.artist_has_valid_photo(artist) # Check if artist has genres (more than just basic ones) existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre) for genre in (artist.genres or [])) has_good_genres = len(existing_genres) >= 2 # At least 2 genres indicates Spotify processing # Process if missing photo OR insufficient genres return not has_valid_photo or not has_good_genres except Exception as e: print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}") return True # Process if we can't determine status def artist_has_valid_photo(self, artist): """Check if artist has a valid photo""" try: if not hasattr(artist, 'thumb') or not artist.thumb: return False # Quick check for suspicious URLs (default Plex placeholders often contain 'default' or are very short) thumb_url = str(artist.thumb) if 'default' in thumb_url.lower() or len(thumb_url) < 50: return False return True except Exception: return False def on_metadata_progress(self, current_artist, processed, total, percentage): """Handle metadata update progress""" self.metadata_widget.update_progress(True, current_artist, processed, total, percentage) def on_artist_updated(self, artist_name, success, details): """Handle individual artist update completion""" if success: self.add_activity_item("✅", "Artist Updated", f"'{artist_name}' - {details}", "Now") else: self.add_activity_item("❌", "Artist Failed", f"'{artist_name}' - {details}", "Now") def on_metadata_finished(self, total_processed, successful, failed): """Handle metadata update completion""" self.metadata_widget.update_progress(False, "", 0, 0, 0.0) summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed" self.add_activity_item("đŸŽĩ", "Metadata Complete", summary, "Now") def on_metadata_error(self, error_message): """Handle metadata update error""" self.metadata_widget.update_progress(False, "", 0, 0, 0.0) self.add_activity_item("❌", "Metadata Error", error_message, "Now") def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str): """Handle service status updates from data provider""" if service in self.service_cards: self.service_cards[service].update_status(connected, response_time, error) # Only add activity item if status actually changed if service not in self.previous_service_status or self.previous_service_status[service] != connected: self.previous_service_status[service] = connected status = "Connected" if connected else "Disconnected" icon = "✅" if connected else "❌" self.add_activity_item(icon, f"{service.capitalize()} {status}", f"Response time: {response_time:.0f}ms" if connected else f"Error: {error}" if error else "Connection test completed", "Now") def on_download_stats_updated(self, active_count: int, finished_count: int, total_speed: float): """Handle download statistics updates""" if 'active_downloads' in self.stats_cards: self.stats_cards['active_downloads'].update_values(str(active_count), "Currently downloading") if 'finished_downloads' in self.stats_cards: self.stats_cards['finished_downloads'].update_values(str(finished_count), "Completed today") if 'download_speed' in self.stats_cards: # Format speed based on magnitude if total_speed <= 0: speed_text = "0 B/s" elif total_speed >= 1024 * 1024: # MB/s speed_text = f"{total_speed / (1024 * 1024):.1f} MB/s" elif total_speed >= 1024: # KB/s speed_text = f"{total_speed / 1024:.1f} KB/s" else: speed_text = f"{total_speed:.0f} B/s" self.stats_cards['download_speed'].update_values(speed_text, "Combined speed") def on_metadata_progress_updated(self, is_running: bool, current_artist: str, processed: int, total: int, percentage: float): """Handle metadata update progress""" self.metadata_widget.update_progress(is_running, current_artist, processed, total, percentage) def on_sync_progress_updated(self, current_playlist: str, active_syncs: int): """Handle sync progress updates""" if 'active_syncs' in self.stats_cards: self.stats_cards['active_syncs'].update_values(str(active_syncs), "Playlists syncing") def on_system_stats_updated(self, uptime: str, memory: str): """Handle system statistics updates""" if 'uptime' in self.stats_cards: self.stats_cards['uptime'].update_values(uptime, "Application runtime") if 'memory' in self.stats_cards: self.stats_cards['memory'].update_values(memory, "Current usage") def on_stat_card_clicked(self, card_title: str): """Handle stat card clicks for detailed views""" # This can be implemented later for detailed views pass def add_activity_item(self, icon: str, title: str, subtitle: str, time_ago: str = "Now"): """Add new activity item to the feed and potentially show a toast""" # Show toast for immediate user actions (if toast manager is available) if hasattr(self, 'toast_manager') and self.toast_manager: self._maybe_show_toast(icon, title, subtitle) # Remove placeholder if it exists if self.has_placeholder: # Clear the entire layout while self.activity_layout.count(): item = self.activity_layout.takeAt(0) if item.widget(): item.widget().deleteLater() self.has_placeholder = False # Add separator if there are existing items if self.activity_layout.count() > 0: separator = QFrame() separator.setFixedHeight(1) separator.setStyleSheet("background: #404040;") self.activity_layout.insertWidget(0, separator) # Add new activity item at the top new_item = ActivityItem(icon, title, subtitle, time_ago) self.activity_layout.insertWidget(0, new_item) # Limit to 5 most recent items (5 items + 4 separators = 9 total) while self.activity_layout.count() > 9: item = self.activity_layout.takeAt(self.activity_layout.count() - 1) if item.widget(): item.widget().deleteLater() def _maybe_show_toast(self, icon: str, title: str, subtitle: str): """Determine if this activity should show a toast notification""" from ui.components.toast_manager import ToastType # Success activities that deserve toasts if icon == "✅" and any(keyword in title.lower() for keyword in ["download started", "sync completed", "complete"]): self.toast_manager.success(f"{title}: {subtitle}") return if icon == "đŸ“Ĩ" and "Download Started" in title: self.toast_manager.success(f"{subtitle}") return if icon == "🔍" and "Search Complete" in title: self.toast_manager.info(f"{subtitle}") return # Error activities that need immediate attention if icon == "❌": # Skip routine background errors if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]): return # Show errors for user-initiated actions if any(keyword in title.lower() for keyword in ["download failed", "sync failed", "search failed"]): self.toast_manager.error(f"{title}: {subtitle}") return # Warning activities if icon == "âš ī¸": self.toast_manager.warning(f"{title}: {subtitle}") return # Info activities for searches and connections if icon == "🔍" and "Search Started" in title: self.toast_manager.info(f"{subtitle}") return def closeEvent(self, event): """Clean up threads when dashboard is closed""" self.cleanup_threads() # Stop wishlist timers if hasattr(self, 'wishlist_update_timer'): self.wishlist_update_timer.stop() if hasattr(self, 'wishlist_retry_timer'): self.wishlist_retry_timer.stop() # Stop the data provider timers if hasattr(self.data_provider, 'download_stats_timer'): self.data_provider.download_stats_timer.stop() if hasattr(self.data_provider, 'system_stats_timer'): self.data_provider.system_stats_timer.stop() # Clean up database-related threads and timers (only on actual shutdown) if hasattr(self, 'database_worker') and self.database_worker and self.database_worker.isRunning(): try: self.database_worker.stop() self.database_worker.wait(2000) # Give it more time if self.database_worker.isRunning(): self.database_worker.terminate() self.database_worker.deleteLater() except Exception as e: logger.debug(f"Error cleaning up database worker: {e}") if hasattr(self, 'database_stats_timer') and self.database_stats_timer: try: self.database_stats_timer.stop() except Exception as e: logger.debug(f"Error stopping database stats timer: {e}") # Clean up any running stats workers if hasattr(self, '_active_stats_workers') and self._active_stats_workers: try: for worker in self._active_stats_workers[:]: # Copy list to avoid modification issues if worker and worker.isRunning(): worker.stop() worker.wait(1000) if worker: worker.deleteLater() self._active_stats_workers.clear() except Exception as e: logger.debug(f"Error cleaning up stats workers: {e}") # Clean up metadata worker as well (only on shutdown) if hasattr(self, 'metadata_worker') and self.metadata_worker and self.metadata_worker.isRunning(): try: self.metadata_worker.stop() self.metadata_worker.wait(2000) # Give it more time if self.metadata_worker.isRunning(): self.metadata_worker.terminate() self.metadata_worker.deleteLater() except Exception as e: logger.debug(f"Error cleaning up metadata worker: {e}") super().closeEvent(event) def cleanup_threads(self): """Clean up all running test threads""" if hasattr(self.data_provider, '_test_threads'): for service, thread in self.data_provider._test_threads.items(): if thread.isRunning(): thread.quit() thread.wait(1000) # Wait up to 1 second thread.deleteLater() self.data_provider._test_threads.clear() def on_wishlist_button_clicked(self): """ Shows the persistent wishlist modal, creating it if it doesn't exist yet. If a search is in progress, this will reveal the live state. """ try: # If the modal doesn't exist and there are no tracks, show info and return. if self.wishlist_download_modal is None and self.wishlist_service.get_wishlist_count() == 0: QMessageBox.information(self, "Wishlist", "Your wishlist is empty!") return # Ensure the modal instance exists before trying to show it. if not self._ensure_wishlist_modal_exists(): return # Modal creation failed, error message already shown. # Now that we're sure the modal exists, just show it. self.wishlist_download_modal.show() self.wishlist_download_modal.activateWindow() self.wishlist_download_modal.raise_() except Exception as e: logger.error(f"Error opening wishlist: {e}") QMessageBox.critical(self, "Error", f"Failed to open wishlist: {str(e)}") def update_wishlist_button_count(self): """Update the wishlist button with current count""" try: count = self.wishlist_service.get_wishlist_count() if hasattr(self, 'wishlist_button'): self.wishlist_button.setText(f"đŸŽĩ Wishlist ({count})") # Enable/disable button based on count if count == 0: self.wishlist_button.setStyleSheet(""" QPushButton { background: #404040; border: none; border-radius: 22px; color: #888888; font-size: 12px; font-weight: bold; padding: 8px 16px; } QPushButton:hover { background: #505050; color: #999999; } """) else: self.wishlist_button.setStyleSheet(""" QPushButton { background: #1db954; border: none; border-radius: 22px; color: #000000; font-size: 12px; font-weight: bold; padding: 8px 16px; } QPushButton:hover { background: #1ed760; } QPushButton:pressed { background: #169c46; } """) except Exception as e: logger.error(f"Error updating wishlist button count: {e}") def process_wishlist_automatically(self): """Automatically process wishlist tracks in the background.""" try: if self.auto_processing_wishlist: logger.debug("Wishlist auto-processing already running, skipping.") # Reschedule the next check self.wishlist_retry_timer.start(3600000) # 60 minutes return if self.wishlist_service.get_wishlist_count() == 0: logger.debug("No tracks in wishlist for auto-processing.") # Reschedule the next check self.wishlist_retry_timer.start(3600000) # 60 minutes return logger.info("Starting automatic wishlist processing...") # Use the central method to start the process self.start_wishlist_search_process() # The on_all_downloads_complete method will handle rescheduling the timer. except Exception as e: logger.error(f"Error starting automatic wishlist processing: {e}") self.auto_processing_wishlist = False # Reschedule on error self.wishlist_retry_timer.start(3600000) # 60 minutes def on_auto_wishlist_processing_complete(self, successful, failed, total): """Handle completion of automatic wishlist processing""" try: self.auto_processing_wishlist = False logger.info(f"Automatic wishlist processing complete: {successful} successful, {failed} failed, {total} total") # Update button count since tracks may have been removed self.update_wishlist_button_count() # Refresh any open wishlist modals for widget in QApplication.instance().allWidgets(): if isinstance(widget, DownloadMissingWishlistTracksModal) and widget.isVisible(): widget.refresh_if_auto_processing_complete() # Show toast notification if there were successful downloads if successful > 0 and hasattr(self, 'toast_manager') and self.toast_manager: message = f"Found {successful} wishlist track{'s' if successful != 1 else ''} automatically!" self.toast_manager.success(message) # Schedule next wishlist processing in 60 minutes if hasattr(self, 'wishlist_retry_timer') and self.wishlist_retry_timer: logger.info("Scheduling next automatic wishlist processing in 60 minutes") self.wishlist_retry_timer.start(3600000) # 60 minutes (3600000 ms) except Exception as e: logger.error(f"Error handling automatic wishlist processing completion: {e}") def on_auto_wishlist_processing_error(self, error_message): """Handle error in automatic wishlist processing""" try: self.auto_processing_wishlist = False logger.error(f"Automatic wishlist processing failed: {error_message}") # Schedule next wishlist processing in 60 minutes even after error if hasattr(self, 'wishlist_retry_timer') and self.wishlist_retry_timer: logger.info("Scheduling next automatic wishlist processing in 60 minutes (after error)") self.wishlist_retry_timer.start(3600000) # 60 minutes (3600000 ms) except Exception as e: logger.error(f"Error handling automatic wishlist processing error: {e}") class AutoWishlistProcessorWorker(QRunnable): """Background worker for automatic wishlist processing""" class Signals(QObject): processing_complete = pyqtSignal(int, int, int) # successful, failed, total processing_error = pyqtSignal(str) # error_message def __init__(self, wishlist_service, spotify_client, plex_client, soulseek_client, downloads_page): super().__init__() self.wishlist_service = wishlist_service self.spotify_client = spotify_client self.plex_client = plex_client self.soulseek_client = soulseek_client self.downloads_page = downloads_page self.signals = self.Signals() def run(self): """Run automatic wishlist processing""" try: # Get quality preference from config.settings import config_manager quality_preference = config_manager.get_quality_preference() # Get wishlist tracks (limit to prevent overwhelming the system) wishlist_tracks = self.wishlist_service.get_wishlist_tracks_for_download(limit=25) if not wishlist_tracks: self.signals.processing_complete.emit(0, 0, 0) return total_tracks = len(wishlist_tracks) successful_downloads = 0 failed_downloads = 0 logger.info(f"Processing {total_tracks} wishlist tracks automatically") # Process each track for track_data in wishlist_tracks: try: # Create search query artist_name = track_data.get('artists', [{}])[0].get('name', '') if track_data.get('artists') else '' track_name = track_data.get('name', '') if not track_name: failed_downloads += 1 continue query = f"{artist_name} {track_name}".strip() if not query: failed_downloads += 1 continue # Attempt download loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: download_id = loop.run_until_complete( self.soulseek_client.search_and_download_best(query, quality_preference) ) track_id = track_data.get('spotify_track_id') if download_id and track_id: # Mark as successful (removes from wishlist) self.wishlist_service.mark_track_download_result(track_id, success=True) successful_downloads += 1 logger.info(f"Auto-downloaded wishlist track: '{track_name}' by {artist_name}") else: # Mark as failed (increment retry count) if track_id: self.wishlist_service.mark_track_download_result(track_id, success=False, error_message="No search results found") failed_downloads += 1 finally: loop.close() except Exception as e: logger.error(f"Error processing wishlist track '{track_name}': {e}") # Mark as failed track_id = track_data.get('spotify_track_id') if track_id: self.wishlist_service.mark_track_download_result(track_id, success=False, error_message=str(e)) failed_downloads += 1 # Emit completion self.signals.processing_complete.emit(successful_downloads, failed_downloads, total_tracks) except Exception as e: logger.error(f"Critical error in automatic wishlist processing: {e}") self.signals.processing_error.emit(str(e)) # Worker is complete - no cleanup needed for this simple background task