diff --git a/core/media_scan_manager.py b/core/media_scan_manager.py deleted file mode 100644 index f7661128..00000000 --- a/core/media_scan_manager.py +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env python3 - -import threading -import time -from utils.logging_config import get_logger - -logger = get_logger("media_scan_manager") - -class MediaScanManager: - """ - Smart media library scan manager with debouncing and scan-aware follow-up logic. - Supports both Plex and Jellyfin servers based on active configuration. - - Features: - - Debounces multiple scan requests to prevent spam - - Tracks downloads that happen during active scans - - Automatically triggers follow-up scans when needed - - Thread-safe operation - - Works with both Plex and Jellyfin - """ - - def __init__(self, delay_seconds: int = 60): - """ - Initialize the scan manager. - - Args: - delay_seconds: Debounce delay in seconds (default 60s) - """ - self.delay = delay_seconds - self._timer = None - self._scan_in_progress = False - self._downloads_during_scan = False - self._lock = threading.Lock() - self._scan_completion_callbacks = [] # List of callback functions to call when scan completes - self._scan_start_time = None # Track when scan started for timeout - self._max_scan_time = 1800 # Maximum scan time in seconds (30 minutes) - - # New periodic update system - self._periodic_update_timer = None # Timer for 5-minute periodic updates - self._periodic_update_interval = 300 # 5 minutes in seconds - self._is_doing_periodic_updates = False # Track if we're in periodic update mode - self._shutting_down = False - - logger.info(f"MediaScanManager initialized with {delay_seconds}s debounce delay") - - def _get_active_media_client(self): - """Get the active media client based on config settings""" - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - # Try to get client instances from app - try: - # Try PyQt6 first (GUI mode) - try: - from PyQt6.QtWidgets import QApplication - app = QApplication.instance() - - if app: - # Try to find the main window from top-level widgets - main_window = None - for widget in app.topLevelWidgets(): - if (hasattr(widget, 'plex_client') and hasattr(widget, 'jellyfin_client') and - hasattr(widget, 'navidrome_client')): - main_window = widget - break - - if main_window: - server_attr_map = { - 'jellyfin': 'jellyfin_client', - 'navidrome': 'navidrome_client', - 'plex': 'plex_client' - } - client_attr = server_attr_map.get(active_server) - if client_attr: - client = getattr(main_window, client_attr, None) - if client and client.is_connected(): - return client, active_server - else: - logger.warning(f"{active_server.title()} client not connected — scan skipped") - else: - logger.debug("No main window found in Qt application") - else: - logger.debug("No QApplication instance found") - - except ImportError: - logger.debug("PyQt6 not available, trying headless mode") - - # Headless mode - try to get clients from global instances - import sys - server_attr_map = { - 'jellyfin': 'jellyfin_client', - 'navidrome': 'navidrome_client', - 'plex': 'plex_client' - } - client_attr = server_attr_map.get(active_server) - if client_attr: - for module_name, module in sys.modules.items(): - if hasattr(module, client_attr): - client = getattr(module, client_attr, None) - if client and hasattr(client, 'is_connected') and client.is_connected(): - return client, active_server - break - - except Exception as e: - logger.debug(f"Could not access clients: {e}") - - logger.error("No active media client available") - return None, None - except Exception as e: - logger.error(f"Error determining active media server: {e}") - return None, None - - def request_scan(self, reason: str = "Download completed"): - """ - Request a library scan with smart debouncing logic. - - Args: - reason: Optional reason for the scan request (for logging) - """ - logger.info(f"DEBUG: Media scan requested - reason: {reason}") - with self._lock: - if self._shutting_down: - logger.debug("Media scan request ignored during shutdown") - return - if self._scan_in_progress: - # Server is currently scanning - mark that we need another scan later - self._downloads_during_scan = True - logger.info(f"Media scan in progress - queueing follow-up scan ({reason})") - return - - # Cancel any existing timer and start a new one - if self._timer: - self._timer.cancel() - logger.debug(f"Resetting scan timer ({reason})") - else: - logger.info(f"Media scan queued - will execute in {self.delay}s ({reason})") - - # Start the debounce timer - self._timer = threading.Timer(self.delay, self._execute_scan) - self._timer.daemon = True - self._timer.start() - - def add_scan_completion_callback(self, callback): - """ - Add a callback function to be called when scan completes. - - Args: - callback: Function to call when scan completes (no arguments) - """ - with self._lock: - if callback not in self._scan_completion_callbacks: - self._scan_completion_callbacks.append(callback) - logger.info(f"DEBUG: Added scan completion callback: {callback.__name__}") - logger.info(f"DEBUG: Total callbacks registered: {len(self._scan_completion_callbacks)}") - - def remove_scan_completion_callback(self, callback): - """ - Remove a previously registered callback. - - Args: - callback: Function to remove from callbacks - """ - with self._lock: - if callback in self._scan_completion_callbacks: - self._scan_completion_callbacks.remove(callback) - logger.debug(f"Removed scan completion callback: {callback.__name__}") - - def _execute_scan(self): - """Execute the actual media library scan""" - with self._lock: - if self._shutting_down: - logger.debug("Media scan execution skipped during shutdown") - return - if self._scan_in_progress: - logger.warning("Scan already in progress - skipping duplicate execution") - return - - self._scan_in_progress = True - self._downloads_during_scan = False - self._timer = None - self._scan_start_time = time.time() - - # Get the active media client - media_client, server_type = self._get_active_media_client() - if not media_client: - logger.error("No active media client available for library scan") - self._reset_scan_state() - return - - logger.info(f"Starting {server_type.upper()} library scan...") - - try: - success = media_client.trigger_library_scan() - - if success: - logger.info(f"{server_type.upper()} library scan initiated successfully") - # Start new periodic update system instead of completion detection - self._start_periodic_updates() - else: - logger.error(f"Failed to initiate {server_type.upper()} library scan") - self._reset_scan_state() - - except Exception as e: - logger.error(f"Exception during {server_type.upper()} library scan: {e}") - self._reset_scan_state() - - def _start_periodic_updates(self): - """Start periodic database updates while media server is scanning""" - try: - with self._lock: - if self._is_doing_periodic_updates: - logger.debug("Periodic updates already in progress") - return - - self._is_doing_periodic_updates = True - - logger.info(f"Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes") - - # Schedule first periodic update after 5 minutes - self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) - self._periodic_update_timer.daemon = True - self._periodic_update_timer.start() - - except Exception as e: - logger.error(f"Error starting periodic updates: {e}") - self._reset_scan_state() - - def _do_periodic_update(self): - """Execute periodic database update and check if scanning continues""" - try: - with self._lock: - if not self._scan_in_progress: - logger.debug("Scan no longer in progress, stopping periodic updates") - return - - # Check for timeout - if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time: - logger.warning(f"Media scan timeout reached ({self._max_scan_time}s), stopping periodic updates") - self._stop_periodic_updates() - return - - # Get the active media client - media_client, server_type = self._get_active_media_client() - if not media_client: - logger.warning("No active media client available for scan status check") - self._stop_periodic_updates() - return - - # Check if media server is still scanning - is_scanning = media_client.is_library_scanning("Music") - elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0 - - logger.info(f"PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - {server_type.upper()} scanning: {is_scanning}") - - if is_scanning: - # Still scanning - trigger database update and continue periodic updates - logger.info(f"{server_type.upper()} still scanning - triggering database update") - if self._shutting_down: - return - self._call_completion_callbacks() - - # Schedule next periodic update - if self._shutting_down: - return - logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes") - self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) - self._periodic_update_timer.daemon = True - self._periodic_update_timer.start() - else: - # Scanning stopped - final update and cleanup - logger.info(f"{server_type.upper()} scanning completed - doing final database update") - if self._shutting_down: - return - self._call_completion_callbacks() - self._stop_periodic_updates() - - except Exception as e: - logger.error(f"Error during periodic update: {e}") - self._stop_periodic_updates() - - def _stop_periodic_updates(self): - """Stop periodic updates and clean up""" - try: - with self._lock: - self._is_doing_periodic_updates = False - - if self._periodic_update_timer: - self._periodic_update_timer.cancel() - self._periodic_update_timer = None - - logger.info("Stopped periodic database updates") - self._scan_completed() - - except Exception as e: - logger.error(f"Error stopping periodic updates: {e}") - - def _scan_completed(self): - """Called when we assume the scan has completed""" - with self._lock: - was_in_progress = self._scan_in_progress - downloads_during_scan = self._downloads_during_scan - - # Reset scan state - self._scan_in_progress = False - - if not was_in_progress: - logger.debug("Scan completion callback called but scan was not in progress") - return - - logger.info("Media library scan completed") - - # Call registered completion callbacks - self._call_completion_callbacks() - - # Check if we need a follow-up scan - if downloads_during_scan: - logger.info("Downloads occurred during scan - triggering follow-up scan") - self.request_scan("Follow-up scan for downloads during previous scan") - else: - logger.info("No downloads during scan - scan cycle complete") - - def _call_completion_callbacks(self): - """Call all registered scan completion callbacks""" - with self._lock: - callbacks = self._scan_completion_callbacks.copy() # Copy to avoid lock issues - - logger.info(f"DEBUG: Calling {len(callbacks)} scan completion callbacks") - for callback in callbacks: - try: - logger.info(f"DEBUG: Executing callback: {callback.__name__}") - callback() - logger.info(f"DEBUG: Callback {callback.__name__} completed successfully") - except Exception as e: - logger.error(f"Error in scan completion callback {callback.__name__}: {e}") - - def _reset_scan_state(self): - """Reset scan state after an error""" - with self._lock: - self._scan_in_progress = False - self._scan_start_time = None - - # Cancel periodic updates if running - if self._periodic_update_timer: - self._periodic_update_timer.cancel() - self._periodic_update_timer = None - self._is_doing_periodic_updates = False - - def force_scan(self): - """ - Force an immediate scan, bypassing debouncing. - Use sparingly - mainly for manual/administrative triggers. - """ - with self._lock: - if self._timer: - self._timer.cancel() - self._timer = None - - if self._scan_in_progress: - logger.warning("Force scan requested but scan already in progress") - return - - logger.info("Force scan requested - executing immediately") - self._execute_scan() - - def get_status(self) -> dict: - """Get current status of the scan manager""" - with self._lock: - return { - 'scan_in_progress': self._scan_in_progress, - 'downloads_during_scan': self._downloads_during_scan, - 'timer_active': self._timer is not None, - 'delay_seconds': self.delay - } - - def shutdown(self): - """Clean shutdown - cancel any pending timers""" - with self._lock: - self._shutting_down = True - if self._timer: - self._timer.cancel() - self._timer = None - - if self._periodic_update_timer: - self._periodic_update_timer.cancel() - self._periodic_update_timer = None - - self._is_doing_periodic_updates = False - logger.info("MediaScanManager shutdown - cancelled all pending timers") diff --git a/main.py b/main.py deleted file mode 100644 index c5b286d9..00000000 --- a/main.py +++ /dev/null @@ -1,461 +0,0 @@ - -#!/usr/bin/env python3 - -import sys -import asyncio -import time -from pathlib import Path -from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QStackedWidget -from PyQt6.QtCore import QThread, pyqtSignal, QTimer, QThreadPool -from PyQt6.QtGui import QFont, QPalette, QColor - -from config.settings import config_manager -from utils.logging_config import setup_logging, get_logger -from core.spotify_client import SpotifyClient -from core.plex_client import PlexClient -from core.jellyfin_client import JellyfinClient -from core.navidrome_client import NavidromeClient -from core.soulseek_client import SoulseekClient - -from ui.sidebar import ModernSidebar -from ui.pages.dashboard import DashboardPage -from ui.pages.sync import SyncPage -from ui.pages.downloads import DownloadsPage -from ui.pages.artists import ArtistsPage -from ui.pages.settings import SettingsPage -from ui.components.toast_manager import ToastManager - -logger = get_logger("main") - -class ServiceStatusThread(QThread): - status_updated = pyqtSignal(str, bool) - - def __init__(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client): - super().__init__() - self.spotify_client = spotify_client - self.plex_client = plex_client - self.jellyfin_client = jellyfin_client - self.navidrome_client = navidrome_client - self.soulseek_client = soulseek_client - self.running = True - - # Import here to avoid circular imports - from config.settings import config_manager - self.config_manager = config_manager - - def run(self): - while self.running: - try: - # Check Spotify authentication - but don't trigger OAuth - spotify_status = self.spotify_client.sp is not None - self.status_updated.emit("spotify", spotify_status) - - # Check active media server connection - active_server = self.config_manager.get_active_media_server() - if active_server == "plex": - server_status = self.plex_client.is_connected() - self.status_updated.emit("plex", server_status) - elif active_server == "jellyfin": - # Use the JellyfinClient for status checking - jellyfin_status = self.jellyfin_client.is_connected() - self.status_updated.emit("jellyfin", jellyfin_status) - elif active_server == "navidrome": - # Use the NavidromeClient for status checking - navidrome_status = self.navidrome_client.is_connected() - self.status_updated.emit("navidrome", navidrome_status) - - # Check Soulseek connection (simplified check to avoid event loop issues) - soulseek_status = self.soulseek_client.is_configured() - self.status_updated.emit("soulseek", soulseek_status) - - self.msleep(10000) # Check every 10 seconds (less aggressive) - - except Exception as e: - logger.error(f"Error checking service status: {e}") - self.msleep(10000) - - def stop(self): - self.running = False - self.quit() - self.wait(2000) # Wait max 2 seconds - -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - - # Track application start time for uptime calculation - self.app_start_time = time.time() - - self.spotify_client = SpotifyClient() - self.plex_client = PlexClient() - self.jellyfin_client = JellyfinClient() - self.navidrome_client = NavidromeClient() - self.soulseek_client = SoulseekClient() - - self.status_thread = None - self.init_ui() - self.setup_status_monitoring() - - # Setup periodic search maintenance (rolling 50-search window) - self.setup_search_maintenance() - - def setup_search_maintenance(self): - """Setup periodic search history maintenance to keep only the 50 most recent searches""" - try: - # Create timer for periodic search maintenance - self.search_maintenance_timer = QTimer() - self.search_maintenance_timer.timeout.connect(self._run_search_maintenance) - - # Run maintenance every 2 minutes (120 seconds) - # This keeps search history clean without being too frequent - self.search_maintenance_timer.start(120000) - - logger.info("Search maintenance timer started (every 2 minutes, keeps 200 most recent searches)") - - except Exception as e: - logger.error(f"Error setting up search maintenance: {e}") - - def _run_search_maintenance(self): - """Run search maintenance in background thread to avoid blocking UI""" - try: - # Only run if Soulseek client seems to be available - if hasattr(self.soulseek_client, 'base_url') and self.soulseek_client.base_url: - # Run maintenance in background thread - import threading - - def maintenance_thread(): - try: - import asyncio - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Run the maintenance (keep 200 most recent searches) - success = loop.run_until_complete(self.soulseek_client.maintain_search_history(200)) - - if not success: - logger.warning("Search maintenance completed with some failures") - - except Exception as e: - logger.error(f"Error in search maintenance thread: {e}") - finally: - loop.close() - - thread = threading.Thread(target=maintenance_thread, daemon=True) - thread.start() - else: - logger.debug("Soulseek client not configured, skipping search maintenance") - - except Exception as e: - logger.error(f"Error running search maintenance: {e}") - - def init_ui(self): - self.setWindowTitle("SoulSync - Music Sync & Manager") - self.setGeometry(100, 100, 1400, 900) - - # Set dark theme palette - self.setStyleSheet(""" - QMainWindow { - background: #121212; - } - """) - - # Create central widget - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # Main layout - main_layout = QHBoxLayout(central_widget) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - - # Create sidebar - self.sidebar = ModernSidebar() - self.sidebar.page_changed.connect(self.change_page) - main_layout.addWidget(self.sidebar) - - # Create stacked widget for pages - self.stacked_widget = QStackedWidget() - - # Create toast manager - self.toast_manager = ToastManager(self) - - # Create and add pages - self.dashboard_page = DashboardPage() - self.downloads_page = DownloadsPage(self.soulseek_client) - self.sync_page = SyncPage( - spotify_client=self.spotify_client, - plex_client=self.plex_client, - soulseek_client=self.soulseek_client, - downloads_page=self.downloads_page, - jellyfin_client=self.jellyfin_client, - navidrome_client=self.navidrome_client - ) - self.artists_page = ArtistsPage(downloads_page=self.downloads_page) - self.settings_page = SettingsPage() - - # Set toast manager for pages that need direct access - self.downloads_page.set_toast_manager(self.toast_manager) - self.sync_page.set_toast_manager(self.toast_manager) - self.artists_page.set_toast_manager(self.toast_manager) - self.settings_page.set_toast_manager(self.toast_manager) - - # Configure dashboard with service clients and page references - self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.jellyfin_client, self.navidrome_client, self.soulseek_client) - self.dashboard_page.set_page_references(self.downloads_page, self.sync_page) - self.dashboard_page.set_app_start_time(self.app_start_time) - self.dashboard_page.set_toast_manager(self.toast_manager) - - # Connect download completion signal for session tracking - self.downloads_page.download_session_completed.connect( - self.dashboard_page.data_provider.increment_completed_downloads - ) - - # Connect sync activities to dashboard - self.sync_page.sync_activity.connect( - self.dashboard_page.add_activity_item - ) - - # Connect download activities to dashboard - self.downloads_page.download_activity.connect( - self.dashboard_page.add_activity_item - ) - - # --- ADD THESE TWO LINES TO FIX THE UI UPDATE --- - self.sync_page.database_updated_externally.connect(self.dashboard_page.database_updated_externally) - self.artists_page.database_updated_externally.connect(self.dashboard_page.database_updated_externally) - # ------------------------------------------------ - - self.stacked_widget.addWidget(self.dashboard_page) - self.stacked_widget.addWidget(self.sync_page) - self.stacked_widget.addWidget(self.downloads_page) - self.stacked_widget.addWidget(self.artists_page) - self.stacked_widget.addWidget(self.settings_page) - - main_layout.addWidget(self.stacked_widget) - - # Set dashboard as default page - self.change_page("dashboard") - - # Connect media player signals between sidebar and downloads page - self.setup_media_player_connections() - - # Connect settings change signals for live updates - self.setup_settings_connections() - - def setup_status_monitoring(self): - # Start status monitoring thread - self.status_thread = ServiceStatusThread( - self.spotify_client, - self.plex_client, - self.jellyfin_client, - self.navidrome_client, - self.soulseek_client - ) - self.status_thread.status_updated.connect(self.update_service_status) - self.status_thread.start() - - def setup_media_player_connections(self): - """Connect signals between downloads page and sidebar media player""" - # Connect downloads page signals to sidebar media player - self.downloads_page.track_started.connect(self.sidebar.media_player.set_track_info) - self.downloads_page.track_paused.connect(lambda: self.sidebar.media_player.set_playing_state(False)) - self.downloads_page.track_resumed.connect(lambda: self.sidebar.media_player.set_playing_state(True)) - self.downloads_page.track_stopped.connect(self.sidebar.media_player.clear_track) - self.downloads_page.track_finished.connect(self.sidebar.media_player.clear_track) - - # Connect loading animation signals - self.downloads_page.track_loading_started.connect(lambda result: self.sidebar.media_player.show_loading()) - self.downloads_page.track_loading_finished.connect(lambda result: self.sidebar.media_player.hide_loading()) - self.downloads_page.track_loading_progress.connect(lambda progress, result: self.sidebar.media_player.set_loading_progress(progress)) - - # Connect sidebar media player signals to downloads page - self.sidebar.media_player.play_pause_requested.connect(self.downloads_page.handle_sidebar_play_pause) - self.sidebar.media_player.stop_requested.connect(self.downloads_page.handle_sidebar_stop) - self.sidebar.media_player.volume_changed.connect(self.downloads_page.handle_sidebar_volume) - - logger.info("Media player connections established between sidebar and downloads page") - - def setup_settings_connections(self): - """Connect settings change signals for live updates across pages""" - self.settings_page.settings_changed.connect(self.on_settings_changed) - logger.info("Settings change connections established") - - def on_settings_changed(self, key: str, value: str): - """Handle settings changes and broadcast to relevant pages""" - # Reinitialize service clients when their settings change - if key.startswith('spotify.'): - try: - self.spotify_client._setup_client() - except Exception as e: - logger.error("Failed to reinitialize Spotify client") - - elif key.startswith('plex.'): - try: - # Reset Plex connection to force reconnection with new settings - self.plex_client.server = None - self.plex_client.music_library = None - self.plex_client._connection_attempted = False - except Exception as e: - logger.error("Failed to reset Plex client") - - elif key.startswith('soulseek.'): - try: - self.soulseek_client._setup_client() - except Exception as e: - logger.error("Failed to reinitialize Soulseek client") - - # Broadcast to all pages that need to know about path changes - if hasattr(self.downloads_page, 'on_paths_updated'): - self.downloads_page.on_paths_updated(key, value) - if hasattr(self.artists_page, 'on_paths_updated'): - self.artists_page.on_paths_updated(key, value) - - def change_page(self, page_id: str): - page_map = { - "dashboard": 0, - "sync": 1, - "downloads": 2, - "artists": 3, - "settings": 4 - } - - if page_id in page_map: - self.stacked_widget.setCurrentIndex(page_map[page_id]) - logger.info(f"Changed to page: {page_id}") - - def update_service_status(self, service: str, connected: bool): - self.sidebar.update_service_status(service, connected) - - # Update dashboard with service status - if hasattr(self.dashboard_page, 'data_provider'): - self.dashboard_page.data_provider.update_service_status(service, connected) - - # Force a refresh of the Spotify client if needed - if service == "spotify" and not connected: - try: - self.spotify_client._setup_client() - except Exception as e: - logger.error(f"Error refreshing Spotify client: {e}") - - def closeEvent(self, event): - logger.info("Closing application...") - - try: - # Stop all page threads first - if hasattr(self, 'downloads_page') and self.downloads_page: - logger.info("Cleaning up Downloads page threads...") - self.downloads_page.cleanup_all_threads() - - # Stop dashboard threads - if hasattr(self, 'dashboard_page') and self.dashboard_page: - logger.info("Cleaning up Dashboard page threads...") - self.dashboard_page.cleanup_threads() - - # Stop other page threads and background tasks - if hasattr(self, 'artists_page') and self.artists_page: - logger.info("Cleaning up Artists page threads...") - if hasattr(self.artists_page, 'cleanup_threads'): - self.artists_page.cleanup_threads() - - if hasattr(self, 'sync_page') and self.sync_page: - logger.info("Cleaning up Sync page threads...") - if hasattr(self.sync_page, 'cleanup_threads'): - self.sync_page.cleanup_threads() - - if hasattr(self, 'downloads_page') and self.downloads_page: - logger.info("Cleaning up Downloads page threads...") - if hasattr(self.downloads_page, 'cleanup_threads'): - self.downloads_page.cleanup_threads() - - # Stop all QThreadPool tasks - logger.info("Stopping global thread pool...") - QThreadPool.globalInstance().clear() - QThreadPool.globalInstance().waitForDone(1000) # Wait max 1 second - - # Stop status monitoring thread - if self.status_thread: - logger.info("Stopping status monitoring thread...") - self.status_thread.stop() - - # Stop search maintenance timer - if hasattr(self, 'search_maintenance_timer') and self.search_maintenance_timer: - logger.info("Stopping search maintenance timer...") - self.search_maintenance_timer.stop() - - # Close Soulseek client - try: - logger.info("Closing Soulseek client...") - # Use modern asyncio approach instead of deprecated get_event_loop - try: - loop = asyncio.get_running_loop() - # Create a new task to close the client - task = asyncio.create_task(self.soulseek_client.close()) - # Wait for it to complete - asyncio.run_coroutine_threadsafe(self.soulseek_client.close(), loop).result(timeout=3.0) - except RuntimeError: - # No running loop, create new one - asyncio.run(self.soulseek_client.close()) - except Exception as e: - logger.error(f"Error closing Soulseek client: {e}") - - # Close database connection - try: - logger.info("Closing database connection...") - from database import close_database - close_database() - except Exception as e: - logger.error(f"Error closing database: {e}") - - logger.info("Application closed successfully") - event.accept() - - except Exception as e: - logger.error(f"Error during application shutdown: {e}") - # Force accept the event to prevent hanging - event.accept() - -def main(): - # Check for saved log level preference in database - try: - from database.music_database import MusicDatabase - db = MusicDatabase() - saved_log_level = db.get_preference('log_level') - if saved_log_level: - log_level = saved_log_level - else: - # Fall back to config file - logging_config = config_manager.get_logging_config() - log_level = logging_config.get('level', 'INFO') - except: - # If database isn't available yet, use config file - logging_config = config_manager.get_logging_config() - log_level = logging_config.get('level', 'INFO') - - logging_config = config_manager.get_logging_config() - log_file = logging_config.get('path', 'logs/newmusic.log') - setup_logging(level=log_level, log_file=log_file) - - logger.info("Starting Soulsync application") - - if not config_manager.config_path.exists(): - logger.error("Configuration file not found. Please check config/config.json") - sys.exit(1) - - app = QApplication(sys.argv) - app.setApplicationName("SoulSync") - app.setApplicationVersion("0.6") - - main_window = MainWindow() - main_window.show() - - try: - sys.exit(app.exec()) - except KeyboardInterrupt: - logger.info("Application interrupted by user") - sys.exit(0) - except Exception as e: - logger.error(f"Unexpected error: {e}") - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 73c9ec00..00000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -PyQt6[multimedia]>=6.6.0 -spotipy>=2.23.0 -PlexAPI>=4.17.0 -requests>=2.31.0 -asyncio-mqtt>=0.16.0 -python-dotenv>=1.0.0 -cryptography>=41.0.0 -mutagen>=1.47.0 -Pillow>=10.0.0 -aiohttp>=3.9.0 -unidecode>=1.3.8 -yt-dlp>=2024.12.13 -Flask>=3.0.0 -Flask-Limiter>=3.5.0 -lrclibapi>=0.3.1 -pyacoustid>=1.3.0 \ No newline at end of file diff --git a/ui/assets/jellyfin_icon.png b/ui/assets/jellyfin_icon.png deleted file mode 100644 index 71ee43a3..00000000 Binary files a/ui/assets/jellyfin_icon.png and /dev/null differ diff --git a/ui/assets/navidrome_icon.png b/ui/assets/navidrome_icon.png deleted file mode 100644 index 1fa2234e..00000000 Binary files a/ui/assets/navidrome_icon.png and /dev/null differ diff --git a/ui/assets/plex_icon.png b/ui/assets/plex_icon.png deleted file mode 100644 index 4cf59c4f..00000000 Binary files a/ui/assets/plex_icon.png and /dev/null differ diff --git a/ui/components/database_updater_widget.py b/ui/components/database_updater_widget.py deleted file mode 100644 index ad8598fd..00000000 --- a/ui/components/database_updater_widget.py +++ /dev/null @@ -1,396 +0,0 @@ -#!/usr/bin/env python3 - -from PyQt6.QtWidgets import (QFrame, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QProgressBar, QComboBox, QGroupBox) -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QFont -from utils.logging_config import get_logger - -logger = get_logger("database_updater_widget") - -class DatabaseUpdaterWidget(QFrame): - """UI widget for updating SoulSync database with media server library data (Plex, Jellyfin, or Navidrome)""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - self.setStyleSheet(""" - DatabaseUpdaterWidget { - 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("Update SoulSync Database") - header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - header_label.setStyleSheet("color: #ffffff;") - - # Info label - dynamic based on active server - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - if active_server == "jellyfin": - server_name = "Jellyfin" - elif active_server == "navidrome": - server_name = "Navidrome" - else: - server_name = "Plex" - except: - server_name = "Plex" # Fallback - - info_label = QLabel(f"Syncs your {server_name} music library into the local database for faster searches and analytics") - info_label.setFont(QFont("Arial", 9)) - info_label.setStyleSheet("color: #b3b3b3; margin-bottom: 5px;") - info_label.setWordWrap(True) - - # Recommendation label - self.recommendation_label = QLabel("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy") - self.recommendation_label.setFont(QFont("Arial", 9)) - self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;") - self.recommendation_label.setWordWrap(True) - - # Last full refresh label - self.last_refresh_label = QLabel("") - self.last_refresh_label.setFont(QFont("Arial", 8)) - self.last_refresh_label.setStyleSheet("color: #888888; margin-bottom: 5px;") - self.last_refresh_label.setWordWrap(True) - - # Control section - control_layout = QVBoxLayout() - control_layout.setSpacing(12) - - # Top row: Button - button_layout = QHBoxLayout() - self.start_button = QPushButton("Update Database") - 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) - - # Update type dropdown - update_type_layout = QVBoxLayout() - update_type_layout.setSpacing(4) - - type_label = QLabel("Update Type:") - type_label.setFont(QFont("Arial", 9)) - type_label.setStyleSheet("color: #b3b3b3;") - - self.update_type_combo = QComboBox() - self.update_type_combo.setFixedHeight(32) - self.update_type_combo.setFont(QFont("Arial", 10)) - self.update_type_combo.addItems([ - "Incremental Update", - "Full Refresh" - ]) - self.update_type_combo.setCurrentText("Incremental Update") - self.update_type_combo.setStyleSheet(""" - QComboBox { - background: #333333; - color: #ffffff; - border: 1px solid #555555; - border-radius: 4px; - padding: 4px 8px; - min-width: 140px; - } - 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; - } - """) - - update_type_layout.addWidget(type_label) - update_type_layout.addWidget(self.update_type_combo) - - # Current status display - status_layout = QVBoxLayout() - status_layout.setSpacing(4) - - current_label = QLabel("Current Status:") - current_label.setFont(QFont("Arial", 9)) - current_label.setStyleSheet("color: #b3b3b3;") - - self.current_status_label = QLabel("Ready") - self.current_status_label.setFont(QFont("Arial", 11, QFont.Weight.Medium)) - self.current_status_label.setStyleSheet("color: #ffffff;") - - status_layout.addWidget(current_label) - status_layout.addWidget(self.current_status_label) - - settings_layout.addLayout(update_type_layout) - settings_layout.addLayout(status_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 artists processed") - 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) - - # Statistics section (shows current database info) - stats_group = QGroupBox("Database Statistics") - stats_group.setFont(QFont("Arial", 10, QFont.Weight.Bold)) - stats_group.setStyleSheet(""" - QGroupBox { - color: #ffffff; - border: 1px solid #555555; - border-radius: 6px; - margin-top: 6px; - padding-top: 10px; - } - QGroupBox::title { - subcontrol-origin: margin; - left: 10px; - padding: 0 5px 0 5px; - } - """) - - stats_layout = QHBoxLayout(stats_group) - stats_layout.setSpacing(20) - - # Artists stat - artists_layout = QVBoxLayout() - self.artists_count_label = QLabel("0") - self.artists_count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - self.artists_count_label.setStyleSheet("color: #1db954;") - self.artists_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - artists_text_label = QLabel("Artists") - artists_text_label.setFont(QFont("Arial", 9)) - artists_text_label.setStyleSheet("color: #b3b3b3;") - artists_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - artists_layout.addWidget(self.artists_count_label) - artists_layout.addWidget(artists_text_label) - - # Albums stat - albums_layout = QVBoxLayout() - self.albums_count_label = QLabel("0") - self.albums_count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - self.albums_count_label.setStyleSheet("color: #1db954;") - self.albums_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - albums_text_label = QLabel("Albums") - albums_text_label.setFont(QFont("Arial", 9)) - albums_text_label.setStyleSheet("color: #b3b3b3;") - albums_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - albums_layout.addWidget(self.albums_count_label) - albums_layout.addWidget(albums_text_label) - - # Tracks stat - tracks_layout = QVBoxLayout() - self.tracks_count_label = QLabel("0") - self.tracks_count_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - self.tracks_count_label.setStyleSheet("color: #1db954;") - self.tracks_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - tracks_text_label = QLabel("Tracks") - tracks_text_label.setFont(QFont("Arial", 9)) - tracks_text_label.setStyleSheet("color: #b3b3b3;") - tracks_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - tracks_layout.addWidget(self.tracks_count_label) - tracks_layout.addWidget(tracks_text_label) - - # Database size stat - size_layout = QVBoxLayout() - self.size_label = QLabel("0.0 MB") - self.size_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - self.size_label.setStyleSheet("color: #1db954;") - self.size_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - size_text_label = QLabel("DB Size") - size_text_label.setFont(QFont("Arial", 9)) - size_text_label.setStyleSheet("color: #b3b3b3;") - size_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - size_layout.addWidget(self.size_label) - size_layout.addWidget(size_text_label) - - stats_layout.addLayout(artists_layout) - stats_layout.addLayout(albums_layout) - stats_layout.addLayout(tracks_layout) - stats_layout.addLayout(size_layout) - stats_layout.addStretch() - - # Add all sections to main layout - layout.addWidget(header_label) - layout.addWidget(info_label) - layout.addWidget(self.recommendation_label) - layout.addWidget(self.last_refresh_label) - layout.addLayout(control_layout) - layout.addLayout(progress_layout) - layout.addWidget(stats_group) - - def update_progress(self, is_running: bool, current_item: str, processed: int, total: int, percentage: float): - """Update progress display during database update""" - if is_running: - self.start_button.setText("Stop Update") - self.start_button.setEnabled(True) - self.current_status_label.setText(current_item if current_item else "Processing...") - self.progress_label.setText(f"Progress: {percentage:.1f}%") - self.count_label.setText(f"{processed} / {total} artists processed") - self.progress_bar.setValue(int(percentage)) - else: - self.start_button.setText("Update Database") - self.start_button.setEnabled(True) - self.current_status_label.setText("Ready") - self.progress_label.setText("Progress: 0%") - self.count_label.setText("0 artists processed") - self.progress_bar.setValue(0) - - def update_statistics(self, stats: dict): - """Update database statistics display""" - self.artists_count_label.setText(str(stats.get('artists', 0))) - self.albums_count_label.setText(str(stats.get('albums', 0))) - self.tracks_count_label.setText(str(stats.get('tracks', 0))) - self.size_label.setText(f"{stats.get('database_size_mb', 0.0):.1f} MB") - - def update_phase(self, phase: str): - """Update current phase display""" - self.current_status_label.setText(phase) - - def is_full_refresh(self) -> bool: - """Check if full refresh is selected""" - return self.update_type_combo.currentText() == "Full Refresh" - - def set_button_text(self, text: str): - """Set custom button text""" - self.start_button.setText(text) - - def set_button_enabled(self, enabled: bool): - """Enable/disable the start button""" - self.start_button.setEnabled(enabled) - - def update_last_refresh_info(self, last_refresh_date: str = None): - """Update the last refresh information with color-coded warnings""" - if not last_refresh_date: - self.last_refresh_label.setText("No full refresh recorded") - self.last_refresh_label.setStyleSheet("color: #ff6666; margin-bottom: 5px; font-style: italic;") - self._update_recommendation_urgency(urgent=True) - return - - try: - from datetime import datetime - last_date = datetime.fromisoformat(last_refresh_date.replace('Z', '+00:00')) - days_ago = (datetime.now() - last_date.replace(tzinfo=None)).days - - if days_ago == 0: - time_text = "today" - color = "#1db954" # Green - urgent = False - elif days_ago == 1: - time_text = "yesterday" - color = "#1db954" # Green - urgent = False - elif days_ago < 7: - time_text = f"{days_ago} days ago" - color = "#1db954" # Green - urgent = False - elif days_ago < 14: - time_text = f"{days_ago} days ago" - color = "#ffaa00" # Orange warning - urgent = False - else: - time_text = f"{days_ago} days ago" - color = "#ff6666" # Red warning - urgent = True - - self.last_refresh_label.setText(f"Last full refresh: {time_text}") - self.last_refresh_label.setStyleSheet(f"color: {color}; margin-bottom: 5px;") - self._update_recommendation_urgency(urgent=urgent) - - except Exception: - self.last_refresh_label.setText("Last full refresh: unknown") - self.last_refresh_label.setStyleSheet("color: #888888; margin-bottom: 5px;") - self._update_recommendation_urgency(urgent=False) - - def _update_recommendation_urgency(self, urgent: bool = False): - """Update the recommendation label styling based on urgency""" - if urgent: - self.recommendation_label.setText("Recommended: Run a Full Refresh - it's been over 2 weeks!") - self.recommendation_label.setStyleSheet("color: #ffffff; margin-bottom: 8px; padding: 6px 8px; background: #cc3300; border-radius: 4px;") - else: - self.recommendation_label.setText("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy") - self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;") \ No newline at end of file diff --git a/ui/components/toast_manager.py b/ui/components/toast_manager.py deleted file mode 100644 index 0cd824bf..00000000 --- a/ui/components/toast_manager.py +++ /dev/null @@ -1,260 +0,0 @@ -from PyQt6.QtWidgets import QWidget, QLabel, QHBoxLayout, QVBoxLayout, QGraphicsOpacityEffect -from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve, pyqtSignal, QRect -from PyQt6.QtGui import QFont, QPainter, QPaintEvent -import time -from typing import List, Optional -from enum import Enum - -class ToastType(Enum): - SUCCESS = "success" - INFO = "info" - WARNING = "warning" - ERROR = "error" - -class Toast(QWidget): - """Individual toast notification widget""" - closed = pyqtSignal(object) # Emits self when closing - - def __init__(self, message: str, toast_type: ToastType = ToastType.INFO, duration: int = 4000, parent=None): - super().__init__(parent) - self.message = message - self.toast_type = toast_type - self.duration = duration - self.created_time = time.time() - - self.setup_ui() - self.setup_animations() - self.setup_auto_dismiss() - - def setup_ui(self): - """Setup the toast UI""" - self.setFixedHeight(60) - self.setMinimumWidth(300) - self.setMaximumWidth(400) - - # Make the widget click-through for the background but clickable for the content - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) - - # Main layout - layout = QHBoxLayout(self) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(12) - - # Icon label - self.icon_label = QLabel() - self.icon_label.setFont(QFont("Segoe UI", 14)) - self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.icon_label.setFixedSize(24, 24) - - # Message label - self.message_label = QLabel(self.message) - self.message_label.setFont(QFont("Segoe UI", 10)) - self.message_label.setWordWrap(True) - self.message_label.setAlignment(Qt.AlignmentFlag.AlignVCenter) - - layout.addWidget(self.icon_label) - layout.addWidget(self.message_label, 1) - - # Apply styling based on toast type - self.apply_styling() - - def apply_styling(self): - """Apply styling based on toast type""" - if self.toast_type == ToastType.SUCCESS: - icon = "" - accent_color = "#1db954" # Spotify green - bg_color = "rgba(29, 185, 84, 0.15)" - border_color = "rgba(29, 185, 84, 0.3)" - elif self.toast_type == ToastType.ERROR: - icon = "" - accent_color = "#f04747" - bg_color = "rgba(240, 71, 71, 0.15)" - border_color = "rgba(240, 71, 71, 0.3)" - elif self.toast_type == ToastType.WARNING: - icon = "" - accent_color = "#ffa500" - bg_color = "rgba(255, 165, 0, 0.15)" - border_color = "rgba(255, 165, 0, 0.3)" - else: # INFO - icon = "ℹ️" - accent_color = "#5865f2" - bg_color = "rgba(88, 101, 242, 0.15)" - border_color = "rgba(88, 101, 242, 0.3)" - - self.icon_label.setText(icon) - - self.setStyleSheet(f""" - Toast {{ - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(45, 45, 45, 0.95), - stop:1 rgba(35, 35, 35, 0.95)); - border: 1px solid {border_color}; - border-left: 3px solid {accent_color}; - border-radius: 8px; - }} - """) - - self.message_label.setStyleSheet(f""" - color: #ffffff; - background: transparent; - """) - - def setup_animations(self): - """Setup slide-in and fade-out animations""" - # Opacity effect for fade animations - self.opacity_effect = QGraphicsOpacityEffect() - self.setGraphicsEffect(self.opacity_effect) - - # Slide-in animation (from right) - self.slide_animation = QPropertyAnimation(self, b"geometry") - self.slide_animation.setDuration(300) - self.slide_animation.setEasingCurve(QEasingCurve.Type.OutCubic) - - # Fade-out animation - self.fade_animation = QPropertyAnimation(self.opacity_effect, b"opacity") - self.fade_animation.setDuration(200) - self.fade_animation.setEasingCurve(QEasingCurve.Type.OutQuad) - - # Connect fade animation to close - self.fade_animation.finished.connect(self._on_fade_complete) - - def setup_auto_dismiss(self): - """Setup auto-dismiss timer""" - if self.duration > 0: - self.dismiss_timer = QTimer() - self.dismiss_timer.setSingleShot(True) - self.dismiss_timer.timeout.connect(self.dismiss) - self.dismiss_timer.start(self.duration) - - def show_at_position(self, target_rect: QRect): - """Show the toast with slide-in animation at the specified position""" - # Start position (off-screen to the right) - start_rect = QRect(target_rect.x() + 50, target_rect.y(), target_rect.width(), target_rect.height()) - - # Set initial position and show - self.setGeometry(start_rect) - self.show() - - # Animate to target position - self.slide_animation.setStartValue(start_rect) - self.slide_animation.setEndValue(target_rect) - self.slide_animation.start() - - def dismiss(self): - """Dismiss the toast with fade-out animation""" - if hasattr(self, 'dismiss_timer'): - self.dismiss_timer.stop() - - self.fade_animation.setStartValue(1.0) - self.fade_animation.setEndValue(0.0) - self.fade_animation.start() - - def _on_fade_complete(self): - """Called when fade animation completes""" - self.closed.emit(self) - self.hide() - self.deleteLater() - - def mousePressEvent(self, event): - """Handle click to dismiss""" - if event.button() == Qt.MouseButton.LeftButton: - self.dismiss() - super().mousePressEvent(event) - - -class ToastManager(QWidget): - """Manages multiple toast notifications""" - - def __init__(self, parent=None): - super().__init__(parent) - self.parent_widget = parent - self.active_toasts: List[Toast] = [] - self.toast_spacing = 10 - self.margin_from_edge = 20 - - # Make this widget transparent and non-interactive - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - def show_toast(self, message: str, toast_type: ToastType = ToastType.INFO, duration: int = 4000): - """Show a new toast notification""" - toast = Toast(message, toast_type, duration, self.parent_widget) - toast.closed.connect(self._on_toast_closed) - - # Calculate position for this toast - position = self._calculate_toast_position(len(self.active_toasts)) - - # Add to active toasts list - self.active_toasts.append(toast) - - # Show the toast - toast.show_at_position(position) - - # Reposition existing toasts if needed - self._reposition_existing_toasts() - - def _calculate_toast_position(self, index: int) -> QRect: - """Calculate position for a toast at the given index""" - if not self.parent_widget: - return QRect(0, 0, 350, 60) - - parent_rect = self.parent_widget.rect() - toast_height = 60 - toast_width = 350 - - x = parent_rect.width() - toast_width - self.margin_from_edge - y = self.margin_from_edge + (index * (toast_height + self.toast_spacing)) - - return QRect(x, y, toast_width, toast_height) - - def _reposition_existing_toasts(self): - """Reposition existing toasts to make room for new ones""" - for i, toast in enumerate(self.active_toasts[:-1]): # Exclude the newest toast - new_position = self._calculate_toast_position(i) - - # Animate to new position if needed - current_geo = toast.geometry() - if current_geo.y() != new_position.y(): - toast.slide_animation.stop() - toast.slide_animation.setStartValue(current_geo) - toast.slide_animation.setEndValue(new_position) - toast.slide_animation.start() - - def _on_toast_closed(self, toast: Toast): - """Handle when a toast is closed""" - if toast in self.active_toasts: - self.active_toasts.remove(toast) - - # Reposition remaining toasts - for i, remaining_toast in enumerate(self.active_toasts): - new_position = self._calculate_toast_position(i) - current_geo = remaining_toast.geometry() - - if current_geo != new_position: - remaining_toast.slide_animation.stop() - remaining_toast.slide_animation.setStartValue(current_geo) - remaining_toast.slide_animation.setEndValue(new_position) - remaining_toast.slide_animation.start() - - def clear_all_toasts(self): - """Dismiss all active toasts""" - for toast in self.active_toasts.copy(): - toast.dismiss() - - # Convenience methods for different toast types - def success(self, message: str, duration: int = 4000): - """Show a success toast""" - self.show_toast(message, ToastType.SUCCESS, duration) - - def error(self, message: str, duration: int = 6000): - """Show an error toast (longer duration)""" - self.show_toast(message, ToastType.ERROR, duration) - - def warning(self, message: str, duration: int = 5000): - """Show a warning toast""" - self.show_toast(message, ToastType.WARNING, duration) - - def info(self, message: str, duration: int = 4000): - """Show an info toast""" - self.show_toast(message, ToastType.INFO, duration) \ No newline at end of file diff --git a/ui/components/version_info_modal.py b/ui/components/version_info_modal.py deleted file mode 100644 index c62b45e3..00000000 --- a/ui/components/version_info_modal.py +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env python3 - -from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QFrame, QScrollArea, QWidget) -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QFont -from utils.logging_config import get_logger - -logger = get_logger("version_info_modal") - -class VersionInfoModal(QDialog): - """Modal displaying recent changes and version information""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("What's New in SoulSync v1.0") - self.setModal(True) - self.setFixedSize(600, 500) - self.setup_ui() - - def setup_ui(self): - self.setStyleSheet(""" - VersionInfoModal { - background: #1a1a1a; - border-radius: 12px; - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Header - header = self.create_header() - layout.addWidget(header) - - # Content area with scroll - content_area = self.create_content_area() - layout.addWidget(content_area) - - # Footer with close button - footer = self.create_footer() - layout.addWidget(footer) - - def create_header(self): - header = QFrame() - header.setFixedHeight(80) - header.setStyleSheet(""" - QFrame { - background: #1a1a1a; - border-top-left-radius: 12px; - border-top-right-radius: 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - """) - - layout = QVBoxLayout(header) - layout.setContentsMargins(30, 20, 30, 15) - layout.setSpacing(5) - - # Title - title = QLabel("What's New in SoulSync") - title.setFont(QFont("SF Pro Display", 18, QFont.Weight.Bold)) - title.setStyleSheet(""" - color: #ffffff; - letter-spacing: -0.5px; - font-weight: 700; - """) - - # Version subtitle - version_subtitle = QLabel("Version 1.0 - Complete WebUI Rebuild") - version_subtitle.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium)) - version_subtitle.setStyleSheet(""" - color: rgba(255, 255, 255, 0.7); - letter-spacing: 0.1px; - margin-top: 2px; - """) - - layout.addWidget(title) - layout.addWidget(version_subtitle) - - return header - - def create_content_area(self): - # Scroll area for content - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll_area.setStyleSheet(""" - QScrollArea { - border: none; - background: #1a1a1a; - } - QScrollBar:vertical { - background: #2a2a2a; - width: 8px; - border-radius: 4px; - } - QScrollBar::handle:vertical { - background: #555555; - border-radius: 4px; - } - QScrollBar::handle:vertical:hover { - background: #666666; - } - """) - - # Content widget - content_widget = QWidget() - content_layout = QVBoxLayout(content_widget) - content_layout.setContentsMargins(30, 25, 30, 25) - content_layout.setSpacing(25) - - # WebUI Transformation - webui_section = self.create_feature_section( - "Complete WebUI Transformation", - "SoulSync has been completely rebuilt from the ground up as a modern web application, moving from desktop GUI to web-based interface", - [ - "• Full transition from PyQt6 desktop application to responsive web interface", - "• Modern HTML5, CSS3, and JavaScript implementation with premium glassmorphic design", - "• Real-time updates and live status monitoring through WebSocket connections", - "• Cross-platform compatibility - access from any device with a web browser", - "• Mobile-responsive design optimized for tablets and smartphones", - "• Dark theme with sophisticated visual effects and smooth animations", - "• RESTful API architecture enabling future third-party integrations" - ], - "Access SoulSync through your web browser at localhost:8888 - no desktop installation required!" - ) - content_layout.addWidget(webui_section) - - # Docker Support - docker_section = self.create_feature_section( - "Docker Container Support", - "Complete containerization with Docker for easy deployment and scalability", - [ - "• Pre-built Docker images available for instant deployment", - "• Multi-architecture support (AMD64, ARM64) for various server platforms", - "• Volume mounting for persistent configuration and downloads", - "• Environment variable configuration for easy customization", - "• Docker Compose templates for simplified multi-container setups", - "• Automatic health checks and restart policies for reliability", - "• Lightweight Alpine Linux base for minimal resource usage" - ] - ) - content_layout.addWidget(docker_section) - - # Enhanced Music Management - music_section = self.create_feature_section( - "Enhanced Music Management", - "All beloved features preserved and enhanced with new web-based capabilities", - [ - "• Complete Spotify, Tidal, and YouTube Music playlist synchronization", - "• Advanced Soulseek integration with real-time download management", - "• Intelligent music matching engine with improved accuracy", - "• Plex and Jellyfin server integration with automatic library updates", - "• Artist watchlist with automatic new release detection", - "• Comprehensive metadata enhancement with high-quality album artwork", - "• Real-time download progress with detailed logging and status updates" - ] - ) - content_layout.addWidget(music_section) - - # Performance & Reliability - performance_section = self.create_feature_section( - "Performance & Reliability", - "Significant improvements in speed, stability, and resource efficiency", - [ - "• Asynchronous processing for improved responsiveness", - "• Multi-threaded download management with concurrent processing", - "• Optimized database operations with connection pooling", - "• Intelligent caching system for faster API responses", - "• Robust error handling with automatic retry mechanisms", - "• Memory-efficient architecture suitable for long-running deployments", - "• Comprehensive logging system for easy troubleshooting" - ] - ) - content_layout.addWidget(performance_section) - - scroll_area.setWidget(content_widget) - return scroll_area - - def create_feature_section(self, title, description, features, usage_note=None): - section = QFrame() - section.setStyleSheet(""" - QFrame { - background: transparent; - border: none; - border-left: 3px solid rgba(29, 185, 84, 0.4); - border-radius: 0px; - padding: 0px; - margin-left: 5px; - } - """) - - layout = QVBoxLayout(section) - layout.setContentsMargins(20, 18, 20, 18) - layout.setSpacing(12) - - # Section title - title_label = QLabel(title) - title_label.setFont(QFont("SF Pro Text", 14, QFont.Weight.Bold)) - title_label.setStyleSheet(""" - color: #1ed760; - font-weight: 600; - letter-spacing: -0.2px; - margin-bottom: 3px; - """) - layout.addWidget(title_label) - - # Description - desc_label = QLabel(description) - desc_label.setFont(QFont("SF Pro Text", 11)) - desc_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.8); - line-height: 1.4; - margin-bottom: 8px; - """) - desc_label.setWordWrap(True) - layout.addWidget(desc_label) - - # Features list - for feature in features: - feature_label = QLabel(feature) - feature_label.setFont(QFont("SF Pro Text", 10)) - feature_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.7); - line-height: 1.5; - padding-left: 8px; - margin: 2px 0px; - """) - feature_label.setWordWrap(True) - layout.addWidget(feature_label) - - # Usage note if provided - if usage_note: - usage_label = QLabel(f"{usage_note}") - usage_label.setFont(QFont("SF Pro Text", 10)) - usage_label.setStyleSheet(""" - color: #1ed760; - background: transparent; - border: none; - padding: 8px 0px; - margin-top: 8px; - line-height: 1.4; - font-style: italic; - """) - usage_label.setWordWrap(True) - layout.addWidget(usage_label) - - return section - - def create_footer(self): - footer = QFrame() - footer.setFixedHeight(65) - footer.setStyleSheet(""" - QFrame { - background: rgba(255, 255, 255, 0.02); - border-top: 1px solid rgba(255, 255, 255, 0.08); - border-bottom-left-radius: 12px; - border-bottom-right-radius: 12px; - } - """) - - layout = QHBoxLayout(footer) - layout.setContentsMargins(30, 15, 30, 15) - - # Close button - close_button = QPushButton("Close") - close_button.setFixedSize(100, 35) - close_button.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) - close_button.setStyleSheet(""" - QPushButton { - background: #1db954; - color: white; - border: none; - border-radius: 6px; - font-weight: 500; - letter-spacing: 0.1px; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:pressed { - background: #169c46; - } - """) - close_button.clicked.connect(self.accept) - - layout.addStretch() - layout.addWidget(close_button) - - return footer \ No newline at end of file diff --git a/ui/components/watchlist_status_modal.py b/ui/components/watchlist_status_modal.py deleted file mode 100644 index da52f330..00000000 --- a/ui/components/watchlist_status_modal.py +++ /dev/null @@ -1,1314 +0,0 @@ -#!/usr/bin/env python3 - -""" -Watchlist Status Modal - Shows live status of watchlist scanning -""" - -from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QFrame, QScrollArea, QWidget, QProgressBar, QMessageBox, QLineEdit) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread -from PyQt6.QtGui import QFont -from datetime import datetime -from typing import Optional, List - -from core.spotify_client import SpotifyClient -from core.watchlist_scanner import get_watchlist_scanner, ScanResult -from database.music_database import get_database, WatchlistArtist -from utils.logging_config import get_logger - -logger = get_logger("watchlist_status_modal") - -class WatchlistScanWorker(QThread): - """Background worker for watchlist scanning""" - - # Signals for progress updates - scan_started = pyqtSignal() - artist_scan_started = pyqtSignal(str) # artist_name - artist_totals_discovered = pyqtSignal(str, int, int) # artist_name, total_singles_eps_releases, total_albums - album_scan_started = pyqtSignal(str, str, int) # artist_name, album_name, total_tracks - track_check_started = pyqtSignal(str, str, str) # artist_name, album_name, track_name - release_completed = pyqtSignal(str, str, int) # artist_name, album_name, total_tracks - artist_scan_completed = pyqtSignal(str, int, int, bool) # artist_name, albums_checked, new_tracks, success - scan_completed = pyqtSignal(list) # List of ScanResult - - def __init__(self, spotify_client: SpotifyClient): - super().__init__() - self.spotify_client = spotify_client - self.should_stop = False - - # Progress state for reconnection - self.current_scan_state = { - 'total_artists': 0, - 'completed_artists': 0, - 'current_artist_name': '', - 'current_artist_total_singles_eps': 0, - 'current_artist_completed_singles_eps': 0, - 'current_artist_total_albums': 0, - 'current_artist_completed_albums': 0, - 'scan_active': False, - 'scan_completed': False - } - - def stop(self): - """Stop the scanning process""" - self.should_stop = True - self.current_scan_state['scan_active'] = False - - def get_current_progress(self): - """Get current progress state for reconnection""" - return self.current_scan_state.copy() - - def run(self): - """Run the watchlist scan with detailed progress updates""" - try: - # Initialize progress state - database = get_database() - watchlist_artists = database.get_watchlist_artists() - - self.current_scan_state.update({ - 'total_artists': len(watchlist_artists), - 'completed_artists': 0, - 'scan_active': True, - 'scan_completed': False - }) - - self.scan_started.emit() - - scan_results = [] - - for i, artist in enumerate(watchlist_artists): - if self.should_stop: - break - - # Update current artist progress state - self.current_scan_state.update({ - 'current_artist_name': artist.artist_name, - 'current_artist_total_singles_eps': 0, - 'current_artist_completed_singles_eps': 0, - 'current_artist_total_albums': 0, - 'current_artist_completed_albums': 0 - }) - - self.artist_scan_started.emit(artist.artist_name) - - # Perform detailed scan with progress updates - result = self._scan_artist_with_progress(artist, database) - scan_results.append(result) - - # Update completed artists count - self.current_scan_state['completed_artists'] = i + 1 - - self.artist_scan_completed.emit( - artist.artist_name, - result.albums_checked, - result.new_tracks_found, - result.success - ) - - # Mark scan as completed - self.current_scan_state.update({ - 'scan_active': False, - 'scan_completed': True - }) - - self.scan_completed.emit(scan_results) - - except Exception as e: - logger.error(f"Error in watchlist scan worker: {e}") - self.current_scan_state.update({ - 'scan_active': False, - 'scan_completed': True - }) - self.scan_completed.emit([]) - - def _scan_artist_with_progress(self, watchlist_artist, database): - """Scan artist with detailed progress emissions""" - try: - # Get watchlist scanner - scanner = get_watchlist_scanner(self.spotify_client) - - # Get artist discography - albums = scanner.get_artist_discography( - watchlist_artist.spotify_artist_id, - watchlist_artist.last_scan_timestamp - ) - - if albums is None: - return ScanResult( - artist_name=watchlist_artist.artist_name, - spotify_artist_id=watchlist_artist.spotify_artist_id, - albums_checked=0, - new_tracks_found=0, - tracks_added_to_wishlist=0, - success=False, - error_message="Failed to get artist discography from Spotify" - ) - - # Analyze the albums list to get total counts upfront - total_singles_eps_releases = 0 - total_albums = 0 - - for album in albums: - try: - # Get full album data to count tracks - album_data = self.spotify_client.get_album(album.id) - if not album_data or 'tracks' not in album_data: - continue - - track_count = len(album_data['tracks'].get('items', [])) - - # Check if user wants this type of release - if not scanner._should_include_release(track_count, watchlist_artist): - continue # Skip counting this release - - # Categorize based on track count - COUNT RELEASES not tracks - if track_count >= 4: - total_albums += 1 - else: - total_singles_eps_releases += 1 # Count the release, not the tracks - - # Rate limiting: small delay between album fetches to avoid hitting Spotify limits - import time - time.sleep(0.1) # 100ms delay between albums - - except Exception as e: - logger.warning(f"Error analyzing album {album.name} for totals: {e}") - continue - - # Update current artist totals in state - self.current_scan_state.update({ - 'current_artist_total_singles_eps': total_singles_eps_releases, - 'current_artist_total_albums': total_albums - }) - - # Emit the discovered totals - self.artist_totals_discovered.emit( - watchlist_artist.artist_name, - total_singles_eps_releases, - total_albums - ) - - new_tracks_found = 0 - tracks_added_to_wishlist = 0 - - for album in albums: - if self.should_stop: - break - - try: - # Get full album data with tracks first - album_data = self.spotify_client.get_album(album.id) - if not album_data or 'tracks' not in album_data or not album_data['tracks'].get('items'): - continue - - tracks = album_data['tracks']['items'] - - # Check if user wants this type of release - if not scanner._should_include_release(len(tracks), watchlist_artist): - continue # Skip this release - - # Emit album progress with track count - self.album_scan_started.emit(watchlist_artist.artist_name, album.name, len(tracks)) - - # Check each track - for track in tracks: - if self.should_stop: - break - - # Emit track check progress - self.track_check_started.emit( - watchlist_artist.artist_name, - album_data.get('name', 'Unknown'), - track.get('name', 'Unknown') - ) - - if scanner.is_track_missing_from_library(track): - new_tracks_found += 1 - - # Add to wishlist - if scanner.add_track_to_wishlist(track, album_data, watchlist_artist): - tracks_added_to_wishlist += 1 - - # Emit release completion signal - self.release_completed.emit(watchlist_artist.artist_name, album.name, len(tracks)) - - # Update progress state for this completed release - if len(tracks) >= 4: # Album - self.current_scan_state['current_artist_completed_albums'] += 1 - else: # Single/EP - self.current_scan_state['current_artist_completed_singles_eps'] += 1 - - # Rate limiting: small delay between album processing to avoid hitting Spotify limits - import time - time.sleep(0.1) # 100ms delay between albums - - except Exception as e: - logger.warning(f"Error checking album {album.name}: {e}") - continue - - # Update last scan timestamp - scanner.update_artist_scan_timestamp(watchlist_artist) - - return ScanResult( - artist_name=watchlist_artist.artist_name, - spotify_artist_id=watchlist_artist.spotify_artist_id, - albums_checked=len(albums), - new_tracks_found=new_tracks_found, - tracks_added_to_wishlist=tracks_added_to_wishlist, - success=True - ) - - except Exception as e: - logger.error(f"Error scanning artist {watchlist_artist.artist_name}: {e}") - return ScanResult( - artist_name=watchlist_artist.artist_name, - spotify_artist_id=watchlist_artist.spotify_artist_id, - albums_checked=0, - new_tracks_found=0, - tracks_added_to_wishlist=0, - success=False, - error_message=str(e) - ) - -class WatchlistStatusModal(QDialog): - """Modal showing live watchlist scanning status""" - - # Class-level shared scan worker that persists across modal instances - _shared_scan_worker = None - _scan_owner_modal = None - - def __init__(self, parent=None, spotify_client: SpotifyClient = None): - super().__init__(parent) - self.spotify_client = spotify_client - self.scan_worker = None - self.current_artists = [] - self.scan_in_progress = False - - # Keep track of whether this modal started the scan (vs background scan) - self.is_manual_scan_owner = False - - # Track when we're reconnecting to ongoing scan vs starting fresh - self.is_reconnecting_to_ongoing_scan = False - - # Simple progress tracking - self.total_artists = 0 - self.completed_artists = 0 - - # Current artist progress (resets for each artist) - self.current_artist_name = "" - self.current_artist_total_singles_eps = 0 # Total singles + EPs releases - self.current_artist_completed_singles_eps = 0 # Completed singles + EPs releases - self.current_artist_total_albums = 0 # Total albums - self.current_artist_completed_albums = 0 # Completed albums - - self.setup_ui() - self.load_watchlist_data() - - def setup_ui(self): - """Setup the modal UI with clean tool-style design""" - self.setWindowTitle("Watchlist Status") - self.setFixedSize(700, 700) - self.setStyleSheet(""" - QDialog { - background: #121212; - color: #ffffff; - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Header - header_layout = QHBoxLayout() - - title_label = QLabel("Artist Watchlist Status") - title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #ffffff; border: none;") - - self.status_label = QLabel("Ready") - self.status_label.setFont(QFont("Arial", 11)) - self.status_label.setStyleSheet("color: #b3b3b3; border: none;") - - header_layout.addWidget(title_label) - header_layout.addStretch() - header_layout.addWidget(self.status_label) - - layout.addLayout(header_layout) - - # Progress section - tool style - progress_frame = QFrame() - progress_frame.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - progress_layout = QVBoxLayout(progress_frame) - progress_layout.setContentsMargins(20, 15, 20, 15) - progress_layout.setSpacing(12) - - # Progress header - progress_header = QLabel("Scan Progress") - progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - progress_header.setStyleSheet("color: #ffffff; border: none;") - - self.current_action_label = QLabel("No scan in progress") - self.current_action_label.setFont(QFont("Arial", 11)) - self.current_action_label.setStyleSheet("color: #ffffff; border: none;") - - # Top row: Tracks and Albums side by side - top_progress_layout = QHBoxLayout() - top_progress_layout.setSpacing(15) - - # Tracks progress (left) - tracks_layout = QVBoxLayout() - tracks_layout.setSpacing(4) - - singles_label = QLabel("Total Singles and EPs:") - singles_label.setFont(QFont("Arial", 9)) - singles_label.setStyleSheet("color: #b3b3b3; border: none;") - - self.singles_progress_bar = QProgressBar() - self.singles_progress_bar.setFixedHeight(16) - self.singles_progress_bar.setRange(0, 100) - self.singles_progress_bar.setValue(0) - self.singles_progress_bar.setStyleSheet(""" - QProgressBar { - border: 1px solid #555; - border-radius: 8px; - text-align: center; - background-color: #444; - color: #fff; - font-size: 10px; - } - QProgressBar::chunk { - background-color: #ff9800; - border-radius: 7px; - } - """) - - tracks_layout.addWidget(singles_label) - tracks_layout.addWidget(self.singles_progress_bar) - - # Albums progress (right) - albums_layout = QVBoxLayout() - albums_layout.setSpacing(4) - - albums_label = QLabel("Total Albums:") - albums_label.setFont(QFont("Arial", 9)) - albums_label.setStyleSheet("color: #b3b3b3; border: none;") - - self.albums_progress_bar = QProgressBar() - self.albums_progress_bar.setFixedHeight(16) - self.albums_progress_bar.setRange(0, 100) - self.albums_progress_bar.setValue(0) - self.albums_progress_bar.setStyleSheet(""" - QProgressBar { - border: 1px solid #555; - border-radius: 8px; - text-align: center; - background-color: #444; - color: #fff; - font-size: 10px; - } - QProgressBar::chunk { - background-color: #ffc107; - border-radius: 7px; - } - """) - - albums_layout.addWidget(albums_label) - albums_layout.addWidget(self.albums_progress_bar) - - top_progress_layout.addLayout(tracks_layout) - top_progress_layout.addLayout(albums_layout) - - # Overall artists progress (bottom, full width) - overall_progress_label = QLabel("Overall Progress:") - overall_progress_label.setFont(QFont("Arial", 9)) - overall_progress_label.setStyleSheet("color: #b3b3b3; border: none;") - - self.artists_progress_bar = QProgressBar() - self.artists_progress_bar.setFixedHeight(20) - self.artists_progress_bar.setRange(0, 100) - self.artists_progress_bar.setValue(0) - self.artists_progress_bar.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.scan_summary_label = QLabel("") - self.scan_summary_label.setFont(QFont("Arial", 9)) - self.scan_summary_label.setStyleSheet("color: #b3b3b3; border: none;") - - progress_layout.addWidget(progress_header) - progress_layout.addWidget(self.current_action_label) - progress_layout.addLayout(top_progress_layout) - progress_layout.addWidget(overall_progress_label) - progress_layout.addWidget(self.artists_progress_bar) - progress_layout.addWidget(self.scan_summary_label) - - layout.addWidget(progress_frame) - - # Artists list - artists_header_layout = QHBoxLayout() - - list_label = QLabel("Watched Artists:") - list_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - list_label.setStyleSheet("color: #ffffff; border: none;") - - # Artist count label - self.artist_count_label = QLabel("0 artists") - self.artist_count_label.setFont(QFont("Arial", 10)) - self.artist_count_label.setStyleSheet("color: #b3b3b3; border: none;") - - artists_header_layout.addWidget(list_label) - artists_header_layout.addStretch() - artists_header_layout.addWidget(self.artist_count_label) - - layout.addLayout(artists_header_layout) - - # Search bar - self.search_bar = QLineEdit() - self.search_bar.setPlaceholderText("Search all artists...") - self.search_bar.setFixedHeight(32) - self.search_bar.setStyleSheet(""" - QLineEdit { - background: #333333; - color: #ffffff; - border: 1px solid #555555; - border-radius: 6px; - padding: 6px 12px; - font-size: 11px; - } - QLineEdit:focus { - border: 1px solid #1db954; - } - QLineEdit::placeholder { - color: #888888; - } - """) - self.search_bar.textChanged.connect(self.filter_artists) - layout.addWidget(self.search_bar) - - # Scroll area for artists - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setStyleSheet(""" - QScrollArea { - border: 1px solid #404040; - border-radius: 8px; - background: #282828; - } - QScrollBar:vertical { - background: rgba(60, 60, 60, 0.3); - width: 8px; - border-radius: 4px; - } - QScrollBar::handle:vertical { - background: #1db954; - border-radius: 4px; - min-height: 20px; - } - """) - - self.artists_widget = QWidget() - self.artists_layout = QVBoxLayout(self.artists_widget) - self.artists_layout.setContentsMargins(10, 10, 10, 10) - self.artists_layout.setSpacing(8) - - scroll_area.setWidget(self.artists_widget) - layout.addWidget(scroll_area) - - # Buttons - button_layout = QHBoxLayout() - - self.scan_button = QPushButton("Start Scan") - self.scan_button.setFixedHeight(36) - self.scan_button.clicked.connect(self.start_scan) - self.scan_button.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 18px; - color: #000000; - font-size: 12px; - font-weight: bold; - padding: 0 16px; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:pressed { - background: #1aa34a; - } - QPushButton:disabled { - background: #404040; - color: #666666; - } - """) - - close_button = QPushButton("Close") - close_button.setFixedHeight(36) - close_button.clicked.connect(self.close) - close_button.setStyleSheet(""" - QPushButton { - background: rgba(80, 80, 80, 0.6); - border: 1px solid rgba(120, 120, 120, 0.4); - border-radius: 18px; - color: #ffffff; - font-size: 12px; - font-weight: bold; - padding: 0 16px; - } - QPushButton:hover { - background: rgba(100, 100, 100, 0.8); - border: 1px solid rgba(140, 140, 140, 0.6); - } - """) - - button_layout.addWidget(self.scan_button) - button_layout.addStretch() - button_layout.addWidget(close_button) - - layout.addLayout(button_layout) - - def load_watchlist_data(self): - """Load and display watchlist artists""" - try: - database = get_database() - self.current_artists = database.get_watchlist_artists() - - logger.info(f"Loading watchlist data: found {len(self.current_artists)} artists") - - # Clear existing widgets - for i in reversed(range(self.artists_layout.count())): - child = self.artists_layout.itemAt(i).widget() - if child: - child.deleteLater() - - if not self.current_artists: - no_artists_label = QLabel("No artists in watchlist") - no_artists_label.setStyleSheet("color: #888888; font-style: italic; padding: 20px; border: none;") - no_artists_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.artists_layout.addWidget(no_artists_label) - self.scan_button.setEnabled(False) - self.artist_count_label.setText("0 artists") - logger.info("No artists in watchlist - showing empty message") - return - - self.scan_button.setEnabled(True) - - # Use filter method to populate (handles initial load and filtering) - self.filter_artists() - - # Update status - self.status_label.setText(f"{len(self.current_artists)} artists being monitored") - - except Exception as e: - logger.error(f"Error loading watchlist data: {e}") - - def filter_artists(self): - """Filter artists based on search text""" - search_text = self.search_bar.text().lower().strip() - - if not hasattr(self, 'current_artists') or not self.current_artists: - return - - # Clear existing widgets - for i in reversed(range(self.artists_layout.count())): - child = self.artists_layout.itemAt(i).widget() - if child: - child.setParent(None) - - # Determine which artists to show - if search_text: - # When searching: filter from ALL artists - filtered_artists = [ - artist for artist in self.current_artists - if search_text in artist.artist_name.lower() - ] - else: - # When empty: show only the last 5 added artists - # Artists are already sorted with most recent first (insertWidget(0)) - filtered_artists = self.current_artists[:5] - - # Add filtered artist cards or show empty message - if filtered_artists: - for artist in filtered_artists: - artist_card = self.create_artist_card(artist) - self.artists_layout.insertWidget(0, artist_card) - elif search_text: - # Show "no results" message when search returns no matches - no_results_label = QLabel(f"No artists found matching '{search_text}'") - no_results_label.setStyleSheet("color: #888888; font-style: italic; padding: 20px; border: none;") - no_results_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.artists_layout.addWidget(no_results_label) - - self.artists_layout.addStretch() - - # Update count labels - total_count = len(self.current_artists) - filtered_count = len(filtered_artists) - - if search_text: - self.artist_count_label.setText(f"{filtered_count} of {total_count} artists") - else: - if total_count <= 5: - self.artist_count_label.setText(f"{total_count} artists") - else: - self.artist_count_label.setText(f"Showing 5 of {total_count} artists") - - def get_artist_status_icon(self, artist): - """Determine the appropriate status icon and color for an artist based on scan history""" - if not artist.last_scan_timestamp: - return "", "#888888" # Not scanned yet (gray circle) - - try: - from datetime import datetime, timezone - # Check how long ago the last scan was - now = datetime.now(timezone.utc) - last_scan = artist.last_scan_timestamp - - # If last_scan is naive (no timezone), assume it's UTC - if last_scan.tzinfo is None: - last_scan = last_scan.replace(tzinfo=timezone.utc) - - time_diff = now - last_scan - hours_ago = time_diff.total_seconds() / 3600 - - # If scanned within the last 24 hours, show as up to date - # If older, show as potentially stale but still scanned - if hours_ago <= 24: - return "", "#4caf50" # Recently up to date (bright green) - else: - return "", "#888888" # Scanned but older (gray checkmark) - - except Exception: - # Fallback if datetime parsing fails - return "", "#4caf50" # Default to up to date - - def create_artist_card(self, artist: WatchlistArtist) -> QFrame: - """Create a professional artist card widget""" - card = QFrame() - card.setFixedHeight(48) # Increased height for better visual hierarchy - card.setStyleSheet(""" - QFrame { - background: rgba(40, 40, 40, 0.8); - border-radius: 10px; - border: 1px solid rgba(80, 80, 80, 0.4); - } - QFrame:hover { - background: rgba(45, 45, 45, 0.9); - border: 1px solid rgba(100, 100, 100, 0.6); - } - """) - - layout = QHBoxLayout(card) - layout.setContentsMargins(16, 8, 16, 8) - layout.setSpacing(16) - - # Status indicator with icon - status_icon, status_color = self.get_artist_status_icon(artist) - status_label = QLabel(status_icon) - status_label.setFont(QFont("Arial", 14)) - status_label.setStyleSheet(f"color: {status_color}; border: none; background: transparent;") - status_label.setFixedWidth(24) - status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Left side: Artist info - info_layout = QVBoxLayout() - info_layout.setSpacing(2) - - # Artist name with label - artist_label = QLabel(f"Artist: {artist.artist_name}") - artist_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - artist_label.setStyleSheet("color: #ffffff; border: none; background: transparent;") - - # Last scan info with professional formatting - if artist.last_scan_timestamp: - try: - from datetime import datetime, timezone - # Ensure both timestamps have timezone info - now = datetime.now(timezone.utc) - last_scan = artist.last_scan_timestamp - - # If last_scan is naive (no timezone), assume it's UTC - if last_scan.tzinfo is None: - last_scan = last_scan.replace(tzinfo=timezone.utc) - - time_diff = now - last_scan - if time_diff.days > 0: - scan_time = f"{time_diff.days} day{'s' if time_diff.days > 1 else ''} ago" - elif time_diff.seconds > 3600: - hours = time_diff.seconds // 3600 - scan_time = f"{hours} hour{'s' if hours > 1 else ''} ago" - else: - minutes = max(1, time_diff.seconds // 60) - scan_time = f"{minutes} minute{'s' if minutes > 1 else ''} ago" - except Exception as e: - # Fallback to formatted date - scan_time = last_scan.strftime("%m/%d/%Y at %I:%M %p") - else: - scan_time = "never scanned" - - sync_label = QLabel(f"Last Sync: {scan_time}") - sync_label.setFont(QFont("Arial", 10)) - sync_label.setStyleSheet("color: #b3b3b3; border: none; background: transparent;") - - info_layout.addWidget(artist_label) - info_layout.addWidget(sync_label) - - # Delete button with modern styling - delete_button = QPushButton("") - delete_button.setFixedSize(28, 28) - delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - delete_button.setStyleSheet(""" - QPushButton { - background: rgba(244, 67, 54, 0.1); - color: #f44336; - border: 1px solid rgba(244, 67, 54, 0.3); - border-radius: 14px; - font-weight: bold; - } - QPushButton:hover { - background: rgba(244, 67, 54, 0.8); - color: white; - border: 1px solid #f44336; - } - QPushButton:pressed { - background: rgba(200, 50, 40, 1.0); - } - """) - delete_button.setToolTip(f"Remove {artist.artist_name} from watchlist") - delete_button.clicked.connect(lambda: self.delete_artist(artist)) - - # Store references for updates - setattr(card, 'status_indicator', status_label) - setattr(card, 'artist_id', artist.spotify_artist_id) - - layout.addWidget(status_label) - layout.addLayout(info_layout) - layout.addStretch() - layout.addWidget(delete_button) - - return card - - def delete_artist(self, artist: WatchlistArtist): - """Delete an artist from the watchlist with confirmation""" - try: - # Show confirmation dialog - reply = QMessageBox.question( - self, - "Remove Artist from Watchlist", - f"Are you sure you want to remove '{artist.artist_name}' from your watchlist?\n\n" - "This will stop monitoring this artist for new releases.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - - if reply == QMessageBox.StandardButton.Yes: - # Remove from database - database = get_database() - success = database.remove_artist_from_watchlist(artist.spotify_artist_id) - - if success: - logger.info(f"Removed {artist.artist_name} from watchlist") - - # Refresh the artist list to show the updated watchlist - self.load_watchlist_data() - - # Update parent window if it has a watchlist count (like dashboard) - if self.parent() and hasattr(self.parent(), 'update_watchlist_button_count'): - self.parent().update_watchlist_button_count() - else: - QMessageBox.warning( - self, - "Error", - f"Failed to remove '{artist.artist_name}' from watchlist.\nPlease try again." - ) - - except Exception as e: - logger.error(f"Error deleting artist from watchlist: {e}") - QMessageBox.critical( - self, - "Error", - f"An error occurred while removing the artist:\n{str(e)}" - ) - - def start_scan(self): - """Start the watchlist scan""" - if self.scan_in_progress: - return - - if not self.spotify_client: - logger.error("No Spotify client available for watchlist scan") - return - - try: - self.scan_in_progress = True - self.is_manual_scan_owner = True # This modal started the scan - self.scan_button.setText("Scanning...") - self.scan_button.setEnabled(False) - - # Reset artist status indicators - for i in range(self.artists_layout.count()): - item = self.artists_layout.itemAt(i) - if item and item.widget(): - card = item.widget() - if hasattr(card, 'status_indicator'): - card.status_indicator.setText("") # Not scanned yet - card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;") - - # Use shared scan worker so it persists across modal close/open - WatchlistStatusModal._shared_scan_worker = WatchlistScanWorker(self.spotify_client) - WatchlistStatusModal._scan_owner_modal = self - self.scan_worker = WatchlistStatusModal._shared_scan_worker - - self.scan_worker.scan_started.connect(self.on_scan_started) - self.scan_worker.artist_scan_started.connect(self.on_artist_scan_started) - self.scan_worker.artist_totals_discovered.connect(self.on_artist_totals_discovered) - self.scan_worker.album_scan_started.connect(self.on_album_scan_started) - self.scan_worker.track_check_started.connect(self.on_track_check_started) - self.scan_worker.release_completed.connect(self.on_release_completed) - self.scan_worker.artist_scan_completed.connect(self.on_artist_scan_completed) - self.scan_worker.scan_completed.connect(self.on_scan_completed) - self.scan_worker.start() - - except Exception as e: - logger.error(f"Error starting watchlist scan: {e}") - self.scan_in_progress = False - self.scan_button.setText("Start Scan") - self.scan_button.setEnabled(True) - - def on_scan_started(self): - """Handle scan start""" - # Only reset progress if this is a fresh scan, not a reconnection - if not self.is_reconnecting_to_ongoing_scan: - self.current_action_label.setText("Starting watchlist scan...") - - # Reset overall counters - self.total_artists = len(self.current_artists) - self.completed_artists = 0 - - # Reset current artist tracking - self.current_artist_name = "" - self.current_artist_total_singles_eps = 0 - self.current_artist_completed_singles_eps = 0 - self.current_artist_total_albums = 0 - self.current_artist_completed_albums = 0 - - # Reset progress bars - self.singles_progress_bar.setValue(0) - self.albums_progress_bar.setValue(0) - self.artists_progress_bar.setValue(0) - self.scan_summary_label.setText("Preparing to scan artists...") - - # Clear the reconnection flag after handling - self.is_reconnecting_to_ongoing_scan = False - - def on_artist_scan_started(self, artist_name: str): - """Handle individual artist scan start""" - self.current_action_label.setText(f"Scanning: {artist_name}") - self.scan_summary_label.setText("Getting artist discography...") - - # Reset for new artist - self.current_artist_name = artist_name - self.current_artist_total_singles_eps = 0 - self.current_artist_completed_singles_eps = 0 - self.current_artist_total_albums = 0 - self.current_artist_completed_albums = 0 - - # Reset progress bars for new artist - self.singles_progress_bar.setValue(0) - self.albums_progress_bar.setValue(0) - - # Update status indicator to yellow (scanning) - for i in range(self.artists_layout.count()): - item = self.artists_layout.itemAt(i) - if item and item.widget(): - card = item.widget() - if hasattr(card, 'status_indicator') and hasattr(card, 'artist_id'): - # Find artist by name (we don't have ID in signal) - for artist in self.current_artists: - if artist.artist_name == artist_name: - card.status_indicator.setText("") # Scanning - card.status_indicator.setStyleSheet("color: #ffc107; border: none; background: transparent;") - break - - def on_artist_totals_discovered(self, artist_name: str, total_singles_eps_releases: int, total_albums: int): - """Handle discovery of artist's total release counts""" - # Set the total counts for this artist - now counting RELEASES not tracks - self.current_artist_total_singles_eps = total_singles_eps_releases - self.current_artist_total_albums = total_albums - - # Reset completed counts to 0 for new artist - self.current_artist_completed_singles_eps = 0 - self.current_artist_completed_albums = 0 - - # Update progress bars to show 0% progress with known totals - self.singles_progress_bar.setValue(0) - self.albums_progress_bar.setValue(0) - - logger.debug(f"Artist {artist_name}: {total_singles_eps_releases} singles/EPs, {total_albums} albums") - - def on_album_scan_started(self, artist_name: str, album_name: str, total_tracks: int): - """Handle album/release scan start""" - self.current_action_label.setText(f"Scanning: {artist_name}") - self.scan_summary_label.setText(f"Release: {album_name}") - - def on_track_check_started(self, artist_name: str, album_name: str, track_name: str): - """Handle track check start""" - # Truncate long track names to keep UI readable - display_track = track_name[:40] + "..." if len(track_name) > 40 else track_name - self.current_action_label.setText(f"Scanning: {artist_name}") - self.scan_summary_label.setText(f"Track: {display_track}") - - def on_release_completed(self, artist_name: str, album_name: str, total_tracks: int): - """Handle when a release (album/single/EP) finishes being scanned""" - # Determine if this was a single/EP or album - if total_tracks >= 4: - # This was an album - self.current_artist_completed_albums += 1 - - # Update albums progress bar - if self.current_artist_total_albums > 0: - progress = int((self.current_artist_completed_albums / self.current_artist_total_albums) * 100) - self.albums_progress_bar.setValue(progress) - else: - # This was a single/EP - self.current_artist_completed_singles_eps += 1 - - # Update singles progress bar - if self.current_artist_total_singles_eps > 0: - progress = int((self.current_artist_completed_singles_eps / self.current_artist_total_singles_eps) * 100) - self.singles_progress_bar.setValue(progress) - - def on_artist_scan_completed(self, artist_name: str, albums_checked: int, new_tracks: int, success: bool): - """Handle individual artist scan completion""" - # Mark this artist as completed - self.completed_artists += 1 - - # Update overall artists progress bar - if self.total_artists > 0: - progress = int((self.completed_artists / self.total_artists) * 100) - self.artists_progress_bar.setValue(progress) - - # Update status indicator - for i in range(self.artists_layout.count()): - item = self.artists_layout.itemAt(i) - if item and item.widget(): - card = item.widget() - if hasattr(card, 'status_indicator'): - # Find artist by name - for artist in self.current_artists: - if artist.artist_name == artist_name: - if success: - if new_tracks > 0: - card.status_indicator.setText("") # New tracks found - card.status_indicator.setStyleSheet("color: #1db954; border: none; background: transparent;") - else: - card.status_indicator.setText("") # Up to date - card.status_indicator.setStyleSheet("color: #4caf50; border: none; background: transparent;") - else: - card.status_indicator.setText("") # Error - card.status_indicator.setStyleSheet("color: #f44336; border: none; background: transparent;") - break - - def on_scan_completed(self, scan_results: List[ScanResult]): - """Handle scan completion""" - self.scan_in_progress = False - self.is_manual_scan_owner = False # Reset ownership - self.scan_button.setText("Start Scan") - self.scan_button.setEnabled(True) - - # Keep shared worker around for a bit so other modals can see completed results - # Only clear it if this modal was the owner (manual scan starter) - if (self.scan_worker == WatchlistStatusModal._shared_scan_worker - and WatchlistStatusModal._scan_owner_modal == self): - # Keep the worker alive for 30 seconds to allow other modals to see results - QTimer.singleShot(30000, self._cleanup_shared_worker_delayed) - WatchlistStatusModal._scan_owner_modal = None - - # Calculate summary - successful_scans = [r for r in scan_results if r.success] - total_new_tracks = sum(r.new_tracks_found for r in successful_scans) - total_albums_checked = sum(r.albums_checked for r in successful_scans) - - self.current_action_label.setText("Scan completed") - self.singles_progress_bar.setValue(100) - self.albums_progress_bar.setValue(100) - self.artists_progress_bar.setValue(100) - - if scan_results: - summary = f"Scanned {len(successful_scans)}/{len(scan_results)} artists, {total_albums_checked} albums, found {total_new_tracks} new tracks" - else: - summary = "Scan failed - check logs for details" - - self.scan_summary_label.setText(summary) - - # Update status - if total_new_tracks > 0: - self.status_label.setText(f"Found {total_new_tracks} new tracks!") - else: - self.status_label.setText("All artists up to date") - - def on_background_scan_started(self): - """Handle background scan start from dashboard""" - if not self.scan_in_progress: # Only update if we're not already doing a manual scan - self.current_action_label.setText("Starting background scan...") - self.singles_progress_bar.setValue(0) - self.albums_progress_bar.setValue(0) - self.artists_progress_bar.setValue(0) - self.scan_summary_label.setText("Automatic watchlist scan in progress...") - self.scan_button.setText("Background Scanning...") - self.scan_button.setEnabled(False) - - # Reset artist status indicators - for i in range(self.artists_layout.count()): - item = self.artists_layout.itemAt(i) - if item and item.widget(): - card = item.widget() - if hasattr(card, 'status_indicator'): - card.status_indicator.setText("") # Not scanned yet - card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;") - - def on_background_scan_completed(self, total_artists: int, total_new_tracks: int, total_added_to_wishlist: int): - """Handle background scan completion from dashboard""" - if not self.scan_in_progress: # Only update if we're not doing a manual scan - self.current_action_label.setText("Background scan completed") - - if total_new_tracks > 0: - summary = f"Background scan found {total_new_tracks} new tracks from {total_artists} artists" - self.status_label.setText(f"Found {total_new_tracks} new tracks!") - else: - summary = f"Background scan completed - all {total_artists} artists up to date" - self.status_label.setText("All artists up to date") - - self.scan_summary_label.setText(summary) - self.scan_button.setText("Start Scan") - self.scan_button.setEnabled(True) - - # Refresh the artist list to show updated status - self.load_watchlist_data() - - def showEvent(self, event): - """Handle modal show - refresh data and connect to any ongoing scan""" - super().showEvent(event) - self.load_watchlist_data() - - # First check if there's a manual scan worker (running or recently completed) - if WatchlistStatusModal._shared_scan_worker: - - logger.info("Found manual watchlist scan worker - reconnecting to it") - - # Reconnect to the shared manual scan worker - self.scan_worker = WatchlistStatusModal._shared_scan_worker - - # Check if scan is still active - progress_state = self.scan_worker.get_current_progress() - self.scan_in_progress = progress_state.get('scan_active', False) if progress_state else False - self.is_reconnecting_to_ongoing_scan = True - - try: - # Restore progress state BEFORE connecting signals to prevent reset conflicts - self._restore_progress_state(progress_state) - - # Now connect to future signals - self.scan_worker.scan_started.connect(self.on_scan_started) - self.scan_worker.artist_scan_started.connect(self.on_artist_scan_started) - self.scan_worker.artist_totals_discovered.connect(self.on_artist_totals_discovered) - self.scan_worker.album_scan_started.connect(self.on_album_scan_started) - self.scan_worker.track_check_started.connect(self.on_track_check_started) - self.scan_worker.release_completed.connect(self.on_release_completed) - self.scan_worker.artist_scan_completed.connect(self.on_artist_scan_completed) - self.scan_worker.scan_completed.connect(self.on_scan_completed) - - # Update UI to show reconnection status (will be overridden by _restore_progress_state if needed) - if self.scan_in_progress: - self.current_action_label.setText("Reconnected to manual scan...") - self.scan_button.setText("Scanning...") - self.scan_button.setEnabled(False) - else: - self.current_action_label.setText("Viewing completed manual scan results") - self.scan_button.setText("Start Scan") - self.scan_button.setEnabled(True) - - except Exception as e: - logger.debug(f"Could not connect to manual scan signals (may already be connected): {e}") - - # Otherwise check if there's a background scan already running and connect to it - elif not self.scan_in_progress: - try: - # Get the dashboard page to check for running background scan - dashboard = None - if self.parent(): - # Try to find the dashboard page in the parent hierarchy - parent_widget = self.parent() - while parent_widget and not hasattr(parent_widget, 'background_watchlist_worker'): - parent_widget = parent_widget.parent() - - if parent_widget and hasattr(parent_widget, 'background_watchlist_worker'): - dashboard = parent_widget - - # If we found the dashboard and there's an active background worker - if (dashboard and hasattr(dashboard, 'background_watchlist_worker') - and dashboard.background_watchlist_worker - and dashboard.background_watchlist_worker.isRunning() - and hasattr(dashboard, 'auto_processing_watchlist') - and dashboard.auto_processing_watchlist): - - logger.info("Found active background watchlist scan - connecting modal to live updates") - - # Set reconnection flag and restore progress before connecting signals - self.is_reconnecting_to_ongoing_scan = True - - # Restore progress state BEFORE connecting signals - try: - progress_state = dashboard.background_watchlist_worker.get_current_progress() - self._restore_progress_state(progress_state) - except Exception as e: - logger.debug(f"Could not restore background scan progress: {e}") - - # Connect to the background worker's signals for live updates - # Now using the same WatchlistScanWorker signals (no .signals attribute needed) - try: - dashboard.background_watchlist_worker.scan_started.connect(self.on_scan_started) - dashboard.background_watchlist_worker.artist_scan_started.connect(self.on_artist_scan_started) - dashboard.background_watchlist_worker.artist_totals_discovered.connect(self.on_artist_totals_discovered) - dashboard.background_watchlist_worker.album_scan_started.connect(self.on_album_scan_started) - dashboard.background_watchlist_worker.track_check_started.connect(self.on_track_check_started) - dashboard.background_watchlist_worker.release_completed.connect(self.on_release_completed) - dashboard.background_watchlist_worker.artist_scan_completed.connect(self.on_artist_scan_completed) - - # Update UI to show reconnection status - self.current_action_label.setText("Reconnected to background scan...") - self.scan_button.setText("Background Scanning...") - self.scan_button.setEnabled(False) - - except Exception as e: - logger.debug(f"Could not connect to background scan signals (may already be connected): {e}") - - except Exception as e: - logger.debug(f"Error checking for background scan: {e}") - # Not critical - just means we can't detect ongoing scans - - @staticmethod - def _cleanup_shared_worker_delayed(): - """Clean up shared worker after delay to allow other modals to see results""" - try: - if WatchlistStatusModal._shared_scan_worker: - if not WatchlistStatusModal._shared_scan_worker.isRunning(): - WatchlistStatusModal._shared_scan_worker = None - logger.debug("Cleaned up completed shared scan worker") - except Exception as e: - logger.debug(f"Error cleaning up shared worker: {e}") - - def _restore_progress_state(self, progress_state): - """Restore progress bars and UI state from worker's current progress""" - if not progress_state: - return - - # Handle both active and completed scans - is_active = progress_state.get('scan_active', False) - is_completed = progress_state.get('scan_completed', False) - - if not is_active and not is_completed: - return - - try: - # Fully sync modal state with worker state - self.total_artists = progress_state.get('total_artists', 0) - self.completed_artists = progress_state.get('completed_artists', 0) - self.current_artist_name = progress_state.get('current_artist_name', '') - self.current_artist_total_singles_eps = progress_state.get('current_artist_total_singles_eps', 0) - self.current_artist_completed_singles_eps = progress_state.get('current_artist_completed_singles_eps', 0) - self.current_artist_total_albums = progress_state.get('current_artist_total_albums', 0) - self.current_artist_completed_albums = progress_state.get('current_artist_completed_albums', 0) - - # Update UI elements - if self.total_artists > 0: - overall_progress = int((self.completed_artists / self.total_artists) * 100) - self.artists_progress_bar.setValue(overall_progress) - - if self.current_artist_name: - self.current_action_label.setText(f"Scanning: {self.current_artist_name}") - - # Update current artist progress bars - if self.current_artist_total_singles_eps > 0: - singles_progress = int((self.current_artist_completed_singles_eps / self.current_artist_total_singles_eps) * 100) - self.singles_progress_bar.setValue(singles_progress) - - if self.current_artist_total_albums > 0: - albums_progress = int((self.current_artist_completed_albums / self.current_artist_total_albums) * 100) - self.albums_progress_bar.setValue(albums_progress) - - # Update scan summary and UI state based on scan status - if is_completed: - self.scan_summary_label.setText("Scan completed - viewing final results") - self.scan_button.setText("Start Scan") - self.scan_button.setEnabled(True) - # Set progress bars to 100% for completed scans - self.artists_progress_bar.setValue(100) - if self.current_artist_total_singles_eps > 0: - self.singles_progress_bar.setValue(100) - if self.current_artist_total_albums > 0: - self.albums_progress_bar.setValue(100) - elif is_active: - remaining_artists = self.total_artists - self.completed_artists - self.scan_summary_label.setText(f"Reconnected to ongoing scan - {remaining_artists} artists remaining") - self.scan_button.setText("Scanning...") - self.scan_button.setEnabled(False) - - logger.info(f"Restored progress state: {self.completed_artists}/{self.total_artists} artists, current: {self.current_artist_name}") - - except Exception as e: - logger.error(f"Error restoring progress state: {e}") - - def closeEvent(self, event): - """Handle modal close""" - # Only stop the scan if this modal owns it and it's not a shared manual scan - if (self.scan_worker and self.scan_worker.isRunning() - and self.is_manual_scan_owner - and self.scan_worker != WatchlistStatusModal._shared_scan_worker): - self.scan_worker.stop() - self.scan_worker.wait() - - # Don't stop shared manual scans - they should continue running - - # Disconnect from any background worker signals to prevent duplicates - try: - if self.parent(): - parent_widget = self.parent() - while parent_widget and not hasattr(parent_widget, 'background_watchlist_worker'): - parent_widget = parent_widget.parent() - - if (parent_widget and hasattr(parent_widget, 'background_watchlist_worker') - and parent_widget.background_watchlist_worker): - try: - parent_widget.background_watchlist_worker.scan_started.disconnect(self.on_scan_started) - parent_widget.background_watchlist_worker.artist_scan_started.disconnect(self.on_artist_scan_started) - parent_widget.background_watchlist_worker.artist_totals_discovered.disconnect(self.on_artist_totals_discovered) - parent_widget.background_watchlist_worker.album_scan_started.disconnect(self.on_album_scan_started) - parent_widget.background_watchlist_worker.track_check_started.disconnect(self.on_track_check_started) - parent_widget.background_watchlist_worker.release_completed.disconnect(self.on_release_completed) - parent_widget.background_watchlist_worker.artist_scan_completed.disconnect(self.on_artist_scan_completed) - except: - pass # Ignore if signals weren't connected - except: - pass # Not critical - - event.accept() \ No newline at end of file diff --git a/ui/pages/__init__.py b/ui/pages/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ui/pages/artists.py b/ui/pages/artists.py deleted file mode 100644 index e0c6da33..00000000 --- a/ui/pages/artists.py +++ /dev/null @@ -1,5507 +0,0 @@ -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QFrame, QPushButton, QLineEdit, QScrollArea, - QGridLayout, QSizePolicy, QSpacerItem, QApplication, - QDialog, QDialogButtonBox, QProgressBar, QMessageBox, - QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, QObject, QRunnable, QThreadPool, QPropertyAnimation, QEasingCurve, QRect -from PyQt6.QtGui import QFont, QPixmap, QPainter, QPen, QColor -import functools -import os -import threading -import requests -import re -from typing import List, Optional -from dataclasses import dataclass - -# Import core components -from core.spotify_client import SpotifyClient, Artist, Album -from core.plex_client import PlexClient -from core.soulseek_client import SoulseekClient, AlbumResult -from core.matching_engine import MusicMatchingEngine -from core.wishlist_service import get_wishlist_service -from core.plex_scan_manager import PlexScanManager -from database.music_database import get_database -from utils.logging_config import get_logger -import asyncio -from datetime import datetime - -logger = get_logger("artists") - - -@dataclass -class ArtistMatch: - """Represents an artist match with confidence score""" - artist: Artist - confidence: float - match_reason: str = "" - -@dataclass -class AlbumOwnershipStatus: - """Represents album ownership status with completeness info""" - album_name: str - is_owned: bool - is_complete: bool - is_nearly_complete: bool - owned_tracks: int - expected_tracks: int - completion_ratio: float - - @property - def completion_level(self) -> str: - """Get completion level as string""" - if not self.is_owned: - return "missing" - elif self.completion_ratio >= 0.9: - return "complete" - elif self.completion_ratio >= 0.8: - return "nearly_complete" - else: - return "partial" - -class DownloadCompletionWorkerSignals(QObject): - """Signals for the download completion worker""" - completed = pyqtSignal(object, str) # download_item, organized_path - error = pyqtSignal(object, str) # download_item, error_message - -class DownloadCompletionWorker(QRunnable): - """Background worker to handle download completion processing without blocking UI""" - - def __init__(self, download_item, absolute_file_path, organize_func): - super().__init__() - self.download_item = download_item - self.absolute_file_path = absolute_file_path - self.organize_func = organize_func - self.signals = DownloadCompletionWorkerSignals() - - def run(self): - """Process download completion in background thread""" - try: - print(f"Background worker processing download...") - - # Add a small delay to ensure file is fully written - import time - time.sleep(1) - - # Organize the file into Transfer folder structure - organized_path = self.organize_func(self.download_item, self.absolute_file_path) - - # Emit completion signal - self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path) - - except Exception as e: - print(f"Error in background worker: {e}") - import traceback - traceback.print_exc() - # Emit error signal - self.signals.error.emit(self.download_item, str(e)) - - - - -class ImageDownloaderSignals(QObject): - """Signals for the ImageDownloader worker.""" - finished = pyqtSignal(QLabel, QPixmap) - error = pyqtSignal(str) - -class ImageDownloader(QRunnable): - """Worker to download an image in the background.""" - def __init__(self, url: str, target_label: QLabel): - super().__init__() - self.signals = ImageDownloaderSignals() - self.url = url - self.target_label = target_label - - def run(self): - try: - if not self.url: - self.signals.error.emit("No image URL provided.") - return - - response = requests.get(self.url, stream=True, timeout=10) - response.raise_for_status() - - pixmap = QPixmap() - pixmap.loadFromData(response.content) - - if not pixmap.isNull(): - self.signals.finished.emit(self.target_label, pixmap) - else: - self.signals.error.emit("Failed to load image from data.") - - except requests.RequestException as e: - self.signals.error.emit(f"Network error downloading image: {e}") - except Exception as e: - self.signals.error.emit(f"Error processing image: {e}") - -class ArtistSearchWorker(QThread): - """Background worker for artist search""" - artists_found = pyqtSignal(list) # List of ArtistMatch objects - search_failed = pyqtSignal(str) - - def __init__(self, query: str, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine): - super().__init__() - self.query = query - self.spotify_client = spotify_client - self.matching_engine = matching_engine - - def run(self): - try: - # Search for artists using Spotify - artists = self.spotify_client.search_artists(self.query, limit=10) - - # Create artist matches with confidence scores - artist_matches = [] - for artist in artists: - # Calculate confidence based on name similarity - confidence = self.matching_engine.similarity_score(self.query.lower(), artist.name.lower()) - match = ArtistMatch( - artist=artist, - confidence=confidence, - match_reason=f"Name similarity: {confidence:.1%}" - ) - artist_matches.append(match) - - # Sort by confidence score - artist_matches.sort(key=lambda x: x.confidence, reverse=True) - - self.artists_found.emit(artist_matches) - - except Exception as e: - self.search_failed.emit(str(e)) - -class AlbumFetchWorker(QThread): - """Background worker for fetching artist albums""" - albums_found = pyqtSignal(list, object) # List of albums, selected artist - fetch_failed = pyqtSignal(str) - - def __init__(self, artist: Artist, spotify_client: SpotifyClient): - super().__init__() - self.artist = artist - self.spotify_client = spotify_client - - def run(self): - try: - print(f"Fetching all releases (albums & singles) for artist: {self.artist.name} (ID: {self.artist.id})") - - # Always fetch both albums and singles from the Spotify API - albums = self.spotify_client.get_artist_albums(self.artist.id, album_type='album,single', limit=50) - - print(f"Found {len(albums)} total releases for {self.artist.name}") - - # Remove duplicates based on name (case insensitive) - seen_names = set() - unique_albums = [] - for album in albums: - album_name_lower = album.name.lower() - if album_name_lower not in seen_names: - seen_names.add(album_name_lower) - unique_albums.append(album) - - # Sort by release date (newest first) - unique_albums.sort(key=lambda x: x.release_date if x.release_date else '', reverse=True) - - print(f"Returning {len(unique_albums)} unique releases") - self.albums_found.emit(unique_albums, self.artist) - - except Exception as e: - error_msg = f"Failed to fetch albums for {self.artist.name}: {str(e)}" - print(f"{error_msg}") - self.fetch_failed.emit(error_msg) - -class AlbumSearchWorker(QThread): - """Background worker for searching albums on Soulseek""" - search_results = pyqtSignal(list) # List of AlbumResult objects - search_failed = pyqtSignal(str) - search_progress = pyqtSignal(str) # Progress messages - - def __init__(self, query: str, soulseek_client: SoulseekClient): - super().__init__() - self.query = query - self.soulseek_client = soulseek_client - self._stop_requested = False - - def stop(self): - """Request to stop the search""" - self._stop_requested = True - - def run(self): - """Executes the album search asynchronously.""" - loop = None - try: - if not self.soulseek_client: - self.search_failed.emit("Soulseek client not available") - return - - # Create a new event loop for this thread to run async operations - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - self.search_progress.emit(f"Searching for: {self.query}") - - # Perform the async search using the provided query - results = loop.run_until_complete(self.soulseek_client.search(self.query)) - - if self._stop_requested: - return - - # The search method returns a tuple of (tracks, albums) - tracks, albums = results if results else ([], []) - album_results = albums if albums else [] - - # Sort by a combination of track count and total size for relevance - album_results.sort(key=lambda x: (x.track_count, x.total_size), reverse=True) - - self.search_results.emit(album_results) - - except Exception as e: - if not self._stop_requested: - import traceback - traceback.print_exc() - self.search_failed.emit(str(e)) - finally: - # Ensure the event loop is properly closed - if loop: - try: - loop.close() - except Exception as e: - print(f"Error closing event loop in AlbumSearchWorker: {e}") - -class AlbumStatusProcessingWorkerSignals(QObject): - """Signals for the AlbumStatusProcessingWorker""" - completed = pyqtSignal(list) # List of status update results - error = pyqtSignal(str) # Error message - -class AlbumStatusProcessingWorker(QRunnable): - """ - Background worker for processing album download status updates. - Based on the working pattern from downloads.py and sync.py. - """ - - def __init__(self, soulseek_client, download_items_data): - super().__init__() - self.signals = AlbumStatusProcessingWorkerSignals() - self.soulseek_client = soulseek_client - self.download_items_data = download_items_data - - def run(self): - """Process status updates for album downloads in background thread""" - try: - import asyncio - import os - - # Create new event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - # Get all current transfers from slskd API - transfers_data = loop.run_until_complete( - self.soulseek_client._make_request('GET', 'transfers/downloads') - ) - - if not transfers_data: - self.signals.completed.emit([]) - return - - # Parse transfers into flat list and create lookup dictionary - all_transfers = [] - transfers_by_id = {} - - for user_data in transfers_data: - username = user_data.get('username', '') - - # Handle files directly under user object (newer API format) - if 'files' in user_data and isinstance(user_data['files'], list): - for file_data in user_data['files']: - file_data['username'] = username - all_transfers.append(file_data) - if 'id' in file_data: - transfers_by_id[file_data['id']] = file_data - - # Handle files nested in directories (older API format) - 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): - for file_data in directory['files']: - file_data['username'] = username - all_transfers.append(file_data) - if 'id' in file_data: - transfers_by_id[file_data['id']] = file_data - - print(f"Album status worker found {len(all_transfers)} total transfers") - - # Process each download item - results = [] - used_transfer_ids = set() # Prevent duplicate matching - - for item_data in self.download_items_data: - download_id = item_data.get('download_id') - file_path = item_data.get('file_path', '') - widget_id = item_data.get('widget_id') - - print(f"Processing album download: ID={download_id}, file={os.path.basename(file_path)}") - - matching_transfer = None - - # Primary matching: by download ID - if download_id and download_id in transfers_by_id and download_id not in used_transfer_ids: - matching_transfer = transfers_by_id[download_id] - used_transfer_ids.add(download_id) - print(f" ID match found for {download_id}") - - # Fallback matching: by filename - elif file_path: - expected_basename = os.path.basename(file_path).lower() - for transfer in all_transfers: - transfer_id = transfer.get('id') - if transfer_id in used_transfer_ids: - continue - - transfer_filename = transfer.get('filename', '') - transfer_basename = os.path.basename(transfer_filename).lower() - - if transfer_basename == expected_basename: - matching_transfer = transfer - used_transfer_ids.add(transfer_id) - print(f" Filename match: {expected_basename}") - # Update download_id if it was missing - if not download_id: - download_id = transfer_id - break - - # Determine status and create result - if matching_transfer: - state = matching_transfer.get('state', '').strip() - progress = 0.0 - - # Map slskd states to our status system - 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' - progress = 100.0 - elif 'InProgress' in state: - new_status = 'downloading' - # Extract progress from state or progress field - if 'progress' in matching_transfer: - progress = float(matching_transfer.get('progress', 0.0)) - else: - # Try to extract from state string - import re - progress_match = re.search(r'(\d+(?:\.\d+)?)%', state) - if progress_match: - progress = float(progress_match.group(1)) - else: - new_status = 'queued' - - result = { - 'widget_id': widget_id, - 'download_id': download_id, - 'status': new_status, - 'progress': progress, - 'state': state, - 'filename': matching_transfer.get('filename', ''), - 'size': matching_transfer.get('size', 0), - 'transferred': matching_transfer.get('bytesTransferred', 0), - 'speed': matching_transfer.get('averageSpeed', 0) - } - - print(f" Status: {new_status} ({progress:.1f}%)") - else: - # Download not found in API - increment missing count - api_missing_count = item_data.get('api_missing_count', 0) + 1 - - if api_missing_count >= 3: - # Grace period exceeded - mark as failed - new_status = 'failed' - print(f" Download missing from API (failed after 3 checks)") - else: - # Still in grace period - new_status = 'missing' - print(f" Download missing from API (attempt {api_missing_count}/3)") - - result = { - 'widget_id': widget_id, - 'download_id': download_id, - 'status': new_status, - 'api_missing_count': api_missing_count, - 'progress': 0.0 - } - - results.append(result) - - print(f"Album status worker completed: {len(results)} results") - self.signals.completed.emit(results) - - finally: - loop.close() - - except Exception as e: - import traceback - traceback.print_exc() - self.signals.error.emit(f"Album status processing failed: {str(e)}") - - - -class SinglesEPsLibraryWorker(QThread): - """Background worker for checking singles and EPs using track-level matching""" - check_completed = pyqtSignal(dict) # Dict of release_name -> AlbumOwnershipStatus - release_matched = pyqtSignal(str, object) # release_name, AlbumOwnershipStatus - check_failed = pyqtSignal(str) - - def __init__(self, releases, matching_engine): - super().__init__() - self.releases = releases - self.matching_engine = matching_engine - self._stop_requested = False - - def stop(self): - """Request to stop the check""" - self._stop_requested = True - - def run(self): - try: - print("Starting track-level matching for singles and EPs...") - release_statuses = {} # release_name -> AlbumOwnershipStatus - - # Get database instance - db = get_database() - - if self._stop_requested: - return - - print(f"Checking {len(self.releases)} singles/EPs against database...") - - for i, release in enumerate(self.releases): - if self._stop_requested: - return - - print(f"Checking release {i+1}/{len(self.releases)}: {release.name} ({release.total_tracks} tracks)") - - if release.total_tracks == 1: - # SINGLE: Use track-level matching - status = self._check_single_ownership(release, db) - else: - # EP: Use track-by-track matching for completion percentage - status = self._check_ep_ownership(release, db) - - release_statuses[release.name] = status - - # Emit individual match for real-time UI update - self.release_matched.emit(release.name, status) - - print(f"Singles/EPs check complete: {len(release_statuses)} releases processed") - self.check_completed.emit(release_statuses) - - except Exception as e: - error_msg = f"Error checking singles/EPs library: {e}" - print(f"{error_msg}") - import traceback - traceback.print_exc() - self.check_failed.emit(error_msg) - - def _check_single_ownership(self, single_release, db): - """Check if a single track exists anywhere in the library""" - try: - # For singles, we need to get the track info from Spotify first - # Since the release object might not have track details - from core.spotify_client import SpotifyClient - spotify_client = SpotifyClient() - - if not spotify_client.is_authenticated(): - # Fallback: use release name as track name - track_name = single_release.name - artist_name = single_release.artists[0] if single_release.artists else "" - else: - try: - # Get full album data to get track info - album_data = spotify_client.get_album(single_release.id) - if album_data and album_data.get('tracks') and album_data['tracks']: - # Handle different track data formats from Spotify API - tracks_data = album_data['tracks'] - if isinstance(tracks_data, dict) and 'items' in tracks_data and tracks_data['items']: - first_track = tracks_data['items'][0] # Paginated response - elif isinstance(tracks_data, list) and tracks_data: - first_track = tracks_data[0] # Direct list - else: - raise Exception("No track data in expected format") - - track_name = first_track['name'] - artist_name = first_track['artists'][0]['name'] if first_track['artists'] else single_release.artists[0] - else: - # Fallback - track_name = single_release.name - artist_name = single_release.artists[0] if single_release.artists else "" - except Exception as e: - print(f" Debug single track fetch error: {e}") - if album_data and 'tracks' in album_data: - print(f" Debug: tracks data type = {type(album_data['tracks'])}") - # Fallback if Spotify call fails - track_name = single_release.name - artist_name = single_release.artists[0] if single_release.artists else "" - - print(f" Searching for single track: '{track_name}' by '{artist_name}'") - - # Search for the track anywhere in the library (active server only) - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server) - - if db_track and confidence >= 0.7: - print(f" Single found: '{track_name}' in album '{db_track.album_title}' (confidence: {confidence:.2f})") - - # For singles, if we find the track, it's "complete" - return AlbumOwnershipStatus( - album_name=single_release.name, - is_owned=True, - is_complete=True, - is_nearly_complete=False, - owned_tracks=1, - expected_tracks=1, - completion_ratio=1.0 - ) - else: - print(f" Single not found: '{track_name}'") - return AlbumOwnershipStatus( - album_name=single_release.name, - is_owned=False, - is_complete=False, - is_nearly_complete=False, - owned_tracks=0, - expected_tracks=1, - completion_ratio=0.0 - ) - - except Exception as e: - print(f" Error checking single '{single_release.name}': {e}") - return AlbumOwnershipStatus( - album_name=single_release.name, - is_owned=False, - is_complete=False, - is_nearly_complete=False, - owned_tracks=0, - expected_tracks=1, - completion_ratio=0.0 - ) - - def _check_ep_ownership(self, ep_release, db): - """Check EP ownership by checking individual tracks""" - try: - # Get EP tracks from Spotify - from core.spotify_client import SpotifyClient - spotify_client = SpotifyClient() - - if not spotify_client.is_authenticated(): - print(f" Spotify not available, cannot check EP tracks for '{ep_release.name}'") - return AlbumOwnershipStatus( - album_name=ep_release.name, - is_owned=False, - is_complete=False, - is_nearly_complete=False, - owned_tracks=0, - expected_tracks=ep_release.total_tracks, - completion_ratio=0.0 - ) - - try: - album_data = spotify_client.get_album(ep_release.id) - if not album_data or not album_data.get('tracks'): - raise Exception("No track data available") - - # Handle different track data formats from Spotify API - tracks_data = album_data['tracks'] - if isinstance(tracks_data, dict) and 'items' in tracks_data: - tracks = tracks_data['items'] # Paginated response - elif isinstance(tracks_data, list): - tracks = tracks_data # Direct list - else: - raise Exception(f"Unexpected tracks data format: {type(tracks_data)}") - - except Exception as e: - print(f" Could not fetch EP tracks for '{ep_release.name}': {e}") - if album_data and 'tracks' in album_data: - print(f" Debug: tracks data type = {type(album_data['tracks'])}") - if hasattr(album_data['tracks'], '__len__') and len(album_data['tracks']) > 0: - print(f" Debug: first item type = {type(album_data['tracks'][0])}") - return AlbumOwnershipStatus( - album_name=ep_release.name, - is_owned=False, - is_complete=False, - is_nearly_complete=False, - owned_tracks=0, - expected_tracks=ep_release.total_tracks, - completion_ratio=0.0 - ) - - print(f" Checking {len(tracks)} tracks in EP '{ep_release.name}'") - - owned_tracks = 0 - expected_tracks = len(tracks) - - for track in tracks: - if self._stop_requested: - break - - track_name = track['name'] - artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "") - - # Search for this track (active server only) - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server) - - if db_track and confidence >= 0.7: - owned_tracks += 1 - print(f" Track found: '{track_name}'") - else: - print(f" Track missing: '{track_name}'") - - completion_ratio = owned_tracks / max(expected_tracks, 1) - is_complete = completion_ratio >= 0.9 - is_nearly_complete = completion_ratio >= 0.8 and completion_ratio < 0.9 - is_owned = owned_tracks > 0 - - print(f" EP '{ep_release.name}': {owned_tracks}/{expected_tracks} tracks ({int(completion_ratio * 100)}%)") - - return AlbumOwnershipStatus( - album_name=ep_release.name, - is_owned=is_owned, - is_complete=is_complete, - is_nearly_complete=is_nearly_complete, - owned_tracks=owned_tracks, - expected_tracks=expected_tracks, - completion_ratio=completion_ratio - ) - - except Exception as e: - print(f" Error checking EP '{ep_release.name}': {e}") - return AlbumOwnershipStatus( - album_name=ep_release.name, - is_owned=False, - is_complete=False, - is_nearly_complete=False, - owned_tracks=0, - expected_tracks=ep_release.total_tracks, - completion_ratio=0.0 - ) - -class DatabaseLibraryWorker(QThread): - """Background worker for checking database library with completeness info (replaces PlexLibraryWorker)""" - library_checked = pyqtSignal(dict) # Dict of album_name -> AlbumOwnershipStatus - album_matched = pyqtSignal(str, object) # album_name, AlbumOwnershipStatus - check_failed = pyqtSignal(str) - - def __init__(self, albums, matching_engine): - super().__init__() - self.albums = albums - self.matching_engine = matching_engine - self._stop_requested = False - - def stop(self): - """Request to stop the check""" - self._stop_requested = True - - def run(self): - try: - print("Starting robust database album matching with completeness checking...") - album_statuses = {} # album_name -> AlbumOwnershipStatus - - # Get database instance - db = get_database() - - # Get active server for filtering - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - print(f"Checking albums against {active_server.upper()} library only") - except Exception as e: - print(f"Could not get active server, defaulting to 'plex': {e}") - active_server = 'plex' - - if self._stop_requested: - return - - print(f"Checking {len(self.albums)} Spotify albums against local database...") - - # Use robust matching for each album - for i, spotify_album in enumerate(self.albums): - if self._stop_requested: - return - - print(f"Checking album {i+1}/{len(self.albums)}: {spotify_album.name}") - - # Create multiple search variations - album_variations = [] - - # Original name - album_variations.append(spotify_album.name) - - # Cleaned name (removes versions, etc.) - cleaned_name = self.matching_engine.clean_album_name(spotify_album.name) - if cleaned_name != spotify_album.name.lower(): - album_variations.append(cleaned_name) - - # Try different artist combinations - artists_to_try = spotify_album.artists[:2] if spotify_album.artists else [""] - if "Korn" in artists_to_try: - artists_to_try.append("KoЯn") - best_album = None - best_confidence = 0.0 - best_owned_tracks = 0 - best_expected_tracks = 0 - best_is_complete = False - - # Get expected track count from Spotify - expected_track_count = getattr(spotify_album, 'total_tracks', None) - - # Search with different combinations - for artist in artists_to_try: - if self._stop_requested: - return - - artist_clean = self.matching_engine.clean_artist(artist) if artist else "" - - for album_name in album_variations: - if self._stop_requested: - return - - # Search database for this combination with completeness info - print(f" Searching database: album='{album_name}', artist='{artist_clean}'") - db_album, confidence, owned_tracks, expected_tracks, is_complete, *_ = db.check_album_exists_with_completeness( - album_name, artist_clean, expected_track_count, confidence_threshold=0.7, server_source=active_server - ) - - if db_album and confidence > best_confidence: - best_album = db_album - best_confidence = confidence - best_owned_tracks = owned_tracks - best_expected_tracks = expected_tracks - best_is_complete = is_complete - print(f" Found database match with confidence {confidence:.2f} ({owned_tracks}/{expected_tracks} tracks)") - - # If we have a very confident match, we can stop searching for this album - if confidence >= 0.95: - break - - # Backup search with original uncleaned artist name - if not db_album and artist and artist != artist_clean: - print(f" Backup search with original artist: album='{album_name}', artist='{artist}'") - db_album_backup, confidence_backup, owned_backup, expected_backup, complete_backup, *_ = db.check_album_exists_with_completeness( - album_name, artist, expected_track_count, confidence_threshold=0.7, server_source=active_server - ) - - if db_album_backup and confidence_backup > best_confidence: - best_album = db_album_backup - best_confidence = confidence_backup - best_owned_tracks = owned_backup - best_expected_tracks = expected_backup - best_is_complete = complete_backup - print(f" Found backup match with confidence {confidence_backup:.2f} ({owned_backup}/{expected_backup} tracks)") - - # Additional fallback: remove commas - if not db_album_backup and ',' in artist: - artist_no_comma = artist.replace(',', '').strip() - artist_no_comma = ' '.join(artist_no_comma.split()) - print(f" Comma-removal fallback: album='{album_name}', artist='{artist_no_comma}'") - db_album_comma, confidence_comma, owned_comma, expected_comma, complete_comma, *_ = db.check_album_exists_with_completeness( - album_name, artist_no_comma, expected_track_count, confidence_threshold=0.7, server_source=active_server - ) - - if db_album_comma and confidence_comma > best_confidence: - best_album = db_album_comma - best_confidence = confidence_comma - best_owned_tracks = owned_comma - best_expected_tracks = expected_comma - best_is_complete = complete_comma - print(f" Found comma-removal match with confidence {confidence_comma:.2f} ({owned_comma}/{expected_comma} tracks)") - - # If we found a very confident match, stop searching other artists - if best_confidence >= 0.95: - break - - # Create ownership status - if best_album and best_confidence >= 0.8: - completion_ratio = best_owned_tracks / max(best_expected_tracks, 1) - is_nearly_complete = completion_ratio >= 0.8 and completion_ratio < 0.9 - status = AlbumOwnershipStatus( - album_name=spotify_album.name, - is_owned=True, - is_complete=best_is_complete, - is_nearly_complete=is_nearly_complete, - owned_tracks=best_owned_tracks, - expected_tracks=best_expected_tracks, - completion_ratio=completion_ratio - ) - album_statuses[spotify_album.name] = status - - # Log detailed result - if best_is_complete: - print(f"Complete album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)") - elif is_nearly_complete: - print(f"Nearly complete album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)") - else: - print(f"Partial album: '{spotify_album.name}' -> '{best_album.title}' ({best_owned_tracks}/{best_expected_tracks} tracks)") - - # Emit individual match for real-time UI update - self.album_matched.emit(spotify_album.name, status) - else: - # Create status for missing album - status = AlbumOwnershipStatus( - album_name=spotify_album.name, - is_owned=False, - is_complete=False, - is_nearly_complete=False, - owned_tracks=0, - expected_tracks=expected_track_count or 0, - completion_ratio=0.0 - ) - album_statuses[spotify_album.name] = status - - if best_album: - print(f"No confident match for '{spotify_album.name}' (best: {best_confidence:.2f})") - else: - print(f"No database candidates found for '{spotify_album.name}'") - - # Count results for summary - complete_count = sum(1 for status in album_statuses.values() if status.is_complete) - nearly_complete_count = sum(1 for status in album_statuses.values() if status.is_nearly_complete) - partial_count = sum(1 for status in album_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete) - missing_count = sum(1 for status in album_statuses.values() if not status.is_owned) - - print(f"Final result: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {len(self.albums)} albums") - print(f"Emitting detailed album statuses") - self.library_checked.emit(album_statuses) - - except Exception as e: - if not self._stop_requested: - error_msg = f"Error checking database library: {e}" - print(f"{error_msg}") - self.check_failed.emit(error_msg) - - -# Keep the old class name as an alias for backward compatibility -PlexLibraryWorker = DatabaseLibraryWorker - -class AlbumSearchDialog(QDialog): - """Dialog for displaying album search results and allowing selection""" - album_selected = pyqtSignal(object) # AlbumResult object - - def __init__(self, album: Album, parent=None): - super().__init__(parent) - self.album = album - self.selected_album_result = None - self.selected_widget = None - self.search_worker = None - self.setup_ui() - self.start_search() # Start automatic search on open - - def setup_ui(self): - self.setWindowTitle(f"Download Source for: {self.album.name}") - self.setFixedSize(800, 700) - self.setStyleSheet(""" - QDialog { background: #191414; color: #ffffff; } - QScrollArea { border: 1px solid #404040; border-radius: 8px; background: #282828; } - QLineEdit { - background: #333; border: 1px solid #555; border-radius: 4px; - padding: 8px; font-size: 12px; - } - QPushButton { - background-color: #444; border: 1px solid #666; border-radius: 4px; - padding: 8px 12px; font-size: 12px; - } - QPushButton:hover { background-color: #555; } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Header - header_label = QLabel(f"Searching for: {self.album.name} by {', '.join(self.album.artists)}") - header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - - # Manual Search Section - search_layout = QHBoxLayout() - self.manual_search_input = QLineEdit() - self.manual_search_input.setPlaceholderText("Refine search: Artist Album Title...") - self.manual_search_input.returnPressed.connect(self.trigger_manual_search) - - self.search_cancel_btn = QPushButton("Search") - self.search_cancel_btn.setFixedWidth(120) - self.search_cancel_btn.clicked.connect(self.handle_search_cancel_click) - - search_layout.addWidget(self.manual_search_input, 1) - search_layout.addWidget(self.search_cancel_btn) - - # Status - self.status_label = QLabel("Initializing search...") - self.status_label.setStyleSheet("color: #b3b3b3;") - - # Results Area - self.results_scroll = QScrollArea() - self.results_scroll.setWidgetResizable(True) - self.results_widget = QWidget() - self.results_layout = QVBoxLayout(self.results_widget) - self.results_layout.setSpacing(8) - self.results_layout.setContentsMargins(10, 10, 10, 10) - self.results_layout.addStretch(1) - self.results_scroll.setWidget(self.results_widget) - - # Bottom Buttons - button_layout = QHBoxLayout() - self.download_btn = QPushButton("Download Selected") - self.download_btn.setEnabled(False) # Initially disabled - self.download_btn.setStyleSheet("background-color: #1db954; color: black;") - - close_btn = QPushButton("Close") - - self.download_btn.clicked.connect(self.download_selected) - close_btn.clicked.connect(self.reject) - - button_layout.addStretch(1) - button_layout.addWidget(self.download_btn) - button_layout.addWidget(close_btn) - - layout.addWidget(header_label) - layout.addLayout(search_layout) - layout.addWidget(self.status_label) - layout.addWidget(self.results_scroll, 1) - layout.addLayout(button_layout) - - def handle_search_cancel_click(self): - """Toggles between starting a search and cancelling an active one.""" - if self.search_worker and self.search_worker.isRunning(): - self.cancel_search() - else: - self.trigger_manual_search() - - def trigger_manual_search(self): - """Starts a new search using the text from the manual search input.""" - query = self.manual_search_input.text().strip() - if query: - self.start_search(query) - - def start_search(self, query: Optional[str] = None): - """ - Starts the album search. If a query is provided, it's a manual search. - Otherwise, it constructs an automatic query. - """ - if self.search_worker and self.search_worker.isRunning(): - self.search_worker.stop() - self.search_worker.wait() - - self.clear_results() - self.download_btn.setEnabled(False) - self.status_label.setText("Searching...") - self.set_search_button_to_cancel(True) - - if query is None: - artist_part = self.album.artists[0] if self.album.artists else "" - query = f"{artist_part} {self.album.name}".strip() - - self.manual_search_input.setText(query) - - parent_page = self.parent() - if hasattr(parent_page, 'soulseek_client') and parent_page.soulseek_client: - self.search_worker = AlbumSearchWorker(query, parent_page.soulseek_client) - self.search_worker.search_results.connect(self.on_search_results) - self.search_worker.search_failed.connect(self.on_search_failed) - self.search_worker.search_progress.connect(self.on_search_progress) - self.search_worker.start() - else: - self.on_search_failed("Soulseek client not available") - - def cancel_search(self): - if self.search_worker and self.search_worker.isRunning(): - self.search_worker.stop() - self.status_label.setText("Search cancelled.") - self.set_search_button_to_cancel(False) - - def on_search_progress(self, message): - self.status_label.setText(message) - - def on_search_results(self, album_results): - self.set_search_button_to_cancel(False) - self.clear_results() - if not album_results: - self.status_label.setText("No albums found for this query.") - return - - self.status_label.setText(f"Found {len(album_results)} potential albums. Click one to select.") - - for album_result in album_results[:25]: # Show top 25 - result_item = self.create_result_item(album_result) - self.results_layout.insertWidget(self.results_layout.count() - 1, result_item) - - def on_search_failed(self, error): - self.set_search_button_to_cancel(False) - self.status_label.setText(f"Search failed: {error}") - - def create_result_item(self, album_result: AlbumResult): - """Creates a larger, more informative, and clickable result item widget.""" - item = QFrame() - item.setFixedHeight(75) # Increased height for better readability - item.setCursor(Qt.CursorShape.PointingHandCursor) - item.setStyleSheet(""" - QFrame { - background: rgba(40, 40, 40, 0.8); - border: 1px solid #555; - border-radius: 6px; - } - """) - # Connect the click event for the whole frame - item.mousePressEvent = lambda event: self.select_result(album_result, item) - - layout = QHBoxLayout(item) - layout.setContentsMargins(15, 10, 15, 10) - layout.setSpacing(15) - - info_layout = QVBoxLayout() - info_layout.setSpacing(4) - - title_label = QLabel(f"{album_result.album_title} by {album_result.artist}") - title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - - details_text = (f"{album_result.track_count} tracks | " - f"{self.format_size(album_result.total_size)} | " - f"Uploader: {album_result.username}") - details_label = QLabel(details_text) - details_label.setFont(QFont("Arial", 9)) - details_label.setStyleSheet("color: #b3b3b3;") - - info_layout.addWidget(title_label) - info_layout.addWidget(details_label) - - quality_badge = self.create_quality_badge(album_result) - - layout.addLayout(info_layout, 1) - layout.addWidget(quality_badge) - - return item - - def create_quality_badge(self, album_result: AlbumResult): - """Creates a styled badge for displaying audio quality.""" - quality = album_result.dominant_quality.upper() - - # Safely calculate average bitrate from the album's tracks - bitrate = 0 - if hasattr(album_result, 'tracks') and album_result.tracks: - valid_bitrates = [ - track.bitrate for track in album_result.tracks - if hasattr(track, 'bitrate') and track.bitrate - ] - if valid_bitrates: - bitrate = sum(valid_bitrates) // len(valid_bitrates) - - badge_text = quality - if quality == 'MP3' and bitrate > 0: - badge_text = f"MP3 {bitrate}k" - elif quality == 'VBR': - badge_text = "MP3 VBR" - - badge = QLabel(badge_text) - badge.setFixedWidth(80) - badge.setAlignment(Qt.AlignmentFlag.AlignCenter) - badge.setFont(QFont("Arial", 9, QFont.Weight.Bold)) - - if quality == 'FLAC': - style = "background-color: #4CAF50; color: white; border-radius: 4px; padding: 5px;" - elif bitrate >= 320: - style = "background-color: #2196F3; color: white; border-radius: 4px; padding: 5px;" - elif bitrate >= 192 or quality == 'VBR': - style = "background-color: #FFC107; color: black; border-radius: 4px; padding: 5px;" - else: - style = "background-color: #F44336; color: white; border-radius: 4px; padding: 5px;" - - badge.setStyleSheet(style) - return badge - - def clear_results(self): - """Removes all result widgets from the layout, preserving the stretch item.""" - self.selected_widget = None # Clear selection - # Iterate backwards to safely remove items while preserving the stretch - for i in reversed(range(self.results_layout.count())): - item = self.results_layout.itemAt(i) - if item.widget(): - widget = item.widget() - widget.deleteLater() - - def format_size(self, size_bytes): - if size_bytes >= 1024**3: return f"{size_bytes / 1024**3:.1f} GB" - if size_bytes >= 1024**2: return f"{size_bytes / 1024**2:.1f} MB" - return f"{size_bytes / 1024:.1f} KB" - - def select_result(self, album_result, selected_item_widget): - """Handles the selection of a result and provides visual feedback.""" - self.selected_album_result = album_result - self.download_btn.setEnabled(True) - - # Deselect previous widget - if self.selected_widget: - self.selected_widget.setStyleSheet(""" - QFrame { background: rgba(40, 40, 40, 0.8); border: 1px solid #555; border-radius: 6px; } - """) - - # Apply selected style to the new widget - selected_item_widget.setStyleSheet(""" - QFrame { background: rgba(29, 185, 84, 0.2); border: 1px solid #1db954; border-radius: 6px; } - """) - self.selected_widget = selected_item_widget - - def download_selected(self): - if self.selected_album_result: - self.album_selected.emit(self.selected_album_result) - self.accept() - - def set_search_button_to_cancel(self, is_searching: bool): - """Changes the search button's text and style.""" - if is_searching: - self.search_cancel_btn.setText("Cancel Search") - self.search_cancel_btn.setStyleSheet("background-color: #F44336; color: white;") - else: - self.search_cancel_btn.setText("Search") - self.search_cancel_btn.setStyleSheet("background-color: #1db954; color: black;") - - def closeEvent(self, event): - self.cancel_search() - super().closeEvent(event) - - - -class ArtistResultCard(QFrame): - """Card widget for displaying artist search results""" - artist_selected = pyqtSignal(object) # Artist object - - def __init__(self, artist_match: ArtistMatch, parent=None): - super().__init__(parent) - self.artist_match = artist_match - self.artist = artist_match.artist - self.setup_ui() - self.load_artist_image() - self.check_watchlist_status() - - def setup_ui(self): - self.setFixedSize(200, 280) - self.setCursor(Qt.CursorShape.PointingHandCursor) - - # Base styling with gradient background - self.setStyleSheet(""" - ArtistResultCard { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(45, 45, 45, 0.95), - stop:1 rgba(35, 35, 35, 0.98)); - border-radius: 12px; - border: 2px solid rgba(80, 80, 80, 0.4); - } - ArtistResultCard:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.2), - stop:1 rgba(24, 156, 71, 0.3)); - border: 2px solid rgba(29, 185, 84, 0.8); - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(8) - - # Artist image container - self.image_container = QFrame() - self.image_container.setFixedSize(176, 176) - self.image_container.setStyleSheet(""" - QFrame { - background: #404040; - border-radius: 88px; - border: 2px solid #606060; - } - """) - - image_layout = QVBoxLayout(self.image_container) - image_layout.setContentsMargins(0, 0, 0, 0) - - self.image_label = QLabel() - self.image_label.setFixedSize(172, 172) - self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_label.setStyleSheet(""" - QLabel { - background: transparent; - border-radius: 86px; - color: #b3b3b3; - font-size: 48px; - } - """) - self.image_label.setText("") - - image_layout.addWidget(self.image_label) - - # Artist name - name_label = QLabel(self.artist.name) - name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - name_label.setStyleSheet("color: #ffffff; padding: 4px;") - name_label.setWordWrap(True) - name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Confidence score - confidence_label = QLabel(f"Match: {self.artist_match.confidence:.0%}") - confidence_label.setFont(QFont("Arial", 9)) - confidence_label.setStyleSheet("color: #1db954; padding: 2px;") - confidence_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Followers count - followers_text = self.format_followers(self.artist.followers) - followers_label = QLabel(f"{followers_text} followers") - followers_label.setFont(QFont("Arial", 8)) - followers_label.setStyleSheet("color: #b3b3b3; padding: 2px;") - followers_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Watchlist eye indicator (positioned absolutely in top-right corner) - self.watchlist_indicator = QLabel(self) - self.watchlist_indicator.setText("") - self.watchlist_indicator.setFont(QFont("Arial", 14)) - self.watchlist_indicator.setFixedSize(24, 24) - self.watchlist_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.watchlist_indicator.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.9); - border-radius: 12px; - border: 1px solid rgba(29, 185, 84, 1); - color: white; - } - """) - self.watchlist_indicator.move(168, 8) # Position in top-right corner - self.watchlist_indicator.hide() # Hidden by default - - layout.addWidget(self.image_container, 0, Qt.AlignmentFlag.AlignCenter) - layout.addWidget(name_label) - layout.addWidget(confidence_label) - layout.addWidget(followers_label) - layout.addStretch() - - def format_followers(self, count: int) -> str: - """Format follower count in human readable format""" - if count >= 1000000: - return f"{count / 1000000:.1f}M" - elif count >= 1000: - return f"{count / 1000:.1f}K" - else: - return str(count) - - def load_artist_image(self): - """Load artist image in background""" - if self.artist.image_url: - downloader = ImageDownloader(self.artist.image_url, self.image_label) - downloader.signals.finished.connect(self.on_image_loaded) - downloader.signals.error.connect(self.on_image_error) - QThreadPool.globalInstance().start(downloader) - - def on_image_loaded(self, label, pixmap): - """Handle successful image load""" - if label == self.image_label: - # Scale and mask the image to fit the circular container - scaled_pixmap = pixmap.scaled(172, 172, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) - - # Create circular mask - masked_pixmap = QPixmap(172, 172) - masked_pixmap.fill(Qt.GlobalColor.transparent) - - painter = QPainter(masked_pixmap) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setBrush(QColor(255, 255, 255)) - painter.setPen(QPen(QColor(255, 255, 255))) - painter.drawEllipse(0, 0, 172, 172) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) - painter.drawPixmap(0, 0, scaled_pixmap) - painter.end() - - self.image_label.setPixmap(masked_pixmap) - - def on_image_error(self, error): - """Handle image load error""" - print(f"Failed to load artist image: {error}") - - def check_watchlist_status(self): - """Check if this artist is in the watchlist and show eye indicator""" - try: - database = get_database() - is_watching = database.is_artist_in_watchlist(self.artist.id) - - if is_watching: - self.watchlist_indicator.show() - else: - self.watchlist_indicator.hide() - - except Exception as e: - logger.error(f"Error checking watchlist status for artist {self.artist.name}: {e}") - self.watchlist_indicator.hide() - - def refresh_watchlist_status(self): - """Refresh the watchlist indicator (call this when watchlist changes)""" - self.check_watchlist_status() - - def mousePressEvent(self, event): - """Handle click to select artist""" - try: - if event.button() == Qt.MouseButton.LeftButton: - self.artist_selected.emit(self.artist) - super().mousePressEvent(event) - except RuntimeError as e: - # Qt object has been deleted, ignore the event silently - print(f"ArtistCard object deleted during mouse event: {e}") - pass - -class AlbumCard(QFrame): - """Card widget for displaying album information""" - download_requested = pyqtSignal(object) # Album object - - def __init__(self, album: Album, is_owned: bool = False, parent=None): - super().__init__(parent) - self.album = album - self.is_owned = is_owned - self.ownership_status = None # Will store AlbumOwnershipStatus - self.setup_ui() - self.load_album_image() - - def setup_ui(self): - self.setFixedSize(180, 240) - - self.setStyleSheet(""" - AlbumCard { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(45, 45, 50, 0.95), - stop:0.5 rgba(35, 35, 40, 0.97), - stop:1 rgba(28, 28, 33, 0.99)); - border-radius: 12px; - border: 1px solid rgba(80, 80, 85, 0.4); - } - AlbumCard:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(55, 55, 60, 0.98), - stop:0.5 rgba(45, 45, 50, 0.99), - stop:1 rgba(38, 38, 43, 1.0)); - border: 1px solid rgba(29, 185, 84, 0.8); - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(6) - - # Album image container - self.image_container = QFrame() - self.image_container.setFixedSize(164, 164) - self.image_container.setStyleSheet(""" - QFrame { - background: #404040; - border-radius: 6px; - border: 1px solid #606060; - } - """) - - image_layout = QVBoxLayout(self.image_container) - image_layout.setContentsMargins(0, 0, 0, 0) - - self.image_label = QLabel() - self.image_label.setFixedSize(162, 162) - self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.image_label.setStyleSheet(""" - QLabel { - background: transparent; - border-radius: 5px; - color: #b3b3b3; - font-size: 32px; - } - """) - self.image_label.setText("") - - image_layout.addWidget(self.image_label) - - # Overlay for ownership status - self.overlay = QLabel(self.image_container) - self.overlay.setFixedSize(164, 164) - self.overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Set up initial overlay appearance (will be updated by update_ownership) - self.overlay.setStyleSheet(""" - QLabel { - background: rgba(0, 0, 0, 0.7); - border-radius: 6px; - color: white; - font-size: 16px; - font-weight: bold; - } - """) - self.overlay.setText("Loading...") - self.overlay.hide() # Initially hidden, shown on hover - - # Download progress overlay (shown during downloads) - self.progress_overlay = QLabel(self.image_container) - self.progress_overlay.setFixedSize(164, 164) - self.progress_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.progress_overlay.setStyleSheet(""" - QLabel { - background: rgba(0, 0, 0, 0.8); - border-radius: 6px; - color: white; - font-size: 12px; - font-weight: bold; - padding: 8px; - } - """) - self.progress_overlay.hide() # Initially hidden - - # Permanent ownership indicator (always visible) - self.status_indicator = QLabel(self.image_container) - self.status_indicator.setFixedSize(24, 24) - self.status_indicator.move(140, 8) # Top-right corner - self.status_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.update_status_indicator() - - # Album name - album_label = QLabel(self.album.name) - album_label.setFont(QFont("Arial", 9, QFont.Weight.Bold)) - album_label.setStyleSheet("color: #ffffff; padding: 2px;") - album_label.setWordWrap(True) - album_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - album_label.setMaximumHeight(32) - - # Release year - year_label = QLabel(self.album.release_date[:4] if self.album.release_date else "Unknown") - year_label.setFont(QFont("Arial", 8)) - year_label.setStyleSheet("color: #b3b3b3; padding: 1px;") - year_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - layout.addWidget(self.image_container, 0, Qt.AlignmentFlag.AlignCenter) - layout.addWidget(album_label) - layout.addWidget(year_label) - layout.addStretch() - - # Initialize overlay text based on current ownership status - self._refresh_overlay_text() - - def load_album_image(self): - """Load album image in background""" - if self.album.image_url: - downloader = ImageDownloader(self.album.image_url, self.image_label) - downloader.signals.finished.connect(self.on_image_loaded) - downloader.signals.error.connect(self.on_image_error) - QThreadPool.globalInstance().start(downloader) - - def on_image_loaded(self, label, pixmap): - """Handle successful image load""" - if label == self.image_label: - scaled_pixmap = pixmap.scaled(162, 162, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) - self.image_label.setPixmap(scaled_pixmap) - - def on_image_error(self, error): - """Handle image load error""" - print(f"Failed to load album image: {error}") - - def enterEvent(self, event): - """Show overlay on hover""" - try: - if hasattr(self, 'overlay') and self.overlay: - self.overlay.show() - self.overlay.raise_() # Bring to front - except (RuntimeError, AttributeError): - # Object has been deleted or is invalid, skip - pass - super().enterEvent(event) - - def leaveEvent(self, event): - """Hide overlay when not hovering""" - try: - if hasattr(self, 'overlay') and self.overlay: - self.overlay.hide() - except (RuntimeError, AttributeError): - # Object has been deleted or is invalid, skip - pass - super().leaveEvent(event) - - def _refresh_overlay_text(self): - """Refresh overlay text based on current ownership status""" - if self.is_owned: - if self.ownership_status and self.ownership_status.is_complete: - # Complete album (90%+) - green checkmark overlay - self.overlay.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.8); - border-radius: 6px; - color: white; - font-size: 16px; - font-weight: bold; - } - """) - self.overlay.setText("Complete\nVerify tracks") - self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) - elif self.ownership_status and self.ownership_status.is_nearly_complete: - # Nearly complete album (80-89%) - blue overlay - self.overlay.setStyleSheet(""" - QLabel { - background: rgba(13, 110, 253, 0.8); - border-radius: 6px; - color: white; - font-size: 14px; - font-weight: bold; - } - """) - percentage = int(self.ownership_status.completion_ratio * 100) - missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks - self.overlay.setText(f"◐ Nearly Complete\n({percentage}%)\nGet {missing_tracks} missing") - self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) - elif self.ownership_status: - # Partial album (<80%) - yellow warning overlay - self.overlay.setStyleSheet(""" - QLabel { - background: rgba(255, 193, 7, 0.8); - border-radius: 6px; - color: #212529; - font-size: 14px; - font-weight: bold; - } - """) - percentage = int(self.ownership_status.completion_ratio * 100) - missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks - self.overlay.setText(f"Partial\n({percentage}%)\nGet {missing_tracks} missing") - self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) - else: - # Legacy complete album - green checkmark overlay - self.overlay.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.8); - border-radius: 6px; - color: white; - font-size: 16px; - font-weight: bold; - } - """) - self.overlay.setText("Complete\nVerify tracks") - self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) - else: - # Missing album - download overlay - self.overlay.setStyleSheet(""" - QLabel { - background: rgba(0, 0, 0, 0.7); - border-radius: 6px; - color: white; - font-size: 16px; - font-weight: bold; - } - """) - self.overlay.setText("Missing\n(0%)\nDownload") - self.overlay.setCursor(Qt.CursorShape.PointingHandCursor) - - def update_status_indicator(self): - """Update the permanent status indicator""" - if self.is_owned: - if self.ownership_status and self.ownership_status.is_complete: - # Complete album (90%+) - green checkmark - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.9); - border-radius: 12px; - color: white; - font-size: 14px; - font-weight: bold; - } - """) - self.status_indicator.setText("") - self.status_indicator.setToolTip(f"Complete album - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({int(self.ownership_status.completion_ratio * 100)}%)") - elif self.ownership_status and self.ownership_status.is_nearly_complete: - # Nearly complete album (80-89%) - blue half-circle - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(13, 110, 253, 0.9); - border-radius: 12px; - color: white; - font-size: 14px; - font-weight: bold; - } - """) - self.status_indicator.setText("◐") - percentage = int(self.ownership_status.completion_ratio * 100) - missing_tracks = self.ownership_status.expected_tracks - self.ownership_status.owned_tracks - self.status_indicator.setToolTip(f"Nearly complete - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({percentage}%) • {missing_tracks} missing") - elif self.ownership_status and not self.ownership_status.is_complete and not self.ownership_status.is_nearly_complete: - # Partial album (<80%) - yellow warning - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(255, 193, 7, 0.9); - border-radius: 12px; - color: #212529; - font-size: 14px; - font-weight: bold; - } - """) - self.status_indicator.setText("") - percentage = int(self.ownership_status.completion_ratio * 100) - self.status_indicator.setToolTip(f"Partial album - {self.ownership_status.owned_tracks}/{self.ownership_status.expected_tracks} tracks ({percentage}%)") - else: - # Fallback for legacy owned albums without detailed status - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.9); - border-radius: 12px; - color: white; - font-size: 14px; - font-weight: bold; - } - """) - self.status_indicator.setText("") - self.status_indicator.setToolTip("Album owned in library") - else: - # Missing album - red download icon - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(220, 53, 69, 0.8); - border-radius: 12px; - color: white; - font-size: 12px; - font-weight: bold; - } - """) - self.status_indicator.setText("") - self.status_indicator.setToolTip("Album available for download") - - def update_ownership(self, ownership_info): - """Update ownership status and refresh UI - supports bool or AlbumOwnershipStatus""" - if isinstance(ownership_info, bool): - # Legacy support for simple boolean - is_owned = ownership_info - self.ownership_status = None - else: - # New detailed ownership status - is_owned = ownership_info.is_owned - self.ownership_status = ownership_info - - if self.is_owned != is_owned: # Only log if status actually changed - if self.ownership_status: - print(f"'{self.album.name}' ownership: {self.is_owned} -> {is_owned} (complete: {self.ownership_status.is_complete})") - else: - print(f"'{self.album.name}' ownership: {self.is_owned} -> {is_owned}") - - self.is_owned = is_owned - - # Update the permanent indicator - self.update_status_indicator() - - # Update the hover overlay - self._refresh_overlay_text() - - def set_download_in_progress(self): - """Set album card to download in progress state""" - # Hide hover overlay and show progress overlay - try: - if hasattr(self, 'overlay') and self.overlay: - self.overlay.hide() - except (RuntimeError, AttributeError): - # Object has been deleted or is invalid, skip - pass - - try: - if hasattr(self, 'progress_overlay') and self.progress_overlay and not self.progress_overlay.isNull(): - self.progress_overlay.setText("\nPreparing...") - self.progress_overlay.show() - except (RuntimeError, AttributeError): - # Object has been deleted or is invalid, skip - pass - - # Update status indicator - try: - if hasattr(self, 'status_indicator') and self.status_indicator: - self.status_indicator.setStyleSheet(""" - QLabel { - background: rgba(255, 193, 7, 0.9); - border-radius: 12px; - color: white; - font-size: 12px; - font-weight: bold; - } - """) - self.status_indicator.setText("") - self.status_indicator.setToolTip("Album downloading...") - except (RuntimeError, AttributeError): - # Object has been deleted or is invalid, skip - pass - - def update_download_progress(self, completed_tracks: int, total_tracks: int, percentage: int): - """Update download progress display""" - try: - progress_text = f"Downloading\n{completed_tracks}/{total_tracks} tracks\n{percentage}%" - if hasattr(self, 'progress_overlay') and self.progress_overlay: - self.progress_overlay.setText(progress_text) - self.progress_overlay.show() - except (RuntimeError, AttributeError): - # Progress overlay has been deleted, skip - pass - - # Update status indicator with progress - try: - if hasattr(self, 'status_indicator') and self.status_indicator: - self.status_indicator.setText(f"{percentage}%") - self.status_indicator.setToolTip(f"Downloading: {completed_tracks}/{total_tracks} tracks ({percentage}%)") - except (RuntimeError, AttributeError): - # Status indicator has been deleted, skip - pass - - def set_download_completed(self): - """Set album card to download completed state""" - try: - # Hide progress overlay if it still exists - if hasattr(self, 'progress_overlay') and self.progress_overlay is not None: - try: - self.progress_overlay.hide() - except RuntimeError: - # Widget has been deleted, ignore - pass - - # Update to owned state - self.update_ownership(True) - - # Show completion message briefly if overlay still exists - if hasattr(self, 'progress_overlay') and self.progress_overlay is not None: - try: - self.progress_overlay.setText("\nCompleted!") - self.progress_overlay.setStyleSheet(""" - QLabel { - background: rgba(29, 185, 84, 0.9); - border-radius: 6px; - color: white; - font-size: 12px; - font-weight: bold; - padding: 8px; - } - """) - self.progress_overlay.show() - - # Hide completion message after 3 seconds - QTimer.singleShot(3000, lambda: self.safe_hide_overlay()) - except RuntimeError: - # Widget has been deleted, ignore - pass - - except Exception as e: - print(f"Error in set_download_completed: {e}") - # Still try to update ownership even if overlay fails - try: - self.update_ownership(True) - except: - pass - - def safe_hide_overlay(self): - """Safely hide the progress overlay with error checking""" - try: - if hasattr(self, 'progress_overlay') and self.progress_overlay is not None: - self.progress_overlay.hide() - except RuntimeError: - # Widget has been deleted, ignore - pass - - def mousePressEvent(self, event): - """Handle click for download""" - try: - # Don't allow downloads if already downloading - if (event.button() == Qt.MouseButton.LeftButton and - not self.progress_overlay.isVisible()): - print(f"Album card clicked: {self.album.name} (owned: {self.is_owned})") - self.download_requested.emit(self.album) - super().mousePressEvent(event) - except RuntimeError as e: - # Qt object has been deleted, ignore the event silently - print(f"AlbumCard object deleted during mouse event: {e}") - pass - -class DownloadMissingAlbumTracksModal(QDialog): - """Enhanced modal for downloading missing album tracks with live progress tracking""" - process_finished = pyqtSignal() - - def __init__(self, album, album_card, parent_page, downloads_page, media_client, server_type): - super().__init__(parent_page) - self.album = album - self.album_card = album_card - self.parent_page = parent_page - self.parent_artists_page = parent_page # Reference to artists page for scan manager - self.downloads_page = downloads_page - self.media_client = media_client - self.server_type = server_type - self.matching_engine = MusicMatchingEngine() - self.wishlist_service = get_wishlist_service() - - # State tracking - self.total_tracks = len(album.tracks) - self.matched_tracks_count = 0 - self.tracks_to_download_count = 0 - self.downloaded_tracks_count = 0 - self.analysis_complete = False - - # Initialize attributes to prevent crash on close - self.download_in_progress = False - self.cancel_requested = False - self.permanently_failed_tracks = [] - self.cancelled_tracks = set() # Track indices of cancelled tracks - - print(f"Total album tracks: {self.total_tracks}") - - # Track analysis results - self.analysis_results = [] - self.missing_tracks = [] - - # Worker tracking - self.active_workers = [] - self.fallback_pools = [] - - # 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.active_downloads = [] - - print("Setting up album modal UI...") - self.setup_ui() - print("Album modal initialization complete") - - def generate_smart_search_queries(self, artist_name, track_name): - """Generate smart search query variations with album-in-title detection""" - # Create a mock spotify track object for the matching engine - 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 - - # Pass album information if we're in the context of an album - album_name = getattr(self, 'album', None) - album_title = album_name.name if hasattr(album_name, 'name') else str(album_name) if album_name else None - - mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], album_title) - - # Use the enhanced matching engine to generate queries - queries = self.matching_engine.generate_download_queries(mock_track) - - # Add some legacy fallback queries for compatibility - legacy_queries = [] - - # Add first word of artist approach (legacy compatibility) - 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()) - - # Add track-only query - legacy_queries.append(track_name.strip()) - - # Combine enhanced queries with legacy fallbacks - all_queries = queries + legacy_queries - - # Remove duplicates while preserving order - unique_queries = [] - seen = set() - for query in all_queries: - if query and query.lower() not in seen: - unique_queries.append(query) - seen.add(query.lower()) - - print(f"Generated {len(unique_queries)} smart queries for '{track_name}' (enhanced with album detection)") - for i, query in enumerate(unique_queries): - print(f" {i+1}. '{query}'") - - return unique_queries - - def setup_ui(self): - """Set up the enhanced modal UI""" - self.setWindowTitle(f"Download Missing Tracks - {self.album.name}") - 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): - """Create compact top section with header and dashboard combined""" - top_frame = QFrame() - top_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 15px; - } - """) - - layout = QVBoxLayout(top_frame) - layout.setSpacing(15) - - header_layout = QHBoxLayout() - title_section = QVBoxLayout() - title_section.setSpacing(2) - - title = QLabel("Download Missing Album Tracks") - title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - title.setStyleSheet("color: #1db954;") - - subtitle = QLabel(f"Album: {self.album.name} by {', '.join(self.album.artists)}") - subtitle.setFont(QFont("Arial", 11)) - subtitle.setStyleSheet("color: #aaaaaa;") - - title_section.addWidget(title) - title_section.addWidget(subtitle) - - dashboard_layout = QHBoxLayout() - dashboard_layout.setSpacing(20) - - self.total_card = self.create_compact_counter_card("Total", str(self.total_tracks), "#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) - dashboard_layout.addStretch() - - 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): - """Create a compact counter card widget""" - card = QFrame() - card.setStyleSheet(f""" - QFrame {{ - background-color: #3a3a3a; border: 2px solid {color}; - border-radius: 6px; padding: 8px 12px; min-width: 80px; - }} - """) - - layout = QVBoxLayout(card) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) - - 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): - """Create compact dual progress bar section""" - progress_frame = QFrame() - progress_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 12px; - } - """) - - layout = QVBoxLayout(progress_frame) - layout.setSpacing(8) - - analysis_container = QVBoxLayout() - analysis_container.setSpacing(4) - - analysis_label = QLabel("Plex Analysis") - analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - analysis_label.setStyleSheet("color: #cccccc;") - - self.analysis_progress = QProgressBar() - self.analysis_progress.setFixedHeight(20) - self.analysis_progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #555555; border-radius: 10px; text-align: center; - background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; - } - 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_container.setSpacing(4) - - download_label = QLabel("⬇️ Download Progress") - download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - download_label.setStyleSheet("color: #cccccc;") - - self.download_progress = QProgressBar() - self.download_progress.setFixedHeight(20) - self.download_progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #555555; border-radius: 10px; text-align: center; - background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; - } - 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""" - table_frame = QFrame() - table_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 0px; - } - """) - - layout = QVBoxLayout(table_frame) - layout.setContentsMargins(15, 15, 15, 15) - layout.setSpacing(10) - - header_label = QLabel("Album Track Analysis") - header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold)) - header_label.setStyleSheet("color: #ffffff; padding: 5px;") - - self.track_table = QTableWidget() - self.track_table.setColumnCount(6) - self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status", "Cancel"]) - self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) - self.track_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) - self.track_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) - self.track_table.setColumnWidth(2, 90) - self.track_table.setColumnWidth(3, 140) - self.track_table.setColumnWidth(5, 70) - - self.track_table.setStyleSheet(""" - QTableWidget { - background-color: #3a3a3a; alternate-background-color: #424242; - selection-background-color: #1db954; selection-color: #000000; - gridline-color: #555555; color: #ffffff; border: 1px solid #555555; - font-size: 12px; - } - QHeaderView::section { - background-color: #1db954; color: #000000; 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().setDefaultSectionSize(50) - self.track_table.verticalHeader().setVisible(False) - - self.populate_track_table() - - layout.addWidget(header_label) - layout.addWidget(self.track_table) - - return table_frame - - def populate_track_table(self): - """Populate track table with album tracks""" - # Filter out invalid tracks before populating table - valid_tracks = [] - for track in self.album.tracks: - if self.is_valid_track(track): - valid_tracks.append(track) - else: - print(f"Skipping invalid track: name='{getattr(track, 'name', 'None')}', artists={getattr(track, 'artists', 'None')}, duration={getattr(track, 'duration_ms', 'None')}") - - # Update album tracks to only include valid ones - self.album.tracks = valid_tracks - self.total_tracks = len(valid_tracks) - - self.track_table.setRowCount(len(valid_tracks)) - for i, track in enumerate(valid_tracks): - # Use defensive get methods for track data - track_name = getattr(track, 'name', '') or 'Unknown Track' - artist_name = track.artists[0] if track.artists else "Unknown Artist" - duration_ms = getattr(track, 'duration_ms', 0) or 0 - - self.track_table.setItem(i, 0, QTableWidgetItem(track_name)) - self.track_table.setItem(i, 1, QTableWidgetItem(artist_name)) - duration = self.format_duration(duration_ms) - duration_item = QTableWidgetItem(duration) - duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.track_table.setItem(i, 2, duration_item) - matched_item = QTableWidgetItem("Pending") - matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.track_table.setItem(i, 3, matched_item) - status_item = QTableWidgetItem("—") - status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.track_table.setItem(i, 4, status_item) - - # Create empty container for cancel button (will be populated later for missing tracks only) - container = QWidget() - container.setStyleSheet("background: transparent;") - layout = QVBoxLayout(container) - layout.setContentsMargins(5, 5, 5, 5) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.track_table.setCellWidget(i, 5, container) - - for col in range(5): - self.track_table.item(i, col).setFlags(self.track_table.item(i, col).flags() & ~Qt.ItemFlag.ItemIsEditable) - - def is_valid_track(self, track) -> bool: - """Check if a track has valid data for display and download""" - # Check if track has a valid name - track_name = getattr(track, 'name', None) - if not track_name or track_name.strip() == '': - return False - - # Check if track has valid artists - artists = getattr(track, 'artists', None) - if not artists or len(artists) == 0: - return False - - # Check if track has valid duration (allow 0 duration but not None/missing attribute) - duration_ms = getattr(track, 'duration_ms', None) - if duration_ms is None: - return False - - # Allow 0 duration (some tracks like intros can be very short) - # Only reject if the duration attribute is completely missing - - return True - - def format_duration(self, duration_ms): - """Convert milliseconds to MM:SS format""" - seconds = duration_ms // 1000 - return f"{seconds // 60}:{seconds % 60:02d}" - - def add_cancel_button_to_row(self, row): - """Add cancel button to a specific row (only for missing tracks)""" - container = self.track_table.cellWidget(row, 5) - if container and container.layout().count() == 0: # Only add if container is empty - cancel_button = QPushButton("×") - cancel_button.setFixedSize(20, 20) - cancel_button.setMinimumSize(20, 20) - cancel_button.setMaximumSize(20, 20) - cancel_button.setStyleSheet(""" - QPushButton { - background-color: #dc3545; - color: white; - border: 1px solid #c82333; - border-radius: 3px; - font-size: 14px; - font-weight: bold; - padding: 0px; - margin: 0px; - text-align: center; - min-width: 20px; - max-width: 20px; - width: 20px; - } - QPushButton:hover { - background-color: #c82333; - border-color: #bd2130; - } - QPushButton:pressed { - background-color: #bd2130; - border-color: #b21f2d; - } - QPushButton:disabled { - background-color: #28a745; - color: white; - border-color: #1e7e34; - } - """) - cancel_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - cancel_button.clicked.connect(lambda checked, row_idx=row: self.cancel_track(row_idx)) - - layout = container.layout() - layout.addWidget(cancel_button) - - def hide_cancel_button_for_row(self, row): - """Hide cancel button for a specific row (when track is downloaded)""" - container = self.track_table.cellWidget(row, 5) - if container: - layout = container.layout() - if layout and layout.count() > 0: - cancel_button = layout.itemAt(0).widget() - if cancel_button: - cancel_button.setVisible(False) - print(f"🫥 Hidden cancel button for downloaded track at row {row}") - - def cancel_track(self, row): - """Cancel a specific track - works at any phase""" - # Get cancel button and disable it - container = self.track_table.cellWidget(row, 5) - if container: - layout = container.layout() - if layout and layout.count() > 0: - cancel_button = layout.itemAt(0).widget() - if cancel_button: - cancel_button.setEnabled(False) - cancel_button.setText("") - - # Update status to cancelled - self.track_table.setItem(row, 4, QTableWidgetItem("Cancelled")) - - # Add to cancelled tracks set - if not hasattr(self, 'cancelled_tracks'): - self.cancelled_tracks = set() - self.cancelled_tracks.add(row) - - track = self.album.tracks[row] - print(f"Track cancelled: {track.name} (row {row})") - - # If downloads are active, also handle active download cancellation - download_index = None - - # Check active_downloads list - if hasattr(self, 'active_downloads'): - for download in self.active_downloads: - if download.get('table_index') == row: - download_index = download.get('download_index', row) - print(f"Found active download {download_index} for cancelled track") - break - - # Check parallel_search_tracking for download index - if download_index is None and hasattr(self, 'parallel_search_tracking'): - for idx, track_info in self.parallel_search_tracking.items(): - if track_info.get('table_index') == row: - download_index = idx - print(f"Found parallel tracking {download_index} for cancelled track") - break - - # If we found an active download, trigger completion to free up the worker - if download_index is not None and hasattr(self, 'on_parallel_track_completed'): - print(f"Triggering completion for active download {download_index}") - self.on_parallel_track_completed(download_index, success=False) - - def create_buttons(self): - """Create improved button section""" - button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;") - layout = QHBoxLayout(button_frame) - layout.setSpacing(15) - layout.setContentsMargins(0, 10, 0, 0) - - self.correct_failed_btn = QPushButton("Correct Failed Matches") - self.correct_failed_btn.setFixedWidth(220) - self.correct_failed_btn.setStyleSheet(""" - QPushButton { background-color: #ffc107; color: #000000; border-radius: 20px; font-weight: bold; } - QPushButton:hover { background-color: #ffca28; } - """) - 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: #000000; border: none; - border-radius: 20px; font-size: 14px; font-weight: bold; - } - QPushButton:hover { background-color: #1ed760; } - """) - 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: #ffffff; border-radius: 20px;} - QPushButton:hover { background-color: #f44336; } - """) - 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: #ffffff; border-radius: 20px;} - QPushButton:hover { background-color: #757575; } - """) - 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 - - def on_begin_search_clicked(self): - """Handle Begin Search button click - starts Plex analysis""" - # Trigger UI updates on album card - try: - if self.album_card and hasattr(self.album_card, 'set_download_in_progress'): - self.album_card.set_download_in_progress() - except (RuntimeError, AttributeError): - # Album card object has been deleted, skip UI update - print("Album card object deleted, skipping progress update") - pass - - 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): - """Start database analysis for album tracks (server-aware)""" - from ui.pages.sync import PlaylistTrackAnalysisWorker - worker = PlaylistTrackAnalysisWorker(self.album.tracks, self.media_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 on_analysis_started(self, total_tracks): - print(f"Album analysis started for {total_tracks} tracks") - - def on_track_analyzed(self, track_index, result): - """Handle individual track analysis completion with live UI updates""" - self.analysis_progress.setValue(track_index) - row_index = track_index - 1 - 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)) - else: - matched_text = "Missing" - self.tracks_to_download_count += 1 - self.download_count_label.setText(str(self.tracks_to_download_count)) - # Add cancel button for missing tracks only - self.add_cancel_button_to_row(row_index) - self.track_table.setItem(row_index, 3, QTableWidgetItem(matched_text)) - - def on_analysis_completed(self, results): - """Handle analysis completion""" - self.analysis_complete = True - self.analysis_results = results - self.missing_tracks = [r for r in results if not r.exists_in_plex] - print(f"Album 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() - try: - self.process_finished.emit() - except RuntimeError as e: - print(f"Modal object deleted during analysis complete signal: {e}") - QMessageBox.information(self, "Analysis Complete", "All album tracks already exist in Plex! No downloads needed.") - # Close with accept since all tracks are already available (success case) - self.accept() - - def on_analysis_failed(self, error_message): - print(f"Album analysis failed: {error_message}") - QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze album tracks: {error_message}") - self.cancel_btn.hide() - self.begin_search_btn.show() - - def start_download_progress(self): - """Start actual download progress tracking""" - 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): - """Start multiple track downloads in parallel for better performance""" - 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): - """Start the next batch of downloads up to the concurrent limit""" - 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_album(track) - - # Skip if track was cancelled - if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks: - print(f"Skipping cancelled track at index {track_index}: {track.name}") - self.download_queue_index += 1 - self.completed_downloads += 1 - continue - - self.track_table.setItem(track_index, 4, 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): - """Search for track and download via infrastructure path - PARALLEL VERSION""" - 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): - """Start track search with parallel completion handling""" - 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): - """Start search worker with parallel completion handling.""" - 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.parent_page.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): - """Handle completion of a parallel search query. If it fails, trigger the next query.""" - if hasattr(self, 'cancel_requested') and self.cancel_requested: return - - valid_candidates = self.get_valid_candidates(results, spotify_track, query) - - if valid_candidates: - # Cache the candidates for future retries - 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): - """Start download with validated metadata""" - track_info = self.parallel_search_tracking[download_index] - - # Reset state if this track was previously marked as completed (for retries) - if track_info.get('completed', False): - print(f"Resetting state for manually retried track (index: {download_index}).") - 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 - - # Add the new download source to used sources to prevent retrying with same user/file - source_key = f"{getattr(slskd_result, 'username', 'unknown')}_{slskd_result.filename}" - track_info['used_sources'].add(source_key) - - # Update UI to show the new download has been queued - spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata) - self.track_table.setItem(table_index, 4, QTableWidgetItem("... Queued")) - - # Start the actual download process - self.start_matched_download_via_infrastructure_parallel(spotify_based_result, track_index, table_index, download_index) - - def find_existing_download_for_track(self, spotify_based_result): - """Find existing download item in queue that matches this track""" - if not self.downloads_page or not hasattr(self.downloads_page, 'download_queue'): - return None - - target_title = spotify_based_result.title if hasattr(spotify_based_result, 'title') else spotify_based_result.filename - target_artist = spotify_based_result.artist if hasattr(spotify_based_result, 'artist') else "" - - # Check active queue for existing downloads - if hasattr(self.downloads_page.download_queue, 'active_queue'): - for item in self.downloads_page.download_queue.active_queue.download_items: - # Match by title and artist similarity - if (hasattr(item, 'title') and hasattr(item, 'artist') and - item.title.lower().strip() == target_title.lower().strip()): - # For better matching, also check artist if available - if target_artist and hasattr(item, 'artist'): - if target_artist.lower() in item.artist.lower() or item.artist.lower() in target_artist.lower(): - return item - else: - return item # Match by title only if no artist info - return None - - def cancel_existing_download(self, download_item): - """Cancel an existing download item""" - if download_item and hasattr(download_item, 'cancel_download'): - print(f"Cancelling existing queued download: '{download_item.title}' by {download_item.artist}") - download_item.cancel_download() - return True - return False - - def start_matched_download_via_infrastructure_parallel(self, spotify_based_result, track_index, table_index, download_index): - """Start infrastructure download with parallel completion tracking""" - try: - # Check for existing download and cancel if found - existing_download = self.find_existing_download_for_track(spotify_based_result) - if existing_download: - print(f"Found existing download for '{spotify_based_result.title}', canceling before retry...") - self.cancel_existing_download(existing_download) - - 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): - """Poll download statuses for active downloads""" - if self._is_status_update_running or not self.active_downloads: - return - self._is_status_update_running = True - - # Create a snapshot of data needed by the worker thread - 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 - - # Import the worker from sync.py - from ui.pages.sync import SyncStatusProcessingWorker - worker = SyncStatusProcessingWorker( - self.parent_page.soulseek_client, - items_to_check - ) - - worker.signals.completed.connect(self._handle_processed_status_updates) - worker.signals.error.connect(lambda e: print(f"Album Status Worker Error: {e}")) - self.download_status_pool.start(worker) - - def _handle_processed_status_updates(self, results): - """Handle status updates from the background worker and trigger retry logic""" - import time - - # Create a lookup for faster access to active download items - 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 - - # Update the main download_info object with the latest missing count - if 'api_missing_count' in result: - download_info['api_missing_count'] = result['api_missing_count'] - - # Update the download_id if the worker found a match by filename - if result.get('transfer_id') and download_info.get('download_id') != result['transfer_id']: - print(f"ℹ️ Corrected download ID for '{download_info['slskd_result'].filename}'") - download_info['download_id'] = result['transfer_id'] - - # Handle terminal states (completed, failed, cancelled) - 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) - - # Handle transient states (downloading, queued) - elif new_status == 'downloading': - progress = result.get('progress', 0) - self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem(f"⏬ Downloading ({progress}%)")) - - # Reset queue timer if it exists - if 'queued_start_time' in download_info: - del download_info['queued_start_time'] - - # Add timeout for downloads stuck at 0% - if progress < 1: - if 'downloading_start_time' not in download_info: - download_info['downloading_start_time'] = time.time() - # 90-second timeout for being stuck at 0% - elif time.time() - download_info['downloading_start_time'] > 90: - print(f"Download for '{download_info['slskd_result'].filename}' is stuck at 0%. Cancelling and retrying.") - # Cancel the old download before retry - 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: - # Progress is being made, reset the timer - 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'], 4, QTableWidgetItem("... Queued")) - # Start a timer to detect if it's stuck in queue - if 'queued_start_time' not in download_info: - download_info['queued_start_time'] = time.time() - elif time.time() - download_info['queued_start_time'] > 90: # 90-second timeout - print(f"Download for '{download_info['slskd_result'].filename}' is stuck in queue. Cancelling and retrying.") - # Cancel the old download before retry - 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): - """Cancel the current download before retrying with alternative source""" - try: - slskd_result = download_info.get('slskd_result') - if not slskd_result: - print("No slskd_result found in download_info for cancellation") - return - - # Extract download details for cancellation - download_id = download_info.get('download_id') - username = getattr(slskd_result, 'username', None) - - if download_id and username: - print(f"Cancelling timed-out album download: {download_id} from {username}") - - # Use asyncio to call the async cancel method - import asyncio - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - success = loop.run_until_complete( - self.soulseek_client.cancel_download(download_id, username, remove=False) - ) - if success: - print(f"Successfully cancelled album download {download_id}") - else: - print(f"Failed to cancel album download {download_id}") - finally: - loop.close() - else: - print(f"Missing download_id ({download_id}) or username ({username}) for album cancellation") - - except Exception as e: - print(f"Error cancelling album download: {e}") - - def retry_parallel_download_with_fallback(self, failed_download_info): - """Retries a failed download by selecting the next-best cached candidate""" - 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: # Max 3 attempts total (1 initial + 2 retries) - 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 - - print(f"Retrying album download {download_index + 1} with next candidate: {next_candidate.filename}") - self.track_table.setItem(failed_download_info['table_index'], 4, 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): - """Handle completion of a parallel track download""" - if not hasattr(self, 'parallel_search_tracking'): - print(f"parallel_search_tracking not initialized yet, skipping completion for download {download_index}") - return - 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'], 4, QTableWidgetItem("Downloaded")) - # Hide cancel button since track is now downloaded - self.hide_cancel_button_for_row(track_info['table_index']) - self.downloaded_tracks_count += 1 - self.downloaded_count_label.setText(str(self.downloaded_tracks_count)) - self.successful_downloads += 1 - else: - # Check if track was cancelled (don't overwrite cancelled status) - table_index = track_info['table_index'] - current_status = self.track_table.item(table_index, 4) - if current_status and "Cancelled" in current_status.text(): - print(f"Track {download_index} was cancelled - preserving cancelled status") - else: - self.track_table.setItem(table_index, 4, QTableWidgetItem("Failed")) - if track_info not in self.permanently_failed_tracks: - self.permanently_failed_tracks.append(track_info) - self.update_failed_matches_button() - self.failed_downloads += 1 - - self.completed_downloads += 1 - self.active_parallel_downloads -= 1 - self.download_progress.setValue(self.completed_downloads) - - # FIX: Use QTimer.singleShot to avoid deep recursion on rapid failures. - # This schedules the next batch to start after the current call stack unwinds. - QTimer.singleShot(0, self.start_next_batch_of_downloads) - - def on_parallel_track_failed(self, download_index, reason): - """Handle failure of a parallel track download""" - print(f"Album parallel download {download_index + 1} failed: {reason}") - self.on_parallel_track_completed(download_index, False) - - def update_failed_matches_button(self): - """Shows, hides, and updates the counter on the 'Correct Failed Matches' button""" - 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 find_track_index_in_album(self, spotify_track): - """Find the table row index for a given Spotify track""" - for i, album_track in enumerate(self.album.tracks): - if album_track.id == spotify_track.id: - return i - return None - - def on_all_downloads_complete(self): - """Handle completion of all downloads""" - self.download_in_progress = False - print("All album downloads completed!") - self.cancel_btn.hide() - - # Emit process_finished signal to unlock UI - try: - self.process_finished.emit() - except RuntimeError as e: - print(f"Modal object deleted during downloads complete signal: {e}") - - # Request Plex library scan if we have successful downloads - if self.successful_downloads > 0 and hasattr(self, 'parent_artists_page') and self.parent_artists_page.scan_manager: - album_name = getattr(self.album, 'name', 'Unknown Album') - self.parent_artists_page.scan_manager.request_scan(f"Album download completed: {album_name} ({self.successful_downloads} tracks)") - - # Add cancelled tracks that were missing from Plex to permanently_failed_tracks for wishlist inclusion - if hasattr(self, 'cancelled_tracks') and hasattr(self, 'missing_tracks'): - for cancelled_row in self.cancelled_tracks: - # Check if this cancelled track was actually missing from Plex - cancelled_track = self.album.tracks[cancelled_row] - missing_track_result = None - - # Find the corresponding missing track result - for missing_result in self.missing_tracks: - if missing_result.spotify_track.id == cancelled_track.id: - missing_track_result = missing_result - break - - # Only add to wishlist if track was actually missing from Plex AND not successfully downloaded - if missing_track_result: - # Check if track was successfully downloaded (don't add downloaded tracks to wishlist) - status_item = self.track_table.item(cancelled_row, 4) - current_status = status_item.text() if status_item else "" - - if "Downloaded" in current_status: - print(f"Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist addition") - else: - cancelled_track_info = { - 'download_index': cancelled_row, - 'table_index': cancelled_row, - 'track': cancelled_track, - 'track_name': cancelled_track.name, - 'artist_name': cancelled_track.artists[0] if cancelled_track.artists else "Unknown", - 'retry_count': 0, - 'spotify_track': missing_track_result.spotify_track # Include the spotify track for wishlist - } - # Check if not already in permanently_failed_tracks - if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks): - self.permanently_failed_tracks.append(cancelled_track_info) - print(f"Added cancelled missing track {cancelled_track.name} to failed list for wishlist") - else: - print(f"Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist addition") - - # Add permanently failed tracks to wishlist before showing completion message - failed_count = len(self.permanently_failed_tracks) - wishlist_added_count = 0 - - # DEBUG: Log failed tracks details - logger.info(f"DEBUG: Processing {failed_count} failed tracks from album modal") - for i, track_info in enumerate(self.permanently_failed_tracks): - logger.info(f"DEBUG: Failed track {i+1}: keys={list(track_info.keys())}") - if 'spotify_track' in track_info: - st = track_info['spotify_track'] - logger.info(f"DEBUG: Spotify track: {getattr(st, 'name', 'NO_NAME')} by {getattr(st, 'artists', 'NO_ARTISTS')}") - - if self.permanently_failed_tracks: - try: - # Add failed tracks to wishlist - # Handle artist name safely - could be string or dict - artist_name = 'Unknown Artist' - if hasattr(self.album, 'artists') and self.album.artists: - first_artist = self.album.artists[0] - if isinstance(first_artist, str): - artist_name = first_artist - elif isinstance(first_artist, dict): - artist_name = first_artist.get('name', 'Unknown Artist') - else: - artist_name = str(first_artist) - - source_context = { - 'album_name': getattr(self.album, 'name', 'Unknown Album'), - 'album_id': getattr(self.album, 'id', None), - 'artist_name': artist_name, - 'added_from': 'artists_page_modal', - 'timestamp': datetime.now().isoformat() - } - - logger.info(f"DEBUG: Source context: {source_context}") - - for i, failed_track_info in enumerate(self.permanently_failed_tracks): - try: - logger.info(f"DEBUG: Attempting to add track {i+1} to wishlist...") - success = self.wishlist_service.add_failed_track_from_modal( - track_info=failed_track_info, - source_type='album', - source_context=source_context - ) - logger.info(f"DEBUG: Track {i+1} add result: {success}") - if success: - wishlist_added_count += 1 - else: - logger.warning(f"DEBUG: Track {i+1} was NOT added to wishlist (returned False)") - except Exception as e: - logger.error(f"Failed to add album track {i+1} to wishlist: {e}") - import traceback - logger.error(f"Full traceback: {traceback.format_exc()}") - - if wishlist_added_count > 0: - logger.info(f"Added {wishlist_added_count} failed tracks to wishlist from album '{self.album.name}'") - else: - logger.warning(f"NO TRACKS were added to wishlist despite {failed_count} failed tracks!") - - except Exception as e: - logger.error(f"Error adding failed album tracks to wishlist: {e}") - import traceback - logger.error(f"Full outer traceback: {traceback.format_exc()}") - - # Determine the final message based on success or failure - if self.permanently_failed_tracks: - final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing album tracks!\n\n" - - if wishlist_added_count > 0: - final_message += f"Added {wishlist_added_count} failed track{'s' if wishlist_added_count != 1 else ''} to wishlist for automatic retry.\n\n" - - final_message += "You can also manually correct failed downloads or check the wishlist on the dashboard." - - # If there are failures, ensure the modal is visible and bring it to the front - if self.isHidden(): - self.show() - self.activateWindow() - self.raise_() - - # Show the message but DO NOT close the modal - QMessageBox.information(self, "Downloads Complete", final_message) - - else: - final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing album tracks!\n\nAll tracks were downloaded successfully!" - - # Show the success message - QMessageBox.information(self, "Downloads Complete", final_message) - - # FIX: Only accept and close the modal on full success - self.accept() - - def get_valid_candidates(self, results, spotify_track, query): - """Score and filter search results, then perform strict artist verification""" - if not results: - return [] - - # Get initial confident matches with version-aware scoring - initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results) - - if not initial_candidates: - print(f"No initial candidates found for '{spotify_track.name}' from query '{query}'.") - return [] - - print(f"Found {len(initial_candidates)} initial candidates for '{spotify_track.name}'. Now verifying artist...") - - # Perform strict artist verification on the initial candidates - verified_candidates = [] - spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else "" - - # Robust normalization for both artist name and file path - normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower() - - for candidate in initial_candidates: - # The 'filename' from Soulseek includes the full folder path - slskd_full_path = candidate.filename - - # Apply the same robust normalization to the Soulseek path - normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', slskd_full_path).lower() - - # Check if the cleaned artist's name is in the cleaned folder path - if normalized_spotify_artist in normalized_slskd_path: - print(f"Artist '{spotify_artist_name}' VERIFIED in path: '{slskd_full_path}'") - verified_candidates.append(candidate) - else: - print(f"Artist '{spotify_artist_name}' NOT found in path: '{slskd_full_path}'. Discarding candidate.") - - if verified_candidates: - # Apply quality profile filtering before returning - if hasattr(self.parent_artists_page, 'soulseek_client'): - quality_filtered = self.parent_artists_page.soulseek_client.filter_results_by_quality_preference( - verified_candidates - ) - - if quality_filtered: - verified_candidates = quality_filtered - print(f"Applied quality profile filtering: {len(verified_candidates)} candidates remain") - else: - print(f"Quality profile filtering removed all candidates, keeping originals") - - best_confidence = verified_candidates[0].confidence - best_version = getattr(verified_candidates[0], 'version_type', 'unknown') - best_quality = getattr(verified_candidates[0], 'quality', 'unknown') - print(f"Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best: {best_confidence:.2f} ({best_version}, {best_quality.upper()})") - - # Log version breakdown for debugging - version_counts = {} - for candidate in verified_candidates[:5]: # Show top 5 - version = getattr(candidate, 'version_type', 'unknown') - version_counts[version] = version_counts.get(version, 0) + 1 - penalty = getattr(candidate, 'version_penalty', 0.0) - quality = getattr(candidate, 'quality', 'unknown') - bitrate_info = f" {candidate.bitrate}kbps" if hasattr(candidate, 'bitrate') and candidate.bitrate else "" - print(f" {candidate.confidence:.2f} - {version} ({quality.upper()}{bitrate_info}) (penalty: {penalty:.2f}) - {candidate.filename[:100]}...") - - else: - print(f"No verified matches found for '{spotify_track.name}' after checking file paths.") - - return verified_candidates - - def create_spotify_based_search_result_from_validation(self, slskd_result, spotify_metadata): - """Create SpotifyBasedSearchResult from validation results""" - 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() - - # Inner class for the search worker - 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: - import asyncio - 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 on_cancel_clicked(self): - """Handle Cancel button""" - try: - self.cancel_operations() - self.process_finished.emit() - self.reject() - except RuntimeError as e: - print(f"Modal object deleted during cancel: {e}") - pass - - def on_close_clicked(self): - """Handle Close button""" - try: - if self.cancel_requested or not self.download_in_progress: - self.cancel_operations() - self.process_finished.emit() - self.reject() - except RuntimeError as e: - print(f"Modal object deleted during close: {e}") - pass - - def cancel_operations(self): - """Cancel any ongoing operations""" - print("Cancelling album download operations...") - self.cancel_requested = True - - # Stop workers - for worker in self.active_workers: - if hasattr(worker, 'cancel'): - worker.cancel() - self.active_workers.clear() - - # Stop polling - self.download_status_timer.stop() - print("Album modal operations cancelled successfully.") - - def on_correct_failed_matches_clicked(self): - """Handle failed matches correction using ManualMatchModal from sync.py""" - if not self.permanently_failed_tracks: - return - - # Import the ManualMatchModal from sync.py - 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): - """Handle a track being successfully resolved by the ManualMatchModal""" - print(f"Manual match resolved (Artists) - download_index: {resolved_track_info.get('download_index')}, table_index: {resolved_track_info.get('table_index')}") - 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) - print(f"Removed track from permanently_failed_tracks (Artists) - remaining: {len(self.permanently_failed_tracks)}") - else: - print("Could not find original failed track to remove (Artists)") - self.update_failed_matches_button() - -class ArtistsPage(QWidget): - database_updated_externally = pyqtSignal() - def __init__(self, downloads_page=None, parent=None): - super().__init__(parent) - - # Core clients - self.spotify_client = None - self.plex_client = None - self.soulseek_client = None - self.downloads_page = downloads_page # Store reference to DownloadsPage - self.matching_engine = MusicMatchingEngine() - - # State management - self.selected_artist = None - self.current_albums = [] - self.all_releases = [] # Store all releases (albums + singles + eps) - self.albums_only = [] # Store only studio albums - self.singles_and_eps = [] # Store singles and EPs - self.matched_count = 0 - self.artist_search_worker = None - self.album_fetch_worker = None - self.plex_library_worker = None - self.singles_eps_worker = None - - # Album download tracking - self.album_downloads = {} # {album_id: {total_tracks: X, completed_tracks: Y, active_downloads: [download_ids], album_card: card_ref}} - self.completed_downloads = set() # Track downloads that have been completed (to handle cleanup) - self.download_status_timer = QTimer(self) - self.download_status_timer.timeout.connect(self.poll_album_download_statuses) - self.download_status_timer.start(2000) # Poll every 2 seconds (consistent with sync.py) - self.download_status_pool = QThreadPool() - - # Initialize unified media scan manager (will be set when clients are connected) - self.scan_manager = None - self.download_status_pool.setMaxThreadCount(1) # One worker at a time to avoid conflicts - self._is_status_update_running = False - - # Album download session management - self.active_album_sessions = {} # {album_id: {'modal': modal_ref, 'album_with_tracks': album_obj}} - - # UI setup - self.setup_ui() - self.setup_clients() - - def set_toast_manager(self, toast_manager): - """Set the toast manager for showing notifications""" - self.toast_manager = toast_manager - - def _on_media_scan_completed(self): - """Callback triggered when media 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 - from config.settings import config_manager - - # Get the active media client - active_server = config_manager.get_active_media_server() - if active_server == "jellyfin": - media_client = getattr(self, 'jellyfin_client', None) - else: - media_client = getattr(self, 'plex_client', None) - - # Check if we should run incremental update - if not media_client or not media_client.is_connected(): - logger.debug(f"{active_server.upper()} 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(f"Starting automatic incremental database update after {active_server.upper()} scan") - self._start_automatic_incremental_update() - - except Exception as e: - logger.error(f"Error in media 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 - self._auto_database_worker = DatabaseUpdateWorker( - self.media_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") - - # Emit the signal to notify the dashboard to refresh its statistics - self.database_updated_externally.emit() - logger.info("Emitted signal to refresh dashboard database statistics after auto update") - - # 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_clients(self): - """Initialize client connections""" - try: - from config.settings import config_manager - - self.spotify_client = SpotifyClient() - self.plex_client = PlexClient() - - # Add Jellyfin client for multi-server support - from core.jellyfin_client import JellyfinClient - self.jellyfin_client = JellyfinClient() - - # Set up unified media client based on active server - active_server = config_manager.get_active_media_server() - if active_server == "plex": - self.media_client = self.plex_client - self.server_type = "plex" - else: # jellyfin - self.media_client = self.jellyfin_client - self.server_type = "jellyfin" - - self.soulseek_client = SoulseekClient() - - # --- FIX: Ensure the soulseek_client uses the download path from config --- - download_path = config_manager.get('soulseek.download_path') - if download_path and hasattr(self.soulseek_client, 'download_path'): - self.soulseek_client.download_path = download_path - print(f"Set soulseek_client download path for ArtistsPage to: {download_path}") - # --- END FIX --- - - # Initialize unified media scan manager now that clients are available - try: - from core.media_scan_manager import MediaScanManager - self.scan_manager = MediaScanManager(delay_seconds=60) - # Add automatic incremental database update after scan completion - self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed) - print("MediaScanManager initialized for ArtistsPage") - except Exception as e: - print(f"Failed to initialize MediaScanManager: {e}") - - except Exception as e: - print(f"Failed to initialize clients: {e}") - - def setup_ui(self): - self.setStyleSheet(""" - ArtistsPage { - background: #191414; - } - """) - - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(30, 30, 30, 30) - main_layout.setSpacing(20) - - # Create main container for dynamic content switching - self.main_container = QWidget() - container_layout = QVBoxLayout(self.main_container) - container_layout.setContentsMargins(0, 0, 0, 0) - container_layout.setSpacing(0) - - # Initial centered search interface - self.search_interface = self.create_search_interface() - container_layout.addWidget(self.search_interface) - - # Artist view (initially hidden) - self.artist_view = self.create_artist_view() - self.artist_view.hide() - container_layout.addWidget(self.artist_view) - - main_layout.addWidget(self.main_container) - - def create_search_interface(self): - """Create the initial centered search interface""" - widget = QWidget() - layout = QVBoxLayout(widget) - - # Add vertical stretch to center content - layout.addStretch(2) - - # Title section - title_container = QWidget() - title_layout = QVBoxLayout(title_container) - title_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - title_layout.setSpacing(10) - - title_label = QLabel("Discover Artists") - title_label.setFont(QFont("Arial", 32, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #ffffff;") - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - subtitle_label = QLabel("Search for any artist to explore their complete discography") - subtitle_label.setFont(QFont("Arial", 16)) - subtitle_label.setStyleSheet("color: #b3b3b3;") - subtitle_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - title_layout.addWidget(title_label) - title_layout.addWidget(subtitle_label) - - # Search bar - search_container = QFrame() - search_container.setFixedHeight(80) - search_container.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(50, 50, 50, 0.9), - stop:1 rgba(40, 40, 40, 0.95)); - border-radius: 16px; - border: 2px solid rgba(29, 185, 84, 0.3); - } - """) - - search_layout = QHBoxLayout(search_container) - search_layout.setContentsMargins(24, 20, 24, 20) - search_layout.setSpacing(16) - - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Search for an artist... (e.g., 'The Beatles', 'Taylor Swift')") - self.search_input.setFixedHeight(40) - self.search_input.setStyleSheet(""" - QLineEdit { - background: rgba(70, 70, 70, 0.8); - border: 2px solid rgba(100, 100, 100, 0.3); - border-radius: 20px; - padding: 0 20px; - color: #ffffff; - font-size: 16px; - font-weight: 500; - } - QLineEdit:focus { - border: 2px solid rgba(29, 185, 84, 0.8); - background: rgba(80, 80, 80, 0.9); - } - QLineEdit::placeholder { - color: rgba(255, 255, 255, 0.5); - } - """) - self.search_input.returnPressed.connect(self.perform_artist_search) - - search_btn = QPushButton("Search Artists") - search_btn.setFixedHeight(40) - search_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 1.0), - stop:1 rgba(24, 156, 71, 1.0)); - border: none; - border-radius: 20px; - color: #000000; - font-size: 14px; - font-weight: bold; - padding: 0 24px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(30, 215, 96, 1.0), - stop:1 rgba(26, 174, 81, 1.0)); - } - """) - search_btn.clicked.connect(self.perform_artist_search) - - search_layout.addWidget(self.search_input) - search_layout.addWidget(search_btn) - - # Status label - self.search_status = QLabel("Ready to search") - self.search_status.setFont(QFont("Arial", 12)) - self.search_status.setStyleSheet("color: rgba(255, 255, 255, 0.7); padding: 10px;") - self.search_status.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Artist results container (initially hidden) - self.artist_results_container = QFrame() - self.artist_results_container.setStyleSheet(""" - QFrame { - background: rgba(30, 30, 30, 0.6); - border-radius: 12px; - border: 1px solid rgba(60, 60, 60, 0.4); - } - """) - self.artist_results_container.hide() - - results_layout = QVBoxLayout(self.artist_results_container) - results_layout.setContentsMargins(20, 16, 20, 20) - results_layout.setSpacing(16) - - results_header = QLabel("Artist Results") - results_header.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - results_header.setStyleSheet("color: #ffffff;") - - results_layout.addWidget(results_header) - - # Scrollable artist results - self.artist_scroll = QScrollArea() - self.artist_scroll.setWidgetResizable(True) - self.artist_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.artist_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.artist_scroll.setFixedHeight(320) # Fixed height to accommodate artist cards - - # Enable horizontal scrolling with mouse wheel - def wheelEvent(event): - # Convert vertical wheel scrolling to horizontal scrolling - if event.angleDelta().y() != 0: - horizontal_scroll = self.artist_scroll.horizontalScrollBar() - current_value = horizontal_scroll.value() - # Scroll by artist card width (200px + spacing) - scroll_amount = -event.angleDelta().y() // 120 * 220 # Each wheel step scrolls ~1 card - horizontal_scroll.setValue(current_value + scroll_amount) - event.accept() - else: - QScrollArea.wheelEvent(self.artist_scroll, event) - - self.artist_scroll.wheelEvent = wheelEvent - self.artist_scroll.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - } - QScrollBar:horizontal { - background: rgba(80, 80, 80, 0.3); - height: 8px; - border-radius: 4px; - } - QScrollBar::handle:horizontal { - background: rgba(29, 185, 84, 0.8); - border-radius: 4px; - min-width: 20px; - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - border: none; - background: none; - } - """) - - self.artist_results_widget = QWidget() - self.artist_results_layout = QHBoxLayout(self.artist_results_widget) - self.artist_results_layout.setSpacing(16) - self.artist_results_layout.setContentsMargins(0, 0, 0, 0) - - self.artist_scroll.setWidget(self.artist_results_widget) - results_layout.addWidget(self.artist_scroll) - - # Add everything to main layout - layout.addWidget(title_container) - layout.addSpacing(40) - layout.addWidget(search_container) - layout.addSpacing(20) - layout.addWidget(self.search_status) - layout.addSpacing(20) - layout.addWidget(self.artist_results_container) - layout.addStretch(2) - - return widget - - def create_artist_view(self): - """Create the artist view for displaying albums""" - widget = QWidget() - layout = QVBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(20) - - # Header with artist info and repositioned search - header = QFrame() - header.setFixedHeight(100) - header.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(50, 50, 55, 0.95), - stop:0.3 rgba(42, 42, 47, 0.97), - stop:0.7 rgba(35, 35, 40, 0.98), - stop:1 rgba(28, 28, 33, 0.99)); - border-radius: 16px; - border: 1px solid rgba(80, 80, 85, 0.3); - } - """) - - header_layout = QHBoxLayout(header) - header_layout.setContentsMargins(20, 16, 20, 16) - header_layout.setSpacing(20) - - # Artist info section - artist_info_layout = QVBoxLayout() - - self.artist_name_label = QLabel() - self.artist_name_label.setFont(QFont("Arial", 24, QFont.Weight.Bold)) - self.artist_name_label.setStyleSheet(""" - color: #ffffff; - letter-spacing: 1px; - background: transparent; - border: none; - """) - - self.artist_stats_label = QLabel() - self.artist_stats_label.setFont(QFont("Arial", 12)) - self.artist_stats_label.setStyleSheet(""" - color: #c8c8c8; - opacity: 0.9; - background: transparent; - border: none; - """) - - artist_info_layout.addWidget(self.artist_name_label) - artist_info_layout.addWidget(self.artist_stats_label) - - # Watchlist button - self.watchlist_button = QPushButton("Add to Watchlist") - self.watchlist_button.setFixedHeight(36) - self.watchlist_button.setFixedWidth(140) - self.watchlist_button.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.15), - stop:1 rgba(20, 160, 70, 0.1)); - border: 1px solid rgba(29, 185, 84, 0.6); - border-radius: 18px; - color: #1db954; - font-size: 12px; - font-weight: 600; - padding: 0 12px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.25), - stop:1 rgba(20, 160, 70, 0.18)); - border: 1px solid rgba(29, 185, 84, 0.8); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(20, 160, 70, 0.3), - stop:1 rgba(29, 185, 84, 0.25)); - } - QPushButton:disabled { - background: rgba(80, 80, 85, 0.3); - border: 1px solid rgba(80, 80, 85, 0.5); - color: rgba(150, 150, 155, 0.7); - } - """) - self.watchlist_button.clicked.connect(self.toggle_watchlist) - - # New search bar (smaller, in header) - self.header_search_input = QLineEdit() - self.header_search_input.setPlaceholderText("Search for another artist...") - self.header_search_input.setFixedHeight(36) - self.header_search_input.setFixedWidth(300) - self.header_search_input.setStyleSheet(""" - QLineEdit { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(70, 70, 75, 0.9), - stop:1 rgba(55, 55, 60, 0.95)); - border: 1px solid rgba(120, 120, 125, 0.4); - border-radius: 18px; - padding: 0 16px; - color: #ffffff; - font-size: 13px; - font-weight: 500; - } - QLineEdit:focus { - border: 1px solid rgba(29, 185, 84, 0.8); - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(75, 75, 80, 0.95), - stop:1 rgba(60, 60, 65, 1.0)); - } - QLineEdit::placeholder { - color: rgba(200, 200, 200, 0.7); - } - """) - self.header_search_input.returnPressed.connect(self.perform_new_artist_search) - - # Back button - back_btn = QPushButton("← Back to Search") - back_btn.setFixedHeight(36) - back_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.12), - stop:1 rgba(20, 160, 70, 0.08)); - border: 1px solid rgba(29, 185, 84, 0.6); - border-radius: 18px; - color: #1db954; - font-size: 13px; - font-weight: 600; - padding: 0 16px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.2), - stop:1 rgba(20, 160, 70, 0.15)); - border: 1px solid rgba(29, 185, 84, 0.8); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(20, 160, 70, 0.25), - stop:1 rgba(29, 185, 84, 0.2)); - } - """) - back_btn.clicked.connect(self.return_to_search) - - header_layout.addLayout(artist_info_layout) - header_layout.addWidget(self.watchlist_button) - header_layout.addStretch() - header_layout.addWidget(self.header_search_input) - header_layout.addWidget(back_btn) - - # Albums section - albums_container = QFrame() - albums_container.setStyleSheet(""" - QFrame { - background: rgba(25, 25, 25, 0.6); - border-radius: 12px; - border: 1px solid rgba(50, 50, 50, 0.4); - } - """) - - albums_layout = QVBoxLayout(albums_container) - albums_layout.setContentsMargins(20, 16, 20, 20) - albums_layout.setSpacing(16) - - # Albums header with filter toggle buttons - albums_header_layout = QHBoxLayout() - - # Left side: Title and filter buttons - title_and_filters_layout = QHBoxLayout() - title_and_filters_layout.setSpacing(15) - - albums_title = QLabel("Releases") - albums_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - albums_title.setStyleSheet("color: #ffffff;") - - # Toggle buttons for filtering - self.current_filter = "albums" # Default filter - - self.albums_button = QPushButton("Albums") - self.albums_button.setCheckable(True) - self.albums_button.setChecked(True) - self.albums_button.clicked.connect(lambda: self.set_filter("albums")) - - self.singles_eps_button = QPushButton("Singles & EPs") - self.singles_eps_button.setCheckable(True) - self.singles_eps_button.clicked.connect(lambda: self.set_filter("singles_eps")) - - # Style the toggle buttons - toggle_button_style = """ - QPushButton { - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 12px; - padding: 6px 12px; - color: #b3b3b3; - font-size: 11px; - font-weight: 500; - } - QPushButton:checked { - background-color: rgba(29, 185, 84, 0.8); - border-color: rgba(29, 185, 84, 1.0); - color: #ffffff; - } - QPushButton:hover:!checked { - background-color: rgba(255, 255, 255, 0.15); - color: #ffffff; - } - """ - - self.albums_button.setStyleSheet(toggle_button_style) - self.singles_eps_button.setStyleSheet(toggle_button_style) - - title_and_filters_layout.addWidget(albums_title) - title_and_filters_layout.addWidget(self.albums_button) - title_and_filters_layout.addWidget(self.singles_eps_button) - - self.albums_status = QLabel("Loading releases...") - self.albums_status.setFont(QFont("Arial", 11)) - self.albums_status.setStyleSheet("color: #b3b3b3;") - - albums_header_layout.addLayout(title_and_filters_layout) - albums_header_layout.addStretch() - albums_header_layout.addWidget(self.albums_status) - - albums_layout.addLayout(albums_header_layout) - - # Albums grid - self.albums_scroll = QScrollArea() - self.albums_scroll.setWidgetResizable(True) - self.albums_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.albums_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.albums_scroll.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - } - QScrollBar:vertical { - background: rgba(80, 80, 80, 0.3); - width: 8px; - border-radius: 4px; - } - QScrollBar::handle:vertical { - background: rgba(29, 185, 84, 0.8); - border-radius: 4px; - min-height: 20px; - } - """) - - self.albums_widget = QWidget() - self.albums_grid_layout = QGridLayout(self.albums_widget) - self.albums_grid_layout.setSpacing(16) - self.albums_grid_layout.setContentsMargins(0, 0, 0, 0) - - self.albums_scroll.setWidget(self.albums_widget) - albums_layout.addWidget(self.albums_scroll) - - layout.addWidget(header) - layout.addWidget(albums_container, 1) - - return widget - - def set_filter(self, filter_type): - """Handle filter toggle button clicks""" - self.current_filter = filter_type - - # Update button states - self.albums_button.setChecked(filter_type == "albums") - self.singles_eps_button.setChecked(filter_type == "singles_eps") - - # Filter and display appropriate releases - if self.all_releases: # Only filter if we have data - self.filter_and_display_releases() - - def classify_releases(self, releases): - """Classify releases into albums, singles, and EPs""" - albums = [] - singles = [] - eps = [] - - for release in releases: - if release.album_type == 'album': - albums.append(release) - elif release.album_type == 'single': - if release.total_tracks == 1: - singles.append(release) - else: # 2+ tracks = EP - eps.append(release) - - return albums, singles, eps - - def filter_and_display_releases(self): - """Filter releases based on current filter and display them""" - if self.current_filter == "albums": - releases_to_show = self.albums_only - status_text = f"Found {len(releases_to_show)} albums" - else: # singles_eps - releases_to_show = self.singles_and_eps - singles_count = len([r for r in releases_to_show if r.total_tracks == 1]) - eps_count = len([r for r in releases_to_show if r.total_tracks > 1]) - status_text = f"Found {singles_count} singles, {eps_count} EPs" - - # Update status - self.albums_status.setText(status_text) - - # Store current releases for ownership checking - self.current_albums = releases_to_show - - # Display releases immediately (without ownership info) - self.display_albums(releases_to_show, set()) - - # Start appropriate ownership check in background - if self.current_filter == "albums": - self.start_plex_library_check(releases_to_show) # Use existing album-level matching - else: - self.start_singles_eps_library_check(releases_to_show) # New track-level matching - - def perform_artist_search(self): - """Perform artist search""" - query = self.search_input.text().strip() - if not query: - self.search_status.setText("Please enter an artist name") - self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;") - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.warning("Please enter an artist name to search") - return - - if not self.spotify_client or not self.spotify_client.is_authenticated(): - self.search_status.setText("Spotify not connected") - self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;") - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.error("Spotify authentication required") - return - - self.search_status.setText("Searching for artists...") - self.search_status.setStyleSheet("color: #1db954; padding: 10px;") - - # Show toast for search start - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.info(f"Searching for artists: '{query}'") - - # Clear previous results - self.clear_artist_results() - - # Start search worker - if self.artist_search_worker: - self.artist_search_worker.terminate() - self.artist_search_worker.wait() - - self.artist_search_worker = ArtistSearchWorker(query, self.spotify_client, self.matching_engine) - self.artist_search_worker.artists_found.connect(self.on_artists_found) - self.artist_search_worker.search_failed.connect(self.on_artist_search_failed) - self.artist_search_worker.start() - - def perform_new_artist_search(self): - """Perform new artist search from header""" - query = self.header_search_input.text().strip() - if query: - self.search_input.setText(query) - self.return_to_search() - QTimer.singleShot(100, self.perform_artist_search) - - def on_artists_found(self, artist_matches): - """Handle artist search results""" - if not artist_matches: - self.search_status.setText("No artists found") - self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;") - if hasattr(self, 'toast_manager') and self.toast_manager: - query = self.search_input.text().strip() - self.toast_manager.warning(f"No artists found for '{query}'") - return - - self.search_status.setText(f"Found {len(artist_matches)} artists") - self.search_status.setStyleSheet("color: #1db954; padding: 10px;") - - # Show success toast - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.success(f"Found {len(artist_matches)} artists matching your search") - - # Display artist results - for artist_match in artist_matches[:10]: # Show top 10 results - card = ArtistResultCard(artist_match) - card.artist_selected.connect(self.on_artist_selected) - self.artist_results_layout.addWidget(card) - - self.artist_results_layout.addStretch() - self.artist_results_container.show() - - def on_artist_search_failed(self, error): - """Handle artist search failure""" - self.search_status.setText(f"Search failed: {error}") - self.search_status.setStyleSheet("color: #ff6b6b; padding: 10px;") - - # Show error toast - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.error(f"Artist search failed: {error}") - - def on_artist_selected(self, artist): - """Handle artist selection""" - self.selected_artist = artist - - # Update artist view - self.artist_name_label.setText(artist.name) - self.artist_stats_label.setText(f"{artist.followers:,} followers • {len(artist.genres)} genres") - - # Update watchlist button state - try: - database = get_database() - is_watching = database.is_artist_in_watchlist(artist.id) - self.update_watchlist_button(is_watching) - except Exception as e: - logger.error(f"Error checking watchlist status for artist {artist.name}: {e}") - self.update_watchlist_button(False) - - # Switch to artist view - self.search_interface.hide() - self.artist_view.show() - - # Start fetching albums - self.fetch_artist_albums(artist) - - def fetch_artist_albums(self, artist): - """Fetch albums for selected artist""" - self.albums_status.setText("Loading albums...") - - # Show toast for album loading - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.info(f"Loading albums for {artist.name}") - - # Clear previous albums - self.clear_albums() - - # Start album fetch worker - if self.album_fetch_worker: - self.album_fetch_worker.terminate() - self.album_fetch_worker.wait() - - self.album_fetch_worker = AlbumFetchWorker(artist, self.spotify_client) - self.album_fetch_worker.albums_found.connect(self.on_albums_found) - self.album_fetch_worker.fetch_failed.connect(self.on_album_fetch_failed) - self.album_fetch_worker.start() - - def on_albums_found(self, albums, artist): - """Handle album fetch results - now handles all release types""" - if not albums: - self.albums_status.setText("No releases found") - return - - print(f"Processing {len(albums)} releases for {artist.name}") - - # Store all releases and classify them - self.all_releases = albums - self.albums_only, singles, eps = self.classify_releases(albums) - self.singles_and_eps = singles + eps - - print(f"Classification: {len(self.albums_only)} albums, {len(singles)} singles, {len(eps)} EPs") - - # Initialize match counter for real-time updates - self.matched_count = 0 - - # Auto-switch to Singles & EPs if no albums available - if len(self.albums_only) == 0 and len(self.singles_and_eps) > 0: - print("No albums found, automatically switching to Singles & EPs view") - self.current_filter = "singles_eps" - self.albums_button.setChecked(False) - self.singles_eps_button.setChecked(True) - - # Display based on current filter - self.filter_and_display_releases() - - def display_albums(self, albums, ownership_info): - """Display albums in the grid - supports legacy set or new dict of AlbumOwnershipStatus""" - - # Handle both old format (set of owned album names) and new format (dict of statuses) - if isinstance(ownership_info, dict): - print(f"Displaying {len(albums)} albums with detailed ownership info") - else: - print(f"Displaying {len(albums)} albums, {len(ownership_info)} owned") - - # Clear existing albums - self.clear_albums() - - row, col = 0, 0 - max_cols = 5 - - for album in albums: - if isinstance(ownership_info, dict): - # New format - use detailed ownership status - status = ownership_info.get(album.name) - if status: - card = AlbumCard(album, status.is_owned) - card.update_ownership(status) - else: - # Album not found in statuses - assume not owned - card = AlbumCard(album, False) - else: - # Legacy format - simple set of owned album names - is_owned = album.name in ownership_info - card = AlbumCard(album, is_owned) - - # Connect download signal for all albums - we can download missing tracks for partial albums - # and missing albums, but complete albums will show a different modal - card.download_requested.connect(self.on_album_download_requested) - - self.albums_grid_layout.addWidget(card, row, col) - - col += 1 - if col >= max_cols: - col = 0 - row += 1 - - def start_singles_eps_library_check(self, releases): - """Start track-level library check for singles and EPs""" - if not releases: - return - - # Update status to show we're checking - singles_count = len([r for r in releases if r.total_tracks == 1]) - eps_count = len([r for r in releases if r.total_tracks > 1]) - self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • Checking library...") - - # Show toast for library check start - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.info("Checking your library for owned singles and EPs...") - - # Stop any existing worker - if hasattr(self, 'singles_eps_worker') and self.singles_eps_worker: - self.singles_eps_worker.stop() - self.singles_eps_worker.wait() - - # Start new worker for track-level matching - self.singles_eps_worker = SinglesEPsLibraryWorker(releases, MusicMatchingEngine()) - self.singles_eps_worker.release_matched.connect(self.on_single_ep_matched) - self.singles_eps_worker.check_completed.connect(self.on_singles_eps_library_checked) - self.singles_eps_worker.check_failed.connect(self.on_singles_eps_check_failed) - self.singles_eps_worker.start() - - def start_plex_library_check(self, albums): - """Start database library check in background""" - # Get active server for dynamic toast message - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = "Jellyfin" if active_server == "jellyfin" else "Plex" - except: - server_name = "Plex" # Fallback - - # Show toast for library check start - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.info(f"Checking your {server_name} library for owned albums...") - - # Stop any existing Plex worker - if self.plex_library_worker: - self.plex_library_worker.stop() - self.plex_library_worker.terminate() - self.plex_library_worker.wait() - - # Start new Plex worker - self.plex_library_worker = PlexLibraryWorker(albums, self.matching_engine) - self.plex_library_worker.library_checked.connect(self.on_plex_library_checked) - self.plex_library_worker.album_matched.connect(self.on_album_matched) - self.plex_library_worker.check_failed.connect(self.on_plex_library_check_failed) - self.plex_library_worker.start() - - def on_plex_library_checked(self, album_statuses): - """Handle final database library check completion with detailed status info""" - print(f"Database check completed: {len(album_statuses)} album statuses") - - if not self.current_albums: - print("No current albums, skipping final update") - return - - # Count different types of ownership - complete_count = sum(1 for status in album_statuses.values() if status.is_complete) - nearly_complete_count = sum(1 for status in album_statuses.values() if status.is_nearly_complete) - partial_count = sum(1 for status in album_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete) - missing_count = sum(1 for status in album_statuses.values() if not status.is_owned) - total_count = len(self.current_albums) - - # Update final status message with all categories - status_parts = [] - if complete_count > 0: - status_parts.append(f"{complete_count} complete") - if nearly_complete_count > 0: - status_parts.append(f"{nearly_complete_count} nearly complete") - if partial_count > 0: - status_parts.append(f"{partial_count} partial") - if missing_count > 0: - status_parts.append(f"{missing_count} missing") - - self.albums_status.setText(f"Found {total_count} releases • " + " • ".join(status_parts)) - - - # Show toast with library check results - if hasattr(self, 'toast_manager') and self.toast_manager: - owned_count = complete_count + nearly_complete_count + partial_count - if owned_count == 0: - self.toast_manager.info(f"No albums found in your library ({total_count} available for download)") - elif nearly_complete_count > 0 or partial_count > 0: - if nearly_complete_count > 0: - self.toast_manager.success(f"Found {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial albums out of {total_count}") - else: - self.toast_manager.success(f"Found {complete_count} complete, {partial_count} partial albums out of {total_count}") - else: - self.toast_manager.success(f"Found {complete_count} complete albums out of {total_count}") - - print(f"Database check complete: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {total_count} albums") - - # Update the album display with the final ownership statuses - self.display_albums(self.current_albums, album_statuses) - - def on_album_matched(self, album_name, ownership_status): - """Handle individual album match for real-time UI update with detailed status""" - if ownership_status.is_complete: - print(f"Real-time match: '{album_name}' (complete)") - elif ownership_status.is_nearly_complete: - print(f"Real-time match: '{album_name}' (nearly complete {int(ownership_status.completion_ratio * 100)}%)") - else: - print(f"Real-time match: '{album_name}' (partial {int(ownership_status.completion_ratio * 100)}%)") - - # Update match counter - self.matched_count += 1 - - # Update status text in real-time - if self.current_albums: - total_count = len(self.current_albums) - remaining_count = total_count - self.matched_count - self.albums_status.setText(f"Found {total_count} releases • {self.matched_count} owned • {remaining_count} checking...") - - - # Find and update the specific album card - for i in range(self.albums_grid_layout.count()): - item = self.albums_grid_layout.itemAt(i) - if item and item.widget(): - album_card = item.widget() - if hasattr(album_card, 'album') and album_card.album.name == album_name: - if ownership_status.is_complete: - status_text = "complete" - elif ownership_status.is_nearly_complete: - status_text = f"nearly complete ({int(ownership_status.completion_ratio * 100)}%)" - else: - status_text = f"partial ({int(ownership_status.completion_ratio * 100)}%)" - print(f"Real-time update: '{album_name}' -> {status_text}") - album_card.update_ownership(ownership_status) - break - - def on_plex_library_check_failed(self, error): - """Handle Plex library check failure""" - print(f"Plex library check failed: {error}") - - # Get active server for dynamic error message - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = "Jellyfin" if active_server == "jellyfin" else "Plex" - except: - server_name = "Plex" # Fallback - - # Show error toast - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.error(f"{server_name} connection failed - cannot check owned albums") - - if self.current_albums: - self.albums_status.setText(f"Found {len(self.current_albums)} albums • {server_name} check failed") - # Display albums without ownership info - self.display_albums(self.current_albums, set()) - - def on_single_ep_matched(self, release_name, ownership_status): - """Handle real-time single/EP match results""" - if ownership_status.is_owned: - if ownership_status.is_complete: - print(f"Single/EP match: '{release_name}' (complete)") - else: - print(f"Single/EP match: '{release_name}' (partial {int(ownership_status.completion_ratio * 100)}%)") - - # Find the corresponding card and update it - for i in range(self.albums_grid_layout.count()): - item = self.albums_grid_layout.itemAt(i) - if item: - card = item.widget() - if isinstance(card, AlbumCard) and card.album.name == release_name: - card.update_ownership(ownership_status) - break - - def on_singles_eps_library_checked(self, release_statuses): - """Handle singles/EPs library check completion""" - print(f"Singles/EPs check completed: {len(release_statuses)} statuses") - - # Count results for summary - complete_count = sum(1 for status in release_statuses.values() if status.is_complete) - nearly_complete_count = sum(1 for status in release_statuses.values() if status.is_nearly_complete) - partial_count = sum(1 for status in release_statuses.values() if status.is_owned and not status.is_complete and not status.is_nearly_complete) - missing_count = sum(1 for status in release_statuses.values() if not status.is_owned) - total_count = len(release_statuses) - - # Update status text with results - singles_count = len([r for r in self.singles_and_eps if r.total_tracks == 1]) - eps_count = len([r for r in self.singles_and_eps if r.total_tracks > 1]) - owned_count = complete_count + nearly_complete_count + partial_count - self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • {owned_count} owned") - - # Show toast notifications - if hasattr(self, 'toast_manager') and self.toast_manager: - if owned_count == 0: - self.toast_manager.info(f"No releases found in your library ({total_count} available for download)") - else: - if complete_count > 0 and (nearly_complete_count > 0 or partial_count > 0): - self.toast_manager.success(f"Found {complete_count} complete, {nearly_complete_count + partial_count} partial releases") - elif complete_count > 0: - self.toast_manager.success(f"Found {complete_count} complete releases") - else: - self.toast_manager.success(f"Found {owned_count} partial releases") - - print(f"Singles/EPs check complete: {complete_count} complete, {nearly_complete_count} nearly complete, {partial_count} partial, {missing_count} missing out of {total_count} releases") - - # Update the display with the final ownership statuses - self.display_albums(self.current_albums, release_statuses) - - def on_singles_eps_check_failed(self, error): - """Handle singles/EPs check failure""" - print(f"Singles/EPs library check failed: {error}") - - # Update status - singles_count = len([r for r in self.singles_and_eps if r.total_tracks == 1]) - eps_count = len([r for r in self.singles_and_eps if r.total_tracks > 1]) - self.albums_status.setText(f"Found {singles_count} singles, {eps_count} EPs • Check failed") - - # Show error toast - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.error("Library connection failed - cannot check owned releases") - - if self.current_albums: - # Display releases without ownership info - self.display_albums(self.current_albums, set()) - - def on_album_fetch_failed(self, error): - """Handle album fetch failure""" - self.albums_status.setText(f"Failed to load releases: {error}") - - def on_album_download_requested(self, album: Album): - """Handle album download request from an AlbumCard using new modal system.""" - print(f"Download requested for album: {album.name} by {', '.join(album.artists)}") - - # Find the album card for this album to pass to modal - album_card = None - for i in range(self.albums_grid_layout.count()): - item = self.albums_grid_layout.itemAt(i) - if item and item.widget(): - card = item.widget() - if hasattr(card, 'album') and card.album.id == album.id: - album_card = card - break - - if not album_card: - QMessageBox.critical(self, "Error", "Could not find album card for tracking.") - return - - # Check if we have necessary clients - if not self.downloads_page: - QMessageBox.critical(self, "Error", "Downloads page is not connected. Cannot start download.") - return - - if not self.media_client: - QMessageBox.critical(self, "Error", "Music database is not available. Cannot verify existing tracks.") - return - - # Check if there's already an active session for this album - if album.id in self.active_album_sessions: - existing_session = self.active_album_sessions[album.id] - existing_modal = existing_session.get('modal') - - # Check if the modal still exists and is valid - try: - if existing_modal and existing_modal.isVisible(): - print(f"Resuming existing active modal for album: {album.name}") - # Modal is already visible and active, just bring it to front - existing_modal.activateWindow() - existing_modal.raise_() - - # Show toast notification - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.info(f"Downloads already in progress for '{album.name}'") - return - elif existing_modal: - # Modal exists but is not visible - check if downloads are still in progress - if hasattr(existing_modal, 'download_in_progress') and existing_modal.download_in_progress: - print(f"Resuming hidden modal with active downloads for album: {album.name}") - # Show the existing modal to resume progress tracking - existing_modal.show() - existing_modal.activateWindow() - existing_modal.raise_() - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.info(f"Resuming downloads for '{album.name}'") - return - else: - # Modal finished or cancelled, safe to create fresh one - print("Found finished modal, creating fresh modal") - del self.active_album_sessions[album.id] - else: - # No modal reference, clean up stale session - print("Stale session found, cleaning up") - del self.active_album_sessions[album.id] - except RuntimeError: - # Modal was deleted, remove from sessions - print("Existing modal was deleted, creating fresh session") - del self.active_album_sessions[album.id] - - print("Fetching album tracks and creating DownloadMissingAlbumTracksModal...") - - # First, we need to fetch the tracks for this album - try: - # Get the full album data with tracks from Spotify - album_data = self.spotify_client.get_album(album.id) - if not album_data or not album_data.get('tracks'): - QMessageBox.critical(self, "Error", f"Could not fetch tracks for album '{album.name}'. Please try again.") - return - - # Import Track class for track creation - from core.spotify_client import Track - - # Convert track data to Track objects - tracks = [] - track_items = album_data['tracks']['items'] - - for track_data in track_items: - # Add missing fields that are required by Track.from_spotify_track() - track_data['album'] = { - 'name': album_data['name'], - 'id': album_data['id'] - } - # Album tracks don't have popularity field, so set it to 0 - if 'popularity' not in track_data: - track_data['popularity'] = 0 - - track = Track.from_spotify_track(track_data) - tracks.append(track) - - print(f"Fetched {len(tracks)} tracks for album '{album.name}'") - - # Create a copy of the album with tracks added - album_with_tracks = album - album_with_tracks.tracks = tracks # Add tracks attribute dynamically - - # Create and show the new sophisticated modal - # Get active server and media client - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - if active_server == "jellyfin": - media_client = getattr(self, 'jellyfin_client', None) - if not media_client: - QMessageBox.critical(self, "Error", "Jellyfin client not available") - return - else: - media_client = self.plex_client - if not media_client: - QMessageBox.critical(self, "Error", "Plex client not available") - return - - modal = DownloadMissingAlbumTracksModal( - album=album_with_tracks, # Use the album with tracks - album_card=album_card, - parent_page=self, - downloads_page=self.downloads_page, - media_client=media_client, - server_type=active_server - ) - - # Store the session for resumption - self.active_album_sessions[album.id] = { - 'modal': modal, - 'album_with_tracks': album_with_tracks, - 'album_card': album_card - } - - # Connect signals to handle cleanup - only use modal.finished to avoid double handling - modal.finished.connect(lambda result: self.on_album_modal_closed(album.id, album_card, result)) - - # Show the modal - modal.exec() - - except Exception as e: - print(f"Error fetching album tracks: {e}") - QMessageBox.critical(self, "Error", f"Failed to fetch album tracks: {str(e)}\n\nPlease check your Spotify connection and try again.") - - def on_album_download_process_finished(self, album_id: str, album_card): - """Handle cleanup when album download process is actually finished (downloads completed)""" - print(f"Album download process finished for album: {album_id}") - - # Only mark as completed if downloads actually finished successfully - if album_card and hasattr(album_card, 'set_download_completed'): - album_card.set_download_completed() - print(f"Marked album {album_id} as download completed") - - print("Album download process cleanup completed") - - def on_album_modal_closed(self, album_id: str, album_card, result): - """Handle cleanup when album modal is closed (regardless of reason)""" - print(f"Album modal closed for album: {album_id}, result: {'Accepted' if result == 1 else 'Rejected/Cancelled'}") - - # Clean up the session when modal is definitely closing - if album_id in self.active_album_sessions: - session = self.active_album_sessions[album_id] - modal = session.get('modal') - - # Only remove session if downloads are completely finished or cancelled - if result == 1: # QDialog.Accepted = 1 (downloads completed or all tracks exist) - del self.active_album_sessions[album_id] - print(f"Removed completed session for album {album_id}") - elif modal and hasattr(modal, 'cancel_requested') and modal.cancel_requested: - # User explicitly cancelled - remove session for fresh modal on next click - del self.active_album_sessions[album_id] - print(f"Removed cancelled session for album {album_id} - user requested cancellation") - elif modal and hasattr(modal, 'download_in_progress') and not modal.download_in_progress: - # Downloads are not in progress, safe to remove session - del self.active_album_sessions[album_id] - print(f"Removed finished session for album {album_id} - no downloads in progress") - else: - # Downloads still in progress and not cancelled - keep session alive for resumption - print(f"Keeping session for album {album_id} - downloads still in progress, can be resumed") - - if album_card: - try: - if result == 1: # QDialog.Accepted = 1 (downloads actually completed) - # Only mark as completed if downloads were actually successful - if hasattr(album_card, 'set_download_completed'): - album_card.set_download_completed() - print(f"Marked album {album_id} as download completed") - else: - # Modal was cancelled/closed - reset the card to allow reopening (but keep session) - # Reset any download-in-progress indicators - if hasattr(album_card, 'progress_overlay') and album_card.progress_overlay is not None: - try: - album_card.progress_overlay.hide() - print(f"Hidden progress overlay for album {album_id}") - except RuntimeError: - pass - - # Also call the safe hide method if available - if hasattr(album_card, 'safe_hide_overlay'): - album_card.safe_hide_overlay() - - # Reset the card to allow clicking again (if not already owned) - if not album_card.is_owned: - # Show a visual indicator that this album has an active session - if hasattr(album_card, 'status_indicator'): - try: - album_card.status_indicator.setText("") - album_card.status_indicator.setToolTip("Click to resume download session") - except RuntimeError: - pass - print(f"Reset album card for {album_id} to allow resumption") - - except Exception as e: - print(f"Error handling album card state: {e}") - - print("Album modal cleanup completed") - - # === LEGACY METHODS - NO LONGER USED WITH NEW MODAL SYSTEM === - # These methods were part of the old manual album download flow - # Keeping them commented for reference but they are replaced by DownloadMissingAlbumTracksModal - - # def on_album_selected_for_download(self, album_result: AlbumResult): - # """ - # [DEPRECATED] Handles album selection from the search dialog and delegates the - # matched album download process to the main DownloadsPage. - # REPLACED BY: DownloadMissingAlbumTracksModal which handles everything internally - # """ - # print(f"Selected album for download: {album_result.album_title} by {album_result.artist}") - # - # if self.downloads_page: - # # Start tracking this album download - # album_id = f"{self.album_to_download.id}" - # self.start_album_download_tracking(album_id, album_result, self.album_to_download) - # - # # Delegate to the DownloadsPage to handle the matched download - # # This will open the Spotify matching modal and add to the central queue - # print("Delegating to DownloadsPage to start matched album download...") - # self.downloads_page.start_matched_album_download(album_result) - # else: - # QMessageBox.critical(self, "Error", "Downloads page is not connected. Cannot start download.") - - # def start_album_download_tracking(self, album_id: str, album_result: AlbumResult, spotify_album: Album): - # """ - # [DEPRECATED] Start tracking downloads for an album - # REPLACED BY: DownloadMissingAlbumTracksModal handles its own tracking - # """ - # # Find the album card for this album - # album_card = None - # for i in range(self.albums_grid_layout.count()): - # item = self.albums_grid_layout.itemAt(i) - # if item and item.widget(): - # card = item.widget() - # if hasattr(card, 'album') and card.album.id == spotify_album.id: - # album_card = card - # break - # - # if album_card: - # # Initialize tracking for this album - # self.album_downloads[album_id] = { - # 'total_tracks': album_result.track_count, - # 'completed_tracks': 0, - # 'active_downloads': [], - # 'album_card': album_card, - # 'album_result': album_result, - # 'spotify_album': spotify_album - # } - # - # # Update album card to show download in progress - # album_card.set_download_in_progress() - # print(f"Started tracking album: {spotify_album.name} ({album_result.track_count} tracks)") - - # === END LEGACY METHODS === - - def poll_album_download_statuses(self): - """Poll download statuses for tracked albums""" - if self._is_status_update_running or not self.album_downloads: - return - - # Collect all active download IDs from tracked albums - all_download_ids = [] - for album_info in self.album_downloads.values(): - all_download_ids.extend(album_info.get('active_downloads', [])) - - if not all_download_ids: - # No active downloads to check, but we might need to populate the active_downloads - # by checking the downloads page for downloads related to our tracked albums - self.update_active_downloads_from_queue() - return - - self._is_status_update_running = True - - # Create items to check with enhanced data structure for album tracking - items_to_check = [] - - # Build comprehensive data for each tracked download - for album_id, album_info in self.album_downloads.items(): - active_downloads = album_info.get('active_downloads', []) - - for download_id in active_downloads: - # Try to get filename from downloads page if possible - file_path = self._get_download_filename(download_id) - - item_data = { - 'widget_id': download_id, # Use download_id as widget_id for tracking - 'download_id': download_id, - 'file_path': file_path, - 'api_missing_count': 0, # Track for grace period logic - 'album_id': album_id # Link back to album for easier processing - } - items_to_check.append(item_data) - - if not items_to_check: - self._is_status_update_running = False - return - - print(f"Starting album status check for {len(items_to_check)} downloads across {len(self.album_downloads)} albums") - - # Create and start our dedicated album worker - worker = AlbumStatusProcessingWorker( - self.soulseek_client, - items_to_check - ) - worker.signals.completed.connect(self._handle_album_status_updates) - worker.signals.error.connect(lambda e: self._on_album_status_error(e)) - self.download_status_pool.start(worker) - - def _get_download_filename(self, download_id): - """Try to get filename for a download ID from the downloads page""" - if not self.downloads_page or not hasattr(self.downloads_page, 'download_queue'): - return '' - - # Check active queue first - if hasattr(self.downloads_page.download_queue, 'active_queue'): - for item in self.downloads_page.download_queue.active_queue.download_items: - # Check for exact ID match first - if hasattr(item, 'download_id') and item.download_id == download_id: - if hasattr(item, 'filename'): - return item.filename - elif hasattr(item, 'title'): - return f"{item.title}.mp3" # Fallback with extension - - # Also check if the real ID of this item matches - real_id = self._get_real_download_id(item) - if real_id and real_id == download_id: - if hasattr(item, 'filename'): - return item.filename - elif hasattr(item, 'title'): - return f"{item.title}.mp3" # Fallback with extension - - # Check finished queue - if hasattr(self.downloads_page.download_queue, 'finished_queue'): - for item in self.downloads_page.download_queue.finished_queue.download_items: - # Check for exact ID match first - if hasattr(item, 'download_id') and item.download_id == download_id: - if hasattr(item, 'filename'): - return item.filename - elif hasattr(item, 'title'): - return f"{item.title}.mp3" # Fallback with extension - - # Also check if the real ID of this item matches - real_id = self._get_real_download_id(item) - if real_id and real_id == download_id: - if hasattr(item, 'filename'): - return item.filename - elif hasattr(item, 'title'): - return f"{item.title}.mp3" # Fallback with extension - - return '' - - def _on_album_status_error(self, error_msg): - """Handle errors from album status worker""" - print(f"Album status worker error: {error_msg}") - self._is_status_update_running = False - - def update_active_downloads_from_queue(self): - """Update active downloads list by checking the downloads page queue""" - if not self.downloads_page or not hasattr(self.downloads_page, 'download_queue'): - return - - # Get all active downloads from the downloads page - active_items = [] - finished_items = [] - - if hasattr(self.downloads_page.download_queue, 'active_queue'): - active_items = self.downloads_page.download_queue.active_queue.download_items - - if hasattr(self.downloads_page.download_queue, 'finished_queue'): - finished_items = self.downloads_page.download_queue.finished_queue.download_items - - print(f"Checking {len(active_items)} active downloads and {len(finished_items)} finished downloads for album tracking") - - # For each tracked album, check if any downloads match - for album_id, album_info in self.album_downloads.items(): - album_result = album_info.get('album_result') - spotify_album = album_info.get('spotify_album') - if not album_result or not spotify_album: - continue - - album_name = spotify_album.name if spotify_album else 'Unknown' - print(f"Looking for downloads matching album: {album_name} by {album_result.artist}") - - # Look for downloads that match this album's tracks (both active and finished) - matching_downloads = [] - completed_count = 0 - - # Check both active and finished downloads - all_items = active_items + finished_items - - for download_item in all_items: - # Enhanced matching logic for better album detection - is_match = self._is_download_from_album(download_item, album_result, spotify_album) - - if is_match: - # Debug: show what download ID we're working with - current_id = getattr(download_item, 'download_id', 'NO_ID') - title = getattr(download_item, 'title', 'Unknown') - print(f" Found matching item: '{title}' with download_id: {current_id}") - - # Use the download ID directly from the item (should be the real one) - if current_id and current_id != 'NO_ID': - # Check if this item is in finished items (completed) - if download_item in finished_items: - completed_count += 1 - print(f" Found completed track: '{title}' (ID: {current_id})") - else: - # It's an active download - use the current ID - matching_downloads.append(current_id) - print(f" Added active download ID: {current_id} for '{title}'") - else: - print(f" No download ID found for: '{title}'") - - # Update the active downloads and completed count for this album - old_active = album_info.get('active_downloads', []) - old_completed = album_info.get('completed_tracks', 0) - - album_info['active_downloads'] = matching_downloads - - # Update completed tracks count if we found more completed items - if completed_count > old_completed: - print(f"Updating completed tracks: {old_completed} -> {completed_count}") - album_info['completed_tracks'] = completed_count - # Trigger UI update - self.update_album_card_progress(album_id) - - # Log changes - if len(matching_downloads) != len(old_active) or completed_count != old_completed: - print(f"Album '{album_name}': {len(old_active)} -> {len(matching_downloads)} active, {old_completed} -> {completed_count} completed") - - if not matching_downloads and completed_count == 0: - total_tracks = album_info.get('total_tracks', 0) - print(f"No matching downloads found for album: {album_name} (expected {total_tracks} tracks)") - - def _get_real_download_id(self, download_item): - """Extract the real slskd download ID from a download item""" - if not hasattr(download_item, 'download_id'): - return None - - download_id = download_item.download_id - - # Check if it's already a UUID (real ID from slskd) - import re - uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - - if re.match(uuid_pattern, download_id, re.IGNORECASE): - # It's already a real UUID - return download_id - - # Check if it's a simple numeric ID - if download_id.isdigit(): - return download_id - - # If it's a composite ID like "username_filename_timestamp_suffix", - # we need to look it up in the slskd API by filename - if hasattr(download_item, 'filename') and download_item.filename: - # Try to find the real ID by querying current downloads by filename - real_id = self._lookup_download_id_by_filename(download_item.filename) - if real_id: - print(f"Found real ID {real_id} for composite ID {download_id}") - return real_id - - # If we can't determine the real ID, return the composite one - # The worker will try filename matching as fallback - return download_id - - def _lookup_download_id_by_filename(self, filename): - """Look up the real download ID by filename from slskd API""" - if not self.soulseek_client: - return None - - try: - import asyncio - import os - - # Create a temporary event loop to make the API call - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - transfers_data = loop.run_until_complete( - self.soulseek_client._make_request('GET', 'transfers/downloads') - ) - - if not transfers_data: - return None - - expected_basename = os.path.basename(filename).lower() - - # Search through all transfers for matching filename - for user_data in transfers_data: - # Check files directly under user object - if 'files' in user_data and isinstance(user_data['files'], list): - for file_data in user_data['files']: - api_filename = file_data.get('filename', '') - api_basename = os.path.basename(api_filename).lower() - if api_basename == expected_basename: - return file_data.get('id') - - # Check files in directories - 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): - for file_data in directory['files']: - api_filename = file_data.get('filename', '') - api_basename = os.path.basename(api_filename).lower() - if api_basename == expected_basename: - return file_data.get('id') - - finally: - loop.close() - - except Exception as e: - print(f"Error looking up download ID for {filename}: {e}") - - return None - - def _is_download_from_album(self, download_item, album_result, spotify_album): - """Enhanced matching logic to determine if a download belongs to the tracked album""" - # Check for explicit album match flag (from Spotify matching modal) - if hasattr(download_item, 'matched_download') and download_item.matched_download: - print(f" Found explicitly matched download: {getattr(download_item, 'title', 'Unknown')}") - return True - - # Check for album metadata match - if hasattr(download_item, 'album') and download_item.album and spotify_album: - download_album = download_item.album.lower().strip() - spotify_album_name = spotify_album.name.lower().strip() - - # Exact or partial album name match - if (download_album == spotify_album_name or - download_album in spotify_album_name or - spotify_album_name in download_album): - print(f" Album name match: '{download_album}' ~ '{spotify_album_name}'") - return True - - # Check artist matching - artist_match = False - if hasattr(download_item, 'artist') and download_item.artist: - download_artist = download_item.artist.lower().strip() - - # Check against album result artist - if album_result and album_result.artist: - album_artist = album_result.artist.lower().strip() - if (download_artist == album_artist or - download_artist in album_artist or - album_artist in download_artist): - artist_match = True - - # Check against Spotify album artists - if spotify_album and spotify_album.artists: - for spotify_artist in spotify_album.artists: - spotify_artist_name = spotify_artist.lower().strip() - if (download_artist == spotify_artist_name or - download_artist in spotify_artist_name or - spotify_artist_name in download_artist): - artist_match = True - break - - # For artist match, also check if it's recent (to avoid false positives from other albums) - if artist_match: - # Check if download was started recently (within album tracking timeframe) - # This helps filter out downloads from other albums by the same artist - if hasattr(download_item, 'created_time') or hasattr(download_item, 'start_time'): - # Could add timestamp checking here if needed - pass - print(f" Artist match found for: {getattr(download_item, 'title', 'Unknown')}") - return True - - # Check filename-based matching as last resort - if hasattr(download_item, 'filename') and download_item.filename: - filename = download_item.filename.lower() - - # Check if filename contains album name - if spotify_album and spotify_album.name.lower() in filename: - print(f" Filename contains album name: {download_item.filename}") - return True - - # Check if filename contains artist name - if album_result and album_result.artist and album_result.artist.lower() in filename: - print(f" Filename contains artist name: {download_item.filename}") - return True - - return False - - def _handle_album_status_updates(self, results): - """Handle status updates from the background worker""" - if not results: - self._is_status_update_running = False - return - - print(f"Processing {len(results)} album download status updates") - - albums_to_update = set() - albums_completed = set() - - for result in results: - download_id = result.get('download_id') - widget_id = result.get('widget_id') - status = result.get('status', '') - progress = result.get('progress', 0.0) - album_id = result.get('album_id') # Direct album link from our enhanced data - - # Handle missing downloads with grace period - if status == 'missing': - api_missing_count = result.get('api_missing_count', 0) - # Check if this download was previously completed but now missing (due to cleanup) - if self._was_download_previously_completed(download_id): - print(f"Download {download_id} was previously completed (now cleaned up)") - status = 'completed' # Treat as completed - else: - # Update the missing count in our tracking data for next poll - self._update_missing_count(download_id, api_missing_count) - continue - - # Find which album this download belongs to - target_album_id = album_id # Use direct link if available - if not target_album_id: - # Fallback: search through all albums - for aid, album_info in self.album_downloads.items(): - if download_id in album_info.get('active_downloads', []): - target_album_id = aid - break - - if not target_album_id or target_album_id not in self.album_downloads: - print(f"Could not find album for download {download_id}") - continue - - album_info = self.album_downloads[target_album_id] - album_name = album_info.get('spotify_album', {}).name if album_info.get('spotify_album') else 'Unknown' - - print(f"Album '{album_name}': Download {download_id} status = {status} ({progress:.1f}%)") - - # Handle status changes - if status == 'completed': - # Only process if not already handled by notification system - if not self._was_download_previously_completed(download_id): - # Mark this download as completed in our tracking - self._mark_download_as_completed(download_id) - - # Only increment if not already counted - if download_id in album_info.get('active_downloads', []): - album_info['completed_tracks'] += 1 - album_info['active_downloads'].remove(download_id) - albums_to_update.add(target_album_id) - print(f"Album track completed via polling: {album_info['completed_tracks']}/{album_info['total_tracks']}") - - # Check if album is fully completed - if (album_info['completed_tracks'] >= album_info['total_tracks'] and - not album_info.get('active_downloads')): - albums_completed.add(target_album_id) - else: - print(f"Download {download_id} already counted as completed") - - elif status in ['failed', 'cancelled']: - # Remove from active downloads but don't increment completed - if download_id in album_info['active_downloads']: - album_info['active_downloads'].remove(download_id) - albums_to_update.add(target_album_id) - print(f"Album track {status}: {download_id}") - - elif status in ['downloading', 'queued']: - # Update progress for in-progress downloads - albums_to_update.add(target_album_id) - if progress > 0: - print(f"Track downloading: {progress:.1f}%") - - # Update album cards for albums that had status changes - for album_id in albums_to_update: - self.update_album_card_progress(album_id) - - # Handle completed albums - for album_id in albums_completed: - album_info = self.album_downloads[album_id] - album_card = album_info.get('album_card') - spotify_album = album_info.get('spotify_album') - album_name = spotify_album.name if spotify_album else 'Unknown' - - if album_card: - album_card.set_download_completed() - - # Remove from tracking - del self.album_downloads[album_id] - print(f"Album download completed and removed from tracking: {album_name}") - - self._is_status_update_running = False - - def _update_missing_count(self, download_id, missing_count): - """Update missing count for downloads in grace period""" - # Find and update the missing count in our tracked items - # This helps maintain grace period logic across polling cycles - for album_info in self.album_downloads.values(): - if download_id in album_info.get('active_downloads', []): - # We could store per-download missing counts if needed - # For now, if missing count reaches 3, the worker marks it as failed - if missing_count >= 3: - # Remove from active downloads as it's considered failed - album_info['active_downloads'] = [ - did for did in album_info.get('active_downloads', []) - if did != download_id - ] - print(f"Removed failed download {download_id} from album tracking") - break - - def _mark_download_as_completed(self, download_id): - """Mark a download as completed to handle cleanup detection""" - if download_id: - self.completed_downloads.add(download_id) - print(f"Marked download {download_id} as completed") - - def _was_download_previously_completed(self, download_id): - """Check if a download was previously marked as completed""" - return download_id in self.completed_downloads - - def notify_download_completed(self, download_id, download_item=None): - """Called by downloads page when a download completes (before cleanup)""" - print(f"Downloads page notified completion of: {download_id}") - if download_item: - print(f" Item: '{getattr(download_item, 'title', 'Unknown')}' by '{getattr(download_item, 'artist', 'Unknown')}'") - - # Check if already processed to prevent double counting - if self._was_download_previously_completed(download_id): - print(f"Download {download_id} already processed, skipping") - return - - # Mark as completed immediately - self._mark_download_as_completed(download_id) - - # Find which album this belongs to - try multiple approaches - target_album_id = None - - # Approach 1: Direct ID match (might work if IDs were updated) - for album_id, album_info in self.album_downloads.items(): - if download_id in album_info.get('active_downloads', []): - target_album_id = album_id - print(f"Found album by direct ID match: {album_id}") - break - - # Approach 2: Match by download item attributes if we have the item - if not target_album_id and download_item: - for album_id, album_info in self.album_downloads.items(): - album_result = album_info.get('album_result') - spotify_album = album_info.get('spotify_album') - - if self._is_download_from_album(download_item, album_result, spotify_album): - target_album_id = album_id - print(f"Found album by item matching: {album_id}") - break - - # Approach 3: Remove any composite ID that might match this download - if not target_album_id and download_item: - item_title = getattr(download_item, 'title', '') - for album_id, album_info in self.album_downloads.items(): - # Look for any active download that might be this track - active_downloads = album_info.get('active_downloads', []) - for active_id in active_downloads[:]: # Copy list to avoid modification during iteration - # Check if this composite ID refers to the same track - if item_title and item_title.lower() in active_id.lower(): - # Replace the composite ID with the real ID - album_info['active_downloads'].remove(active_id) - album_info['active_downloads'].append(download_id) - target_album_id = album_id - print(f"Found album by title matching and updated ID: {active_id} -> {download_id}") - break - - if target_album_id: - break - - if target_album_id: - album_info = self.album_downloads[target_album_id] - - # Remove the download ID from active downloads (might be composite or real) - if download_id in album_info['active_downloads']: - album_info['active_downloads'].remove(download_id) - - # Increment completed count - album_info['completed_tracks'] += 1 - - # Update UI immediately - self.update_album_card_progress(target_album_id) - - spotify_album = album_info.get('spotify_album') - album_name = spotify_album.name if spotify_album else 'Unknown' - print(f"Album '{album_name}' track completed via notification: {album_info['completed_tracks']}/{album_info['total_tracks']}") - - # Check if album is complete - if (album_info['completed_tracks'] >= album_info['total_tracks'] and - not album_info.get('active_downloads')): - - album_card = album_info.get('album_card') - if album_card: - album_card.set_download_completed() - - # Remove from tracking - del self.album_downloads[target_album_id] - print(f"Album download completed via notification: {album_name}") - else: - print(f"Could not find album for completed download: {download_id}") - if download_item: - print(f" Title: '{getattr(download_item, 'title', 'Unknown')}'") - print(f" Artist: '{getattr(download_item, 'artist', 'Unknown')}'") - print(f" Album: '{getattr(download_item, 'album', 'Unknown')}'") - - # List current tracked albums for debugging - print(f" Currently tracking {len(self.album_downloads)} albums:") - for aid, ainfo in self.album_downloads.items(): - sa = ainfo.get('spotify_album') - name = sa.name if sa else 'Unknown' - active_count = len(ainfo.get('active_downloads', [])) - print(f" {aid}: '{name}' ({active_count} active downloads)") - - def update_album_card_progress(self, album_id: str): - """Update the album card with current download progress""" - album_info = self.album_downloads.get(album_id) - if not album_info: - return - - album_card = album_info.get('album_card') - if not album_card: - return - - completed = album_info.get('completed_tracks', 0) - total = album_info.get('total_tracks', 1) # Avoid division by zero - active_downloads = album_info.get('active_downloads', []) - - # Calculate progress percentage - percentage = int((completed / total) * 100) if total > 0 else 0 - - # Determine album download state - if completed >= total and not active_downloads: - # Album is fully complete - this will be handled in the main status handler - # Don't call set_download_completed here to avoid duplicate processing - print(f"Album '{album_info.get('spotify_album', {}).name if album_info.get('spotify_album') else 'Unknown'}' is complete: {completed}/{total}") - return - elif not active_downloads and completed == 0: - # No active downloads and nothing completed - might be initializing - album_card.set_download_in_progress() - print(f"Album initializing downloads...") - elif active_downloads: - # Has active downloads - show progress - album_card.update_download_progress(completed, total, percentage) - print(f"Album progress: {completed}/{total} tracks ({percentage}%)") - else: - # Some completed but no active - might be stalled or failed - if completed > 0: - album_card.update_download_progress(completed, total, percentage) - print(f"Album partially complete: {completed}/{total} tracks ({percentage}%)") - else: - album_card.set_download_in_progress() - print(f"Album status unclear, showing in progress...") - - # Update the album card's status indicator to show download activity - if hasattr(album_card, 'status_indicator'): - if active_downloads: - # Show progress percentage or downloading indicator - if percentage > 0: - album_card.status_indicator.setText(f"{percentage}%") - album_card.status_indicator.setToolTip(f"Downloading: {completed}/{total} tracks ({percentage}%)") - else: - album_card.status_indicator.setText("") - album_card.status_indicator.setToolTip("Starting download...") - elif completed > 0: - # Show partial completion - album_card.status_indicator.setText(f"{percentage}%") - album_card.status_indicator.setToolTip(f"Partially downloaded: {completed}/{total} tracks") - - def return_to_search(self): - """Return to search interface""" - # Stop any running workers - self.stop_all_workers() - - # Clear state - self.selected_artist = None - self.current_albums = [] - self.matched_count = 0 - self.header_search_input.clear() - - # Clear albums display - self.clear_albums() - - # Switch views - self.artist_view.hide() - self.search_interface.show() - - def toggle_watchlist(self): - """Toggle artist in watchlist""" - if not hasattr(self, 'selected_artist') or not self.selected_artist: - return - - try: - database = get_database() - artist_id = self.selected_artist.id - artist_name = self.selected_artist.name - - if database.is_artist_in_watchlist(artist_id): - # Remove from watchlist - success = database.remove_artist_from_watchlist(artist_id) - if success: - self.update_watchlist_button(False) - # Emit signal to update dashboard button count - self.database_updated_externally.emit() - # Refresh all artist card watchlist indicators - self.refresh_all_artist_card_watchlist_status() - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.success(f"Removed {artist_name} from watchlist") - else: - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.error(f"Failed to remove {artist_name} from watchlist") - else: - # Add to watchlist - success = database.add_artist_to_watchlist(artist_id, artist_name) - if success: - self.update_watchlist_button(True) - # Emit signal to update dashboard button count - self.database_updated_externally.emit() - # Refresh all artist card watchlist indicators - self.refresh_all_artist_card_watchlist_status() - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.success(f"Added {artist_name} to watchlist") - else: - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.error(f"Failed to add {artist_name} to watchlist") - - except Exception as e: - logger.error(f"Error toggling watchlist for artist: {e}") - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.error("Error updating watchlist") - - def refresh_all_artist_card_watchlist_status(self): - """Refresh watchlist indicators on all visible artist cards""" - try: - # Find all artist cards in the search results layout - for i in range(self.artist_results_layout.count()): - item = self.artist_results_layout.itemAt(i) - if item and item.widget(): - widget = item.widget() - if isinstance(widget, ArtistResultCard): - widget.refresh_watchlist_status() - except Exception as e: - logger.error(f"Error refreshing artist card watchlist status: {e}") - - def update_watchlist_button(self, is_watching): - """Update watchlist button appearance based on watching status""" - if is_watching: - self.watchlist_button.setText("Watching...") - self.watchlist_button.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 193, 7, 0.15), - stop:1 rgba(255, 165, 0, 0.1)); - border: 1px solid rgba(255, 193, 7, 0.6); - border-radius: 18px; - color: #ffc107; - font-size: 12px; - font-weight: 600; - padding: 0 12px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 193, 7, 0.25), - stop:1 rgba(255, 165, 0, 0.18)); - border: 1px solid rgba(255, 193, 7, 0.8); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 165, 0, 0.3), - stop:1 rgba(255, 193, 7, 0.25)); - } - """) - else: - self.watchlist_button.setText("Add to Watchlist") - self.watchlist_button.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.15), - stop:1 rgba(20, 160, 70, 0.1)); - border: 1px solid rgba(29, 185, 84, 0.6); - border-radius: 18px; - color: #1db954; - font-size: 12px; - font-weight: 600; - padding: 0 12px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.25), - stop:1 rgba(20, 160, 70, 0.18)); - border: 1px solid rgba(29, 185, 84, 0.8); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(20, 160, 70, 0.3), - stop:1 rgba(29, 185, 84, 0.25)); - } - QPushButton:disabled { - background: rgba(80, 80, 85, 0.3); - border: 1px solid rgba(80, 80, 85, 0.5); - color: rgba(150, 150, 155, 0.7); - } - """) - - def cleanup_download_tracking(self): - """Clean up download tracking resources""" - print("Starting album download tracking cleanup...") - - # Stop the download status timer - if hasattr(self, 'download_status_timer') and self.download_status_timer.isActive(): - self.download_status_timer.stop() - print(" Stopped download status timer") - - # Reset any album cards that are showing download progress - cards_reset = 0 - for album_info in list(self.album_downloads.values()): - album_card = album_info.get('album_card') - if album_card: - # Hide progress overlays - if hasattr(album_card, 'progress_overlay'): - album_card.progress_overlay.hide() - - # Reset status indicator if album wasn't owned originally - if hasattr(album_card, 'update_ownership') and not album_card.is_owned: - # Reset to available for download state - album_card.update_ownership(False) - - cards_reset += 1 - - if cards_reset > 0: - print(f" Reset {cards_reset} album cards") - - # Clear download tracking state - tracked_albums = len(self.album_downloads) - completed_downloads = len(self.completed_downloads) - self.album_downloads.clear() - self.completed_downloads.clear() - self._is_status_update_running = False - - if tracked_albums > 0: - print(f" Cleared tracking for {tracked_albums} albums") - - # Shutdown the download status thread pool gracefully - if hasattr(self, 'download_status_pool'): - try: - # Clear any pending tasks - self.download_status_pool.clear() - - # Wait for active tasks to complete (with timeout) - if not self.download_status_pool.waitForDone(2000): # Wait up to 2 seconds - print(" Download status pool did not finish within timeout") - else: - print(" Download status pool shut down cleanly") - - except Exception as e: - print(f" Error cleaning up download status pool: {e}") - - print("Album download tracking cleanup completed") - - def cleanup_album_sessions(self): - """Clean up active album download sessions""" - if not self.active_album_sessions: - return - - session_count = len(self.active_album_sessions) - print(f"Cleaning up {session_count} active album sessions...") - - for album_id, session in list(self.active_album_sessions.items()): - try: - modal = session.get('modal') - if modal: - modal.cancel_operations() - modal.close() - except Exception as e: - print(f" Error cleaning up session for album {album_id}: {e}") - - self.active_album_sessions.clear() - print(f"Cleaned up {session_count} album sessions") - - def restart_download_tracking(self): - """Restart download tracking timer if stopped""" - if hasattr(self, 'download_status_timer') and not self.download_status_timer.isActive(): - self.download_status_timer.start(2000) - print("Download tracking timer restarted") - - def stop_all_workers(self): - """Stop all background workers""" - print("Stopping all artist page workers...") - - workers_stopped = 0 - - if self.artist_search_worker and self.artist_search_worker.isRunning(): - print(" Stopping artist search worker...") - self.artist_search_worker.terminate() - if self.artist_search_worker.wait(2000): # Wait up to 2 seconds - print(" Artist search worker stopped") - else: - print(" Artist search worker did not stop within timeout") - self.artist_search_worker = None - workers_stopped += 1 - - if self.album_fetch_worker and self.album_fetch_worker.isRunning(): - print(" Stopping album fetch worker...") - self.album_fetch_worker.terminate() - if self.album_fetch_worker.wait(2000): # Wait up to 2 seconds - print(" Album fetch worker stopped") - else: - print(" Album fetch worker did not stop within timeout") - self.album_fetch_worker = None - workers_stopped += 1 - - if self.plex_library_worker and self.plex_library_worker.isRunning(): - print(" Stopping Plex library worker...") - self.plex_library_worker.stop() - self.plex_library_worker.terminate() - if self.plex_library_worker.wait(2000): # Wait up to 2 seconds - print(" Plex library worker stopped") - else: - print(" Plex library worker did not stop within timeout") - self.plex_library_worker = None - - if hasattr(self, 'singles_eps_worker') and self.singles_eps_worker: - self.singles_eps_worker.stop() - self.singles_eps_worker.wait() - self.singles_eps_worker = None - workers_stopped += 1 - - if workers_stopped > 0: - print(f" Stopped {workers_stopped} background workers") - - # Stop download tracking (this includes its own worker cleanup) - self.cleanup_download_tracking() - - # Clean up active album sessions - self.cleanup_album_sessions() - - print("All workers stopped") - - def clear_artist_results(self): - """Clear artist search results""" - while self.artist_results_layout.count() > 0: - item = self.artist_results_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - self.artist_results_container.hide() - - def clear_albums(self): - """Clear album display""" - while self.albums_grid_layout.count() > 0: - item = self.albums_grid_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - # Don't clear self.current_albums here - it's needed for Plex updates - - def on_paths_updated(self, key: str, value: str): - """Handle settings path updates for immediate effect""" - # No action needed - paths are fetched dynamically via config_manager.get() - # This method exists for future extensibility if caching is added later - pass - - def closeEvent(self, event): - """Handle page close/cleanup""" - self.stop_all_workers() - super().closeEvent(event) - - - diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py deleted file mode 100644 index c9dd81eb..00000000 --- a/ui/pages/dashboard.py +++ /dev/null @@ -1,4216 +0,0 @@ -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 core.watchlist_scanner import get_watchlist_scanner -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 - - # Use server-aware database query to check only active server - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server) - - 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.cancelled_tracks = set() # Track indices of cancelled 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() - - # Update button states after loading tracks - self._update_button_states() - - 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 4 to 5 for Cancel column - self.track_table.setColumnCount(5) - # Add "Cancel" column (no Duration column) - self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Matched", "Status", "Cancel"]) - - # Adjust resize modes for column indices - self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # "Matched" is column 2 - self.track_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # "Cancel" is column 4 - self.track_table.setColumnWidth(2, 140) # Set width for "Matched" column - self.track_table.setColumnWidth(4, 70) # Set width for "Cancel" 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().setDefaultSectionSize(50) - 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) - - # Create empty container for cancel button (will be populated later for missing tracks only) - container = QWidget() - container.setStyleSheet("background: transparent;") - layout = QVBoxLayout(container) - layout.setContentsMargins(5, 5, 5, 5) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.track_table.setCellWidget(i, 4, container) - - # Loop over 4 columns instead of 5 (don't include cancel column) - 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 add_cancel_button_to_row(self, row): - """Add cancel button to a specific row (only for missing tracks)""" - container = self.track_table.cellWidget(row, 4) - if container and container.layout().count() == 0: # Only add if container is empty - cancel_button = QPushButton("×") - cancel_button.setFixedSize(20, 20) - cancel_button.setMinimumSize(20, 20) - cancel_button.setMaximumSize(20, 20) - cancel_button.setStyleSheet(""" - QPushButton { - background-color: #dc3545; - color: white; - border: 1px solid #c82333; - border-radius: 3px; - font-size: 14px; - font-weight: bold; - padding: 0px; - margin: 0px; - text-align: center; - min-width: 20px; - max-width: 20px; - width: 20px; - } - QPushButton:hover { - background-color: #c82333; - border-color: #bd2130; - } - QPushButton:pressed { - background-color: #bd2130; - border-color: #b21f2d; - } - QPushButton:disabled { - background-color: #28a745; - color: white; - border-color: #1e7e34; - } - """) - cancel_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - cancel_button.clicked.connect(lambda checked, row_idx=row: self.cancel_track(row_idx)) - - layout = container.layout() - layout.addWidget(cancel_button) - - def hide_cancel_button_for_row(self, row): - """Hide cancel button for a specific row (when track is downloaded)""" - container = self.track_table.cellWidget(row, 4) - if container: - layout = container.layout() - if layout and layout.count() > 0: - cancel_button = layout.itemAt(0).widget() - if cancel_button: - cancel_button.setVisible(False) - print(f"🫥 Hidden cancel button for downloaded track at row {row}") - - def cancel_track(self, row): - """Cancel a specific track - works at any phase""" - # Get cancel button and disable it - container = self.track_table.cellWidget(row, 4) - if container: - layout = container.layout() - if layout and layout.count() > 0: - cancel_button = layout.itemAt(0).widget() - if cancel_button: - cancel_button.setEnabled(False) - cancel_button.setText("") - - # Update status to cancelled (column 3 for dashboard) - self.track_table.setItem(row, 3, QTableWidgetItem("Cancelled")) - - # Add to cancelled tracks set - if not hasattr(self, 'cancelled_tracks'): - self.cancelled_tracks = set() - self.cancelled_tracks.add(row) - - track = self.wishlist_tracks[row] - print(f"Track cancelled: {track.name} (row {row})") - - # If downloads are active, also handle active download cancellation - download_index = None - - # Check active_downloads list - if hasattr(self, 'active_downloads'): - for download in self.active_downloads: - if download.get('table_index') == row: - download_index = download.get('download_index', row) - print(f"Found active download {download_index} for cancelled track") - break - - # Check parallel_search_tracking for download index - if download_index is None and hasattr(self, 'parallel_search_tracking'): - for idx, track_info in self.parallel_search_tracking.items(): - if track_info.get('table_index') == row: - download_index = idx - print(f"Found parallel tracking {download_index} for cancelled track") - break - - # If we found an active download, trigger completion to free up the worker - if download_index is not None and hasattr(self, 'on_parallel_track_completed'): - print(f"Triggering completion for active download {download_index}") - self.on_parallel_track_completed(download_index, success=False) - - 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.clear_wishlist_btn = QPushButton("Clear Wishlist") - self.clear_wishlist_btn.setFixedSize(150, 40) - self.clear_wishlist_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }") - self.clear_wishlist_btn.clicked.connect(self.on_clear_wishlist_clicked) - 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.clear_wishlist_btn) - 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._update_button_states() - 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) - row_index = track_index - 1 - 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)) - # Add cancel button for missing tracks only - self.add_cancel_button_to_row(row_index) - self.track_table.setItem(row_index, 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._update_button_states() - 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: - # Skip if track was cancelled - if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks: - print(f"Skipping cancelled track at index {track_index}: {track.name}") - self.download_queue_index += 1 - self.completed_downloads += 1 - continue - - # 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): - if not hasattr(self, 'parallel_search_tracking'): - print(f"parallel_search_tracking not initialized yet, skipping completion for download {download_index}") - return - 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")) - # Hide cancel button since track is now downloaded - self.hide_cancel_button_for_row(track_info['table_index']) - 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: - # Check if track was cancelled (don't overwrite cancelled status) - table_index = track_info['table_index'] - current_status = self.track_table.item(table_index, 3) - if current_status and "Cancelled" in current_status.text(): - print(f"Track {download_index} was cancelled - preserving cancelled status") - else: - self.track_table.setItem(table_index, 3, QTableWidgetItem("Failed")) - if track_info not in self.permanently_failed_tracks: - self.permanently_failed_tracks.append(track_info) - self.failed_downloads += 1 - 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._update_button_states() - 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)") - - # Add cancelled tracks that were missing from Plex to permanently_failed_tracks for wishlist re-addition - if hasattr(self, 'cancelled_tracks') and hasattr(self, 'missing_tracks'): - for cancelled_row in self.cancelled_tracks: - # Check if this cancelled track was actually missing from Plex - cancelled_track = self.wishlist_tracks[cancelled_row] - missing_track_result = None - - # Find the corresponding missing track result - for missing_result in self.missing_tracks: - if missing_result.spotify_track.id == cancelled_track.id: - missing_track_result = missing_result - break - - # Only add to wishlist if track was actually missing from Plex AND not successfully downloaded - if missing_track_result: - # Check if track was successfully downloaded (don't re-add downloaded tracks to wishlist) - status_item = self.track_table.item(cancelled_row, 3) - current_status = status_item.text() if status_item else "" - - if "Downloaded" in current_status: - print(f"Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition") - else: - cancelled_track_info = { - 'download_index': cancelled_row, - 'table_index': cancelled_row, - 'track': cancelled_track, - 'track_name': cancelled_track.name, - 'artist_name': cancelled_track.artists[0] if cancelled_track.artists else "Unknown", - 'retry_count': 0, - 'spotify_track': missing_track_result.spotify_track # Include the spotify track for wishlist - } - # Check if not already in permanently_failed_tracks - if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks): - self.permanently_failed_tracks.append(cancelled_track_info) - print(f"Added cancelled missing track {cancelled_track.name} to failed list for wishlist re-addition") - else: - print(f"Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist re-addition") - - 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 10 minutes.") - self.parent_dashboard.wishlist_retry_timer.start(600000) # 10 minutes - # Removed success modal - users don't need to see completion notification - - def on_cancel_clicked(self): - self.cancel_operations() - self._update_button_states() - self.process_finished.emit() - self.reject() - - def on_clear_wishlist_clicked(self): - """Handle Clear Wishlist button click with confirmation""" - # Don't allow clearing during active download - if self.download_in_progress: - return - - # Show confirmation dialog - reply = QMessageBox.question( - self, - "Clear Wishlist", - "Are you sure you want to clear the entire wishlist?\n\n" - "This action cannot be undone and will permanently remove all wishlist tracks.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - - if reply == QMessageBox.StandardButton.Yes: - try: - # Clear the wishlist using the service - success = self.wishlist_service.clear_wishlist() - - if success: - # Reset all UI elements - self._reset_ui_after_clear() - - # Update dashboard wishlist button count - self.parent_dashboard.update_wishlist_button_count() - # Update dashboard watchlist button count - self.parent_dashboard.update_watchlist_button_count() - - # Show success message - QMessageBox.information( - self, - "Wishlist Cleared", - "The wishlist has been successfully cleared." - ) - - logger.info("Wishlist cleared successfully by user") - - else: - QMessageBox.critical( - self, - "Error", - "Failed to clear the wishlist. Please try again." - ) - logger.error("Failed to clear wishlist") - - except Exception as e: - QMessageBox.critical( - self, - "Error", - f"An error occurred while clearing the wishlist: {str(e)}" - ) - logger.error(f"Error clearing wishlist: {e}") - - def _reset_ui_after_clear(self): - """Reset all UI elements after clearing the wishlist""" - # Reset counters - 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.permanently_failed_tracks = [] - self.analysis_results = [] - self.missing_tracks = [] - - # Update counter labels - self.total_count_label.setText("0") - self.matched_count_label.setText("0") - self.download_count_label.setText("0") - self.downloaded_count_label.setText("0") - - # Clear and reset track table - self.track_table.setRowCount(0) - - # Reset progress bars - self.analysis_progress.setValue(0) - self.analysis_progress.setVisible(False) - self.download_progress.setValue(0) - self.download_progress.setVisible(False) - - # Reset buttons to initial state - self.begin_search_btn.show() - self.cancel_btn.hide() - self.correct_failed_btn.hide() - - # Update button state - self._update_button_states() - - def _update_button_states(self): - """Update button states based on current modal state""" - # Disable Clear Wishlist button during download operations - if self.download_in_progress: - self.clear_wishlist_btn.setEnabled(False) - self.clear_wishlist_btn.setStyleSheet( - "QPushButton { background-color: #666666; color: #999999; border-radius: 20px; font-size: 14px; font-weight: bold; }" - ) - else: - # Enable only if there are tracks to clear - has_tracks = len(self.wishlist_tracks) > 0 - self.clear_wishlist_btn.setEnabled(has_tracks) - if has_tracks: - self.clear_wishlist_btn.setStyleSheet( - "QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }" - ) - else: - self.clear_wishlist_btn.setStyleSheet( - "QPushButton { background-color: #666666; color: #999999; border-radius: 20px; font-size: 14px; font-weight: bold; }" - ) - - 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...") - - # 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) - ) - - 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): - """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 profile - # 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 profile - filtered_results = self.soulseek_client.filter_results_by_quality_preference(results) - 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 artist metadata using Spotify data (supports both Plex and Jellyfin)""" - 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, media_client, spotify_client, server_type, refresh_interval_days=30): - super().__init__() - self.artists = artists - self.media_client = media_client # Can be plex_client or jellyfin_client - self.spotify_client = spotify_client - self.server_type = server_type # "plex" or "jellyfin" - 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 get_artist_name(self, artist): - """Get artist name consistently across Plex and Jellyfin""" - # Both Plex and Jellyfin wrapper objects have .title attribute - return getattr(artist, 'title', 'Unknown Artist') - - def run(self): - """Process all artists one by one""" - try: - # Load artists in background if not provided - if self.artists is None: - # Enable lightweight mode for Jellyfin to skip track caching - if self.server_type == "jellyfin": - self.media_client.set_metadata_only_mode(True) - - all_artists = self.media_client.get_all_artists() - if not all_artists: - self.error.emit(f"No artists found in {self.server_type.title()} 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.media_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: - # Check if artist is manually ignored - if self.media_client.is_artist_ignored(artist): - return False - - # Use media client's age-based checking with configured interval - return self.media_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') - - # Skip processing for artists with no valid name - if artist_name == 'Unknown Artist' or not artist_name or not artist_name.strip(): - return False, "Skipped: No valid artist name" - - # --- 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 (only for Plex, skip for Jellyfin due to API issues) - if self.server_type == "plex": - albums_updated = self.update_album_artwork(artist, spotify_artist) - if albums_updated > 0: - changes_made.append(f"{albums_updated} album art") - else: - # Skip album artwork for Jellyfin until API issues are resolved - logger.debug(f"Skipping album artwork updates for Jellyfin artist: {artist.title}") - - if changes_made: - # Update artist biography with timestamp to track last update - biography_updated = self.media_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.media_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 media client API to update genres - success = self.media_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 using media client - success = self.media_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 using media client""" - try: - # Use media client's update method if available - if hasattr(self.media_client, 'update_artist_poster'): - return self.media_client.update_artist_poster(artist, image_data) - - # Fallback for Plex: direct API call - if self.server_type == "plex": - import requests - server = self.media_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 - else: - # For other server types, return False since we only have fallback for Plex - return False - - 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()), - 'jellyfin': ServiceStatus('Jellyfin', False, datetime.now()), - 'navidrome': ServiceStatus('Navidrome', 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, jellyfin_client, navidrome_client, soulseek_client): - self.service_clients = { - 'spotify_client': spotify_client, - 'plex_client': plex_client, - 'jellyfin_client': jellyfin_client, - 'navidrome_client': navidrome_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""" - - # Map service names to client keys - service_key_map = { - 'spotify': 'spotify_client', - 'plex': 'plex_client', - 'jellyfin': None, # Jellyfin doesn't need a client, tests via config - 'navidrome': 'navidrome_client', - 'soulseek': 'soulseek_client' - } - - client_key = service_key_map.get(service, service) - - # Handle Jellyfin special case (no client needed) - if service == 'jellyfin': - client = None # Jellyfin test uses config directly - elif client_key not in self.service_clients: - print(f"DEBUG: Service {service} (key: {client_key}) not found in service_clients") - return - else: - client = 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, client) - 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 - 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 == 'jellyfin': - # Test Jellyfin connection using HTTP request - try: - from config.settings import config_manager - jellyfin_config = config_manager.get_jellyfin_config() - base_url = jellyfin_config.get('base_url', '').rstrip('/') - api_key = jellyfin_config.get('api_key', '') - - if base_url and api_key: - import requests - headers = {'X-Emby-Token': api_key} - response = requests.get(f"{base_url}/System/Info", headers=headers, timeout=5) - connected = response.status_code == 200 - else: - connected = False - error = "Missing Jellyfin configuration (base_url or api_key)" - except Exception as e: - connected = False - error = str(e) - 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 - Make it dynamic based on active server - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_display = active_server.title() - header_label = QLabel(f"{server_display} 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() - - # Watchlist scanning signals for live updates to open modal - watchlist_scan_started = pyqtSignal() - watchlist_artist_scan_started = pyqtSignal(str) # artist_name - watchlist_artist_scan_completed = pyqtSignal(str, int, int, bool) # artist_name, albums_checked, new_tracks, success - watchlist_scan_completed = pyqtSignal(int, int, int) # total_artists, total_new_tracks, total_added_to_wishlist - - 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) - self.database_updated_externally.connect(self.update_watchlist_button_count) - - # 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.timeout.connect(self.update_watchlist_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 - - # Watchlist scanning timer and state - self.watchlist_scan_timer = QTimer() - self.watchlist_scan_timer.setSingleShot(True) - self.watchlist_scan_timer.timeout.connect(self.process_watchlist_automatically) - self.watchlist_scan_timer.start(60000) # Start first scan 1 minute after app launch - - self.auto_processing_watchlist = False - self.watchlist_status_modal = None - self.background_watchlist_worker = 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) - QTimer.singleShot(1500, self.update_watchlist_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, jellyfin_client, navidrome_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, jellyfin_client, navidrome_client, soulseek_client) - - # Store service clients for wishlist modal - self.service_clients = { - 'spotify_client': spotify_client, - 'plex_client': plex_client, - 'jellyfin_client': jellyfin_client, - 'navidrome_client': navidrome_client, - 'soulseek_client': soulseek_client, - 'downloads_page': downloads_page - } - - # Initialize unified media scan manager for wishlist modal integration - self.scan_manager = None - try: - from core.media_scan_manager import MediaScanManager - self.scan_manager = MediaScanManager(delay_seconds=60) - # Add automatic incremental database update after scan completion - self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed) - logger.info("MediaScanManager initialized for Dashboard wishlist modal") - except Exception as e: - logger.error(f"Failed to initialize MediaScanManager: {e}") - - 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_media_scan_completed(self): - """Callback triggered when media 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 - from config.settings import config_manager - - # Get the active media client - active_server = config_manager.get_active_media_server() - if active_server == "jellyfin": - media_client = self.service_clients.get('jellyfin_client') - else: - media_client = self.service_clients.get('plex_client') - - # Check if we should run incremental update - if not media_client or not media_client.is_connected(): - logger.debug(f"{active_server.upper()} 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(f"Starting automatic incremental database update after {active_server.upper()} scan") - self._start_automatic_incremental_update() - - except Exception as e: - logger.error(f"Error in media 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 - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - # Get the appropriate client - if active_server == "plex": - media_client = self.service_clients.get('plex_client') - elif active_server == "jellyfin": - from core.jellyfin_client import JellyfinClient - media_client = JellyfinClient() - else: - logger.error(f"Unknown active server for auto-update: {active_server}") - return - - self._auto_database_worker = DatabaseUpdateWorker( - media_client, - "database/music_library.db", - full_refresh=False, # Always incremental for automatic updates - server_type=active_server - ) - - # 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() - - # Buttons layout - buttons_layout = QHBoxLayout() - buttons_layout.setSpacing(10) - - # 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; - } - """) - - # Watchlist button - self.watchlist_button = QPushButton("Watchlist (0)") - self.watchlist_button.setFixedHeight(45) - self.watchlist_button.setFixedWidth(150) - self.watchlist_button.clicked.connect(self.on_watchlist_button_clicked) - self.watchlist_button.setStyleSheet(""" - QPushButton { - background: #ffc107; - border: none; - border-radius: 22px; - color: #000000; - font-size: 12px; - font-weight: bold; - padding: 8px 16px; - } - QPushButton:hover { - background: #ffca28; - } - QPushButton:pressed { - background: #ff8f00; - } - QPushButton:disabled { - background: #404040; - color: #666666; - } - """) - - buttons_layout.addWidget(self.watchlist_button) - buttons_layout.addWidget(self.wishlist_button) - - right_layout.addLayout(buttons_layout) - 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 with dynamic media server - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name_map = { - 'plex': 'Plex', - 'jellyfin': 'Jellyfin', - 'navidrome': 'Navidrome' - } - server_name = server_name_map.get(active_server, 'Jellyfin') - services = ['Spotify', server_name, '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) - only show for Plex - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - if active_server == "plex": - self.metadata_widget = MetadataUpdaterWidget() - self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update) - else: - self.metadata_widget = None # Hide for Jellyfin - - layout.addWidget(header_label) - layout.addWidget(self.database_widget) - if self.metadata_widget: # Only add if it exists - 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')}") - - # Check that we have a data provider - if not hasattr(self, 'data_provider'): - self.add_activity_item("", "Database Update", "Service clients not available", "Now") - return - - # Get the active media server and check if client is available - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'): - self.add_activity_item("", "Database Update", "Plex client not available", "Now") - return - elif active_server == "jellyfin": - # Jellyfin client will be created on-demand, just verify config exists - jellyfin_config = config_manager.get_jellyfin_config() - if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'): - self.add_activity_item("", "Database Update", "Jellyfin not configured", "Now") - return - - try: - # Get update type from dropdown - full_refresh = self.database_widget.is_full_refresh() - - # Show confirmation dialog for full refresh - if full_refresh: - reply = QMessageBox.question( - self, - "Confirm Full Database Refresh", - "You've selected FULL REFRESH mode.\n\n" - "This will completely rebuild your database and may take several minutes.\n" - "All existing data will be cleared and rebuilt from your Plex library.\n\n" - "Are you sure you want to continue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No # Default to No for safety - ) - - if reply != QMessageBox.StandardButton.Yes: - logger.debug("Full refresh cancelled by user") - return # Cancel the operation - - # Get the active media server - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - # Get the appropriate client - if active_server == "plex": - media_client = self.data_provider.service_clients['plex_client'] - elif active_server == "jellyfin": - # Import and get Jellyfin client - from core.jellyfin_client import JellyfinClient - media_client = JellyfinClient() - else: - logger.error(f"Unknown active server: {active_server}") - self.add_activity_item("", "Database Update", f"Unknown server type: {active_server}", "Now") - return - - # Start the database update worker - self.database_worker = DatabaseUpdateWorker( - media_client, - "database/music_library.db", - full_refresh, - server_type=active_server - ) - - # 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" - server_display = active_server.title() # "Plex" or "Jellyfin" - self.add_activity_item("", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "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""" - if not self.metadata_widget: - return # Metadata widget not available (Jellyfin server) - - 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())}") - - # Check active server and client availability - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - # Currently metadata updater only supports Plex - # Check if we have the active media server client - if active_server == "jellyfin": - media_client = self.data_provider.service_clients.get('jellyfin_client') - if not media_client: - self.add_activity_item("", "Metadata Update", "Jellyfin client not available", "Now") - return - else: - media_client = self.data_provider.service_clients.get('plex_client') - if not media_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() if self.metadata_widget else 30 - - # 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 - media_client, - self.data_provider.service_clients['spotify_client'], - active_server, - 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 - if self.metadata_widget: - self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0) - self.add_activity_item("", "Metadata Update", "Loading artists from 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() - - if self.metadata_widget: - 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""" - if self.metadata_widget: - 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""" - if self.metadata_widget: - 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""" - if self.metadata_widget: - 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""" - if self.metadata_widget: - 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 on_watchlist_button_clicked(self): - """Show the watchlist status modal""" - try: - # Check if any artists are in watchlist - database = get_database() - watchlist_count = database.get_watchlist_count() - - if watchlist_count == 0: - QMessageBox.information(self, "Watchlist", "Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.") - return - - # Create and show watchlist status modal - from ui.components.watchlist_status_modal import WatchlistStatusModal - spotify_client = self.service_clients.get('spotify_client') - - # Always recreate the modal to ensure fresh state and signal connections - if hasattr(self, 'watchlist_status_modal') and self.watchlist_status_modal: - # Disconnect old signals to prevent duplicates - try: - self.watchlist_scan_started.disconnect(self.watchlist_status_modal.on_background_scan_started) - self.watchlist_scan_completed.disconnect(self.watchlist_status_modal.on_background_scan_completed) - except: - pass # Ignore if signals weren't connected - self.watchlist_status_modal.deleteLater() - - self.watchlist_status_modal = WatchlistStatusModal(self, spotify_client) - - # Connect dashboard signals to modal for live updates during background scans - self.watchlist_scan_started.connect(self.watchlist_status_modal.on_background_scan_started) - self.watchlist_scan_completed.connect(self.watchlist_status_modal.on_background_scan_completed) - - # If a background scan is currently running, connect the detailed progress signals - if hasattr(self, 'background_watchlist_worker') and self.background_watchlist_worker: - try: - self.background_watchlist_worker.signals.scan_started.connect(self.watchlist_status_modal.on_scan_started) - self.background_watchlist_worker.signals.artist_scan_started.connect(self.watchlist_status_modal.on_artist_scan_started) - self.background_watchlist_worker.signals.artist_totals_discovered.connect(self.watchlist_status_modal.on_artist_totals_discovered) - self.background_watchlist_worker.signals.album_scan_started.connect(self.watchlist_status_modal.on_album_scan_started) - self.background_watchlist_worker.signals.track_check_started.connect(self.watchlist_status_modal.on_track_check_started) - self.background_watchlist_worker.signals.release_completed.connect(self.watchlist_status_modal.on_release_completed) - self.background_watchlist_worker.signals.artist_scan_completed.connect(self.watchlist_status_modal.on_artist_scan_completed) - except Exception as e: - logger.debug(f"Background worker signals already connected or unavailable: {e}") - - # Always refresh data when showing the modal - self.watchlist_status_modal.load_watchlist_data() - - self.watchlist_status_modal.show() - self.watchlist_status_modal.activateWindow() - self.watchlist_status_modal.raise_() - - except Exception as e: - logger.error(f"Error opening watchlist status: {e}") - QMessageBox.critical(self, "Error", f"Failed to open watchlist status: {str(e)}") - - def update_watchlist_button_count(self): - """Update the watchlist button with current count""" - try: - database = get_database() - count = database.get_watchlist_count() - - if hasattr(self, 'watchlist_button'): - self.watchlist_button.setText(f"Watchlist ({count})") - - # Enable/disable button based on count - if count == 0: - self.watchlist_button.setStyleSheet(""" - QPushButton { - background: #404040; - border: none; - border-radius: 22px; - color: #888888; - font-size: 12px; - font-weight: bold; - padding: 8px 16px; - } - """) - else: - self.watchlist_button.setStyleSheet(""" - QPushButton { - background: #ffc107; - border: none; - border-radius: 22px; - color: #000000; - font-size: 12px; - font-weight: bold; - padding: 8px 16px; - } - QPushButton:hover { - background: #ffca28; - } - QPushButton:pressed { - background: #ff8f00; - } - """) - except Exception as e: - logger.error(f"Error updating watchlist 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(600000) # 10 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(600000) # 10 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(600000) # 10 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 10 minutes - if hasattr(self, 'wishlist_retry_timer') and self.wishlist_retry_timer: - logger.info("Scheduling next automatic wishlist processing in 10 minutes") - self.wishlist_retry_timer.start(600000) # 10 minutes (600000 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(600000) # 10 minutes (600000 ms) - - except Exception as e: - logger.error(f"Error handling automatic wishlist processing error: {e}") - - def process_watchlist_automatically(self): - """Automatically scan watchlist artists for new releases""" - try: - if self.auto_processing_watchlist: - logger.debug("Watchlist auto-scanning already running, skipping.") - # Reschedule the next check - self.watchlist_scan_timer.start(600000) # 10 minutes - return - - # Check if there's an ongoing manual scan from the watchlist modal - from ui.components.watchlist_status_modal import WatchlistStatusModal - if (WatchlistStatusModal._shared_scan_worker - and WatchlistStatusModal._shared_scan_worker.isRunning()): - logger.debug("Manual watchlist scan already running, skipping automatic scan.") - # Reschedule the next check - self.watchlist_scan_timer.start(600000) # 10 minutes - return - - database = get_database() - watchlist_count = database.get_watchlist_count() - - if watchlist_count == 0: - logger.debug("No artists in watchlist for auto-scanning.") - # Reschedule the next check - self.watchlist_scan_timer.start(600000) # 10 minutes - return - - spotify_client = self.service_clients.get('spotify_client') - if not spotify_client or not spotify_client.is_authenticated(): - logger.warning("Spotify client not available for watchlist scanning") - # Reschedule the next check - self.watchlist_scan_timer.start(600000) # 10 minutes - return - - logger.info(f"Starting automatic watchlist scanning for {watchlist_count} artists...") - self.auto_processing_watchlist = True - - # Emit signal to any open modal - self.watchlist_scan_started.emit() - - # Start background watchlist scan using the same worker as manual scans for consistency - from ui.components.watchlist_status_modal import WatchlistScanWorker - self.background_watchlist_worker = WatchlistScanWorker(spotify_client) - self.background_watchlist_worker.scan_completed.connect(self.on_auto_watchlist_scan_complete_unified) - - # Connect detailed progress signals to modal if it's open - if hasattr(self, 'watchlist_status_modal') and self.watchlist_status_modal and self.watchlist_status_modal.isVisible(): - self.background_watchlist_worker.scan_started.connect(self.watchlist_status_modal.on_scan_started) - self.background_watchlist_worker.artist_scan_started.connect(self.watchlist_status_modal.on_artist_scan_started) - self.background_watchlist_worker.artist_totals_discovered.connect(self.watchlist_status_modal.on_artist_totals_discovered) - self.background_watchlist_worker.album_scan_started.connect(self.watchlist_status_modal.on_album_scan_started) - self.background_watchlist_worker.track_check_started.connect(self.watchlist_status_modal.on_track_check_started) - self.background_watchlist_worker.release_completed.connect(self.watchlist_status_modal.on_release_completed) - self.background_watchlist_worker.artist_scan_completed.connect(self.watchlist_status_modal.on_artist_scan_completed) - - # Start the thread (not QThreadPool since this is now a QThread) - self.background_watchlist_worker.start() - - except Exception as e: - logger.error(f"Error starting automatic watchlist scanning: {e}") - self.auto_processing_watchlist = False - # Reschedule on error - self.watchlist_scan_timer.start(600000) # 10 minutes - - def on_auto_watchlist_scan_complete_unified(self, scan_results): - """Handle completion of automatic watchlist scanning using unified WatchlistScanWorker""" - try: - self.auto_processing_watchlist = False - - # Calculate summary from scan results (same as modal does) - successful_scans = [r for r in scan_results if r.success] - total_artists = len(scan_results) - total_new_tracks = sum(r.new_tracks_found for r in successful_scans) - total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans) - - # Clear background worker reference - if hasattr(self, 'background_watchlist_worker'): - self.background_watchlist_worker = None - - logger.info(f"Automatic watchlist scan complete: {total_artists} artists, {total_new_tracks} new tracks found, {total_added_to_wishlist} added to wishlist") - - # Emit signal to any open modal - self.watchlist_scan_completed.emit(total_artists, total_new_tracks, total_added_to_wishlist) - - # Update button counts since watchlist and wishlist may have changed - self.update_watchlist_button_count() - self.update_wishlist_button_count() - - # Show toast notification if new tracks were found - if total_new_tracks > 0 and hasattr(self, 'toast_manager') and self.toast_manager: - message = f"Found {total_new_tracks} new track{'s' if total_new_tracks != 1 else ''} from watched artists!" - self.toast_manager.success(message) - - # Schedule next watchlist scan in 60 minutes - if hasattr(self, 'watchlist_scan_timer') and self.watchlist_scan_timer: - logger.info("Scheduling next automatic watchlist scan in 60 minutes") - self.watchlist_scan_timer.start(600000) # 10 minutes - - except Exception as e: - logger.error(f"Error handling automatic watchlist scan completion: {e}") - # Ensure we reschedule even on error - if hasattr(self, 'watchlist_scan_timer') and self.watchlist_scan_timer: - self.watchlist_scan_timer.start(600000) # 10 minutes - - - -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 all wishlist tracks (no limit - process everything) - wishlist_tracks = self.wishlist_service.get_wishlist_tracks_for_download() - - 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) - ) - - 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 diff --git a/ui/pages/downloads.py b/ui/pages/downloads.py deleted file mode 100644 index 40f1e068..00000000 --- a/ui/pages/downloads.py +++ /dev/null @@ -1,11364 +0,0 @@ -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QFrame, QPushButton, QProgressBar, QListWidget, - QListWidgetItem, QComboBox, QLineEdit, QScrollArea, QMessageBox, - QSplitter, QSizePolicy, QSpacerItem, QTabWidget, QDialog, QGridLayout) -from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QUrl, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QFileSystemWatcher, pyqtProperty, QObject, QRunnable, QThreadPool -from PyQt6.QtGui import QFont, QPainter, QPen, QColor, QPixmap -from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput -import functools # For fixing lambda memory leaks -import os -import threading -from threading import RLock, Lock -from queue import Queue, Empty -from config.settings import config_manager -# Import the new search result classes -from core.soulseek_client import TrackResult, AlbumResult -from core.spotify_client import SpotifyClient, Artist, Album -from core.matching_engine import MusicMatchingEngine -from core.lyrics_client import LyricsClient -import requests -from typing import List, Optional -from dataclasses import dataclass -# Metadata enhancement imports -from mutagen import File as MutagenFile -from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, TRCK, TCON, TPE2, TPOS, TXXX, APIC -from mutagen.flac import FLAC, Picture -from mutagen.mp4 import MP4, MP4Cover -from mutagen.oggvorbis import OggVorbis -import urllib.request -import mimetypes - -@dataclass -class ArtistMatch: - """Represents an artist match with confidence score""" - artist: Artist - confidence: float - match_reason: str = "" - -@dataclass -class AlbumMatch: - """Represents an album match with confidence score""" - album: Album - confidence: float - match_reason: str = "" - - -class ImageDownloaderSignals(QObject): - """Signals for the ImageDownloader worker.""" - finished = pyqtSignal(QLabel, QPixmap) - error = pyqtSignal(str) - -class ImageDownloader(QRunnable): - """Worker to download an image in the background.""" - def __init__(self, url: str, target_label: QLabel): - super().__init__() - self.signals = ImageDownloaderSignals() - self.url = url - self.target_label = target_label - - def run(self): - try: - if not self.url: - self.signals.error.emit("No image URL provided.") - return - - response = requests.get(self.url, stream=True, timeout=10) - response.raise_for_status() - - pixmap = QPixmap() - pixmap.loadFromData(response.content) - - if not pixmap.isNull(): - self.signals.finished.emit(self.target_label, pixmap) - else: - self.signals.error.emit("Failed to load image from data.") - - except requests.RequestException as e: - self.signals.error.emit(f"Network error downloading image: {e}") - except Exception as e: - self.signals.error.emit(f"Error processing image: {e}") - - -class DownloadCompletionWorkerSignals(QObject): - """Signals for the download completion worker""" - completed = pyqtSignal(object, str) # download_item, organized_path - error = pyqtSignal(object, str) # download_item, error_message - -class DownloadCompletionWorker(QRunnable): - """Background worker to handle download completion processing without blocking UI""" - - def __init__(self, download_item, absolute_file_path, organize_func): - super().__init__() - self.download_item = download_item - self.absolute_file_path = absolute_file_path - self.organize_func = organize_func - self.signals = DownloadCompletionWorkerSignals() - - def run(self): - """Process download completion in background thread""" - try: - print(f"Background worker processing: '{self.download_item.title}' by '{self.download_item.matched_artist.name}'") - - # Add a small delay to ensure file is fully written - import time - time.sleep(1) - - # Organize the file into Transfer folder structure - organized_path = self.organize_func(self.download_item, self.absolute_file_path) - - # Emit completion signal - self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path) - - except Exception as e: - print(f"Error in background worker: {e}") - import traceback - traceback.print_exc() - # Emit error signal - self.signals.error.emit(self.download_item, str(e)) - - -# OPTIMIZATION: This worker runs the existing status update logic in the background. -class StatusProcessingWorkerSignals(QObject): - """Defines the signals available from the StatusProcessingWorker.""" - completed = pyqtSignal(list) - error = pyqtSignal(str) - -class StatusProcessingWorker(QRunnable): - """ - Runs the expensive download status processing in a background thread to prevent UI lag. - It performs all the matching and logic, then returns a list of actions for the main thread to execute. - """ - def __init__(self, soulseek_client, download_items): - super().__init__() - self.signals = StatusProcessingWorkerSignals() - self.soulseek_client = soulseek_client - # We operate on a copy of the item data to avoid thread conflicts - self.download_items_data = [{ - 'widget_id': id(item), - 'download_id': item.download_id, - 'title': item.title, - 'artist': item.artist, - 'file_path': item.file_path, - 'status': item.status, - 'matched_artist': getattr(item, 'matched_artist', None) - } for item in download_items] - - def run(self): - """The main logic of the background worker.""" - try: - # This is the core of your original update_download_status method. - import asyncio - 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: - self.signals.completed.emit([]) - return - - all_transfers = [ - file for user_data in transfers_data if 'directories' in user_data - for directory in user_data['directories'] if 'files' in directory - for file in directory['files'] - ] - - # Create a lookup for faster matching - transfers_by_id = {t['id']: t for t in all_transfers} - - # --- This is your existing, working logic, now running in the background --- - for item_data in self.download_items_data: - if item_data['status'].lower() in ['completed', 'finished', 'cancelled', 'failed']: - continue - - matching_transfer = transfers_by_id.get(item_data['download_id']) - - if not matching_transfer: - # Fallback to filename matching if ID match fails - import os - dl_filename = os.path.basename(item_data['file_path']).lower() - for t in all_transfers: - if os.path.basename(t.get('filename', '')).lower() == dl_filename: - matching_transfer = t - break - - if matching_transfer: - state = matching_transfer.get('state', 'Unknown') - progress = matching_transfer.get('percentComplete', 0) - new_status = 'queued' - - # Determine the new status with the correct order of checks. - # Terminal states like Cancelled and Failed must be checked BEFORE Completed. - 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' - - # **CRITICAL FIX:** Package all necessary data for the main thread. - payload = { - 'widget_id': item_data['widget_id'], - 'status': new_status, - 'progress': int(progress), - 'speed': int(matching_transfer.get('averageSpeed', 0)), - 'path': matching_transfer.get('filename', item_data['file_path']), - 'transfer_id': matching_transfer.get('id'), # This is key for cleanup - 'username': matching_transfer.get('username') # Also key for cleanup - } - - if new_status == 'completed' and item_data['matched_artist']: - payload['action'] = 'process_matched_completion' - - results.append(payload) - else: - # This handles downloads that have disappeared from the API (e.g., failed, cancelled by user). - # We add a grace period before marking as failed to handle temporary API glitches. - item_data['api_missing_count'] = item_data.get('api_missing_count', 0) + 1 - - if item_data['api_missing_count'] >= 3: - # After being missing for 3 update cycles (3 seconds), mark it as failed. - payload = { - 'widget_id': item_data['widget_id'], - 'status': 'failed', - 'progress': item_data.get('progress', 0), - 'speed': 0, - 'path': item_data['file_path'], - 'transfer_id': item_data['download_id'], - 'username': item_data['artist'] - } - results.append(payload) - - self.signals.completed.emit(results) - except Exception as e: - import traceback - traceback.print_exc() - self.signals.error.emit(str(e)) - -class OptimizedDownloadCompletionWorker(QRunnable): - """OPTIMIZATION v2: Non-blocking background worker for download completion processing""" - - def __init__(self, download_item, absolute_file_path, organize_func): - super().__init__() - self.download_item = download_item - self.absolute_file_path = absolute_file_path - self.organize_func = organize_func - self.signals = DownloadCompletionWorkerSignals() - - def run(self): - """Process download completion without blocking operations""" - try: - # OPTIMIZATION: Use file system monitoring instead of sleep - import os - from pathlib import Path - - # Verify file exists and is not being written to - file_path = Path(self.absolute_file_path) - if file_path.exists(): - initial_size = file_path.stat().st_size - # Quick non-blocking check for file stability - import time - time.sleep(0.1) # Minimal delay - if file_path.exists() and file_path.stat().st_size == initial_size: - # File is stable, proceed with organization - organized_path = self.organize_func(self.download_item, self.absolute_file_path) - self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path) - else: - # File still being written, retry with shorter delay - time.sleep(0.5) - organized_path = self.organize_func(self.download_item, self.absolute_file_path) - self.signals.completed.emit(self.download_item, organized_path or self.absolute_file_path) - else: - raise FileNotFoundError(f"Download file not found: {self.absolute_file_path}") - - except Exception as e: - print(f"Error in optimized worker: {e}") - import traceback - traceback.print_exc() - self.signals.error.emit(self.download_item, str(e)) - -class ThreadSafeQueueManager: - """OPTIMIZATION v2: Thread-safe queue management system to prevent race conditions""" - - def __init__(self): - self._download_items_lock = RLock() # Reentrant lock for nested operations - self._state_transition_lock = Lock() # Lock for atomic state changes - self._id_mapping_lock = Lock() # Lock for ID mapping operations - self._download_items = [] - self._pending_operations = Queue() # Queue for pending operations - - def add_download_item_safe(self, download_item): - """Thread-safe addition of download items""" - with self._download_items_lock: - self._download_items.append(download_item) - - def remove_download_item_safe(self, download_item): - """Thread-safe removal of download items""" - with self._download_items_lock: - if download_item in self._download_items: - self._download_items.remove(download_item) - return True - return False - - def get_download_items_copy(self): - """Get thread-safe copy of download items""" - with self._download_items_lock: - return self._download_items.copy() - - def find_item_by_id_safe(self, download_id): - """Thread-safe search for download item by ID""" - with self._download_items_lock: - for item in self._download_items: - if hasattr(item, 'download_id') and item.download_id == download_id: - return item - return None - - def atomic_state_transition(self, download_item, new_status, callback=None): - """Perform atomic state transitions to prevent inconsistencies""" - with self._state_transition_lock: - old_status = getattr(download_item, 'status', 'unknown') - download_item.status = new_status - if callback: - callback(download_item, old_status, new_status) - - def update_id_mapping_safe(self, download_item, new_id): - """Thread-safe ID mapping updates""" - with self._id_mapping_lock: - old_id = getattr(download_item, 'download_id', None) - download_item.download_id = new_id - -class SpotifyMatchingModal(QDialog): - """A redesigned modal for matching downloads to Spotify artists and albums.""" - - match_confirmed = pyqtSignal(Artist, Album) - cancelled = pyqtSignal() - - def __init__(self, original_result: object, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine, parent=None, is_album=False, album_result=None): - super().__init__(parent) - self.original_result = original_result - self.spotify_client = spotify_client - self.matching_engine = matching_engine - self.is_album = is_album - self.album_result = album_result - self.image_download_pool = QThreadPool() - self.image_download_pool.setMaxThreadCount(4) - self.selected_artist: Optional[Artist] = None - self.selected_album: Optional[Album] = None - - self.setWindowTitle("Match Download to Spotify") - self.setModal(True) - self.resize(1100, 750) - - self.setStyleSheet(""" - QDialog { background-color: #121212; border: none; } - QLabel { color: #FFFFFF; } - QPushButton { - background-color: #1DB954; color: #FFFFFF; border: none; - border-radius: 15px; padding: 10px 20px; font-size: 14px; font-weight: 600; - } - QPushButton:hover { background-color: #1ED760; } - QPushButton:disabled { background-color: #2A2A2A; color: #535353; } - QPushButton#cancel, QPushButton#skip { background-color: #535353; } - QPushButton#cancel:hover, QPushButton#skip:hover { background-color: #6A6A6A; } - QLineEdit { - background-color: #2A2A2A; border: 2px solid #535353; border-radius: 15px; - color: white; padding: 12px; font-size: 14px; - } - QLineEdit:focus { border: 2px solid #1DB954; } - QFrame#card { - background-color: #1E1E1E; border: 2px solid #2A2A2A; border-radius: 12px; - } - QFrame#card:hover { border: 2px solid #1DB954; background-color: #282828; } - """) - - self.current_stage = "artist" - self.setup_ui() - self.generate_auto_artist_suggestions() - - def setup_ui(self): - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(20, 20, 20, 20) - main_layout.setSpacing(15) - - # Different header text based on mode - if self.is_album: - header_text = "Step 1: Select the correct Artist" - else: - header_text = "Select the correct Artist for this Single" - self.header_label = QLabel(header_text) - self.header_label.setStyleSheet("font-size: 22px; font-weight: bold; color: #1DB954;") - main_layout.addWidget(self.header_label) - - auto_title = QLabel("Top Suggestions") - auto_title.setStyleSheet("font-size: 16px; color: #B3B3B3;") - self.auto_suggestions_layout = QHBoxLayout() - self.auto_suggestions_layout.setSpacing(20) - - main_layout.addWidget(auto_title) - main_layout.addLayout(self.auto_suggestions_layout) - - manual_title = QLabel("Or, Search Manually") - manual_title.setStyleSheet("font-size: 16px; color: #B3B3B3; margin-top: 15px;") - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Manually search for an artist...") - self.search_input.textChanged.connect(self.on_search_text_changed) - self.manual_results_layout = QHBoxLayout() - self.manual_results_layout.setSpacing(20) - - main_layout.addWidget(manual_title) - main_layout.addWidget(self.search_input) - main_layout.addLayout(self.manual_results_layout) - main_layout.addStretch() - - button_layout = QHBoxLayout() - self.confirm_btn = QPushButton("Confirm Selection") - self.confirm_btn.clicked.connect(self.confirm_selection) - self.confirm_btn.setEnabled(False) - - skip_btn = QPushButton("Skip Matching") - skip_btn.setObjectName("skip") - skip_btn.clicked.connect(self.skip_matching) - - cancel_btn = QPushButton("Cancel") - cancel_btn.setObjectName("cancel") - cancel_btn.clicked.connect(self.reject) - - button_layout.addWidget(skip_btn) - button_layout.addStretch() - button_layout.addWidget(self.confirm_btn) - button_layout.addWidget(cancel_btn) - main_layout.addLayout(button_layout) - - self.search_timer = QTimer() - self.search_timer.setSingleShot(True) - self.search_timer.timeout.connect(self.perform_manual_search) - - def download_and_set_image(self, url: str, target_label: QLabel): - """Starts a background worker to download and set an image.""" - worker = ImageDownloader(url, target_label) - worker.signals.finished.connect(self._on_image_downloaded) - worker.signals.error.connect(lambda msg: print(f"Image Error: {msg} for url {url}")) - self.image_download_pool.start(worker) - - def _on_image_downloaded(self, target_label: QLabel, pixmap: QPixmap): - """Slot to apply the downloaded pixmap to the target label.""" - if target_label and not pixmap.isNull(): - # Scale pixmap to fill the label, cropping if necessary - scaled_pixmap = pixmap.scaled(target_label.size(), - Qt.AspectRatioMode.KeepAspectRatioByExpanding, - Qt.TransformationMode.SmoothTransformation) - target_label.setPixmap(scaled_pixmap) - - def generate_auto_artist_suggestions(self): - self._clear_layout(self.auto_suggestions_layout) - self._show_loading_cards(self.auto_suggestions_layout, "Finding artist...") - self.suggestion_thread = ArtistSuggestionThread( - self.original_result, self.spotify_client, self.matching_engine, self.is_album, self.album_result - ) - self.suggestion_thread.suggestions_ready.connect(self.display_artist_suggestions) - self.suggestion_thread.start() - - def generate_auto_album_suggestions(self): - self._clear_layout(self.auto_suggestions_layout) - self._show_loading_cards(self.auto_suggestions_layout, "Finding album...") - - # Use the correct result object for album suggestions. - # If it's an album download, use the full album_result for context. - # Otherwise, use the original_result (a single track). - context_result = self.album_result if self.is_album and self.album_result else self.original_result - - self.album_suggestion_thread = AlbumSuggestionThread( - self.selected_artist, context_result, self.spotify_client, self.matching_engine - ) - self.album_suggestion_thread.suggestions_ready.connect(self.display_album_suggestions) - self.album_suggestion_thread.start() - - def display_artist_suggestions(self, suggestions: List[ArtistMatch]): - self._clear_layout(self.auto_suggestions_layout) - if not suggestions: - self.auto_suggestions_layout.addWidget(QLabel("No automatic artist matches found.")) - return - self.auto_suggestions_layout.addStretch() - for suggestion in suggestions[:4]: - self.auto_suggestions_layout.addWidget(self.create_artist_card(suggestion.artist, suggestion.confidence)) - self.auto_suggestions_layout.addStretch() - - def display_album_suggestions(self, suggestions: List[AlbumMatch]): - self._clear_layout(self.auto_suggestions_layout) - if not suggestions: - self.auto_suggestions_layout.addWidget(QLabel(f"No automatic album matches found for {self.selected_artist.name}.")) - return - self.auto_suggestions_layout.addStretch() - for suggestion in suggestions[:4]: - self.auto_suggestions_layout.addWidget(self.create_album_card(suggestion.album, suggestion.confidence)) - self.auto_suggestions_layout.addStretch() - - def on_search_text_changed(self): - self.search_timer.stop() - if len(self.search_input.text().strip()) >= 2: - self.search_timer.start(400) - - def perform_manual_search(self): - query = self.search_input.text().strip() - if not query: return - - self._clear_layout(self.manual_results_layout) - - if self.current_stage == "artist": - self._show_loading_cards(self.manual_results_layout, "Searching artists...") - self.search_thread = ArtistSearchThread(query, self.spotify_client, self.matching_engine, self.original_result) - self.search_thread.search_results.connect(self.display_manual_artist_results) - self.search_thread.start() - else: - self._show_loading_cards(self.manual_results_layout, "Searching albums...") - self.search_thread = AlbumSearchThread(query, self.selected_artist, self.spotify_client, self.matching_engine) - self.search_thread.search_results.connect(self.display_manual_album_results) - self.search_thread.start() - - def display_manual_artist_results(self, results: List[ArtistMatch]): - self._clear_layout(self.manual_results_layout) - if not results: - self.manual_results_layout.addWidget(QLabel("No artists found.")) - return - self.manual_results_layout.addStretch() - for result in results[:4]: - self.manual_results_layout.addWidget(self.create_artist_card(result.artist, result.confidence)) - self.manual_results_layout.addStretch() - - def display_manual_album_results(self, results: List[AlbumMatch]): - self._clear_layout(self.manual_results_layout) - if not results: - self.manual_results_layout.addWidget(QLabel("No albums found.")) - return - self.manual_results_layout.addStretch() - for result in results[:4]: - self.manual_results_layout.addWidget(self.create_album_card(result.album, result.confidence)) - self.manual_results_layout.addStretch() - - def select_artist(self, artist: Artist): - self.selected_artist = artist - print(f"Artist selected: {artist.name}") - - # For singles mode, we can confirm with just the artist selection - if not self.is_album: - self.confirm_btn.setEnabled(True) - self.confirm_btn.setText(f"Confirm: {artist.name[:30]}...") - # Update header to indicate completion - self.header_label.setText("Artist Selected - Ready to Download") - else: - # For album mode, proceed to album selection - self.transition_to_album_stage() - - def select_album(self, album: Album): - self.selected_album = album - print(f"Album selected: {album.name}") - self.confirm_btn.setEnabled(True) - self.confirm_btn.setText(f"Confirm: {album.name[:25]}...") - - def transition_to_album_stage(self): - self.current_stage = "album" - self.header_label.setText(f"Step 2: Select Album for {self.selected_artist.name}") - self.search_input.setPlaceholderText("Manually search for an album...") - self.search_input.clear() - self.confirm_btn.setEnabled(False) - self.confirm_btn.setText("Confirm Selection") - self._clear_layout(self.manual_results_layout) - self.generate_auto_album_suggestions() - - def confirm_selection(self): - if self.selected_artist: - # For singles mode, we only need the artist - if not self.is_album: - # Create a dummy album object for singles mode - dummy_album = Album( - id="singles-dummy", - name="Singles Collection", - artists=[self.selected_artist.name], - total_tracks=1, - release_date="", - album_type="single" - ) - self.match_confirmed.emit(self.selected_artist, dummy_album) - self.accept() - # For album mode, we need both artist and album - elif self.selected_album: - self.match_confirmed.emit(self.selected_artist, self.selected_album) - self.accept() - - def skip_matching(self): - self.skipped_matching = True - self.reject() - - def reject(self): - if not hasattr(self, 'skipped_matching'): - self.skipped_matching = False - # Clean up the image download pool - self.image_download_pool.clear() - self.image_download_pool.waitForDone(5000) # Wait max 5 seconds for tasks to finish - self.cancelled.emit() - super().reject() - - def create_artist_card(self, artist: Artist, confidence: float) -> QFrame: - card = QFrame() - card.setObjectName("card") - card.setFixedSize(220, 130) - # The main card needs a layout to stack widgets - card_layout = QGridLayout(card) - card_layout.setContentsMargins(0, 0, 0, 0) - - # 1. Background Layer (for the image) - background_label = QLabel() - background_label.setScaledContents(True) - background_label.setStyleSheet("border-radius: 12px;") - - # 2. Gradient Overlay Layer (for text readability) - gradient_overlay = QWidget() - gradient_overlay.setStyleSheet(""" - QWidget { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(18, 18, 18, 0.4), - stop:0.6 rgba(18, 18, 18, 0.7), - stop:1 rgba(18, 18, 18, 0.9)); - border-radius: 12px; - } - """) - - # 3. Content Layer (the text) - content_layout = QVBoxLayout(gradient_overlay) - content_layout.setContentsMargins(12, 12, 12, 12) - - name = QLabel(artist.name) - name.setWordWrap(True) - name.setStyleSheet("font-size: 16px; font-weight: bold; color: white; background: transparent; border: none;") - - confidence_label = QLabel(f"{confidence:.0%} match") - confidence_label.setStyleSheet("color: #B3B3B3; font-size: 12px; background: transparent; border: none;") - - content_layout.addWidget(name) - content_layout.addStretch() - content_layout.addWidget(confidence_label) - - # Stack the layers - card_layout.addWidget(background_label, 0, 0) - card_layout.addWidget(gradient_overlay, 0, 0) - - # Set up interaction - card.mousePressEvent = lambda event: self.select_artist(artist) - - # Fetch the artist image in the background - if hasattr(artist, 'image_url') and artist.image_url: - self.download_and_set_image(artist.image_url, background_label) - - return card - - def create_album_card(self, album: Album, confidence: float) -> QFrame: - card = QFrame() - card.setObjectName("card") - card.setFixedSize(220, 130) - # The main card needs a layout to stack widgets - card_layout = QGridLayout(card) - card_layout.setContentsMargins(0, 0, 0, 0) - - # 1. Background Layer (for the image) - background_label = QLabel() - background_label.setScaledContents(True) - background_label.setStyleSheet("border-radius: 12px;") - - # 2. Gradient Overlay Layer (for text readability) - gradient_overlay = QWidget() - gradient_overlay.setStyleSheet(""" - QWidget { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(18, 18, 18, 0.4), - stop:0.6 rgba(18, 18, 18, 0.7), - stop:1 rgba(18, 18, 18, 0.9)); - border-radius: 12px; - } - """) - - # 3. Content Layer (the text) - content_layout = QVBoxLayout(gradient_overlay) - content_layout.setContentsMargins(12, 12, 12, 12) - - name = QLabel(album.name) - name.setWordWrap(True) - name.setStyleSheet("font-size: 14px; font-weight: bold; color: white; background: transparent; border: none;") - - year = album.release_date.split('-')[0] if album.release_date else "" - details = QLabel(f"{album.album_type.title()} • {year}") - details.setStyleSheet("color: #B3B3B3; font-size: 12px; background: transparent; border: none;") - - content_layout.addWidget(name) - content_layout.addWidget(details) - content_layout.addStretch() - - # Stack the layers - card_layout.addWidget(background_label, 0, 0) - card_layout.addWidget(gradient_overlay, 0, 0) - - # Set up interaction - card.mousePressEvent = lambda event: self.select_album(album) - - # Fetch the album image in the background - if hasattr(album, 'image_url') and album.image_url: - self.download_and_set_image(album.image_url, background_label) - - return card - - def _show_loading_cards(self, layout: QHBoxLayout, text: str): - layout.addStretch() - for _ in range(4): - loading_card = QFrame() - loading_card.setObjectName("card") - loading_card.setFixedSize(220, 130) - card_layout = QVBoxLayout(loading_card) - card_layout.addWidget(QLabel(text), 0, Qt.AlignmentFlag.AlignCenter) - layout.addWidget(loading_card) - layout.addStretch() - - def _clear_layout(self, layout: QHBoxLayout): - while layout.count(): - child = layout.takeAt(0) - if child.widget(): - child.widget().deleteLater() - -class ArtistSuggestionThread(QThread): - """Background thread for generating artist suggestions""" - - suggestions_ready = pyqtSignal(list) - - def __init__(self, track_result: TrackResult, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine, is_album=False, album_result=None): - super().__init__() - self.track_result = track_result - self.spotify_client = spotify_client - self.matching_engine = matching_engine - self.is_album = is_album - self.album_result = album_result - - def run(self): - """Generate artist suggestions""" - try: - print(f"Starting auto suggestions for: {self.track_result.artist} - {self.track_result.title}") - suggestions = self.generate_artist_suggestions() - print(f"Generated {len(suggestions)} auto suggestions") - self.suggestions_ready.emit(suggestions) - except Exception as e: - print(f"Error generating suggestions: {e}") - self.suggestions_ready.emit([]) - - def generate_artist_suggestions(self) -> List[ArtistMatch]: - """Generate artist suggestions using multiple strategies""" - suggestions = [] - - # Debug logging - print(f"[DEBUG] Auto suggestion input data:") - print(f" track_result.artist: '{getattr(self.track_result, 'artist', 'NOT_FOUND')}'") - print(f" track_result.title: '{getattr(self.track_result, 'title', 'NOT_FOUND')}'") - print(f" track_result.album: '{getattr(self.track_result, 'album', 'NOT_FOUND')}'") - print(f" track_result type: {type(self.track_result)}") - print(f" spotify_client available: {self.spotify_client is not None}") - print(f" matching_engine available: {self.matching_engine is not None}") - if self.spotify_client: - print(f" spotify_client.is_authenticated(): {self.spotify_client.is_authenticated()}") - print(f" track_result attributes: {[attr for attr in dir(self.track_result) if not attr.startswith('_')]}") - - # Special handling for albums - use album title to find artist instead of track data - if self.is_album and self.album_result and self.album_result.album_title: - print(f"[DEBUG] Album mode detected - using album title for artist search") - print(f" album_title: '{self.album_result.album_title}'") - print(f" album_artist: '{getattr(self.album_result, 'artist', 'NOT_FOUND')}'") - - # Clean album title for searching (remove year prefixes like "(2005)") - album_title = self.album_result.album_title - import re - clean_album_title = re.sub(r'^\(\d{4}\)\s*', '', album_title).strip() - print(f" clean_album_title: '{clean_album_title}'") - - # Strategy: Search tracks using album title to find the artist - print(f"Album Strategy: Searching tracks for album '{clean_album_title}'") - tracks = self.spotify_client.search_tracks(clean_album_title, limit=20) - print(f"Found {len(tracks)} tracks from album search") - - # Collect unique artist names and their associated tracks/albums first - unique_artists = {} # artist_name -> list of (track, album) tuples - for track in tracks: - for artist_name in track.artists: - if artist_name not in unique_artists: - unique_artists[artist_name] = [] - unique_artists[artist_name].append((track, track.album)) - - print(f"[PERF] Found {len(unique_artists)} unique artists to lookup (down from {sum(len(track.artists) for track in tracks)} total)") - - # Batch fetch artist objects using concurrent futures for speed - from concurrent.futures import ThreadPoolExecutor, as_completed - import time - - start_time = time.time() - artist_objects = {} # artist_name -> Artist object - - def fetch_artist(artist_name): - """Fetch single artist with error handling""" - try: - matches = self.spotify_client.search_artists(artist_name, limit=1) - if matches: - return artist_name, matches[0] - except Exception as e: - print(f"Error fetching artist '{artist_name}': {e}") - return artist_name, None - - # Use limited concurrency to respect rate limits while improving speed - with ThreadPoolExecutor(max_workers=3) as executor: - future_to_artist = {executor.submit(fetch_artist, name): name for name in unique_artists.keys()} - - for future in as_completed(future_to_artist): - artist_name, artist_obj = future.result() - if artist_obj: - artist_objects[artist_name] = artist_obj - - fetch_time = time.time() - start_time - print(f"[PERF] Fetched {len(artist_objects)} artists in {fetch_time:.2f}s using concurrent API calls") - - # Now calculate confidence scores for each artist - artist_scores = {} - for artist_name, track_album_pairs in unique_artists.items(): - if artist_name not in artist_objects: - continue - - artist = artist_objects[artist_name] - best_confidence = 0 - best_album_match = "" - - # Find the best confidence score across all albums for this artist - for track, album in track_album_pairs: - # Calculate confidence based on album title match - confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(clean_album_title), - self.matching_engine.normalize_string(album) - ) - - # Boost confidence if album artist matches - if hasattr(self.album_result, 'artist') and self.album_result.artist: - artist_confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(self.album_result.artist), - self.matching_engine.normalize_string(artist.name) - ) - confidence = max(confidence, artist_confidence) - - # Keep highest confidence for this artist - if confidence > best_confidence: - best_confidence = confidence - best_album_match = album - - # Store the artist with their best confidence - artist_scores[artist.id] = { - 'artist': artist, - 'confidence': best_confidence, - 'album_match': best_album_match - } - - # Add high-confidence album artists to suggestions - for artist_data in artist_scores.values(): - if artist_data['confidence'] >= 0.6: # Higher threshold for album matches - print(f"Added album artist match: {artist_data['artist'].name} ({artist_data['confidence']:.2f}) via '{artist_data['album_match']}'") - suggestions.append(ArtistMatch( - artist=artist_data['artist'], - confidence=artist_data['confidence'], - match_reason=f"Album match via '{artist_data['album_match']}'" - )) - - print(f"[DEBUG] Album strategy generated {len(suggestions)} suggestions") - - # If we found good album matches, return them (don't try track-based strategies) - if suggestions: - # Remove duplicates and sort by confidence - unique_suggestions = {} - for suggestion in suggestions: - if suggestion.artist.id not in unique_suggestions or unique_suggestions[suggestion.artist.id].confidence < suggestion.confidence: - unique_suggestions[suggestion.artist.id] = suggestion - - final_suggestions = sorted(unique_suggestions.values(), key=lambda x: x.confidence, reverse=True) - print(f"[DEBUG] Returning {len(final_suggestions)} album-based suggestions") - return final_suggestions[:5] - - # Try to get artist name from different sources (for singles or fallback) - artist_name = None - if self.track_result.artist and self.track_result.artist != "Unknown Artist": - artist_name = self.track_result.artist - elif hasattr(self.track_result, 'user') and self.track_result.user: - # Sometimes the artist might be in the user field - artist_name = self.track_result.user - elif hasattr(self.track_result, 'filename') and self.track_result.filename: - # Try to extract artist from filename - import os - filename = os.path.basename(self.track_result.filename) - if ' - ' in filename: - artist_name = filename.split(' - ')[0].strip() - - print(f"[DEBUG] Determined artist name: '{artist_name}'") - - # Strategy 1: Search for the artist name directly - if artist_name and artist_name != "Unknown Artist": - artist_query = self.matching_engine.normalize_string(artist_name) - print(f"Strategy 1: Searching for artist '{artist_query}'") - artists = self.spotify_client.search_artists(artist_query, limit=10) - print(f"Found {len(artists)} artists from Spotify") - - for artist in artists: - confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(artist_name), - self.matching_engine.normalize_string(artist.name) - ) - - if confidence >= 0.3: # Minimum threshold - print(f"Added artist match: {artist.name} ({confidence:.2f})") - suggestions.append(ArtistMatch( - artist=artist, - confidence=confidence, - match_reason="Artist name match" - )) - else: - print(f"Strategy 1 skipped: artist_name='{artist_name}', original_artist='{getattr(self.track_result, 'artist', 'NO_ATTR')}'") - - # Strategy 2: Search for "artist - title" combination - if artist_name and self.track_result.title: - combined_query = f"{artist_name} {self.track_result.title}" - print(f"Strategy 2: Searching for combined query '{combined_query}'") - tracks = self.spotify_client.search_tracks(combined_query, limit=10) - print(f"Found {len(tracks)} tracks from Spotify") - - for track in tracks: - for artist_name in track.artists: - # Find matching artist - artist_matches = self.spotify_client.search_artists(artist_name, limit=1) - if artist_matches: - artist = artist_matches[0] - - # Calculate combined confidence based on artist and title match - artist_confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(artist_name), - self.matching_engine.normalize_string(artist.name) - ) - title_confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(self.track_result.title), - self.matching_engine.normalize_string(track.name) - ) - - combined_confidence = (artist_confidence * 0.7 + title_confidence * 0.3) - - if combined_confidence >= 0.4: - suggestions.append(ArtistMatch( - artist=artist, - confidence=combined_confidence, - match_reason="Track match" - )) - - # Remove duplicates and sort by confidence - unique_suggestions = {} - for suggestion in suggestions: - if suggestion.artist.id not in unique_suggestions or unique_suggestions[suggestion.artist.id].confidence < suggestion.confidence: - unique_suggestions[suggestion.artist.id] = suggestion - - final_suggestions = sorted(unique_suggestions.values(), key=lambda x: x.confidence, reverse=True) - - # Debug final results - print(f"[DEBUG] Final suggestions count: {len(final_suggestions)}") - for i, suggestion in enumerate(final_suggestions[:5]): - print(f" {i+1}. {suggestion.artist.name} ({suggestion.confidence:.2f}) - {suggestion.match_reason}") - - return final_suggestions[:5] - -class ArtistSearchThread(QThread): - """Background thread for manual artist search""" - - search_results = pyqtSignal(list) - - def __init__(self, query: str, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine, track_result: TrackResult): - super().__init__() - self.query = query - self.spotify_client = spotify_client - self.matching_engine = matching_engine - self.track_result = track_result - - def run(self): - """Perform artist search""" - try: - artists = self.spotify_client.search_artists(self.query, limit=10) - results = [] - - for artist in artists: - # Calculate confidence based on search query match - confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(self.query), - self.matching_engine.normalize_string(artist.name) - ) - - # Boost confidence if it also matches the original track artist - if self.track_result.artist: - original_match = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(self.track_result.artist), - self.matching_engine.normalize_string(artist.name) - ) - confidence = max(confidence, original_match) - - results.append(ArtistMatch( - artist=artist, - confidence=confidence, - match_reason="Search result" - )) - - # Sort by confidence - results.sort(key=lambda x: x.confidence, reverse=True) - self.search_results.emit(results) - - except Exception as e: - print(f"Error searching artists: {e}") - self.search_results.emit([]) - -class AlbumSearchThread(QThread): - """Background thread for album search by artist""" - - search_results = pyqtSignal(list) - - def __init__(self, query: str, artist: Artist, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine): - super().__init__() - self.query = query - self.artist = artist - self.spotify_client = spotify_client - self.matching_engine = matching_engine - - def run(self): - """Perform album search""" - try: - # Search for albums by the selected artist - search_query = f"artist:{self.artist.name} {self.query}" - albums = self.spotify_client.search_albums(search_query, limit=10) - results = [] - - for album in albums: - # Check if this album is actually by our selected artist - artist_match = False - for album_artist in album.artists: - if self.matching_engine.similarity_score( - self.matching_engine.normalize_string(self.artist.name), - self.matching_engine.normalize_string(album_artist) - ) >= 0.8: - artist_match = True - break - - if artist_match: - # Calculate confidence based on search query match with album name - confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(self.query), - self.matching_engine.normalize_string(album.name) - ) - - results.append(AlbumMatch( - album=album, - confidence=confidence, - match_reason="Album search result" - )) - - # Sort by confidence - results.sort(key=lambda x: x.confidence, reverse=True) - self.search_results.emit(results) - - except Exception as e: - print(f"Error searching albums: {e}") - self.search_results.emit([]) - -class AlbumSuggestionThread(QThread): - """Background thread for generating automatic album suggestions for a given artist.""" - - suggestions_ready = pyqtSignal(list) - - def __init__(self, selected_artist: Artist, original_result: object, spotify_client: SpotifyClient, matching_engine: MusicMatchingEngine): - super().__init__() - self.artist = selected_artist - self.original_result = original_result - self.spotify_client = spotify_client - self.matching_engine = matching_engine - - def run(self): - """Fetch albums for the artist and find the best matches.""" - try: - import re # Import the regular expression module - - target_album_name = "" - if isinstance(self.original_result, AlbumResult): - target_album_name = self.original_result.album_title - print(f"Album context for auto-match from AlbumResult: '{target_album_name}'") - elif hasattr(self.original_result, 'album') and self.original_result.album: - target_album_name = self.original_result.album - print(f"Album context for auto-match from TrackResult: '{target_album_name}'") - else: - target_album_name = self.original_result.title - print(f"Album context for auto-match using fallback to track title: '{target_album_name}'") - - if not target_album_name: - self.suggestions_ready.emit([]) - return - - # More aggressive cleaning for the search query. - # 1. Remove bracketed content like [flac], (Explicit), [2024], etc. - cleaned_search_term = re.sub(r'\s*[\[\(].*?[\]\)]', '', target_album_name).strip() - - # 2. Remove the artist's name from the string if it's present. - artist_pattern = r'^' + re.escape(self.artist.name) + r'\s*-\s*' - cleaned_search_term = re.sub(artist_pattern, '', cleaned_search_term, flags=re.IGNORECASE) - - # 3. Clean up any remaining leading/trailing dashes or spaces. - cleaned_search_term = cleaned_search_term.strip(' -') - - print(f"Cleaned album search term: '{target_album_name}' -> '{cleaned_search_term}'") - - search_query = f"artist:{self.artist.name} album:{cleaned_search_term}" - print(f"Searching Spotify for albums with query: '{search_query}'") - - spotify_albums = self.spotify_client.search_albums(search_query, limit=10) - print(f"Found {len(spotify_albums)} potential albums from Spotify search.") - - suggestions = [] - for album in spotify_albums: - is_correct_artist = any(self.matching_engine.similarity_score(self.artist.name, art) > 0.85 for art in album.artists) - - if not is_correct_artist: - print(f"Skipping album '{album.name}' as artist does not match '{self.artist.name}'") - continue - - confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(target_album_name), - self.matching_engine.normalize_string(album.name) - ) - - if self.matching_engine.normalize_string(cleaned_search_term) in self.matching_engine.normalize_string(album.name): - confidence = max(confidence, 0.95) - - if confidence >= 0.5: - print(f"Found album match: '{album.name}' with confidence {confidence:.2f}") - suggestions.append(AlbumMatch( - album=album, - confidence=confidence, - match_reason="Direct album search" - )) - - # **IMPROVEMENT**: Intelligently de-duplicate results with the same name. - # This prefers versions marked as 'album' and those with more tracks. - unique_suggestions = {} - for suggestion in suggestions: - album_key = self.matching_engine.normalize_string(suggestion.album.name) - - if album_key not in unique_suggestions: - unique_suggestions[album_key] = suggestion - else: - existing_suggestion = unique_suggestions[album_key] - is_new_better = False - - # Higher confidence is always better - if suggestion.confidence > existing_suggestion.confidence: - is_new_better = True - elif suggestion.confidence == existing_suggestion.confidence: - # 'album' type is preferred over 'single' - if suggestion.album.album_type == 'album' and existing_suggestion.album.album_type != 'album': - is_new_better = True - # More tracks is better if types are the same - elif suggestion.album.album_type == existing_suggestion.album.album_type and hasattr(suggestion.album, 'total_tracks') and hasattr(existing_suggestion.album, 'total_tracks') and suggestion.album.total_tracks > existing_suggestion.album.total_tracks: - is_new_better = True - - if is_new_better: - print(f"Replacing duplicate album '{album_key}' with a better version (type: {suggestion.album.album_type}, tracks: {getattr(suggestion.album, 'total_tracks', 'N/A')})") - unique_suggestions[album_key] = suggestion - - final_suggestions = sorted(unique_suggestions.values(), key=lambda x: x.confidence, reverse=True) - - print(f"Generated {len(final_suggestions)} final album suggestions.") - self.suggestions_ready.emit(final_suggestions[:4]) - - except Exception as e: - print(f"Error generating album suggestions: {e}") - import traceback - traceback.print_exc() - self.suggestions_ready.emit([]) - - -class BouncingDotsWidget(QWidget): - """Animated bouncing dots loading indicator""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedSize(60, 20) - self.dots = ['●', '●', '●'] - self._current_dot = 0 - - # Animation setup - self.setup_animation() - - def setup_animation(self): - """Set up the bouncing animation sequence""" - self.timer = QTimer() - self.timer.timeout.connect(self.update_dots) - - def start_animation(self): - """Start the bouncing animation""" - self.timer.start(400) # Update every 400ms for smoother bouncing - - def stop_animation(self): - """Stop the bouncing animation""" - if hasattr(self, 'timer'): - self.timer.stop() - self._current_dot = 0 - self.update() - - def update_dots(self): - """Update which dot is bouncing""" - self._current_dot = (self._current_dot + 1) % 3 - self.update() - - def get_current_dot(self): - return self._current_dot - - def set_current_dot(self, value): - self._current_dot = value - self.update() - - # Create the Qt property for animation system - current_dot = pyqtProperty(int, get_current_dot, set_current_dot) - - def paintEvent(self, event): - """Custom paint event to draw the bouncing dots""" - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Set color and font - painter.setPen(QPen(Qt.GlobalColor.white, 2)) - font = painter.font() - font.setPointSize(12) - painter.setFont(font) - - # Draw three dots with bouncing effect - dot_width = 20 - for i in range(3): - x = i * dot_width - y = 15 if i == self._current_dot else 10 # Bounce effect - - # Make current dot larger and brighter - if i == self._current_dot: - painter.setPen(QPen(Qt.GlobalColor.green, 3)) - else: - painter.setPen(QPen(Qt.GlobalColor.gray, 2)) - - painter.drawText(x, y, self.dots[i]) - -class SpinningCircleWidget(QWidget): - """Animated spinning circle loading indicator""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedSize(60, 60) # Increased from 30x30 to 60x60 - self._angle = 0 - - # Animation setup - self.animation = QPropertyAnimation(self, b"rotation_angle") - self.animation.setDuration(1000) # 1 second per rotation - self.animation.setStartValue(0) - self.animation.setEndValue(360) - self.animation.setLoopCount(-1) # Infinite loop - self.animation.setEasingCurve(QEasingCurve.Type.Linear) - - def start_animation(self): - """Start the spinning animation""" - self.animation.start() - - def stop_animation(self): - """Stop the spinning animation""" - self.animation.stop() - self._angle = 0 - self.update() - - def get_rotation_angle(self): - return self._angle - - def set_rotation_angle(self, angle): - self._angle = angle - self.update() - - rotation_angle = pyqtProperty(float, get_rotation_angle, set_rotation_angle) - - def paintEvent(self, event): - """Custom paint event to draw the spinning circle""" - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Set up the painting area - rect = self.rect() - center_x = rect.width() // 2 - center_y = rect.height() // 2 - radius = min(center_x, center_y) - 2 - - # Rotate the painter - painter.translate(center_x, center_y) - painter.rotate(self._angle) - - # Draw circle segments with varying opacity - pen = QPen(Qt.GlobalColor.green, 3) - painter.setPen(pen) - - # Draw 8 dots around the circle - import math - for i in range(8): - angle_step = 2 * math.pi / 8 - dot_angle = i * angle_step - - # Calculate position for each dot - x = radius * 0.7 * math.cos(dot_angle) - y = radius * 0.7 * math.sin(dot_angle) - - # Fade effect - dots further from current position are dimmer - distance = abs(i - (self._angle / 45)) % 8 - opacity = max(0.2, 1.0 - distance * 0.15) - - # Set color with proper opacity using QColor and alpha channel - color = QColor(29, 185, 84) # App's green theme color - color.setAlpha(int(opacity * 255)) # Apply calculated opacity - pen.setColor(color) - painter.setPen(pen) - - painter.drawEllipse(int(x-2), int(y-2), 4, 4) - -class AudioPlayer(QMediaPlayer): - """Simple audio player for streaming music files""" - playback_finished = pyqtSignal() - playback_error = pyqtSignal(str) - - def __init__(self, parent=None): - super().__init__(parent) - - # Set up audio output - self.audio_output = QAudioOutput() - self.setAudioOutput(self.audio_output) - - # Connect signals - self.mediaStatusChanged.connect(self._on_media_status_changed) - self.errorOccurred.connect(self._on_error_occurred) - self.playbackStateChanged.connect(self._on_playback_state_changed) - - # Track current file - self.current_file_path = None - self.is_playing = False - - def _on_playback_state_changed(self, state): - """Keep is_playing flag synchronized with actual playback state""" - from PyQt6.QtMultimedia import QMediaPlayer - state_names = { - QMediaPlayer.PlaybackState.StoppedState: "STOPPED", - QMediaPlayer.PlaybackState.PlayingState: "PLAYING", - QMediaPlayer.PlaybackState.PausedState: "PAUSED" - } - print(f"AudioPlayer state changed to: {state_names.get(state, 'UNKNOWN')}") - self.is_playing = (state == QMediaPlayer.PlaybackState.PlayingState) - - def play_file(self, file_path): - """Play an audio file from the given path""" - try: - if not file_path or not os.path.exists(file_path): - self.playback_error.emit(f"File not found: {file_path}") - return False - - # Stop any current playback - self.stop() - - # Set the new media source - self.current_file_path = file_path - self.setSource(QUrl.fromLocalFile(file_path)) - - # Start playback - self.play() - # is_playing will be set automatically by _on_playback_state_changed - - print(f"Started playing: {os.path.basename(file_path)}") - return True - - except Exception as e: - error_msg = f"Error playing audio file: {str(e)}" - print(error_msg) - self.playback_error.emit(error_msg) - return False - - def toggle_playback(self): - """Toggle between play and pause""" - current_state = self.playbackState() - print(f"toggle_playback() - Current state: {current_state}") - print(f"toggle_playback() - Current source: {self.source().toString()}") - - if current_state == QMediaPlayer.PlaybackState.PlayingState: - print("AudioPlayer: Pausing playback") - self.pause() - # is_playing will be set automatically by _on_playback_state_changed - return False # Now paused - else: - print("AudioPlayer: Attempting to resume/play") - - # Check if we have a valid source to play - if not self.source().isValid() and self.current_file_path: - print(f"AudioPlayer: No source set, restoring from: {self.current_file_path}") - self.setSource(QUrl.fromLocalFile(self.current_file_path)) - - self.play() - # is_playing will be set automatically by _on_playback_state_changed - return True # Now playing - - def stop_playback(self): - """Stop playback and reset""" - print("AudioPlayer: stop_playback() called") - self.stop() - # is_playing will be set automatically by _on_playback_state_changed - self.release_file() - - def release_file(self, clear_file_path=True): - """Release the current file handle by clearing the media source - - Args: - clear_file_path (bool): Whether to clear the stored file path. - Set to False to keep the path for potential resuming. - """ - print(f"AudioPlayer: release_file() called - clearing source: {self.source().toString()}") - self.setSource(QUrl()) # Clear the media source to release file handle - if clear_file_path: - self.current_file_path = None - print("Released audio file handle") - - def _on_media_status_changed(self, status): - """Handle media status changes""" - if status == QMediaPlayer.MediaStatus.EndOfMedia: - print("Playback finished") - # is_playing will be set automatically by _on_playback_state_changed - self.playback_finished.emit() - elif status == QMediaPlayer.MediaStatus.InvalidMedia: - error_msg = "Invalid media file or unsupported format" - print(f"{error_msg}") - # is_playing will be set automatically by _on_playback_state_changed - self.playback_error.emit(error_msg) - - def _on_error_occurred(self, error, error_string): - """Handle playback errors""" - error_msg = f"Audio playback error: {error_string}" - print(f"{error_msg}") - # is_playing will be set automatically by _on_playback_state_changed - self.playback_error.emit(error_msg) - -class DownloadThread(QThread): - download_completed = pyqtSignal(str, object) # Download ID or success message, download_item - download_failed = pyqtSignal(str, object) # Error message, download_item - download_progress = pyqtSignal(str, object) # Progress message, download_item - - def __init__(self, soulseek_client, search_result, download_item): - super().__init__() - self.soulseek_client = soulseek_client - self.search_result = search_result - self.download_item = download_item - self._stop_requested = False - - def run(self): - loop = None - try: - import asyncio - self.download_progress.emit(f"Starting download: {self.search_result.filename}", self.download_item) - - # Create a completely fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Perform download with proper error handling - download_id = loop.run_until_complete(self._do_download()) - - if not self._stop_requested: - if download_id: - self.download_completed.emit(f"Download started: {download_id}", self.download_item) - else: - self.download_failed.emit("Download failed to start", self.download_item) - - # Give signals time to be processed before thread exits - import time - time.sleep(0.1) - - except Exception as e: - if not self._stop_requested: - self.download_failed.emit(str(e), self.download_item) - # Give error signal time to be processed - import time - time.sleep(0.1) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up download event loop: {e}") - - async def _do_download(self): - """Perform the actual download with proper async handling""" - return await self.soulseek_client.download( - self.search_result.username, - self.search_result.filename, - self.search_result.size - ) - - def stop(self): - """Stop the download gracefully""" - self._stop_requested = True - -class SessionInfoThread(QThread): - session_info_completed = pyqtSignal(dict) # Session info dict - session_info_failed = pyqtSignal(str) # Error message - - def __init__(self, soulseek_client): - super().__init__() - self.soulseek_client = soulseek_client - self._stop_requested = False - - def run(self): - loop = None - try: - import asyncio - - # Create a completely fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Check if stop was requested before starting - if self._stop_requested: - return - - # Get session info - session_info = loop.run_until_complete(self._get_session_info()) - - # Only emit if not stopped - if not self._stop_requested: - self.session_info_completed.emit(session_info or {}) - - except Exception as e: - if not self._stop_requested: - self.session_info_failed.emit(str(e)) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up session info event loop: {e}") - - async def _get_session_info(self): - """Get the session information""" - return await self.soulseek_client.get_session_info() - - def stop(self): - """Stop the session info gathering gracefully""" - self._stop_requested = True - -class ExploreApiThread(QThread): - exploration_completed = pyqtSignal(dict) # API info dict - exploration_failed = pyqtSignal(str) # Error message - - def __init__(self, soulseek_client): - super().__init__() - self.soulseek_client = soulseek_client - self._stop_requested = False - - def run(self): - loop = None - try: - import asyncio - - # Create a completely fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Check if stop was requested before starting - if self._stop_requested: - return - - # Explore the API - api_info = loop.run_until_complete(self._explore_api()) - - # Only emit if not stopped - if not self._stop_requested: - self.exploration_completed.emit(api_info) - - except Exception as e: - if not self._stop_requested: - self.exploration_failed.emit(str(e)) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up exploration event loop: {e}") - - async def _explore_api(self): - """Perform the actual API exploration""" - return await self.soulseek_client.explore_api_endpoints() - - def stop(self): - """Stop the exploration gracefully""" - self._stop_requested = True - -class TransferStatusThread(QThread): - """Thread for fetching real-time download transfer status from slskd API""" - transfer_status_completed = pyqtSignal(object) # Transfer data from API - transfer_status_failed = pyqtSignal(str) # Error message - - def __init__(self, soulseek_client): - super().__init__() - self.soulseek_client = soulseek_client - self._stop_requested = False - - def run(self): - loop = None - try: - import asyncio - - # Create a fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Check if stop was requested before starting - if self._stop_requested: - return - - # Get transfer status data from /api/v0/transfers/downloads - transfer_data = loop.run_until_complete(self._get_transfer_status()) - - # Only emit if not stopped - if not self._stop_requested: - self.transfer_status_completed.emit(transfer_data or []) - - except Exception as e: - if not self._stop_requested: - self.transfer_status_failed.emit(str(e)) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up transfer status event loop: {e}") - - async def _get_transfer_status(self): - """Get the transfer status from slskd API""" - try: - # Use the soulseek client's _make_request method to get transfer data - response_data = await self.soulseek_client._make_request('GET', 'transfers/downloads') - return response_data - except Exception as e: - print(f"Error fetching transfer status: {e}") - return [] - - def stop(self): - """Stop the transfer status gathering gracefully""" - self._stop_requested = True - -class ApiCleanupThread(QThread): - """Thread for signaling download completion to slskd API without blocking UI""" - cleanup_completed = pyqtSignal(bool, str, str) # success, download_id, username - - def __init__(self, soulseek_client, download_id, username): - super().__init__() - self.soulseek_client = soulseek_client - self.download_id = download_id - self.username = username - - def run(self): - """Signal download completion in background thread""" - loop = None - try: - import asyncio - - # Create a fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Signal download completion - success = loop.run_until_complete( - self.soulseek_client.signal_download_completion( - self.download_id, - self.username, - remove=True - ) - ) - - # Emit completion signal - self.cleanup_completed.emit(success, self.download_id, self.username) - - except Exception as e: - print(f"Error in API cleanup thread: {e}") - self.cleanup_completed.emit(False, self.download_id, self.username) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up API cleanup event loop: {e}") - -class SearchThread(QThread): - search_completed = pyqtSignal(object) # Tuple of (tracks, albums) or list for backward compatibility - search_failed = pyqtSignal(str) # Error message - search_progress = pyqtSignal(str) # Progress message - search_results_partial = pyqtSignal(object, object, int) # tracks, albums, response count - - def __init__(self, soulseek_client, query): - super().__init__() - self.soulseek_client = soulseek_client - self.query = query - self._stop_requested = False - - def progress_callback(self, tracks, albums, response_count): - """Callback function for progressive search results""" - if not self._stop_requested: - # Emit live results immediately - self.search_results_partial.emit(tracks, albums, response_count) - # Update progress message with current count - self.search_progress.emit(f"Found {len(tracks)} tracks, {len(albums)} albums from {response_count} responses") - - def run(self): - loop = None - try: - import asyncio - self.search_progress.emit(f"Searching for: {self.query}") - - # Create a completely fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Perform search with progressive callback - results = loop.run_until_complete(self._do_search()) - - if not self._stop_requested: - # Emit final completion with proper tuple format - # results should be a tuple (tracks, albums) from the search client - self.search_completed.emit(results) - - except Exception as e: - if not self._stop_requested: - self.search_failed.emit(str(e)) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up event loop: {e}") - - async def _do_search(self): - """Perform the actual search with progressive callback""" - return await self.soulseek_client.search(self.query, progress_callback=self.progress_callback) - - def stop(self): - """Stop the search gracefully""" - self._stop_requested = True - -class TrackedStatusUpdateThread(QThread): - """Tracked status update thread that can be properly stopped and cleaned up""" - status_updated = pyqtSignal(list) - - def __init__(self, soulseek_client, parent=None): - super().__init__(parent) - self.soulseek_client = soulseek_client - self._stop_requested = False - - def run(self): - loop = None - try: - import asyncio - - # Check if stop was requested before starting - if self._stop_requested: - return - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - downloads = loop.run_until_complete(self.soulseek_client.get_all_downloads()) - - # Only emit if not stopped - if not self._stop_requested: - self.status_updated.emit(downloads or []) - - except Exception as e: - if not self._stop_requested: - print(f"Error fetching download status: {e}") - self.status_updated.emit([]) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up status update event loop: {e}") - - def stop(self): - """Stop the status update thread gracefully""" - self._stop_requested = True - -class StreamingThread(QThread): - """Thread for streaming audio files without saving them permanently""" - streaming_started = pyqtSignal(str, object) # Message, search_result - streaming_finished = pyqtSignal(str, object) # Message, search_result - streaming_failed = pyqtSignal(str, object) # Error message, search_result - streaming_progress = pyqtSignal(float, object) # Progress percentage (0-100), search_result - streaming_queued = pyqtSignal(str, object) # Queue message, search_result - - def __init__(self, soulseek_client, search_result): - super().__init__() - self.soulseek_client = soulseek_client - self.search_result = search_result - self._stop_requested = False - - def run(self): - loop = None - try: - import asyncio - import os - import time - import shutil - import glob - from pathlib import Path - - self.streaming_started.emit(f"Starting stream: {self.search_result.filename}", self.search_result) - - # Create a fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Get paths - from config.settings import config_manager - download_path = config_manager.get('soulseek.download_path', './downloads') - - # Use the Stream folder in project root (not inside downloads) - project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/ - stream_folder = os.path.join(project_root, 'Stream') - - # Ensure Stream directory exists - os.makedirs(stream_folder, exist_ok=True) - - # Clear any existing files in Stream folder (only one file at a time) - for existing_file in glob.glob(os.path.join(stream_folder, '*')): - try: - if os.path.isfile(existing_file): - os.remove(existing_file) - elif os.path.isdir(existing_file): - shutil.rmtree(existing_file) - except Exception as e: - print(f"Warning: Could not remove existing stream file: {e}") - - # Start the download (goes to normal downloads folder initially) - download_result = loop.run_until_complete(self._do_stream_download()) - - if not self._stop_requested: - if download_result: - self.streaming_started.emit(f"Downloading for stream: {self.search_result.filename}", self.search_result) - - # Standard streaming - wait for complete download - max_wait_time = 45 # Wait up to 45 seconds - poll_interval = 2 # Check every 2 seconds - - last_progress_sent = 0.0 - found_file = None # Initialize found_file outside the loop - - # Queue state tracking - queue_start_time = None - queue_timeout = 15.0 # 15 seconds max in queue - last_download_state = None - actively_downloading = False - - for wait_count in range(max_wait_time // poll_interval): - if self._stop_requested: - break - - # Only use real API progress data - no time-based estimation - - # Check download progress via slskd API - api_progress = None - download_state = None - try: - # Use the same API call as download queue monitoring for consistency - transfers_data = loop.run_until_complete(self.soulseek_client._make_request('GET', 'transfers/downloads')) - download_status = self._find_streaming_download_in_transfers(transfers_data) - if download_status: - api_progress = download_status.get('percentComplete', 0) - download_state = download_status.get('state', '').lower() - print(f"API Download - State: {download_status.get('state')}, Progress: {api_progress:.1f}%") - - # Categorize download state (aligned with download queue logic) - original_state = download_status.get('state', '') # Keep original case for completion check - is_queued = any(keyword in download_state for keyword in ['queued', 'initializing', 'remote']) - is_downloading = 'inprogress' in download_state - is_completed = ('Succeeded' in original_state or ('Completed' in original_state and 'Errored' not in original_state)) or api_progress >= 100 - - # Track queue state timing - if is_queued and queue_start_time is None: - queue_start_time = time.time() - print(f"Download entered queue state: {original_state}") - self.streaming_queued.emit(f"Queuing with uploader...", self.search_result) - elif is_downloading and not actively_downloading: - actively_downloading = True - queue_start_time = None # Reset queue timer - print(f"Download started actively downloading: {original_state}") - # Emit a progress update to indicate downloading has started - if api_progress > 0: - self.streaming_progress.emit(api_progress, self.search_result) - - # Check for queue timeout - if is_queued and queue_start_time: - queue_elapsed = time.time() - queue_start_time - if queue_elapsed > queue_timeout: - print(f"⏰ Queue timeout after {queue_elapsed:.1f}s - download stuck in queue") - self.streaming_failed.emit(f"Queue timeout - uploader not responding. Try another source.", self.search_result) - return - - # Check if download is complete - if is_completed: - print(f"Download completed via API status: {original_state}") - # Try to find the actual file - with retries for file system sync - for retry_count in range(5): # Try up to 5 times with delays - found_file = self._find_downloaded_file(download_path) - if found_file: - print(f"Found completed file after {retry_count} retries: {found_file}") - break - else: - print(f"File not found yet, waiting... (retry {retry_count + 1}/5)") - time.sleep(1) # Wait 1 second for file system to sync - - if found_file: - print(f"Found downloaded file: {found_file}") - - # Move the file to Stream folder with original filename - original_filename = os.path.basename(found_file) - stream_path = os.path.join(stream_folder, original_filename) - - try: - # Move file to Stream folder - shutil.move(found_file, stream_path) - print(f"Moved file to stream folder: {stream_path}") - - # Clean up empty directories left behind - self._cleanup_empty_directories(download_path, found_file) - - # Signal that streaming is ready (100% progress) - self.streaming_progress.emit(100.0, self.search_result) - self.streaming_finished.emit(f"Stream ready: {os.path.basename(found_file)}", self.search_result) - self.temp_file_path = stream_path - print(f"Stream file ready for playback: {stream_path}") - - # Signal API that download is complete - try: - download_id = download_status.get('id', '') - if download_id and self.search_result.username: - import asyncio - from services.service_manager import service_manager - soulseek_client = service_manager.get_soulseek_client() - if soulseek_client: - # Run the async API call - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - success = loop.run_until_complete( - soulseek_client.signal_download_completion(download_id, self.search_result.username, remove=True) - ) - loop.close() - if success: - print(f"Successfully signaled completion for download {download_id}") - else: - print(f"Failed to signal completion for download {download_id}") - except Exception as e: - print(f"Error signaling download completion: {e}") - - break # Exit main polling loop - - except Exception as e: - print(f"Error moving file to stream folder: {e}") - self.streaming_failed.emit(f"Failed to prepare stream file: {e}", self.search_result) - break - else: - # Handle progress updates for active downloads - if is_downloading and actively_downloading and api_progress is not None and api_progress > 0: - if api_progress != last_progress_sent: - self.streaming_progress.emit(api_progress, self.search_result) - print(f"Progress update: {api_progress:.1f}% (Real API data)") - last_progress_sent = api_progress - except Exception as e: - print(f"Warning: Could not check download progress: {e}") - # Continue to next iteration if API call fails - continue - - # Search for the downloaded file in the downloads directory - found_file = self._find_downloaded_file(download_path) - - if found_file: - print(f"Found downloaded file: {found_file}") - - # Move the file to Stream folder with original filename - original_filename = os.path.basename(found_file) - stream_path = os.path.join(stream_folder, original_filename) - - try: - # Move file to Stream folder - shutil.move(found_file, stream_path) - print(f"Moved file to stream folder: {stream_path}") - - # Clean up empty directories left behind - self._cleanup_empty_directories(download_path, found_file) - - # Signal that streaming is ready (100% progress) - self.streaming_progress.emit(100.0, self.search_result) - self.streaming_finished.emit(f"Stream ready: {os.path.basename(found_file)}", self.search_result) - self.temp_file_path = stream_path - print(f"Stream file ready for playback: {stream_path}") - break - - except Exception as e: - print(f"Error moving file to stream folder: {e}") - self.streaming_failed.emit(f"Failed to prepare stream file: {e}", self.search_result) - break - else: - # Still downloading, wait a bit more - print(f"Waiting for download to complete... ({wait_count * poll_interval}s elapsed)") - time.sleep(poll_interval) - else: - # Timed out waiting for file - print(f"Polling loop completed, timeout reached. found_file = {found_file}") - self.streaming_failed.emit("Stream download timed out - file not found", self.search_result) - - else: - self.streaming_failed.emit("Streaming failed to start", self.search_result) - - except Exception as e: - if not self._stop_requested: - self.streaming_failed.emit(str(e), self.search_result) - finally: - # Ensure proper cleanup - if loop: - try: - # Close any remaining tasks - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - - if pending: - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - loop.close() - except Exception as e: - print(f"Error cleaning up streaming event loop: {e}") - - def _find_streaming_download_in_transfers(self, transfers_data): - """Find streaming download in transfer data using same logic as download queue""" - try: - if not transfers_data: - return None - - # Flatten the transfers data structure (same as download queue logic) - all_transfers = [] - for user_data in transfers_data: - if 'directories' in user_data: - for directory in user_data['directories']: - if 'files' in directory: - all_transfers.extend(directory['files']) - - # Look for our specific file by filename and username - target_filename = os.path.basename(self.search_result.filename) - target_username = self.search_result.username - - print(f"Looking for streaming download - Target: {target_username}:{target_filename}") - print(f"Found {len(all_transfers)} total transfers in API") - - for i, transfer in enumerate(all_transfers): - transfer_filename = os.path.basename(transfer.get('filename', '')) - transfer_username = transfer.get('username', '') - - print(f"Transfer {i+1}: {transfer_username}:{transfer_filename} - State: {transfer.get('state')} - Progress: {transfer.get('percentComplete', 0):.1f}%") - - if (transfer_filename == target_filename and - transfer_username == target_username): - print(f"Found matching streaming download: {transfer.get('state')} - {transfer.get('percentComplete', 0):.1f}%") - return transfer - - print(f"No matching streaming download found for {target_username}:{target_filename}") - return None - except Exception as e: - print(f"Error finding streaming download in transfers: {e}") - return None - - def _find_downloaded_file(self, download_path): - """Find the downloaded audio file in the downloads directory tree""" - import os - - # Audio file extensions to look for - audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} - - # Get the base filename without path - target_filename = os.path.basename(self.search_result.filename) - - try: - # Walk through the downloads directory to find the file - for root, dirs, files in os.walk(download_path): - for file in files: - # Check if this is our target file - if file == target_filename: - file_path = os.path.join(root, file) - # Verify it's an audio file and has content - if (os.path.splitext(file)[1].lower() in audio_extensions and - os.path.getsize(file_path) > 1024): # At least 1KB - return file_path - - # Also check for any audio files that might match partially - # (in case filename is slightly different) - file_lower = file.lower() - target_lower = target_filename.lower() - - # Remove common variations - target_clean = target_lower.replace(' ', '').replace('-', '').replace('_', '') - file_clean = file_lower.replace(' ', '').replace('-', '').replace('_', '') - - if (os.path.splitext(file)[1].lower() in audio_extensions and - len(file_clean) > 10 and # Reasonable filename length - (target_clean in file_clean or file_clean in target_clean) and - os.path.getsize(os.path.join(root, file)) > 1024): - return os.path.join(root, file) - - except Exception as e: - print(f"Error searching for downloaded file: {e}") - - return None - - def _cleanup_empty_directories(self, download_path, moved_file_path): - """Clean up empty directories left after moving a file""" - import os - - try: - # Get the directory that contained the moved file - file_dir = os.path.dirname(moved_file_path) - - # Only clean up if it's a subdirectory of downloads (not the downloads folder itself) - if file_dir != download_path and file_dir.startswith(download_path): - # Check if directory is empty - if os.path.isdir(file_dir) and not os.listdir(file_dir): - print(f"Removing empty directory: {file_dir}") - os.rmdir(file_dir) - - # Recursively check parent directories - parent_dir = os.path.dirname(file_dir) - if (parent_dir != download_path and - parent_dir.startswith(download_path) and - os.path.isdir(parent_dir) and - not os.listdir(parent_dir)): - print(f"Removing empty parent directory: {parent_dir}") - os.rmdir(parent_dir) - - except Exception as e: - print(f"Warning: Could not clean up empty directories: {e}") - - async def _do_stream_download(self): - """Perform the streaming download using normal download mechanism""" - # Use the same download mechanism as regular downloads - # The file will be downloaded to the normal downloads folder first - return await self.soulseek_client.download( - self.search_result.username, - self.search_result.filename, - self.search_result.size - ) - - - def stop(self): - """Stop the streaming gracefully""" - self._stop_requested = True - -class TrackItem(QFrame): - """Individual track item within an album""" - track_download_requested = pyqtSignal(object) # TrackResult object - track_stream_requested = pyqtSignal(object) # TrackResult object - - def __init__(self, track_result, parent=None): - super().__init__(parent) - self.track_result = track_result - self.setup_ui() - - def setup_ui(self): - self.setFixedHeight(50) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - self.setStyleSheet(""" - TrackItem { - background: rgba(40, 40, 40, 0.5); - border-radius: 8px; - border: 1px solid rgba(60, 60, 60, 0.3); - margin: 2px 8px; - } - TrackItem:hover { - background: rgba(50, 50, 50, 0.7); - border: 1px solid rgba(29, 185, 84, 0.5); - } - """) - - layout = QHBoxLayout(self) - layout.setContentsMargins(12, 8, 12, 8) - layout.setSpacing(12) - - # Track info - track_info = QVBoxLayout() - track_info.setSpacing(2) - - # Track title - title = QLabel(self.track_result.title or "Unknown Title") - title.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - title.setStyleSheet("color: #ffffff;") - - # Track details - enhanced with more information including prominent artist display - details = [] - if self.track_result.track_number: - details.append(f"#{self.track_result.track_number:02d}") - - # Always show artist information prominently for tracks within albums - if self.track_result.artist: - details.append(f"{self.track_result.artist}") - - details.append(self.track_result.quality.upper()) - if self.track_result.bitrate: - details.append(f"{self.track_result.bitrate}kbps") - - # Add duration if available - if self.track_result.duration: - duration_mins = self.track_result.duration // 60 - duration_secs = self.track_result.duration % 60 - details.append(f"{duration_mins}:{duration_secs:02d}") - - details.append(f"{self.track_result.size // (1024*1024)}MB") - - details_text = " • ".join(details) - track_details = QLabel(details_text) - track_details.setFont(QFont("Arial", 9)) - track_details.setStyleSheet("color: rgba(179, 179, 179, 0.8);") - - track_info.addWidget(title) - track_info.addWidget(track_details) - - # Control buttons - button_layout = QHBoxLayout() - button_layout.setSpacing(8) - - # Play button - play_btn = QPushButton("") - play_btn.setFixedSize(32, 32) - play_btn.clicked.connect(self.request_stream) - play_btn.setStyleSheet(""" - QPushButton { - background: rgba(29, 185, 84, 0.8); - border: none; - border-radius: 16px; - color: #000000; - font-size: 12px; - font-weight: bold; - } - QPushButton:hover { - background: rgba(30, 215, 96, 1.0); - } - """) - - # Download button - download_btn = QPushButton("⬇️") - download_btn.setFixedSize(32, 32) - download_btn.clicked.connect(self.request_download) - download_btn.setStyleSheet(""" - QPushButton { - background: rgba(64, 64, 64, 0.8); - border: 1px solid rgba(29, 185, 84, 0.6); - border-radius: 16px; - color: #1db954; - font-size: 10px; - } - QPushButton:hover { - background: rgba(29, 185, 84, 0.2); - } - """) - - # Matched Download button - matched_download_btn = QPushButton("") - matched_download_btn.setFixedSize(32, 32) - matched_download_btn.clicked.connect(self.request_matched_download) - matched_download_btn.setToolTip("Download with Spotify Matching") - matched_download_btn.setStyleSheet(""" - QPushButton { - background: rgba(64, 64, 64, 0.8); - border: 1px solid rgba(147, 51, 234, 0.6); - border-radius: 16px; - color: #9333ea; - font-size: 10px; - } - QPushButton:hover { - background: rgba(147, 51, 234, 0.2); - } - """) - - button_layout.addWidget(play_btn) - button_layout.addWidget(download_btn) - button_layout.addWidget(matched_download_btn) - - # Store button references for state management - self.play_btn = play_btn - self.download_btn = download_btn - self.matched_download_btn = matched_download_btn - - # Assembly - layout.addLayout(track_info, 1) - layout.addLayout(button_layout) - - def request_stream(self): - """Request streaming of this track""" - self.track_stream_requested.emit(self.track_result) - - def request_download(self): - """Request download of this track""" - self.track_download_requested.emit(self.track_result) - - def request_matched_download(self): - """Request a matched download with Spotify integration""" - # Get reference to the DownloadsPage to handle matched download - downloads_page = self.get_downloads_page() - if downloads_page: - downloads_page.start_matched_download(self.track_result) - - def get_downloads_page(self): - """Get reference to the parent DownloadsPage""" - parent = self.parent() - while parent: - if hasattr(parent, 'audio_player'): # DownloadsPage has audio_player - return parent - parent = parent.parent() - return None - - def set_loading_state(self): - """Set play button to loading state""" - self.play_btn.setText("") - self.play_btn.setEnabled(False) - - def set_queue_state(self): - """Set play button to queue state""" - self.play_btn.setText("") - self.play_btn.setEnabled(False) - self.play_btn.setStyleSheet(""" - QPushButton { - background: rgba(255, 165, 0, 0.2); - border: 1px solid rgba(255, 165, 0, 0.4); - border-radius: 18px; - color: rgba(255, 165, 0, 0.8); - font-size: 12px; - } - """) - - def set_download_queued_state(self): - """Set download button to queued state (disabled, shows queued)""" - self.download_btn.setText("") - self.download_btn.setEnabled(False) - self.download_btn.setStyleSheet(""" - QPushButton { - background: rgba(100, 100, 100, 0.5); - border: 1px solid rgba(150, 150, 150, 0.3); - border-radius: 16px; - color: rgba(255, 255, 255, 0.6); - font-size: 10px; - } - """) - - def set_download_downloading_state(self): - """Set download button to downloading state""" - self.download_btn.setText("") - self.download_btn.setEnabled(False) - self.download_btn.setStyleSheet(""" - QPushButton { - background: rgba(29, 185, 84, 0.3); - border: 1px solid rgba(29, 185, 84, 0.6); - border-radius: 16px; - color: #1db954; - font-size: 10px; - } - """) - - def set_download_completed_state(self): - """Set download button to completed state""" - self.download_btn.setText("") - self.download_btn.setEnabled(False) - self.download_btn.setStyleSheet(""" - QPushButton { - background: rgba(40, 167, 69, 0.3); - border: 1px solid rgba(40, 167, 69, 0.6); - border-radius: 16px; - color: #28a745; - font-size: 10px; - } - """) - - def reset_download_state(self): - """Reset download button to default state""" - self.download_btn.setText("⬇️") - self.download_btn.setEnabled(True) - self.download_btn.setStyleSheet(""" - QPushButton { - background: rgba(64, 64, 64, 0.8); - border: 1px solid rgba(29, 185, 84, 0.6); - border-radius: 16px; - color: #1db954; - font-size: 10px; - } - QPushButton:hover { - background: rgba(29, 185, 84, 0.2); - } - """) - - def set_playing_state(self): - """Set play button to playing/pause state""" - self.play_btn.setText("") - self.play_btn.setEnabled(True) - - def reset_play_state(self): - """Reset play button to default state""" - self.play_btn.setText("") - self.play_btn.setEnabled(True) - -class AlbumResultItem(QFrame): - """Expandable UI component for displaying album search results""" - album_download_requested = pyqtSignal(object) # AlbumResult object - matched_album_download_requested = pyqtSignal(object) # AlbumResult object for matched download - track_download_requested = pyqtSignal(object) # TrackResult object - track_stream_requested = pyqtSignal(object, object) # TrackResult object, TrackItem object - - def __init__(self, album_result, parent=None): - super().__init__(parent) - self.album_result = album_result - self.is_expanded = False - self.track_items = [] - self.tracks_container = None - self.setup_ui() - - def setup_ui(self): - # Dynamic height based on expansion state with better proportions - self.collapsed_height = 110 # Increased from 80px for better breathing room - self.setFixedHeight(self.collapsed_height) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - # Enable mouse tracking for click detection - self.setMouseTracking(True) - - self.setStyleSheet(""" - AlbumResultItem { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(52, 52, 58, 0.95), - stop:1 rgba(42, 42, 48, 0.98)); - border-radius: 20px; - border: 1px solid rgba(75, 75, 82, 0.5); - margin: 10px 5px; - } - AlbumResultItem:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(60, 60, 68, 0.98), - stop:1 rgba(50, 50, 58, 1.0)); - border: 1px solid rgba(29, 185, 84, 0.8); - } - """) - - # Main vertical layout for album header + tracks - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - - # Album header (always visible, clickable) - self.header_widget = QWidget() - self.header_widget.setFixedHeight(90) # Increased to match collapsed_height - self.header_widget.setStyleSheet("QWidget { background: transparent; }") - header_layout = QHBoxLayout(self.header_widget) - header_layout.setContentsMargins(16, 12, 16, 16) # More balanced padding - reduced top, added bottom - header_layout.setSpacing(16) # Consistent spacing with other elements - - # Album icon with expand indicator - icon_container = QVBoxLayout() - album_icon = QLabel("") - album_icon.setFixedSize(48, 48) # Larger for better presence - album_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) - album_icon.setStyleSheet(""" - QLabel { - font-size: 24px; - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, - stop:0 rgba(29, 185, 84, 0.2), - stop:1 rgba(24, 156, 71, 0.15)); - border-radius: 24px; - border: 2px solid rgba(29, 185, 84, 0.4); - color: rgba(29, 185, 84, 1.0); - } - """) - - # Expand indicator - self.expand_indicator = QLabel("") - self.expand_indicator.setFixedSize(20, 20) # Slightly larger - self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.expand_indicator.setStyleSheet(""" - QLabel { - color: rgba(29, 185, 84, 0.9); - font-size: 14px; - font-weight: bold; - background: rgba(29, 185, 84, 0.1); - border-radius: 10px; - border: 1px solid rgba(29, 185, 84, 0.2); - } - """) - - icon_container.addWidget(album_icon) - icon_container.addWidget(self.expand_indicator) - - # Album info section - info_section = QVBoxLayout() - info_section.setSpacing(2) - info_section.setContentsMargins(0, 0, 0, 0) - - # Album title - album_title = QLabel(self.album_result.album_title) - album_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - album_title.setStyleSheet("color: #ffffff;") - - # Artist and details - with prominent artist display - details = [] - - # Make artist more prominent by placing it first and with better formatting - if self.album_result.artist: - details.append(f"{self.album_result.artist}") - - details.append(f"{self.album_result.track_count} tracks") - details.append(f"{self.album_result.size_mb}MB") - details.append(self.album_result.dominant_quality.upper()) - if self.album_result.year: - details.append(f"({self.album_result.year})") - - # Add speed information - speed_info = self._get_album_speed_display() - if speed_info: - details.append(speed_info) - - details_text = " • ".join(details) - album_details = QLabel(details_text) - album_details.setFont(QFont("Arial", 10)) - album_details.setStyleSheet("color: rgba(179, 179, 179, 0.9);") - - # User info - user_info = QLabel(f"{self.album_result.username}") - user_info.setFont(QFont("Arial", 9)) - user_info.setStyleSheet("color: rgba(29, 185, 84, 0.8);") - - info_section.addWidget(album_title) - info_section.addWidget(album_details) - info_section.addWidget(user_info) - - # Download buttons layout - download_buttons_layout = QVBoxLayout() - download_buttons_layout.setSpacing(4) - - # Download button - self.download_btn = QPushButton("⬇️ Download Album") - self.download_btn.setFixedSize(160, 36) # Slightly larger for better presence - self.download_btn.clicked.connect(self.request_album_download) - self.download_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.95), - stop:1 rgba(24, 156, 71, 0.9)); - border: 2px solid rgba(29, 185, 84, 0.3); - border-radius: 18px; - color: #000000; - font-size: 12px; - font-weight: bold; - padding: 8px 12px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(30, 215, 96, 1.0), - stop:1 rgba(25, 180, 80, 1.0)); - border: 2px solid rgba(29, 185, 84, 0.6); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(20, 150, 70, 1.0), - stop:1 rgba(15, 120, 60, 1.0)); - } - """) - - # Matched Download button - self.matched_download_btn = QPushButton("Matched Album") - self.matched_download_btn.setFixedSize(160, 36) # Match the other button - self.matched_download_btn.clicked.connect(self.request_matched_album_download) - self.matched_download_btn.setToolTip("Download Album with Spotify Matching") - self.matched_download_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(147, 51, 234, 0.95), - stop:1 rgba(124, 43, 200, 0.9)); - border: 2px solid rgba(147, 51, 234, 0.3); - border-radius: 18px; - color: #ffffff; - font-size: 12px; - font-weight: bold; - padding: 8px 12px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(167, 71, 254, 1.0), - stop:1 rgba(144, 63, 220, 1.0)); - border: 2px solid rgba(147, 51, 234, 0.6); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(120, 50, 200, 1.0), - stop:1 rgba(100, 40, 180, 1.0)); - } - """) - - download_buttons_layout.addWidget(self.download_btn) - download_buttons_layout.addWidget(self.matched_download_btn) - - # Set minimum width to ensure buttons always visible - self.setMinimumWidth(420) # Increased to accommodate larger buttons - - # Assembly header - header_layout.addLayout(icon_container) - header_layout.addLayout(info_section, 1) # Flexible content area - header_layout.addLayout(download_buttons_layout, 0) # Fixed button area, always visible - - # Tracks container (hidden by default) - self.tracks_container = QWidget() - self.tracks_container.setVisible(False) - tracks_layout = QVBoxLayout(self.tracks_container) - tracks_layout.setContentsMargins(16, 8, 16, 16) - tracks_layout.setSpacing(4) - - # Create track items - for track in self.album_result.tracks: - track_item = TrackItem(track) - track_item.track_download_requested.connect(self.track_download_requested.emit) - # Use lambda to pass both track result and track item reference - track_item.track_stream_requested.connect( - lambda track_result, item=track_item: self.handle_track_stream_request(track_result, item) - ) - tracks_layout.addWidget(track_item) - self.track_items.append(track_item) - - # Assembly main layout - main_layout.addWidget(self.header_widget) - main_layout.addWidget(self.tracks_container) - - # Make header clickable - self.header_widget.mousePressEvent = self.toggle_expansion - - def request_album_download(self): - """Request download of the entire album""" - self.download_btn.setText("") - self.download_btn.setEnabled(False) - self.album_download_requested.emit(self.album_result) - - def request_matched_album_download(self): - """Request matched download of the entire album with Spotify integration""" - self.matched_download_btn.setText("") - self.matched_download_btn.setEnabled(False) - self.matched_album_download_requested.emit(self.album_result) - - def toggle_expansion(self, event): - """Toggle album expansion to show/hide tracks""" - self.is_expanded = not self.is_expanded - - if self.is_expanded: - # Expand to show tracks - self.tracks_container.setVisible(True) - self.expand_indicator.setText("▼") - # Calculate height: header + (tracks * track_height) + padding - track_height = 54 # 50px + margin - total_height = self.collapsed_height + (len(self.track_items) * track_height) + 24 - self.setFixedHeight(total_height) - else: - # Collapse to hide tracks - self.tracks_container.setVisible(False) - self.expand_indicator.setText("") - self.setFixedHeight(self.collapsed_height) - - # Force layout update - self.updateGeometry() - if self.parent(): - self.parent().updateGeometry() - - def handle_track_stream_request(self, track_result, track_item): - """Handle stream request from a track item, passing the correct button reference""" - # Emit the stream request with the track item that contains the button - self.track_stream_requested.emit(track_result, track_item) - - def _get_album_speed_display(self): - """Get formatted speed display for album cards""" - # Get speed data from album result - speed = getattr(self.album_result, 'upload_speed', None) or 0 - slots = getattr(self.album_result, 'free_upload_slots', None) or 0 - - if speed > 0: - # Use same logic as Singles but return text only (no icons for inline display) - if speed > 200: - icon = "" - elif speed > 100: - icon = "" if slots > 0 else "" - elif speed > 50: - icon = "" - else: - icon = "" - - # Convert to MB/s and format - speed_mb = speed / 1024 - if speed_mb >= 1: - return f"{icon} {speed_mb:.1f}MB/s" - else: - return f"{icon} {speed}KB/s" - - return None # No speed data - -class SearchResultItem(QFrame): - download_requested = pyqtSignal(object) # SearchResult object - stream_requested = pyqtSignal(object) # SearchResult object for streaming - expansion_requested = pyqtSignal(object) # Signal when this item wants to expand - - def __init__(self, search_result, parent=None): - super().__init__(parent) - self.search_result = search_result - self.is_downloading = False - self.is_expanded = False - self.setup_ui() - - def setup_ui(self): - # Dynamic height based on state (compact: 85px, expanded: 200px for better visual breathing room) - self.compact_height = 85 # Increased from 75px to match Albums proportions - self.expanded_height = 200 # Increased from 180px for more comfortable content layout - self.setFixedHeight(self.compact_height) - - # Ensure consistent sizing and layout behavior - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - # Enable mouse tracking for click detection - self.setMouseTracking(True) - - self.setStyleSheet(""" - SearchResultItem { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(48, 48, 52, 0.95), - stop:1 rgba(38, 38, 42, 0.98)); - border-radius: 18px; - border: 1px solid rgba(70, 70, 76, 0.5); - margin: 8px 4px; - } - SearchResultItem:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(55, 55, 60, 0.98), - stop:1 rgba(45, 45, 50, 1.0)); - border: 1px solid rgba(29, 185, 84, 0.8); - } - """) - - layout = QHBoxLayout(self) - layout.setContentsMargins(16, 16, 16, 16) # Match Albums margins for consistency - layout.setSpacing(16) # Increased spacing for better visual separation - - # Left section: Music icon + filename - left_section = QHBoxLayout() - left_section.setSpacing(12) # Increased from 8px for better separation - - # Enhanced music icon with modern styling - music_icon = QLabel("") - music_icon.setFixedSize(44, 44) # Slightly larger for better presence - music_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) - music_icon.setStyleSheet(""" - QLabel { - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, - stop:0 rgba(29, 185, 84, 0.3), - stop:1 rgba(24, 156, 71, 0.2)); - border-radius: 22px; - border: 2px solid rgba(29, 185, 84, 0.4); - font-size: 18px; - color: rgba(29, 185, 84, 1.0); - } - QLabel:hover { - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, - stop:0 rgba(29, 185, 84, 0.5), - stop:1 rgba(24, 156, 71, 0.3)); - border: 2px solid rgba(29, 185, 84, 0.7); - } - """) - - # Content area that will change based on expanded state - self.content_widget = QWidget() - self.content_layout = QVBoxLayout(self.content_widget) - self.content_layout.setContentsMargins(0, 4, 0, 4) # Increased vertical margins for better centering - self.content_layout.setSpacing(2) # Reduced spacing to prevent text cut-off - - # Extract song info - primary_info = self._extract_song_info() - - # Create both compact and expanded content but show only one - self.create_persistent_content(primary_info) - - # Right section: Play and download buttons - buttons_layout = QHBoxLayout() - buttons_layout.setSpacing(8) # Increased from 4px for better button separation - - # Play button for streaming preview - self.play_btn = QPushButton("") - self.play_btn.setFixedSize(46, 46) # Larger for better accessibility - self.play_btn.clicked.connect(self.request_stream) - self.play_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 193, 7, 0.95), - stop:1 rgba(255, 152, 0, 0.9)); - border: 2px solid rgba(255, 193, 7, 0.3); - border-radius: 23px; - color: #000000; - font-size: 18px; - font-weight: bold; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 213, 79, 1.0), - stop:1 rgba(255, 171, 64, 1.0)); - border: 2px solid rgba(255, 193, 7, 0.6); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 152, 0, 1.0), - stop:1 rgba(245, 124, 0, 1.0)); - } - """) - - # Download button - self.download_btn = QPushButton("⬇️") - self.download_btn.setFixedSize(46, 46) # Match play button size - self.download_btn.clicked.connect(self.request_download) - self.download_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.95), - stop:1 rgba(24, 156, 71, 0.9)); - border: 2px solid rgba(29, 185, 84, 0.3); - border-radius: 23px; - color: #000000; - font-size: 18px; - font-weight: bold; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(30, 215, 96, 1.0), - stop:1 rgba(25, 180, 80, 1.0)); - border: 2px solid rgba(29, 185, 84, 0.6); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(24, 156, 71, 1.0), - stop:1 rgba(20, 130, 60, 1.0)); - } - """) - - # Matched Download button - self.matched_download_btn = QPushButton("") - self.matched_download_btn.setFixedSize(46, 46) # Match other buttons - self.matched_download_btn.clicked.connect(self.request_matched_download) - self.matched_download_btn.setToolTip("Download with Spotify Matching") - self.matched_download_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(147, 51, 234, 0.95), - stop:1 rgba(124, 43, 200, 0.9)); - border: 2px solid rgba(147, 51, 234, 0.3); - border-radius: 23px; - color: #ffffff; - font-size: 18px; - font-weight: bold; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(167, 71, 254, 1.0), - stop:1 rgba(144, 63, 220, 1.0)); - border: 2px solid rgba(147, 51, 234, 0.6); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(124, 43, 200, 1.0), - stop:1 rgba(104, 33, 180, 1.0)); - } - """) - - # Assemble the layout - left_section.addWidget(music_icon) - left_section.addWidget(self.content_widget, 1) - - buttons_layout.addWidget(self.play_btn) - buttons_layout.addWidget(self.download_btn) - buttons_layout.addWidget(self.matched_download_btn) - - # Set minimum width to ensure buttons always visible - self.setMinimumWidth(300) # Ensure minimum space for content + buttons - - layout.addLayout(left_section, 1) # Flexible content area - layout.addLayout(buttons_layout, 0) # Fixed button area, always visible - - def create_persistent_content(self, primary_info): - """Create both compact and expanded content with visibility control""" - # Title row (always visible) with character limit and ellipsis - title_text = primary_info['title'] - if len(title_text) > 55: # Increased character limit since smaller font fits more text - title_text = title_text[:52] + "..." - - self.title_label = QLabel(title_text) - self.title_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) # 12px matches Albums and prevents cut-off - self.title_label.setStyleSheet("color: #ffffff; letter-spacing: 0.2px;") - self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - # Ensure text doesn't overflow the label and allow click-through - self.title_label.setWordWrap(False) - # Remove text selection to allow clicks to propagate to parent widget - self.title_label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) - - # Expand indicator with enhanced styling - self.expand_indicator = QLabel("⏵") - self.expand_indicator.setFixedSize(20, 20) # Increased size for better visibility - self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.expand_indicator.setStyleSheet(""" - QLabel { - color: rgba(255, 255, 255, 0.7); - font-size: 12px; - background: rgba(255, 255, 255, 0.1); - border-radius: 10px; - } - QLabel:hover { - color: rgba(29, 185, 84, 0.9); - background: rgba(29, 185, 84, 0.15); - } - """) - - # Quality badge (now visible in compact view) - self.quality_badge = self._create_compact_quality_badge() - - # Create uploader info label for compact view with artist information - result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result - - # Build secondary info with artist prominently displayed (excluding uploader) - info_parts = [] - - # Add artist information if available - if hasattr(result, 'artist') and result.artist: - info_parts.append(f"{result.artist}") - - # Add quality info - quality_text = result.quality.upper() - if result.bitrate: - quality_text += f" • {result.bitrate}kbps" - info_parts.append(quality_text) - - # Add size info - size_mb = result.size // (1024*1024) - info_parts.append(f"{size_mb}MB") - - secondary_info_text = " • ".join(info_parts) - self.secondary_info = QLabel(secondary_info_text) - self.secondary_info.setFont(QFont("Arial", 9, QFont.Weight.Normal)) - self.secondary_info.setStyleSheet("color: rgba(179, 179, 179, 0.8); letter-spacing: 0.1px;") - self.secondary_info.setWordWrap(False) - self.secondary_info.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) - - # Create separate uploader info with green styling like albums - self.uploader_info = QLabel(f"{result.username}") - self.uploader_info.setFont(QFont("Arial", 9)) - self.uploader_info.setStyleSheet("color: rgba(29, 185, 84, 0.8);") - self.uploader_info.setWordWrap(False) - self.uploader_info.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) - - title_row = QHBoxLayout() - title_row.setContentsMargins(0, 0, 0, 0) - title_row.addWidget(self.title_label) - title_row.addWidget(self.quality_badge) - title_row.addWidget(self.expand_indicator) - - # Add secondary info row for compact view with uploader info - secondary_row = QHBoxLayout() - secondary_row.setContentsMargins(0, 0, 0, 0) # Remove margins to prevent cut-off - secondary_row.addWidget(self.secondary_info) - secondary_row.addStretch() # Push text to left - secondary_row.addWidget(self.uploader_info) # Add green uploader info on the right - - # Expanded content (initially hidden) - self.expanded_content = QWidget() - expanded_layout = QVBoxLayout(self.expanded_content) - expanded_layout.setContentsMargins(0, 4, 0, 4) # Small margins for better text positioning - expanded_layout.setSpacing(4) # Increased from 1px to 4px for better readability - - # Expanded content shows only unique information not in compact view - # Duration info (if available) - this is unique to expanded view - expanded_details = [] - if self.search_result.duration: - duration_mins = self.search_result.duration // 60 - duration_secs = self.search_result.duration % 60 - expanded_details.append(f"Duration: {duration_mins}:{duration_secs:02d}") - - # Full file path info (unique to expanded view) - result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result - if hasattr(result, 'filename'): - expanded_details.append(f"File: {result.filename}") - - if expanded_details: - self.expanded_details = QLabel(" • ".join(expanded_details)) - self.expanded_details.setFont(QFont("Arial", 10)) - self.expanded_details.setStyleSheet("color: rgba(136, 136, 136, 0.8); letter-spacing: 0.1px;") - self.expanded_details.setWordWrap(True) # Allow wrapping for long filenames - expanded_layout.addWidget(self.expanded_details) - - # Speed indicator (unique to expanded view) - self.speed_indicator = self._create_compact_speed_indicator() - speed_row = QHBoxLayout() - speed_row.addWidget(self.speed_indicator) - speed_row.addStretch() - expanded_layout.addLayout(speed_row) - - # Initially hide expanded content - self.expanded_content.hide() - - # Add to main layout - self.content_layout.addLayout(title_row) - self.content_layout.addLayout(secondary_row) # Add secondary info row - self.content_layout.addWidget(self.expanded_content) - - def update_expanded_state(self): - """Update UI based on expanded state without recreating widgets""" - if self.is_expanded: - self.expand_indicator.setText("⏷") - self.expand_indicator.setStyleSheet(""" - QLabel { - color: rgba(29, 185, 84, 0.9); - font-size: 14px; - background: rgba(29, 185, 84, 0.15); - border-radius: 10px; - } - """) - self.expanded_content.show() - else: - self.expand_indicator.setText("⏵") - self.expand_indicator.setStyleSheet(""" - QLabel { - color: rgba(255, 255, 255, 0.7); - font-size: 14px; - background: rgba(255, 255, 255, 0.1); - border-radius: 10px; - } - QLabel:hover { - color: rgba(29, 185, 84, 0.9); - background: rgba(29, 185, 84, 0.15); - } - """) - self.expanded_content.hide() - - # Quality badge is now always visible in compact view - - def mousePressEvent(self, event): - """Handle mouse clicks to toggle expand/collapse""" - # Only respond to left clicks and avoid clicks on the download button - if event.button() == Qt.MouseButton.LeftButton: - # Check if click is on download button (more precise detection) - button_rect = self.download_btn.geometry() - # Add some padding to the button area to be more forgiving - button_rect.adjust(-5, -5, 5, 5) - if not button_rect.contains(event.pos()): - # Emit signal to parent to handle accordion behavior - self.expansion_requested.emit(self) - super().mousePressEvent(event) - - def set_expanded(self, expanded, animate=True): - """Set expanded state externally (called by parent for accordion behavior)""" - if self.is_expanded == expanded: - return # No change needed - - self.is_expanded = expanded - - if animate: - self._animate_to_state() - else: - # Immediate state change without animation - if self.is_expanded: - self.setFixedHeight(self.expanded_height) - else: - self.setFixedHeight(self.compact_height) - self.update_expanded_state() - - def toggle_expanded(self): - """Toggle between compact and expanded states with animation""" - self.set_expanded(not self.is_expanded, animate=True) - - def _animate_to_state(self): - """Animate to the current expanded state with enhanced easing""" - from PyQt6.QtCore import QPropertyAnimation, QEasingCurve - - # Start height animation with smoother easing - self.animation = QPropertyAnimation(self, b"minimumHeight") - self.animation.setDuration(300) # Slightly longer for smoother feel - self.animation.setEasingCurve(QEasingCurve.Type.OutQuart) # More elegant easing curve - - if self.is_expanded: - # Expand animation - self.animation.setStartValue(self.compact_height) - self.animation.setEndValue(self.expanded_height) - # Show content immediately for expand (feels more responsive) - self.update_expanded_state() - else: - # Collapse animation - self.animation.setStartValue(self.expanded_height) - self.animation.setEndValue(self.compact_height) - # Hide content immediately for collapse (cleaner look) - self.update_expanded_state() - - # Update fixed height when animation completes - self.animation.finished.connect(self._finalize_height) - self.animation.start() - - def _finalize_height(self): - """Set final height after animation completes""" - if self.is_expanded: - self.setFixedHeight(self.expanded_height) - else: - self.setFixedHeight(self.compact_height) - - # Force parent layout update to ensure proper spacing - if self.parent(): - self.parent().updateGeometry() - - def sizeHint(self): - """Provide consistent size hint for layout calculations""" - if self.is_expanded: - return self.size().expandedTo(self.minimumSize()).boundedTo(self.maximumSize()) - else: - return self.size().expandedTo(self.minimumSize()).boundedTo(self.maximumSize()) - - def _truncate_file_path(self, username, filename): - """Truncate file path to show max 3 levels: file + parent + grandparent folder""" - import os - - # If username looks like a simple username (no path separators), return as-is - if '/' not in username and '\\' not in username: - return username - - # Get filename without extension for comparison - file_base = os.path.splitext(os.path.basename(filename))[0] - - # Split path using both Windows and Unix separators - path_parts = username.replace('\\', '/').split('/') - - # Remove empty parts - path_parts = [part for part in path_parts if part.strip()] - - # If path is already short, return as-is - if len(path_parts) <= 3: - return '/'.join(path_parts) - - # Take last 3 components (file + parent + grandparent) - truncated_parts = path_parts[-3:] - - # If we truncated, add ellipsis at the beginning - if len(path_parts) > 3: - return '.../' + '/'.join(truncated_parts) - else: - return '/'.join(truncated_parts) - - def _extract_song_info(self): - """Extract song title and artist from TrackResult""" - # Handle case where search_result is a list (shouldn't happen but be defensive) - if isinstance(self.search_result, list): - if len(self.search_result) > 0: - # Take the first item if it's a list - actual_result = self.search_result[0] - else: - # Empty list, return defaults - return {'title': 'Unknown Title', 'artist': 'Unknown Artist'} - else: - actual_result = self.search_result - - # TrackResult objects have parsed metadata available - if hasattr(actual_result, 'title') and hasattr(actual_result, 'artist'): - # Use parsed metadata from TrackResult - return { - 'title': actual_result.title or 'Unknown Title', - 'artist': actual_result.artist or 'Unknown Artist' - } - - # Fallback: parse from filename if metadata not available - if hasattr(actual_result, 'filename'): - filename = actual_result.filename - - # Remove file extension - name_without_ext = filename.rsplit('.', 1)[0] - - # Common patterns for artist - title separation - separators = [' - ', ' – ', ' — ', '_-_', ' | '] - - for sep in separators: - if sep in name_without_ext: - parts = name_without_ext.split(sep, 1) - return { - 'title': parts[1].strip(), - 'artist': parts[0].strip() - } - - # If no separator found, use filename as title - return { - 'title': name_without_ext, - 'artist': 'Unknown Artist' - } - else: - # No filename attribute, return defaults - return { - 'title': 'Unknown Title', - 'artist': 'Unknown Artist' - } - - def _create_compact_quality_badge(self): - """Create a compact quality indicator badge""" - # Handle list case defensively - result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result - - quality = result.quality.upper() - bitrate = result.bitrate - - if quality == 'FLAC': - badge_text = "FLAC" - badge_color = "#1db954" - elif bitrate and bitrate >= 320: - badge_text = f"{bitrate}k" - badge_color = "#1db954" - elif bitrate and bitrate >= 256: - badge_text = f"{bitrate}k" - badge_color = "#ffa500" - elif bitrate and bitrate >= 192: - badge_text = f"{bitrate}k" - badge_color = "#ffaa00" - else: - badge_text = quality[:3] # Truncate for compact display - badge_color = "#e22134" - - badge = QLabel(badge_text) - badge.setFont(QFont("Arial", 8, QFont.Weight.Bold)) - badge.setAlignment(Qt.AlignmentFlag.AlignCenter) - badge.setFixedSize(40, 16) - badge.setStyleSheet(f""" - QLabel {{ - background: {badge_color}; - color: #000000; - border-radius: 8px; - padding: 1px 4px; - }} - """) - - return badge - - def _create_compact_speed_indicator(self): - """Create compact upload speed indicator""" - # Handle list case defensively - result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result - - # Get speed and slots data with fallback handling - speed = getattr(result, 'upload_speed', None) or 0 - slots = getattr(result, 'free_upload_slots', None) or 0 - - # Debug: Print actual values to see what we're getting - print(f"[DEBUG] Speed indicator - speed: {speed}, slots: {slots}, user: {getattr(result, 'username', 'unknown')}") - - # Speed-focused logic (slots as bonus, not requirement) - if speed > 200: - indicator_color = "#1db954" - icon = "" - tooltip = f"Very Fast: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "") - elif speed > 100: - indicator_color = "#1db954" if slots > 0 else "#4CAF50" - icon = "" if slots > 0 else "" - tooltip = f"Fast: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "") - elif speed > 50: - indicator_color = "#ffa500" - icon = "" - tooltip = f"Good: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "") - elif speed > 0: - indicator_color = "#ffaa00" - icon = "" - tooltip = f"Slow: {speed} KB/s" + (f", {slots} slots" if slots > 0 else "") - else: - indicator_color = "#e22134" - icon = "" - tooltip = "No speed data available" - - # Convert KB/s to MB/s and format nicely - if speed > 0: - speed_mb = speed / 1024 # Convert KB to MB - if speed_mb >= 1: - speed_display = f"{speed_mb:.1f}MB/s" - else: - speed_display = f"{speed}KB/s" - speed_text = f"{icon} {speed_display}" - else: - speed_text = icon - - indicator = QLabel(speed_text) - indicator.setFont(QFont("Arial", 9)) # Slightly smaller to fit text - indicator.setStyleSheet(f"color: {indicator_color};") - indicator.setToolTip(tooltip) # Add tooltip for debugging - indicator.setMinimumWidth(60) # Allow space for icon + speed text - indicator.setFixedHeight(16) - - return indicator - - def _create_quality_badge(self): - """Create a quality indicator badge (legacy - kept for compatibility)""" - return self._create_compact_quality_badge() - - def _create_speed_indicator(self): - """Create upload speed indicator (legacy - kept for compatibility)""" - return self._create_compact_speed_indicator() - - def request_download(self): - if not self.is_downloading: - self.is_downloading = True - self.download_btn.setText("") - self.download_btn.setEnabled(False) - self.download_requested.emit(self.search_result) - - def request_matched_download(self): - """Request a matched download with Spotify integration""" - if not self.is_downloading: - # Get reference to the DownloadsPage to handle matched download - downloads_page = self.get_downloads_page() - if downloads_page: - downloads_page.start_matched_download(self.search_result) - - def request_stream(self): - """Request streaming of this audio file""" - # Check if file is a valid audio type - audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav'] - filename_lower = self.search_result.filename.lower() - - is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions) - - if is_audio: - # Get reference to the DownloadsPage to check audio player state - downloads_page = self.get_downloads_page() - - # If this button is currently playing, toggle pause/resume - if (downloads_page and - downloads_page.currently_playing_button == self and - downloads_page.audio_player.is_playing): - - # Toggle playback (pause/resume) - is_playing = downloads_page.audio_player.toggle_playback() - if is_playing: - self.set_playing_state() - else: - self.play_btn.setText("") # Play icon when paused - self.play_btn.setEnabled(True) - return - - # Otherwise, start new streaming - # Change button state to indicate streaming is starting - self.play_btn.setText("") # Pause icon to indicate playing - self.play_btn.setEnabled(False) - - # Emit streaming request - self.stream_requested.emit(self.search_result) - - # Note: Button state will be managed by the audio player callbacks - # No timer reset - the audio player will handle state changes - else: - print(f"Cannot stream non-audio file: {self.search_result.filename}") - - def get_downloads_page(self): - """Get reference to the parent DownloadsPage""" - parent = self.parent() - while parent: - if hasattr(parent, 'audio_player'): # DownloadsPage has audio_player - return parent - parent = parent.parent() - return None - - def reset_play_state(self, original_text=""): - """Reset the play button state""" - self.play_btn.setText(original_text) - self.play_btn.setEnabled(True) - - def set_playing_state(self): - """Set button to playing state""" - self.play_btn.setText("") - self.play_btn.setEnabled(True) - - def set_loading_state(self): - """Set button to loading state""" - self.play_btn.setText("⌛") - self.play_btn.setEnabled(False) - - def set_queue_state(self): - """Set play button to queue state""" - self.play_btn.setText("") - self.play_btn.setEnabled(False) - self.play_btn.setStyleSheet(""" - QPushButton { - background: rgba(255, 165, 0, 0.2); - border: 1px solid rgba(255, 165, 0, 0.4); - border-radius: 18px; - color: rgba(255, 165, 0, 0.8); - font-size: 12px; - } - """) - - def reset_download_state(self): - """Reset the download button state""" - self.is_downloading = False - self.download_btn.setText("⬇️") - self.download_btn.setEnabled(True) - -class DownloadItem(QFrame): - def __init__(self, title: str, artist: str, status: str, progress: int = 0, - file_size: int = 0, download_speed: int = 0, file_path: str = "", - download_id: str = "", soulseek_client=None, parent=None): - super().__init__(parent) - self.title = title - self.artist = artist - self.status = status - self.progress = progress - self.file_size = file_size - self.download_speed = download_speed - self.file_path = file_path - self.download_id = download_id # Track download ID for cancellation - self.soulseek_client = soulseek_client # For cancellation functionality - - # Add completion tracking to prevent duplicate processing - self._completion_processed = False - self._completion_lock = threading.Lock() - - self.setup_ui() - - def mark_completion_processed(self) -> bool: - """Thread-safe method to mark download as completion-processed. - Returns True if this is the first time marking completion, False if already processed.""" - with self._completion_lock: - if self._completion_processed: - return False # Already processed - self._completion_processed = True - return True # First time processing - - def is_completion_processed(self) -> bool: - """Check if completion has already been processed.""" - with self._completion_lock: - return self._completion_processed - - def setup_ui(self): - self.setFixedHeight(85) # More generous height for better spacing - self.setStyleSheet(""" - DownloadItem { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(40, 40, 40, 0.95), - stop:1 rgba(32, 32, 32, 0.95)); - border-radius: 12px; - border: 1px solid rgba(64, 64, 64, 0.4); - margin: 6px 4px; - } - DownloadItem:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(50, 50, 50, 0.95), - stop:1 rgba(40, 40, 40, 0.98)); - border: 1px solid rgba(29, 185, 84, 0.7); - } - """) - - # Main horizontal layout - layout = QHBoxLayout(self) - layout.setContentsMargins(16, 12, 16, 12) - layout.setSpacing(16) - - # Left section: Filename + uploader (flexible) - left_section = QVBoxLayout() - left_section.setSpacing(4) - - # Extract filename with extension from file_path - filename_with_ext = "Unknown File" - if self.file_path: - from pathlib import Path - try: - filename_with_ext = Path(self.file_path).name - except: - filename_with_ext = self.title # fallback - else: - filename_with_ext = self.title # fallback - - # Filename with extension (main info) - filename_label = QLabel(filename_with_ext) - filename_label.setFont(QFont("Segoe UI", 13, QFont.Weight.Bold)) - filename_label.setStyleSheet("color: #ffffff;") - filename_label.setWordWrap(False) - filename_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - # Uploader info - uploader_label = QLabel(f"from {self.artist}") - uploader_label.setFont(QFont("Segoe UI", 10)) - uploader_label.setStyleSheet("color: #b3b3b3;") - uploader_label.setWordWrap(False) - uploader_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - left_section.addWidget(filename_label) - left_section.addWidget(uploader_label) - - # Middle section: Progress (fixed width) - progress_widget = QWidget() - progress_widget.setFixedWidth(120) - progress_layout = QVBoxLayout(progress_widget) - progress_layout.setSpacing(6) - progress_layout.setContentsMargins(0, 0, 0, 0) - - # Progress bar - self.progress_bar = QProgressBar() - self.progress_bar.setFixedHeight(8) - self.progress_bar.setValue(self.progress) - self.progress_bar.setStyleSheet(""" - QProgressBar { - border: none; - border-radius: 4px; - background: #404040; - } - QProgressBar::chunk { - background: #1db954; - border-radius: 4px; - } - """) - - # Status text - status_mapping = { - "completed, succeeded": "Finished", - "completed, cancelled": "Cancelled", - "completed": "Finished", - "cancelled": "Cancelled", - "downloading": "Downloading", - "failed": "Failed", - "queued": "Queued" - } - - clean_status = status_mapping.get(self.status.lower(), self.status.title()) - if self.status.lower() in ["downloading", "queued"]: - status_text = f"{clean_status} - {self.progress}%" - else: - status_text = clean_status - - self.status_label = QLabel(status_text) - self.status_label.setFont(QFont("Segoe UI", 9)) - self.status_label.setStyleSheet("color: #b3b3b3;") - self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - progress_layout.addWidget(self.progress_bar) - progress_layout.addWidget(self.status_label) - - # Right section: Action button (fixed width) - self.action_btn = QPushButton() - self.action_btn.setFixedSize(90, 36) - - if self.status == "downloading": - self.action_btn.setText("Cancel") - self.action_btn.clicked.connect(self.cancel_download) - self.action_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(220, 53, 69, 0.8), - stop:1 rgba(220, 53, 69, 1.0)); - color: white; - border: none; - border-radius: 8px; - font-weight: bold; - font-size: 11px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(200, 33, 49, 0.9), - stop:1 rgba(200, 33, 49, 1.0)); - } - """) - elif self.status == "failed": - self.action_btn.setText("Retry") - self.action_btn.clicked.connect(self.retry_download) - self.action_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 193, 7, 0.8), - stop:1 rgba(255, 193, 7, 1.0)); - color: #000; - border: none; - border-radius: 8px; - font-weight: bold; - font-size: 11px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(235, 173, 0, 0.9), - stop:1 rgba(235, 173, 0, 1.0)); - } - """) - else: - self.action_btn.setText("Open") - self.action_btn.clicked.connect(self.open_download_location) - self.action_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(40, 167, 69, 0.8), - stop:1 rgba(40, 167, 69, 1.0)); - color: white; - border: none; - border-radius: 8px; - font-weight: bold; - font-size: 11px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(20, 147, 49, 0.9), - stop:1 rgba(20, 147, 49, 1.0)); - } - """) - - # Add everything to main layout - layout.addLayout(left_section, 1) # Flexible - layout.addWidget(progress_widget) # Fixed width - layout.addWidget(self.action_btn) # Fixed width - - def open_download_location(self): - """Open the download location in file explorer""" - import os - import platform - from pathlib import Path - - if not self.file_path: - return - - try: - file_path = Path(self.file_path) - if file_path.exists(): - # Open the folder containing the file - folder_path = file_path.parent - - system = platform.system() - if system == "Windows": - os.startfile(str(folder_path)) - elif system == "Darwin": # macOS - os.system(f'open "{folder_path}"') - else: # Linux - os.system(f'xdg-open "{folder_path}"') - else: - # If file doesn't exist, try to open the download directory from config - from config.settings import config_manager - download_path = config_manager.get('soulseek.download_path', './downloads') - - system = platform.system() - if system == "Windows": - os.startfile(download_path) - elif system == "Darwin": # macOS - os.system(f'open "{download_path}"') - else: # Linux - os.system(f'xdg-open "{download_path}"') - - except Exception as e: - print(f"Error opening download location: {e}") - - def update_status(self, status: str, progress: int = None, download_speed: int = None, file_path: str = None): - """SAFE UPDATE: Update download item status without UI destruction""" - # Update properties - self.status = status - if progress is not None: - self.progress = progress - if download_speed is not None: - self.download_speed = download_speed - if file_path: - self.file_path = file_path - - # SAFE UI UPDATES: Update widgets directly instead of recreating - try: - # Update progress bar safely - if hasattr(self, 'progress_bar') and self.progress_bar: - self.progress_bar.setValue(self.progress) - - # Update status label safely - if hasattr(self, 'status_label') and self.status_label: - # Clean up status text display - status_mapping = { - "completed, succeeded": "Finished", - "completed, cancelled": "Cancelled", - "completed": "Finished", - "cancelled": "Cancelled", - "downloading": "Downloading", - "failed": "Failed", - "queued": "Queued" - } - - clean_status = status_mapping.get(self.status.lower(), self.status.title()) - status_text = clean_status - - if self.status.lower() in ["downloading", "queued"]: - status_text += f" - {self.progress}%" - - self.status_label.setText(status_text) - - # Update action button based on status - if hasattr(self, 'action_btn') and self.action_btn: - if self.status == "downloading": - self.action_btn.setText("Cancel") - # Disconnect old connections - self.action_btn.clicked.disconnect() - self.action_btn.clicked.connect(self.cancel_download) - self.action_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(220, 53, 69, 0.8), - stop:1 rgba(220, 53, 69, 1.0)); - color: white; - border: none; - border-radius: 8px; - font-weight: bold; - font-size: 11px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(200, 33, 49, 0.9), - stop:1 rgba(200, 33, 49, 1.0)); - } - """) - elif self.status == "failed": - self.action_btn.setText("Retry") - # Disconnect old connections - self.action_btn.clicked.disconnect() - self.action_btn.clicked.connect(self.retry_download) - self.action_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 193, 7, 0.8), - stop:1 rgba(255, 193, 7, 1.0)); - color: #000; - border: none; - border-radius: 8px; - font-weight: bold; - font-size: 11px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(235, 173, 0, 0.9), - stop:1 rgba(235, 173, 0, 1.0)); - } - """) - else: - self.action_btn.setText("Open") - # Disconnect old connections - self.action_btn.clicked.disconnect() - self.action_btn.clicked.connect(self.open_download_location) - self.action_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(40, 167, 69, 0.8), - stop:1 rgba(40, 167, 69, 1.0)); - color: white; - border: none; - border-radius: 8px; - font-weight: bold; - font-size: 11px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(20, 147, 49, 0.9), - stop:1 rgba(20, 147, 49, 1.0)); - } - """) - - except Exception as e: - print(f"Error updating download item UI: {e}") - # Fallback: only recreate if safe update fails - self.setup_ui() - - def cancel_download(self): - """Cancel the download using the SoulseekClient""" - if not self.soulseek_client or not self.download_id: - print(f"Cannot cancel download: missing client or download ID") - return - - # Find the parent DownloadsPage to use its async helper - parent_page = self.parent() - while parent_page and not hasattr(parent_page, '_run_async_operation'): - parent_page = parent_page.parent() - - if parent_page: - # Use the parent's async helper for safe event loop management - def on_success(result): - if result: - print(f"Successfully cancelled download: {self.title}") - self.update_status("cancelled", progress=0) - - # Find the parent TabbedDownloadManager and move to finished tab - parent_widget = self.parent() - while parent_widget: - if hasattr(parent_widget, 'move_to_finished'): - parent_widget.move_to_finished(self) - break - parent_widget = parent_widget.parent() - - else: - print(f"Failed to cancel download: {self.title}") - - def on_error(error): - print(f"Error cancelling download {self.title}: {error}") - - parent_page._run_async_operation( - self.soulseek_client.cancel_download, - self.download_id, - success_callback=on_success, - error_callback=on_error - ) - else: - print(f"[ERROR] Could not find parent DownloadsPage for async operation") - - def retry_download(self): - """Retry a failed download""" - # For now, just update status back to downloading - # In a full implementation, this would restart the download - self.update_status("downloading", progress=0) - print(f"Retry requested for: {self.title}") - - def show_details(self): - """Show download details""" - details = f""" -Download Details: -Title: {self.title} -Artist: {self.artist} -Status: {self.status} -Progress: {self.progress}% -File Size: {self.file_size // (1024*1024)}MB -Download ID: {self.download_id} -File Path: {self.file_path} - """ - print(details) - -class CompactDownloadItem(QFrame): - """Compact download item optimized for queue display""" - def __init__(self, title: str, artist: str, status: str = "queued", - progress: int = 0, file_size: int = 0, download_speed: int = 0, - file_path: str = "", download_id: str = "", username: str = "", - soulseek_client=None, queue_type: str = "active", - album: str = None, track_number: int = None, - parent=None): - super().__init__(parent) - self.title = title - self.artist = artist - self.status = status - self.progress = progress - self.file_size = file_size - self.download_speed = download_speed - self.file_path = file_path - self.download_id = download_id - self.username = username - self.soulseek_client = soulseek_client - self.queue_type = queue_type # "active" or "finished" - - # Album metadata for matched downloads - self.album = album - self.track_number = track_number - - # Add completion tracking to prevent duplicate processing - self._completion_processed = False - self._completion_lock = threading.Lock() - - self.setup_ui() - - def mark_completion_processed(self) -> bool: - """Thread-safe method to mark download as completion-processed. - Returns True if this is the first time marking completion, False if already processed.""" - with self._completion_lock: - if self._completion_processed: - return False # Already processed - self._completion_processed = True - return True # First time processing - - def is_completion_processed(self) -> bool: - """Check if completion has already been processed.""" - with self._completion_lock: - return self._completion_processed - - def setup_ui(self): - self.setMinimumHeight(85) # Further increased minimum to give more room for artist names - self.setMaximumHeight(140) # Increased maximum for better text accommodation - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - self.setStyleSheet(""" - CompactDownloadItem { - background: rgba(45, 45, 45, 0.95); - border-radius: 6px; - border: 1px solid rgba(60, 60, 60, 0.6); - margin: 2px 1px; - } - CompactDownloadItem:hover { - background: rgba(55, 55, 55, 1.0); - border: 1px solid rgba(29, 185, 84, 0.5); - } - """) - - # Main vertical layout for better space utilization - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 12, 12, 12) # Further increased margins for better text spacing - layout.setSpacing(10) # Increased spacing between filename and bottom row - - # Top row: Filename with text wrapping - filename_with_ext = self.get_display_filename() - self.filename_label = QLabel(filename_with_ext) - self.filename_label.setFont(QFont("Segoe UI", 10, QFont.Weight.Medium)) - self.filename_label.setStyleSheet("color: #ffffff; background: transparent;") - self.filename_label.setWordWrap(True) # Enable text wrapping - self.filename_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - self.filename_label.setToolTip(filename_with_ext) - - # Bottom row: Uploader, Progress/Status, and Action button - bottom_layout = QHBoxLayout() - bottom_layout.setContentsMargins(0, 2, 0, 0) # Added small top margin for better spacing - bottom_layout.setSpacing(10) # Increased spacing between elements - - # Uploader info - remove fixed width constraint and allow text wrapping - self.uploader_label = QLabel() - self.uploader_label.setFont(QFont("Segoe UI", 9, QFont.Weight.Normal)) - self.uploader_label.setStyleSheet("color: #b8b8b8; background: transparent;") - self.uploader_label.setWordWrap(True) # Enable text wrapping - self.uploader_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) # Changed from Fixed to Minimum - self.uploader_label.setMinimumHeight(20) # Ensure minimum height for text visibility - self.uploader_label.setToolTip(f"Uploader: {self.artist}") - self.uploader_label.setText(self.artist) # Set text directly instead of using ellipsis function - - # Conditional layout based on queue type - PRESERVE ALL EXISTING BUTTON CODE - if self.queue_type == "active": - # Section 3: Progress (KEEP EXISTING PROGRESS WIDGET EXACTLY AS IS) - progress_widget = QWidget() - progress_widget.setFixedWidth(90) - progress_layout = QVBoxLayout(progress_widget) - progress_layout.setContentsMargins(0, 0, 0, 0) - progress_layout.setSpacing(1) - - # Compact progress bar - COMPLETELY UNCHANGED - self.progress_bar = QProgressBar() - self.progress_bar.setFixedHeight(6) - self.progress_bar.setValue(self.progress) - self.progress_bar.setStyleSheet(""" - QProgressBar { - border: none; - border-radius: 3px; - background: rgba(60, 60, 60, 0.8); - } - QProgressBar::chunk { - background: rgba(29, 185, 84, 1.0); - border-radius: 3px; - } - """) - - # Progress percentage - COMPLETELY UNCHANGED - self.progress_label = QLabel(f"{self.progress}%") - self.progress_label.setFont(QFont("Segoe UI", 8)) - self.progress_label.setStyleSheet("color: #c0c0c0;") - self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - progress_layout.addWidget(self.progress_bar) - progress_layout.addWidget(self.progress_label) - - # Section 4: Cancel button - PRESERVE EXACTLY AS IS, NO CHANGES - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.setFixedSize(60, 35) - self.cancel_btn.clicked.connect(self.cancel_download) - self.cancel_btn.setStyleSheet(""" - QPushButton { - background: rgba(220, 53, 69, 0.9); - color: white; - border: 1px solid rgba(220, 53, 69, 0.6); - border-radius: 4px; - font-size: 9px; - font-weight: 500; - } - QPushButton:hover { - background: rgba(240, 73, 89, 1.0); - } - QPushButton:pressed { - background: rgba(200, 43, 58, 1.0); - } - """) - - # Add to bottom layout - bottom_layout.addWidget(self.uploader_label, 1) - bottom_layout.addWidget(progress_widget) - bottom_layout.addWidget(self.cancel_btn) - - else: - # Finished downloads - display a different widget based on the final status. - self.progress_bar = None - self.progress_label = None - - final_status = self.status.lower() - - action_widget = QWidget() - action_layout = QHBoxLayout(action_widget) - action_layout.setContentsMargins(0, 0, 0, 0) - action_layout.setAlignment(Qt.AlignmentFlag.AlignRight) - action_widget.setFixedWidth(70) - - if 'completed' in final_status or 'succeeded' in final_status: - # For successfully completed downloads, show the 'Open' button. - open_btn = QPushButton("Open") - open_btn.setFixedSize(60, 35) - open_btn.clicked.connect(self.open_download_location) - open_btn.setStyleSheet(""" - QPushButton { - background: rgba(40, 167, 69, 0.9); color: white; border: 1px solid rgba(29, 185, 84, 0.6); - border-radius: 4px; font-size: 9px; font-weight: 500; - } - QPushButton:hover { background: rgba(50, 187, 79, 1.0); } - QPushButton:pressed { background: rgba(32, 140, 58, 1.0); } - """) - action_layout.addWidget(open_btn) - - elif 'cancelled' in final_status or 'canceled' in final_status: - # For cancelled downloads, show a "Cancelled" label. - status_label = QLabel("Cancelled") - status_label.setStyleSheet("color: #ffa500; font-weight: bold; font-size: 10px;") - status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - action_layout.addWidget(status_label) - - elif 'failed' in final_status or 'errored' in final_status: - # For failed or errored downloads, show a "Failed" label. - status_label = QLabel("Failed") - status_label.setStyleSheet("color: #e22134; font-weight: bold; font-size: 10px;") - status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - action_layout.addWidget(status_label) - - else: # Fallback for any other unexpected status - open_btn = QPushButton("Open") - open_btn.setFixedSize(60, 35) - open_btn.clicked.connect(self.open_download_location) - action_layout.addWidget(open_btn) - - # Add the uploader label and the new action_widget to the layout. - bottom_layout.addWidget(self.uploader_label, 1) - bottom_layout.addWidget(action_widget) - - # Add both sections to main layout - layout.addWidget(self.filename_label) - layout.addLayout(bottom_layout) - - def _set_ellipsis_text(self, label, text, max_width): - """Set text with ellipsis if it's too long for the given width""" - font_metrics = label.fontMetrics() - # Reserve some padding space (8px total) - available_width = max_width - 8 - - if font_metrics.horizontalAdvance(text) <= available_width: - label.setText(text) - else: - # Truncate with ellipsis - ellipsis_width = font_metrics.horizontalAdvance("...") - available_for_text = available_width - ellipsis_width - - # Binary search for the right length - left, right = 0, len(text) - while left < right: - mid = (left + right + 1) // 2 - if font_metrics.horizontalAdvance(text[:mid]) <= available_for_text: - left = mid - else: - right = mid - 1 - - truncated_text = text[:left] + "..." - label.setText(truncated_text) - - def get_display_filename(self): - """Extract just the filename with extension for display""" - if self.file_path: - from pathlib import Path - try: - return Path(self.file_path).name - except: - pass - # Fallback to title if no file_path or error - return self.title if self.title else "Unknown File" - - def get_status_text(self): - """Get appropriate status text for display""" - status_mapping = { - "completed, succeeded": "Done", - "completed, cancelled": "Cancelled", - "completed": "Done", - "cancelled": "Cancelled", - "downloading": f"{self.progress}%", - "failed": "Failed", - "queued": "Queued" - } - return status_mapping.get(self.status.lower(), self.status.title()) - - def update_status(self, status: str, progress: int = None, download_speed: int = None, file_path: str = None): - """Update the status and progress of the download item""" - self.status = status - if progress is not None: - self.progress = progress - if download_speed is not None: - self.download_speed = download_speed - if file_path: - self.file_path = file_path - # Update filename display if file_path changed - if hasattr(self, 'filename_label') and self.filename_label: - try: - filename_with_ext = self.get_display_filename() - self.filename_label.setText(filename_with_ext) - self.filename_label.setToolTip(filename_with_ext) - except RuntimeError: - # Qt object has been deleted, skip update - pass - - # Update progress components for active downloads only - if self.queue_type == "active": - if hasattr(self, 'progress_bar') and self.progress_bar: - try: - self.progress_bar.setValue(self.progress) - except RuntimeError: - # Qt object has been deleted, skip update - pass - if hasattr(self, 'progress_label') and self.progress_label: - try: - self.progress_label.setText(f"{self.progress}%") - except RuntimeError: - # Qt object has been deleted, skip update - pass - - # Update cancel button state based on status - if hasattr(self, 'cancel_btn') and self.cancel_btn: - try: - if status.lower() in ['cancelled', 'canceled', 'failed']: - # Disable button and update text for cancelled/failed downloads - self.cancel_btn.setText("Cancelled") - self.cancel_btn.setEnabled(False) - self.cancel_btn.setStyleSheet(""" - QPushButton { - background: rgba(100, 100, 100, 0.5); - color: rgba(255, 255, 255, 0.6); - border: 1px solid rgba(100, 100, 100, 0.4); - border-radius: 4px; - font-size: 9px; - font-weight: 500; - } - """) - elif status.lower() in ['downloading', 'queued']: - # Re-enable button for active downloads - self.cancel_btn.setText("Cancel") - self.cancel_btn.setEnabled(True) - self.cancel_btn.setStyleSheet(""" - QPushButton { - background: rgba(220, 53, 69, 0.9); - color: white; - border: 1px solid rgba(220, 53, 69, 0.6); - border-radius: 4px; - font-size: 9px; - font-weight: 500; - } - QPushButton:hover { - background: rgba(240, 73, 89, 1.0); - } - QPushButton:pressed { - background: rgba(200, 43, 58, 1.0); - } - """) - except RuntimeError: - # Qt object has been deleted, skip update - pass - - def cancel_download(self): - """Cancel the download using soulseek client""" - print(f"[DEBUG] Cancel button clicked - download_id: {self.download_id}, username: {self.username}, title: {self.title}") - if self.soulseek_client and self.download_id: - print(f"Cancelling download: {self.download_id}") - - # Find the parent DownloadsPage to use its async helper - parent_page = self.parent() - while parent_page and not hasattr(parent_page, '_run_async_operation'): - parent_page = parent_page.parent() - - if parent_page: - # Use the parent's async helper for safe event loop management - def on_success(result): - print(f"[DEBUG] Cancel result: {result}") - if result: - print(f"Successfully cancelled download: {self.title}") - self.update_status("cancelled") - else: - print(f"Failed to cancel download: {self.title}") - - def on_error(error): - print(f"Failed to cancel download: {error}") - - parent_page._run_async_operation( - self.soulseek_client.cancel_download, - self.download_id, self.username, - success_callback=on_success, - error_callback=on_error - ) - else: - print(f"[ERROR] Could not find parent DownloadsPage for async operation") - else: - print(f"[DEBUG] Cancel failed - soulseek_client: {self.soulseek_client}, download_id: {self.download_id}") - - def retry_download(self): - """Retry a failed download""" - print(f"Retrying download: {self.title}") - # This would trigger a new download attempt - # Implementation depends on how retries are handled in the main system - self.update_status("queued", 0) - - def open_download_location(self): - """Open the download location in file explorer""" - import os - import platform - from pathlib import Path - - print(f"[DEBUG] Open button clicked - file_path: {self.file_path}, title: {self.title}") - - if not self.file_path: - print(f"[DEBUG] No file_path set for download: {self.title}") - # Fallback to opening the general downloads folder - try: - from config.settings import config_manager - download_path = config_manager.get('soulseek.download_path', './downloads') - - system = platform.system() - if system == "Windows": - os.startfile(download_path) - elif system == "Darwin": # macOS - os.system(f'open "{download_path}"') - else: # Linux - os.system(f'xdg-open "{download_path}"') - - print(f"Opened downloads folder: {download_path}") - except Exception as e: - print(f"Failed to open downloads folder: {e}") - return - - try: - file_path = Path(self.file_path) - print(f"[DEBUG] Checking file existence: {file_path}") - - if file_path.exists(): - folder_path = file_path.parent - print(f"[DEBUG] Opening folder: {folder_path}") - - system = platform.system() - if system == "Windows": - os.startfile(folder_path) - elif system == "Darwin": # macOS - os.system(f"open '{folder_path}'") - else: # Linux - os.system(f"xdg-open '{folder_path}'") - - print(f"Opened folder: {folder_path}") - else: - print(f"File not found: {file_path}") - # Try to find the file in the downloads directory using the filename - filename = os.path.basename(self.file_path) - print(f"[DEBUG] Searching for file: {filename}") - - from config.settings import config_manager - download_path = config_manager.get('soulseek.download_path', './downloads') - - # Search for the file in the downloads directory tree - found_file = None - for root, dirs, files in os.walk(download_path): - for file in files: - if file == filename: - found_file = os.path.join(root, file) - print(f"[DEBUG] Found file at: {found_file}") - break - if found_file: - break - - if found_file: - folder_path = os.path.dirname(found_file) - print(f"[DEBUG] Opening found folder: {folder_path}") - - system = platform.system() - if system == "Windows": - os.startfile(folder_path) - elif system == "Darwin": # macOS - os.system(f'open "{folder_path}"') - else: # Linux - os.system(f'xdg-open "{folder_path}"') - - print(f"Opened folder: {folder_path}") - else: - print(f"Could not find file {filename} in downloads directory") - # Fallback to opening the downloads folder - system = platform.system() - if system == "Windows": - os.startfile(download_path) - elif system == "Darwin": # macOS - os.system(f'open "{download_path}"') - else: # Linux - os.system(f'xdg-open "{download_path}"') - - print(f"Opened downloads folder as fallback: {download_path}") - - except Exception as e: - print(f"Failed to open download location: {e}") - -class DownloadQueue(QFrame): - def __init__(self, title="Download Queue", queue_type="active", parent=None): - super().__init__(parent) - self.queue_title = title - self.queue_type = queue_type # "active" or "finished" - - # Widget lifecycle optimization - batch widget deletions - self.deletion_timer = QTimer() - self.deletion_timer.setSingleShot(True) - self.deletion_timer.timeout.connect(self._process_pending_deletions) - self.deletion_timer.setInterval(100) # 100ms delay for batching - self.pending_deletions = [] - - self.setup_ui() - - def setup_ui(self): - self.setStyleSheet(""" - DownloadQueue { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(45, 45, 45, 0.9), - stop:1 rgba(35, 35, 35, 0.95)); - border-radius: 10px; - border: 1px solid rgba(80, 80, 80, 0.5); - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 8, 12, 12) # Slightly increased top padding - layout.setSpacing(8) # Increased spacing for better visual breathing room - - # Header - header_layout = QHBoxLayout() - header_layout.setContentsMargins(0, 0, 0, 0) - - self.title_label = QLabel(self.queue_title) - self.title_label.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) - self.title_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.95); - font-weight: 600; - padding: 0; - margin: 0; - """) - - queue_count = QLabel("Empty") - queue_count.setFont(QFont("Segoe UI", 9)) - queue_count.setStyleSheet(""" - color: rgba(255, 255, 255, 0.6); - padding: 0; - margin: 0; - """) - - header_layout.addWidget(self.title_label) - header_layout.addStretch() - header_layout.addWidget(queue_count) - - # Queue list - queue_scroll = QScrollArea() - queue_scroll.setWidgetResizable(True) - queue_scroll.setMinimumHeight(200) - queue_scroll.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - padding: 0px; - margin: 0px; - } - QScrollArea > QWidget > QWidget { - background: transparent; - } - QScrollBar:vertical { - background: #404040; - width: 8px; - border-radius: 4px; - margin: 0px; - } - QScrollBar::handle:vertical { - background: #1db954; - border-radius: 4px; - margin: 0px; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - border: none; - background: none; - height: 0px; - } - """) - - queue_widget = QWidget() - queue_layout = QVBoxLayout(queue_widget) - queue_layout.setContentsMargins(0, 0, 0, 0) # Remove any internal margins - queue_layout.setSpacing(6) # Increased spacing between download items for better readability - - # Dynamic download items - initially empty - self.queue_layout = queue_layout - self.queue_count_label = queue_count - self.download_items = [] - - # Add initial message when queue is empty - self.empty_message = QLabel("No downloads yet.") - self.empty_message.setFont(QFont("Arial", 10)) - self.empty_message.setStyleSheet("color: rgba(255, 255, 255, 0.5); padding: 15px; text-align: center;") - self.empty_message.setAlignment(Qt.AlignmentFlag.AlignCenter) - queue_layout.addWidget(self.empty_message) - - queue_layout.addStretch() - queue_scroll.setWidget(queue_widget) - - layout.addLayout(header_layout) - layout.addWidget(queue_scroll) - - def add_download_item(self, title: str, artist: str, status: str = "queued", - progress: int = 0, file_size: int = 0, download_speed: int = 0, - file_path: str = "", download_id: str = "", username: str = "", - soulseek_client=None, album: str = None, track_number: int = None): - """Add a new download item to the queue""" - # Hide empty message if this is the first item - if len(self.download_items) == 0: - self.empty_message.hide() - - # Create new compact download item with queue type - item = CompactDownloadItem(title, artist, status, progress, file_size, download_speed, - file_path, download_id, username, soulseek_client, self.queue_type, - album, track_number) - self.download_items.append(item) - - # Insert before the stretch (which is always last) - insert_index = self.queue_layout.count() - 1 - self.queue_layout.insertWidget(insert_index, item) - - # Update count - self.update_queue_count() - - return item - - def update_queue_count(self): - """Update the queue count label""" - count = len(self.download_items) - if count == 0: - self.queue_count_label.setText("Empty") - if not self.empty_message.isHidden(): - self.empty_message.show() - else: - self.queue_count_label.setText(f"{count} item{'s' if count != 1 else ''}") - - def remove_download_item(self, item): - """Remove a download item from the queue""" - print(f"[DEBUG] remove_download_item() called for '{item.title}' with status '{item.status}'") - print(f"[DEBUG] Queue has {len(self.download_items)} items before removal") - - if item in self.download_items: - print(f"[DEBUG] Item found in download_items list, removing...") - self.download_items.remove(item) - print(f"[DEBUG] Removed from download_items list. New count: {len(self.download_items)}") - - print(f"[DEBUG] Removing widget from queue_layout...") - self.queue_layout.removeWidget(item) - print(f"[DEBUG] Scheduling widget deletion...") - self._schedule_widget_deletion(item) - - print(f"[DEBUG] Updating queue count...") - self.update_queue_count() - - # Notify parent download manager to update tab counts - print(f"[DEBUG] Finding parent to update tab counts...") - parent_widget = self.parent() - while parent_widget and not hasattr(parent_widget, 'update_tab_counts'): - parent_widget = parent_widget.parent() - if parent_widget and hasattr(parent_widget, 'update_tab_counts'): - print(f"[DEBUG] Calling parent.update_tab_counts()...") - parent_widget.update_tab_counts() - else: - print(f"[DEBUG] No parent with update_tab_counts found") - - print(f"[DEBUG] remove_download_item() completed for '{item.title}'") - else: - print(f"[DEBUG] Item '{item.title}' NOT found in download_items list!") - - def _schedule_widget_deletion(self, widget): - """Schedule a widget for batched deletion to improve performance""" - self.pending_deletions.append(widget) - if not self.deletion_timer.isActive(): - self.deletion_timer.start() - - def _process_pending_deletions(self): - """Process all pending widget deletions in a batch""" - print(f"[DEBUG] Processing {len(self.pending_deletions)} pending widget deletions") - for widget in self.pending_deletions: - try: - widget.deleteLater() - except Exception as e: - print(f"[DEBUG] Error deleting widget: {e}") - self.pending_deletions.clear() - - def clear_completed_downloads(self): - """Remove all completed and cancelled download items""" - print(f"[DEBUG] DownloadQueue.clear_completed_downloads() called with {len(self.download_items)} items") - items_to_remove = [] - - for item in self.download_items: - print(f"[DEBUG] Checking item '{item.title}' with status '{item.status}'") - - # Normalize status for comparison (handle compound statuses like "Completed, Succeeded") - status_lower = item.status.lower() - should_remove = False - - # Check for exact matches - # Check for terminal states with the correct priority to ensure proper cleanup. - # Cancelled and Failed must be checked BEFORE more general states like Completed. - if any(keyword in status_lower for keyword in ["cancelled", "canceled", "failed", "errored"]): - should_remove = True - print(f"[DEBUG] Matched terminal state (Cancelled/Failed): '{item.status}'") - elif any(keyword in status_lower for keyword in ["completed", "finished", "succeeded"]): - should_remove = True - print(f"[DEBUG] Matched terminal state (Completed): '{item.status}'") - - if should_remove: - print(f"[DEBUG] Item '{item.title}' marked for removal (status: '{item.status}')") - items_to_remove.append(item) - else: - print(f"[DEBUG] Item '{item.title}' NOT marked for removal (status: '{item.status}')") - - print(f"[DEBUG] Removing {len(items_to_remove)} items from queue") - for item in items_to_remove: - print(f"[DEBUG] Removing item: '{item.title}'") - self.remove_download_item(item) - - print(f"[DEBUG] DownloadQueue.clear_completed_downloads() finished. Remaining items: {len(self.download_items)}") - -class TabbedDownloadManager(QTabWidget): - """Tabbed interface for managing active and finished downloads""" - - def __init__(self, parent=None): - super().__init__(parent) - - # UI update batching to prevent excessive updates during transitions - self.update_timer = QTimer() - self.update_timer.setSingleShot(True) - self.update_timer.timeout.connect(self._perform_batched_update) - self.update_timer.setInterval(50) # 50ms batch window - self.pending_updates = set() - - self.setup_ui() - - def setup_ui(self): - """Setup the tabbed interface with active and finished download queues""" - self.setStyleSheet(""" - QTabWidget::pane { - border: 1px solid #404040; - border-radius: 8px; - background: #282828; - padding: 0px; - margin: 0px; - } - QTabWidget::tab-bar { - alignment: center; - } - QTabBar::tab { - background: #404040; - color: #ffffff; - border: 1px solid #606060; - border-bottom: none; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - padding: 6px 12px; - margin-right: 1px; - font-size: 10px; - font-weight: bold; - min-width: 80px; - } - QTabBar::tab:selected { - background: #1db954; - color: #000000; - border: 1px solid #1db954; - } - QTabBar::tab:hover:!selected { - background: #505050; - } - """) - - # Create two download queues with appropriate titles and queue types - self.active_queue = DownloadQueue("Active Downloads", "active") - self.finished_queue = DownloadQueue("Finished Downloads", "finished") - - # Update the finished queue count label - self.finished_queue.queue_count_label.setText("Empty") - - # Add tabs - self.addTab(self.active_queue, "Download Queue") - self.addTab(self.finished_queue, "Finished Downloads") - - # Set initial tab counts - self.update_tab_counts() - - def add_download_item(self, title: str, artist: str, status: str = "queued", - progress: int = 0, file_size: int = 0, download_speed: int = 0, - file_path: str = "", download_id: str = "", username: str = "", - soulseek_client=None, album: str = None, track_number: int = None): - """Add a new download item to the active queue""" - item = self.active_queue.add_download_item( - title, artist, status, progress, file_size, download_speed, - file_path, download_id, username, soulseek_client, album, track_number - ) - self.update_tab_counts() - return item - - def move_to_finished(self, download_item): - """Move a download item from active to finished queue""" - - # Performance monitoring - import time - start_time = time.time() - - if download_item in self.active_queue.download_items: - # Remove from active queue - self.active_queue.remove_download_item(download_item) - - # Ensure completed downloads have 100% progress - final_progress = download_item.progress - if download_item.status == 'completed': - final_progress = 100 - print(f"[DEBUG] Ensuring completed download '{download_item.title}' has 100% progress") - - # Add to finished queue - finished_item = self.finished_queue.add_download_item( - title=download_item.title, - artist=download_item.artist, - status=download_item.status, - progress=final_progress, - file_size=download_item.file_size, - download_speed=download_item.download_speed, - file_path=download_item.file_path, - download_id=download_item.download_id, - username=download_item.username, - soulseek_client=download_item.soulseek_client - ) - - # Signal API that download is complete (only for completed downloads) - # Note: Cancelled downloads already have their API signal sent by cancel_download() - try: - if (download_item.status == 'completed' and - download_item.download_id and download_item.username and download_item.soulseek_client): - - # PERFORMANCE FIX: Use dedicated thread for API cleanup to prevent UI blocking - # Find the parent DownloadsPage that manages the API cleanup threads - parent_page = self.parent() - while parent_page and not hasattr(parent_page, 'api_cleanup_threads'): - parent_page = parent_page.parent() - - if parent_page and hasattr(parent_page, 'api_cleanup_threads'): - # Create and start API cleanup thread - cleanup_thread = ApiCleanupThread( - download_item.soulseek_client, - download_item.download_id, - download_item.username - ) - cleanup_thread.cleanup_completed.connect(parent_page.api_cleanup_finished) - cleanup_thread.finished.connect(lambda: self._cleanup_api_thread(cleanup_thread)) - - # Track the thread - parent_page.api_cleanup_threads.append(cleanup_thread) - - # Start the thread - cleanup_thread.start() - - print(f"Started API cleanup thread for download {download_item.download_id}") - else: - print(f"Cannot find parent DownloadsPage for API cleanup thread") - # Fallback: Skip API cleanup to prevent blocking - print(f"Skipping API cleanup for download {download_item.download_id}") - - except Exception as e: - print(f"Error setting up download completion cleanup: {e}") - - self.update_tab_counts() - - # Emit signal for session download tracking - parent_page = self.parent() - while parent_page and not hasattr(parent_page, 'download_session_completed'): - parent_page = parent_page.parent() - if parent_page and hasattr(parent_page, 'download_session_completed'): - parent_page.download_session_completed.emit(download_item.title, download_item.artist) - - # Performance monitoring - end_time = time.time() - duration_ms = (end_time - start_time) * 1000 - print(f"⏱️ move_to_finished completed in {duration_ms:.2f}ms for '{download_item.title}'") - - return finished_item - - # Performance monitoring for early return - end_time = time.time() - duration_ms = (end_time - start_time) * 1000 - print(f"⏱️ move_to_finished early return in {duration_ms:.2f}ms (item not in active queue)") - return None - - def _cleanup_api_thread(self, thread): - """Clean up API cleanup thread when it finishes""" - try: - # Find the parent DownloadsPage that manages the API cleanup threads - parent_page = self.parent() - while parent_page and not hasattr(parent_page, 'api_cleanup_threads'): - parent_page = parent_page.parent() - - if parent_page and hasattr(parent_page, 'api_cleanup_threads'): - if thread in parent_page.api_cleanup_threads: - parent_page.api_cleanup_threads.remove(thread) - - # Clean up the thread - if thread.isRunning(): - thread.wait(1000) # Wait up to 1 second for completion - thread.deleteLater() - - print(f"Cleaned up API cleanup thread") - except Exception as e: - print(f"Error cleaning up API cleanup thread: {e}") - - def update_tab_counts(self): - """Schedule a batched tab count update to prevent excessive UI updates""" - self.pending_updates.add('tab_counts') - if not self.update_timer.isActive(): - self.update_timer.start() - - def _perform_batched_update(self): - """Perform all pending UI updates in a single batch""" - if 'tab_counts' in self.pending_updates: - self._update_tab_counts_immediate() - - # Clear pending updates - self.pending_updates.clear() - - def _update_tab_counts_immediate(self): - """Immediately update tab labels with current counts (internal use only)""" - active_count = len(self.active_queue.download_items) - finished_count = len(self.finished_queue.download_items) - - self.setTabText(0, f"Download Queue ({active_count})") - self.setTabText(1, f"Finished Downloads ({finished_count})") - - # Also update the download manager stats if they exist - # Find the DownloadsPage in the parent hierarchy - parent_widget = self.parent() - while parent_widget and not hasattr(parent_widget, 'update_download_manager_stats'): - parent_widget = parent_widget.parent() - - if parent_widget and hasattr(parent_widget, 'update_download_manager_stats'): - parent_widget.update_download_manager_stats(active_count, finished_count) - else: - print(f"[DEBUG] Could not find parent with update_download_manager_stats method") - - def clear_completed_downloads(self): - """Clear completed and cancelled downloads from both slskd backend and local queues""" - # Delegate to parent (DownloadsPage) which has access to soulseek_client - if hasattr(self.parent(), 'clear_completed_downloads'): - self.parent().clear_completed_downloads() - else: - # Fallback to local clearing if parent method not available - print("[DEBUG] No parent clear method found, clearing locally only") - # Clear from both active and finished queues - self.active_queue.clear_completed_downloads() - self.finished_queue.clear_completed_downloads() - self.update_tab_counts() - - def clear_local_queues_only(self): - """Clear only the local UI queues without backend operations (for use by parent)""" - print("[DEBUG] TabbedDownloadManager.clear_local_queues_only() called") - print(f"[DEBUG] Active queue has {len(self.active_queue.download_items)} items") - print(f"[DEBUG] Finished queue has {len(self.finished_queue.download_items)} items") - - # Clear from both active and finished queues - print("[DEBUG] Clearing active queue...") - self.active_queue.clear_completed_downloads() - print("[DEBUG] Clearing finished queue...") - self.finished_queue.clear_completed_downloads() - print("[DEBUG] Updating tab counts...") - self.update_tab_counts() - - print(f"[DEBUG] After clearing - Active: {len(self.active_queue.download_items)}, Finished: {len(self.finished_queue.download_items)}") - - @property - def download_items(self): - """Return all download items from active queue for compatibility""" - return self.active_queue.download_items - -class DownloadsPage(QWidget): - # Signals for media player communication - track_started = pyqtSignal(object) # Track result object - track_paused = pyqtSignal() - track_resumed = pyqtSignal() - track_stopped = pyqtSignal() - track_finished = pyqtSignal() - track_position_updated = pyqtSignal(float, float) # current_position, duration in seconds - track_loading_started = pyqtSignal(object) # Track result object when streaming starts - track_loading_finished = pyqtSignal(object) # Track result object when streaming completes - track_loading_progress = pyqtSignal(float, object) # Progress percentage (0-100), track result object - - # Signal for dashboard stats tracking - download_session_completed = pyqtSignal(str, str) # Emitted when a download completes (title, artist) - - # Signals for dashboard activity tracking - download_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time - - # Signal for clear completed downloads completion (thread-safe communication) - clear_completed_finished = pyqtSignal(bool, object) # backend_success, ui_callback - - # Signal for API cleanup completion (thread-safe communication) - api_cleanup_finished = pyqtSignal(bool, str, str) # success, download_id, username - - def __init__(self, soulseek_client=None, parent=None): - super().__init__(parent) - self.soulseek_client = soulseek_client - - # --- FIX: Ensure the soulseek_client uses the download path from config --- - if self.soulseek_client: - from config.settings import config_manager - download_path = config_manager.get('soulseek.download_path') - if download_path and hasattr(self.soulseek_client, 'download_path'): - self.soulseek_client.download_path = download_path - print(f"Set soulseek_client download path to: {download_path}") - # --- END FIX --- - - self.search_thread = None - self.explore_thread = None - self.session_thread = None - self.download_threads = [] - self.status_update_threads = [] - self.api_cleanup_threads = [] - self.search_results = [] - self.current_filtered_results = [] - self.download_items = [] - self.displayed_results = 0 - self.results_per_page = 15 - self.is_loading_more = False - self._results_to_load_queue = [] - self.status_processing_pool = QThreadPool() - self.status_processing_pool.setMaxThreadCount(1) - self._is_status_update_running = False - - self.spotify_client = SpotifyClient() - self.matching_engine = MusicMatchingEngine() - - import threading - from concurrent.futures import ThreadPoolExecutor - self.album_cache_lock = threading.Lock() - self.album_groups = {} - self.album_artists = {} - self.album_editions = {} - self.album_name_cache = {} - - self.api_thread_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SpotifyAPI") - self.active_suggestion_threads = set() - - self.completion_thread_pool = QThreadPool() - self.completion_thread_pool.setMaxThreadCount(2) - - self._optimized_api_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="OptimizedAPI") - self._optimized_completion_pool = QThreadPool() - self._optimized_completion_pool.setMaxThreadCount(4) - self._cleanup_pools = [] - - self._queue_manager = ThreadSafeQueueManager() - self._queue_consistency_lock = RLock() - - self.audio_player = AudioPlayer(self) - self.audio_player.playback_finished.connect(self.on_audio_playback_finished) - self.audio_player.playback_error.connect(self.on_audio_playback_error) - self.currently_playing_button = None - self.currently_expanded_item = None - - self.download_status_timer = QTimer() - self.download_status_timer.timeout.connect(self.update_download_status) - self.download_status_timer.start(1000) - - self._use_optimized_systems = False - - self._polling_intervals = { - 'active': 1500, - 'idle': 3000, - 'bulk_pause': 5000 - } - self._current_polling_mode = 'active' - self._bulk_operation_active = False - self._last_active_count = 0 - - self.downloads_to_cleanup = set() - self.individual_downloads_to_cleanup = [] - - self.clear_completed_finished.connect(self._handle_clear_completion) - - self.api_cleanup_finished.connect(self._handle_api_cleanup_completion) - - self.setup_ui() - - def set_toast_manager(self, toast_manager): - """Set the toast manager for showing notifications""" - self.toast_manager = toast_manager - - - - def setup_ui(self): - self.setStyleSheet(""" - DownloadsPage { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(25, 20, 20, 1.0), - stop:1 rgba(15, 15, 15, 1.0)); - } - """) - - main_layout = QVBoxLayout(self) - # Responsive margins that adapt to window size - main_layout.setContentsMargins(20, 16, 20, 20) # Increased for better breathing room - main_layout.setSpacing(16) # Increased spacing for better visual hierarchy - - # Elegant Header - header = self.create_elegant_header() - main_layout.addWidget(header) - - # Main Content Area with responsive splitter - content_splitter = QSplitter(Qt.Orientation.Horizontal) - content_splitter.setChildrenCollapsible(False) # Prevent panels from collapsing completely - - # LEFT: Search & Results section - search_and_results = self.create_search_and_results_section() - search_and_results.setMinimumWidth(400) # Minimum width for usability - content_splitter.addWidget(search_and_results) - - # RIGHT: Controls Panel - controls_panel = self.create_collapsible_controls_panel() - controls_panel.setMinimumWidth(280) # Minimum width for controls - controls_panel.setMaximumWidth(400) # Maximum width to prevent overgrowth - content_splitter.addWidget(controls_panel) - - # Set initial splitter proportions (roughly 70/30) - content_splitter.setSizes([700, 300]) - content_splitter.setStretchFactor(0, 1) # Search results gets priority for extra space - content_splitter.setStretchFactor(1, 0) # Controls panel stays fixed width when possible - - main_layout.addWidget(content_splitter) - - def create_elegant_header(self): - """Create an elegant, minimal header""" - header = QFrame() - header.setMinimumHeight(80) # Minimum height, can grow if needed - header.setMaximumHeight(120) # Maximum to prevent overgrowth - header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - header.setStyleSheet(""" - QFrame { - background: transparent; - border: none; - } - """) - - layout = QHBoxLayout(header) - layout.setContentsMargins(20, 16, 20, 16) # Increased padding for better header prominence - layout.setSpacing(16) # Increased spacing for better hierarchy - - # Icon and Title - title_section = QVBoxLayout() - title_section.setSpacing(6) # Increased for better title hierarchy - - title_label = QLabel("Music Downloads") - title_label.setFont(QFont("Segoe UI", 28, QFont.Weight.Bold)) # Larger for better prominence - title_label.setStyleSheet(""" - color: #ffffff; - font-weight: 700; - letter-spacing: 1px; - """) - - subtitle_label = QLabel("Search, discover, and download high-quality music") - subtitle_label.setFont(QFont("Segoe UI", 13)) - subtitle_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.85); - font-weight: 300; - letter-spacing: 0.5px; - margin-top: 4px; - """) - - title_section.addWidget(title_label) - title_section.addWidget(subtitle_label) - - layout.addLayout(title_section) - layout.addStretch() - - return header - - def create_search_and_results_section(self): - """Create the main search and results area - the star of the show""" - section = QFrame() - section.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(40, 40, 40, 0.4), - stop:1 rgba(30, 30, 30, 0.6)); - border-radius: 16px; - border: 1px solid rgba(64, 64, 64, 0.3); - } - """) - - layout = QVBoxLayout(section) - layout.setContentsMargins(16, 12, 16, 12) # Responsive spacing consistent with main layout - layout.setSpacing(12) # Consistent 12px spacing - - # Elegant Search Bar - search_container = self.create_elegant_search_bar() - layout.addWidget(search_container) - - # Filter Controls (initially hidden until we have results) - self.filter_container = self.create_filter_controls() - self.filter_container.setVisible(False) # Hide until we have search results - layout.addWidget(self.filter_container) - - # Search Status with better visual feedback and loading animations - status_container = QWidget() - status_layout = QHBoxLayout(status_container) - status_layout.setContentsMargins(10, 8, 10, 8) - status_layout.setSpacing(12) - - # Search status label - self.search_status = QLabel("Ready to search • Enter artist, song, or album name") - self.search_status.setFont(QFont("Arial", 11)) - self.search_status.setStyleSheet(""" - color: rgba(255, 255, 255, 0.7); - padding: 2px 8px; - """) - - # Loading animations (initially hidden) - self.bouncing_dots = BouncingDotsWidget() - self.bouncing_dots.setVisible(False) - - self.spinning_circle = SpinningCircleWidget() - self.spinning_circle.setVisible(False) - - # Add to status layout - status_layout.addWidget(self.spinning_circle) - status_layout.addWidget(self.search_status) - status_layout.addWidget(self.bouncing_dots) - status_layout.addStretch() - - # Style the container - status_container.setStyleSheet(""" - QWidget { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 rgba(29, 185, 84, 0.12), - stop:1 rgba(29, 185, 84, 0.08)); - border-radius: 10px; - border: 1px solid rgba(29, 185, 84, 0.25); - } - """) - - layout.addWidget(status_container) - - # Search Results - The main attraction - results_container = QFrame() - results_container.setStyleSheet(""" - QFrame { - background: rgba(20, 20, 20, 0.3); - border-radius: 12px; - border: 1px solid rgba(64, 64, 64, 0.2); - } - """) - - results_layout = QVBoxLayout(results_container) - results_layout.setContentsMargins(16, 12, 16, 16) # Improved responsive spacing for better breathing room - results_layout.setSpacing(12) # Increased spacing for better visual hierarchy - - # Results header - results_header = QLabel("Search Results") - results_header.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold)) - results_header.setStyleSheet(""" - color: rgba(255, 255, 255, 0.95); - font-weight: 600; - padding: 4px 8px; - """) - results_layout.addWidget(results_header) - - # Scrollable results area - this gets ALL remaining space - self.search_results_scroll = QScrollArea() - self.search_results_scroll.setWidgetResizable(True) - self.search_results_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.search_results_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.search_results_scroll.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - border-radius: 8px; - } - QScrollBar:vertical { - background: rgba(64, 64, 64, 0.3); - width: 8px; - border-radius: 4px; - margin: 0; - } - QScrollBar::handle:vertical { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 0.8), - stop:1 rgba(29, 185, 84, 0.6)); - border-radius: 4px; - min-height: 20px; - } - QScrollBar::handle:vertical:hover { - background: rgba(29, 185, 84, 1.0); - } - """) - - self.search_results_widget = QWidget() - self.search_results_layout = QVBoxLayout(self.search_results_widget) - self.search_results_layout.setSpacing(8) # Reduced spacing for more compact search results - self.search_results_layout.setContentsMargins(12, 12, 12, 12) # Increased for better edge spacing - - # Add centered loading animation for search results area - self.results_loading_container = QWidget() - results_loading_layout = QVBoxLayout(self.results_loading_container) - results_loading_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.results_spinning_circle = SpinningCircleWidget() - self.results_loading_label = QLabel("Searching for results...") - self.results_loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.results_loading_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.7); - font-size: 14px; - margin-top: 10px; - """) - - results_loading_layout.addWidget(self.results_spinning_circle, 0, Qt.AlignmentFlag.AlignCenter) - results_loading_layout.addWidget(self.results_loading_label, 0, Qt.AlignmentFlag.AlignCenter) - self.results_loading_container.setVisible(False) # Initially hidden - - # Add to main results layout - self.search_results_layout.addWidget(self.results_loading_container) - self.search_results_layout.addStretch() - self.search_results_scroll.setWidget(self.search_results_widget) - - # Connect scroll detection for automatic loading - scroll_bar = self.search_results_scroll.verticalScrollBar() - scroll_bar.valueChanged.connect(self.on_scroll_changed) - - results_layout.addWidget(self.search_results_scroll) - layout.addWidget(results_container, 1) # This takes all remaining space - - return section - - def create_elegant_search_bar(self): - """Create a beautiful, modern search bar""" - container = QFrame() - container.setFixedHeight(70) - container.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(50, 50, 50, 0.8), - stop:1 rgba(40, 40, 40, 0.9)); - border-radius: 12px; - border: 1px solid rgba(29, 185, 84, 0.3); - } - """) - - layout = QHBoxLayout(container) - layout.setContentsMargins(20, 16, 20, 16) # Increased responsive spacing for better visual balance - layout.setSpacing(16) # Increased spacing for better visual hierarchy - - # Search input with enhanced styling - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')") - self.search_input.setFixedHeight(40) - self.search_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) # Responsive width - self.search_input.returnPressed.connect(self.perform_search) - self.search_input.setStyleSheet(""" - QLineEdit { - background: rgba(60, 60, 60, 0.7); - border: 2px solid rgba(100, 100, 100, 0.3); - border-radius: 20px; - padding: 0 20px; - color: #ffffff; - font-size: 14px; - font-weight: 500; - } - QLineEdit:focus { - border: 2px solid rgba(29, 185, 84, 0.8); - background: rgba(70, 70, 70, 0.9); - } - QLineEdit::placeholder { - color: rgba(255, 255, 255, 0.5); - } - """) - - # Enhanced search button - self.search_btn = QPushButton("Search") - self.search_btn.setFixedSize(120, 40) - self.search_btn.clicked.connect(self.perform_search) - self.search_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(29, 185, 84, 1.0), - stop:1 rgba(24, 156, 71, 1.0)); - border: none; - border-radius: 20px; - color: #000000; - font-size: 13px; - font-weight: bold; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(30, 215, 96, 1.0), - stop:1 rgba(25, 180, 80, 1.0)); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(24, 156, 71, 1.0), - stop:1 rgba(20, 130, 60, 1.0)); - } - QPushButton:disabled { - background: rgba(100, 100, 100, 0.3); - color: rgba(255, 255, 255, 0.3); - } - """) - - # Cancel search button (initially hidden) - self.cancel_search_btn = QPushButton("Cancel") - self.cancel_search_btn.setFixedSize(100, 40) - self.cancel_search_btn.clicked.connect(self.cancel_search) - self.cancel_search_btn.setVisible(False) # Hidden by default - self.cancel_search_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(220, 53, 69, 0.9), - stop:1 rgba(200, 43, 58, 0.9)); - border: none; - border-radius: 20px; - color: #ffffff; - font-size: 13px; - font-weight: bold; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(240, 73, 89, 1.0), - stop:1 rgba(220, 63, 79, 1.0)); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(200, 43, 58, 1.0), - stop:1 rgba(180, 33, 48, 1.0)); - } - """) - - layout.addWidget(self.search_input) - layout.addWidget(self.cancel_search_btn) - layout.addWidget(self.search_btn) - - return container - - def create_filter_controls(self): - """Create elegant collapsible filter controls for Albums vs Singles, File Formats, and Sorting""" - container = QFrame() - container.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(45, 45, 45, 0.6), - stop:1 rgba(35, 35, 35, 0.8)); - border-radius: 10px; - border: 1px solid rgba(80, 80, 80, 0.25); - } - """) - - main_layout = QVBoxLayout(container) - main_layout.setContentsMargins(16, 8, 16, 8) - main_layout.setSpacing(6) - - # Initialize collapse state - self.filters_collapsed = True - - # Toggle button row - toggle_row = QHBoxLayout() - toggle_row.setSpacing(8) - - self.filter_toggle_btn = QPushButton("⏷ Filters") - self.filter_toggle_btn.setFixedHeight(32) - self.filter_toggle_btn.setMinimumWidth(100) - self.filter_toggle_btn.clicked.connect(self.toggle_filter_panel) - self.filter_toggle_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(80, 80, 80, 0.9), - stop:1 rgba(70, 70, 70, 0.95)); - border: 1px solid rgba(100, 100, 100, 0.3); - border-radius: 6px; - color: rgba(255, 255, 255, 0.8); - font-size: 11px; - font-weight: 600; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - letter-spacing: 0.3px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(90, 90, 90, 0.9), - stop:1 rgba(80, 80, 80, 0.95)); - color: rgba(255, 255, 255, 0.9); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(60, 60, 60, 0.9), - stop:1 rgba(50, 50, 50, 0.95)); - } - """) - - toggle_row.addWidget(self.filter_toggle_btn) - toggle_row.addStretch() - main_layout.addLayout(toggle_row) - - # Collapsible content container - self.filter_content = QWidget() - self.filter_content_layout = QVBoxLayout(self.filter_content) - self.filter_content_layout.setContentsMargins(0, 0, 0, 0) - self.filter_content_layout.setSpacing(6) - - # First row: Type filters (Albums vs Singles) - type_row = QHBoxLayout() - type_row.setSpacing(8) - - type_label = QLabel("Type:") - type_label.setStyleSheet(""" - QLabel { - color: rgba(255, 255, 255, 0.8); - font-size: 11px; - font-weight: 600; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - letter-spacing: 0.3px; - } - """) - - # Initialize filter and sort state - self.current_filter = "all" # "all", "albums", "singles" - self.current_format_filter = "all" # "all", "flac", "mp3", "ogg", "aac", "wma" - self.current_sort = "relevance" # "relevance", "quality", "size", "name", "uploader", "bitrate", "duration", "availability", "speed" - self.reverse_order = False # False = normal order, True = reverse order - self.current_search_query = "" # Store search query for relevance calculation - - # Type filter buttons - self.filter_all_btn = QPushButton("All") - self.filter_albums_btn = QPushButton("Albums") - self.filter_singles_btn = QPushButton("Singles") - - # Store type buttons for easy access - self.filter_buttons = { - "all": self.filter_all_btn, - "albums": self.filter_albums_btn, - "singles": self.filter_singles_btn - } - - # Connect type button signals - self.filter_all_btn.clicked.connect(lambda: self.set_filter("all")) - self.filter_albums_btn.clicked.connect(lambda: self.set_filter("albums")) - self.filter_singles_btn.clicked.connect(lambda: self.set_filter("singles")) - - # Apply styling to type buttons - for btn_key, btn in self.filter_buttons.items(): - btn.setFixedHeight(28) - btn.setMinimumWidth(60) - self.update_filter_button_style(btn, btn_key == "all") - - type_row.addWidget(type_label) - type_row.addWidget(self.filter_all_btn) - type_row.addWidget(self.filter_albums_btn) - type_row.addWidget(self.filter_singles_btn) - type_row.addStretch() - - # Second row: Format filters - format_row = QHBoxLayout() - format_row.setSpacing(8) - - format_label = QLabel("Format:") - format_label.setStyleSheet(""" - QLabel { - color: rgba(255, 255, 255, 0.8); - font-size: 11px; - font-weight: 600; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - letter-spacing: 0.3px; - } - """) - - # Format filter buttons - self.format_all_btn = QPushButton("All") - self.format_flac_btn = QPushButton("FLAC") - self.format_mp3_btn = QPushButton("MP3") - self.format_ogg_btn = QPushButton("OGG") - self.format_aac_btn = QPushButton("AAC") - self.format_wma_btn = QPushButton("WMA") - - # Store format buttons for easy access - self.format_buttons = { - "all": self.format_all_btn, - "flac": self.format_flac_btn, - "mp3": self.format_mp3_btn, - "ogg": self.format_ogg_btn, - "aac": self.format_aac_btn, - "wma": self.format_wma_btn - } - - # Connect format button signals - self.format_all_btn.clicked.connect(lambda: self.set_format_filter("all")) - self.format_flac_btn.clicked.connect(lambda: self.set_format_filter("flac")) - self.format_mp3_btn.clicked.connect(lambda: self.set_format_filter("mp3")) - self.format_ogg_btn.clicked.connect(lambda: self.set_format_filter("ogg")) - self.format_aac_btn.clicked.connect(lambda: self.set_format_filter("aac")) - self.format_wma_btn.clicked.connect(lambda: self.set_format_filter("wma")) - - # Apply styling to format buttons - for btn_key, btn in self.format_buttons.items(): - btn.setFixedHeight(28) - btn.setMinimumWidth(50) - self.update_filter_button_style(btn, btn_key == "all") - - format_row.addWidget(format_label) - format_row.addWidget(self.format_all_btn) - format_row.addWidget(self.format_flac_btn) - format_row.addWidget(self.format_mp3_btn) - format_row.addWidget(self.format_ogg_btn) - format_row.addWidget(self.format_aac_btn) - format_row.addWidget(self.format_wma_btn) - format_row.addStretch() - - # Third row: Sorting controls - sort_row = QHBoxLayout() - sort_row.setSpacing(8) - - sort_label = QLabel("Sort by:") - sort_label.setStyleSheet(""" - QLabel { - color: rgba(255, 255, 255, 0.8); - font-size: 11px; - font-weight: 600; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - letter-spacing: 0.3px; - } - """) - - # Reverse order toggle button - simple arrows - self.reverse_order_btn = QPushButton("↓") - self.reverse_order_btn.setFixedSize(28, 28) # Square button - self.reverse_order_btn.clicked.connect(self.toggle_reverse_order) - self.update_filter_button_style(self.reverse_order_btn, False) # Start inactive - - # Sort buttons - self.sort_relevance_btn = QPushButton("Relevance") - self.sort_quality_btn = QPushButton("Quality") - self.sort_size_btn = QPushButton("Size") - self.sort_name_btn = QPushButton("Name") - self.sort_uploader_btn = QPushButton("Uploader") - self.sort_bitrate_btn = QPushButton("Bitrate") - self.sort_duration_btn = QPushButton("Duration") - self.sort_availability_btn = QPushButton("Available") - self.sort_speed_btn = QPushButton("Speed") - - # Store sort buttons for easy access - self.sort_buttons = { - "relevance": self.sort_relevance_btn, - "quality": self.sort_quality_btn, - "size": self.sort_size_btn, - "name": self.sort_name_btn, - "uploader": self.sort_uploader_btn, - "bitrate": self.sort_bitrate_btn, - "duration": self.sort_duration_btn, - "availability": self.sort_availability_btn, - "speed": self.sort_speed_btn - } - - # Connect sort button signals - self.sort_relevance_btn.clicked.connect(lambda: self.set_sort("relevance")) - self.sort_quality_btn.clicked.connect(lambda: self.set_sort("quality")) - self.sort_size_btn.clicked.connect(lambda: self.set_sort("size")) - self.sort_name_btn.clicked.connect(lambda: self.set_sort("name")) - self.sort_uploader_btn.clicked.connect(lambda: self.set_sort("uploader")) - self.sort_bitrate_btn.clicked.connect(lambda: self.set_sort("bitrate")) - self.sort_duration_btn.clicked.connect(lambda: self.set_sort("duration")) - self.sort_availability_btn.clicked.connect(lambda: self.set_sort("availability")) - self.sort_speed_btn.clicked.connect(lambda: self.set_sort("speed")) - - # Apply styling to sort buttons - for btn_key, btn in self.sort_buttons.items(): - btn.setFixedHeight(28) - btn.setMinimumWidth(55) - self.update_filter_button_style(btn, btn_key == "relevance") - - sort_row.addWidget(sort_label) - sort_row.addWidget(self.reverse_order_btn) - sort_row.addWidget(self.sort_relevance_btn) - sort_row.addWidget(self.sort_quality_btn) - sort_row.addWidget(self.sort_size_btn) - sort_row.addWidget(self.sort_name_btn) - sort_row.addWidget(self.sort_uploader_btn) - sort_row.addWidget(self.sort_bitrate_btn) - sort_row.addWidget(self.sort_duration_btn) - sort_row.addWidget(self.sort_availability_btn) - sort_row.addWidget(self.sort_speed_btn) - sort_row.addStretch() - - # Add all filter rows to the collapsible content - self.filter_content_layout.addLayout(type_row) - self.filter_content_layout.addLayout(format_row) - self.filter_content_layout.addLayout(sort_row) - - # Add collapsible content to main layout - main_layout.addWidget(self.filter_content) - - # Start collapsed - self.filter_content.setVisible(False) - container.setFixedHeight(50) # Height for toggle button only - - return container - - def toggle_filter_panel(self): - """Toggle the filter panel between collapsed and expanded states""" - self.filters_collapsed = not self.filters_collapsed - - if self.filters_collapsed: - # Collapse - self.filter_content.setVisible(False) - self.filter_toggle_btn.setText("⏷ Filters") - self.filter_container.setFixedHeight(50) - else: - # Expand - self.filter_content.setVisible(True) - self.filter_toggle_btn.setText("⏶ Filters") - self.filter_container.setFixedHeight(175) # Height for all content - - def update_filter_button_style(self, button, is_active): - """Update the visual style of filter buttons based on active state""" - if is_active: - button.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 #1ed760, - stop:1 #1db954); - border: none; - border-radius: 16px; - color: #000000; - font-size: 11px; - font-weight: 700; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - padding: 0 12px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 #1fdf64, - stop:1 #1ed760); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 #1ca851, - stop:1 #169c46); - } - """) - else: - button.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(80, 80, 80, 0.4), - stop:1 rgba(60, 60, 60, 0.6)); - border: 1px solid rgba(120, 120, 120, 0.3); - border-radius: 16px; - color: rgba(255, 255, 255, 0.8); - font-size: 11px; - font-weight: 500; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - padding: 0 12px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(100, 100, 100, 0.5), - stop:1 rgba(80, 80, 80, 0.7)); - border: 1px solid rgba(140, 140, 140, 0.4); - color: rgba(255, 255, 255, 0.9); - } - QPushButton:pressed { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(60, 60, 60, 0.6), - stop:1 rgba(40, 40, 40, 0.8)); - } - """) - - def set_filter(self, filter_type): - """Set the active filter and update UI""" - self.current_filter = filter_type - - # Update button styles - for btn_key, btn in self.filter_buttons.items(): - self.update_filter_button_style(btn, btn_key == filter_type) - - # Apply the filter to current results - self.apply_filter() - - def set_format_filter(self, format_type): - """Set the current format filter and update button styles""" - self.current_format_filter = format_type - - # Update format button styles - for btn_key, btn in self.format_buttons.items(): - self.update_filter_button_style(btn, btn_key == format_type) - - # Apply the filter to current results - self.apply_filter() - - def set_sort(self, sort_type): - """Set the current sort type and update button styles""" - self.current_sort = sort_type - - # Update sort button styles - for btn_key, btn in self.sort_buttons.items(): - self.update_filter_button_style(btn, btn_key == sort_type) - - # Apply the sort to current results - self.apply_filter() - - def toggle_reverse_order(self): - """Toggle the reverse order setting and update button styles""" - self.reverse_order = not self.reverse_order - - # Update arrow direction and button style - if self.reverse_order: - self.reverse_order_btn.setText("↑") # Up arrow for reverse order - else: - self.reverse_order_btn.setText("↓") # Down arrow for normal order - - self.update_filter_button_style(self.reverse_order_btn, self.reverse_order) - - # Apply the new sort order to current results - self.apply_filter() - - def sort_results(self, results): - """Sort search results based on current sort type and reverse order setting""" - if not results or not hasattr(self, 'current_sort'): - return results - - # Define default reverse logic for each sort type (normal behavior) - default_reverse_logic = { - "relevance": True, # High relevance first - "quality": True, # High quality first - "size": True, # Large files first - "name": False, # A-Z alphabetical - "uploader": False, # A-Z alphabetical - "bitrate": True, # High bitrate first - "duration": True, # Long duration first - "availability": True, # More available first - "speed": True # Fast speed first - } - - # Get the default reverse setting for current sort type - default_reverse = default_reverse_logic.get(self.current_sort, False) - - # Apply user's reverse order toggle (XOR logic) - # If reverse_order is True, flip the default behavior - final_reverse = default_reverse if not self.reverse_order else not default_reverse - - # Apply the appropriate sorting - if self.current_sort == "relevance": - sorted_results = sorted(results, key=self._sort_by_relevance, reverse=final_reverse) - elif self.current_sort == "quality": - sorted_results = sorted(results, key=self._sort_by_quality, reverse=final_reverse) - elif self.current_sort == "size": - sorted_results = sorted(results, key=self._sort_by_size, reverse=final_reverse) - elif self.current_sort == "name": - sorted_results = sorted(results, key=self._sort_by_name, reverse=final_reverse) - elif self.current_sort == "uploader": - sorted_results = sorted(results, key=self._sort_by_uploader, reverse=final_reverse) - elif self.current_sort == "bitrate": - sorted_results = sorted(results, key=self._sort_by_bitrate, reverse=final_reverse) - elif self.current_sort == "duration": - sorted_results = sorted(results, key=self._sort_by_duration, reverse=final_reverse) - elif self.current_sort == "availability": - sorted_results = sorted(results, key=self._sort_by_availability, reverse=final_reverse) - elif self.current_sort == "speed": - sorted_results = sorted(results, key=self._sort_by_speed, reverse=final_reverse) - else: - sorted_results = results - - return sorted_results - - def _sort_by_relevance(self, result): - """Sort by relevance score combining search matching, quality, completeness, and availability""" - if not hasattr(self, 'current_search_query') or not self.current_search_query: - # Fallback to quality score if no search query - return self._sort_by_quality(result) - - score = 0.0 - query_terms = self.current_search_query.lower().split() - - # 1. Search Term Matching (40% weight - 0.4 max) - search_score = self._calculate_search_match_score(result, query_terms) - score += search_score * 0.4 - - # 2. Quality Score (25% weight - 0.25 max) - quality_score = self._sort_by_quality(result) - score += quality_score * 0.25 - - # 3. File Completeness (20% weight - 0.2 max) - completeness_score = self._calculate_completeness_score(result) - score += completeness_score * 0.2 - - # 4. User Reliability (10% weight - 0.1 max) - reliability_score = self._calculate_reliability_score(result) - score += reliability_score * 0.1 - - # 5. File Freshness (5% weight - 0.05 max) - freshness_score = self._calculate_freshness_score(result) - score += freshness_score * 0.05 - - return score - - def _calculate_search_match_score(self, result, query_terms): - """Calculate search term matching score (0.0 to 1.0)""" - if not query_terms: - return 0.0 - - # Get searchable text - searchable_text = "" - if hasattr(result, 'album_title'): # AlbumResult - searchable_text = f"{result.album_title} {result.artist or ''}" - elif hasattr(result, 'filename'): # TrackResult - searchable_text = f"{result.filename} {result.artist or ''} {result.title or ''} {result.album or ''}" - - searchable_text = searchable_text.lower() - full_query = self.current_search_query.lower() - - score = 0.0 - - # Exact match bonus (1.0 points) - if full_query in searchable_text: - score += 1.0 - - # Individual term matches (0.5 points each) - term_matches = 0 - for term in query_terms: - if term in searchable_text: - term_matches += 1 - score += (term_matches / len(query_terms)) * 0.5 - - # Position bonus (0.3 points if terms appear early) - position_bonus = 0.0 - for term in query_terms: - pos = searchable_text.find(term) - if pos >= 0: - # Earlier positions get higher bonus - position_bonus += max(0, (50 - pos) / 50) * 0.3 - score += position_bonus / len(query_terms) - - return min(score, 1.0) - - def _calculate_completeness_score(self, result): - """Calculate file completeness score (0.0 to 1.0)""" - score = 0.0 - - if hasattr(result, 'tracks'): # AlbumResult - # Complete albums bonus - track_count = len(result.tracks) - if 8 <= track_count <= 20: - score += 0.8 - elif 5 <= track_count <= 25: - score += 0.6 - elif track_count > 25: - score += 0.4 - else: - score += 0.2 - - # Album metadata bonus - if result.artist and result.album_title: - score += 0.2 - else: # TrackResult - # Popular song length bonus - if hasattr(result, 'duration') and result.duration: - if 180 <= result.duration <= 300: # 3-5 minutes - score += 0.6 - elif 120 <= result.duration <= 360: # 2-6 minutes - score += 0.4 - else: - score += 0.2 - else: - score += 0.3 # Default if no duration - - # Track metadata bonus - if result.artist and result.title: - score += 0.4 - elif result.artist or result.title: - score += 0.2 - - return min(score, 1.0) - - def _calculate_reliability_score(self, result): - """Calculate user reliability score (0.0 to 1.0)""" - score = 0.0 - - # High upload speed bonus - if hasattr(result, 'upload_speed'): - if result.upload_speed > 500: - score += 0.3 - elif result.upload_speed > 200: - score += 0.2 - elif result.upload_speed > 100: - score += 0.1 - - # Available slots bonus - if hasattr(result, 'free_upload_slots') and result.free_upload_slots > 0: - score += 0.2 - - # Low queue bonus - if hasattr(result, 'queue_length'): - if result.queue_length < 5: - score += 0.1 - elif result.queue_length > 20: - score -= 0.1 - - return max(0.0, min(score, 1.0)) - - def _calculate_freshness_score(self, result): - """Calculate file freshness/naming quality score (0.0 to 1.0)""" - score = 0.0 - - filename = "" - if hasattr(result, 'album_title'): # AlbumResult - filename = result.album_title - elif hasattr(result, 'filename'): # TrackResult - filename = result.filename - - if filename: - # Proper naming patterns bonus - if any(pattern in filename.lower() for pattern in [' - ', '_', ' / ', ' & ']): - score += 0.2 - - # Standard format bonus - if any(ext in filename.lower() for ext in ['.flac', '.mp3', '.ogg', '.aac']): - score += 0.1 - - # Avoid weird characters penalty - if any(char in filename for char in ['@', '#', '$', '%', '!', '?']): - score -= 0.1 - - # Length bonus (not too short, not too long) - if 10 <= len(filename) <= 100: - score += 0.1 - - return max(0.0, min(score, 1.0)) - - def _sort_by_quality(self, result): - """Sort by quality score (higher is better)""" - if hasattr(result, 'quality_score'): - return result.quality_score - return 0 - - def _sort_by_size(self, result): - """Sort by file/album size (larger first)""" - size = 0 - if hasattr(result, 'total_size'): # AlbumResult - size = result.total_size - elif hasattr(result, 'size'): # TrackResult - size = result.size - return size - - def _sort_by_name(self, result): - """Sort alphabetically by filename/album title""" - name = "" - if hasattr(result, 'album_title'): # AlbumResult - name = result.album_title.lower() - elif hasattr(result, 'filename'): # TrackResult - name = result.filename.lower() - return name - - def _sort_by_uploader(self, result): - """Sort alphabetically by username""" - return result.username.lower() if hasattr(result, 'username') else "" - - def _sort_by_bitrate(self, result): - """Sort by bitrate (higher first)""" - if hasattr(result, 'bitrate') and result.bitrate: - return result.bitrate - # For albums, get average bitrate from tracks - elif hasattr(result, 'tracks') and result.tracks: - bitrates = [track.bitrate for track in result.tracks if track.bitrate] - return sum(bitrates) / len(bitrates) if bitrates else 0 - return 0 - - def _sort_by_duration(self, result): - """Sort by duration (longer first)""" - if hasattr(result, 'duration') and result.duration: - return result.duration - # For albums, sum all track durations - elif hasattr(result, 'tracks') and result.tracks: - durations = [track.duration for track in result.tracks if track.duration] - return sum(durations) if durations else 0 - return 0 - - def _sort_by_availability(self, result): - """Sort by availability (free slots high, queue length low is better)""" - free_slots = result.free_upload_slots if hasattr(result, 'free_upload_slots') else 0 - queue_length = result.queue_length if hasattr(result, 'queue_length') else 0 - # Higher free slots and lower queue length = more available - return free_slots - (queue_length * 0.1) - - def _sort_by_speed(self, result): - """Sort by upload speed (faster first)""" - return result.upload_speed if hasattr(result, 'upload_speed') else 0 - - def apply_filter(self): - """Apply the current type and format filters to search results""" - if not hasattr(self, '_temp_tracks') or not hasattr(self, '_temp_albums'): - return - - # First, filter by type (Albums vs Singles) - if self.current_filter == "all": - type_filtered = self._temp_albums + self._temp_tracks - elif self.current_filter == "albums": - type_filtered = self._temp_albums - elif self.current_filter == "singles": - type_filtered = self._temp_tracks - else: - type_filtered = self._temp_albums + self._temp_tracks - - # Then, filter by format - if self.current_format_filter == "all": - filtered_results = type_filtered - else: - # Filter results by file format - filtered_results = [] - for result in type_filtered: - # For albums, check if any tracks match the format - if hasattr(result, 'tracks') and result.tracks: - # Album result - check if any tracks match format - matching_tracks = [track for track in result.tracks - if track.quality.lower() == self.current_format_filter.lower()] - if matching_tracks: - # Create a copy of the album with only matching tracks - filtered_album = result - filtered_album.tracks = matching_tracks - filtered_results.append(filtered_album) - else: - # Single track result - check format directly - if hasattr(result, 'quality') and result.quality.lower() == self.current_format_filter.lower(): - filtered_results.append(result) - - # Apply sorting to filtered results - sorted_results = self.sort_results(filtered_results) - # Update the filtered results cache for pagination - self.current_filtered_results = sorted_results - - # Clear current display - self.clear_search_results() - self.displayed_results = 0 - self.currently_expanded_item = None # Reset expanded state when applying filters - - # Show sorted results (respecting pagination) - remaining_slots = self.results_per_page - results_to_show = sorted_results[:remaining_slots] - - # Temporarily disable layout updates for smoother batch loading - self.search_results_widget.setUpdatesEnabled(False) - - for result in results_to_show: - if isinstance(result, AlbumResult): - # Create expandable album result item - result_item = AlbumResultItem(result) - result_item.album_download_requested.connect(self.start_album_download) - result_item.matched_album_download_requested.connect(self.start_matched_album_download) - result_item.track_download_requested.connect(self.start_download) - result_item.track_stream_requested.connect(lambda search_result, track_item: self.start_stream(search_result, track_item)) - else: - # Create individual track result item - result_item = SearchResultItem(result) - result_item.download_requested.connect(self.start_download) - result_item.stream_requested.connect(lambda search_result, item=result_item: self.start_stream(search_result, item)) - result_item.expansion_requested.connect(self.handle_expansion_request) - - # Insert before the stretch - insert_position = self.search_results_layout.count() - 1 - self.search_results_layout.insertWidget(insert_position, result_item) - - self.displayed_results = len(results_to_show) - - # Re-enable layout updates - self.search_results_widget.setUpdatesEnabled(True) - - # Update status to show filter results - total_albums = len(self._temp_albums) - total_tracks = len(self._temp_tracks) - total_filtered = len(sorted_results) - - if self.current_filter == "all": - filter_status = f"Showing all {total_filtered} results" - elif self.current_filter == "albums": - filter_status = f"Showing {total_albums} albums" - elif self.current_filter == "singles": - filter_status = f"Showing {total_tracks} singles" - else: - filter_status = f"Showing {total_filtered} results" - - # Update the search status to reflect filtering - if total_filtered > 0: - if total_filtered > self.results_per_page: - filter_status += f" (showing first {len(results_to_show)})" - self.search_status.setText(f"{filter_status} • {total_albums} albums, {total_tracks} singles") - else: - self.search_status.setText(f"No results found for '{self.current_filter}' filter") - - def create_collapsible_controls_panel(self): - """Create a compact, elegant controls panel""" - panel = QFrame() - panel.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(40, 40, 40, 0.85), - stop:1 rgba(25, 25, 25, 0.95)); - border-radius: 18px; - border: 1px solid rgba(80, 80, 80, 0.4); - } - """) - - layout = QVBoxLayout(panel) - layout.setContentsMargins(8, 14, 8, 16) # Reduced side margins for wider download queue - layout.setSpacing(14) # Increased spacing for better section separation - - # Panel header - header = QLabel("Download Manager") - header.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - header.setStyleSheet("color: rgba(255, 255, 255, 0.9); padding: 6px 0; margin: 0;") - layout.addWidget(header) - - # Quick stats with improved styling - stats_frame = QFrame() - stats_frame.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(45, 45, 45, 0.7), - stop:1 rgba(35, 35, 35, 0.8)); - border-radius: 10px; - border: 1px solid rgba(80, 80, 80, 0.3); - } - """) - stats_layout = QVBoxLayout(stats_frame) - stats_layout.setContentsMargins(10, 8, 10, 8) - stats_layout.setSpacing(4) - - self.active_downloads_label = QLabel("• Active Downloads: 0") - self.active_downloads_label.setFont(QFont("Arial", 9)) - self.active_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8); margin: 0; padding: 2px 0;") - - self.finished_downloads_label = QLabel("• Finished Downloads: 0") - self.finished_downloads_label.setFont(QFont("Arial", 9)) - self.finished_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8); margin: 0; padding: 2px 0;") - - stats_layout.addWidget(self.active_downloads_label) - stats_layout.addWidget(self.finished_downloads_label) - layout.addWidget(stats_frame) - - # Control buttons with enhanced styling - controls_frame = QFrame() - controls_frame.setStyleSheet(""" - QFrame { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(50, 50, 50, 0.6), - stop:1 rgba(30, 30, 30, 0.7)); - border-radius: 10px; - border: 1px solid rgba(70, 70, 70, 0.4); - } - """) - controls_layout = QVBoxLayout(controls_frame) - controls_layout.setContentsMargins(10, 10, 10, 10) - controls_layout.setSpacing(6) - - clear_btn = QPushButton("Clear Completed") - clear_btn.setFixedHeight(28) - clear_btn.clicked.connect(self.clear_completed_downloads) - clear_btn.setStyleSheet(self._get_control_button_style("#e22134")) - - controls_layout.addWidget(clear_btn) - layout.addWidget(controls_frame) - - # Download Queue Section - Now with tabs for active and finished downloads - queue_container = QFrame() - queue_container.setStyleSheet(""" - QFrame { - background: transparent; - border: none; - margin-top: 5px; - } - """) - queue_layout = QVBoxLayout(queue_container) - queue_layout.setContentsMargins(0, 0, 0, 0) - - self.download_queue = TabbedDownloadManager(self) - queue_layout.addWidget(self.download_queue) - layout.addWidget(queue_container) - - # Force initial counter update after queue is set up - if self.download_queue: - self.download_queue.update_tab_counts() - - # Initialize stats display - self.update_download_manager_stats(0, 0) - - - return panel - - def update_download_manager_stats(self, active_count, finished_count): - """Update the download manager statistics display""" - if hasattr(self, 'active_downloads_label'): - self.active_downloads_label.setText(f"• Active Downloads: {active_count}") - if hasattr(self, 'finished_downloads_label'): - self.finished_downloads_label.setText(f"• Finished Downloads: {finished_count}") - - def create_compact_status_bar(self): - """Create a minimal status bar""" - status_bar = QFrame() - status_bar.setFixedHeight(40) - status_bar.setStyleSheet(""" - QFrame { - background: rgba(20, 20, 20, 0.8); - border-radius: 8px; - border: 1px solid rgba(64, 64, 64, 0.2); - } - """) - - layout = QHBoxLayout(status_bar) - layout.setContentsMargins(16, 8, 16, 8) - layout.setSpacing(12) - - connection_status = QLabel("slskd Connected") - connection_status.setFont(QFont("Arial", 10)) - connection_status.setStyleSheet("color: rgba(29, 185, 84, 0.9);") - - layout.addWidget(connection_status) - layout.addStretch() - - download_path_info = QLabel(f"Downloads: {self.soulseek_client.download_path if self.soulseek_client else './downloads'}") - download_path_info.setFont(QFont("Arial", 9)) - download_path_info.setStyleSheet("color: rgba(255, 255, 255, 0.6);") - layout.addWidget(download_path_info) - - return status_bar - - def _get_control_button_style(self, color): - """Get consistent button styling with improved aesthetics""" - return f""" - QPushButton {{ - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (40,)}, - stop:1 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (25,)}); - border: 1px solid rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (80,)}; - border-radius: 14px; - color: {color}; - font-size: 10px; - font-weight: 600; - padding: 5px 10px; - text-align: center; - }} - QPushButton:hover {{ - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (60,)}, - stop:1 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (40,)}); - border: 1px solid {color}; - color: #ffffff; - }} - QPushButton:pressed {{ - background: rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (80,)}; - border: 1px solid {color}; - }} - """ - - def create_search_section(self): - section = QFrame() - section.setFixedHeight(350) - section.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - layout = QVBoxLayout(section) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Search header - search_header = QLabel("Search & Download") - search_header.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - search_header.setStyleSheet("color: #ffffff;") - - # Search input and button - search_layout = QHBoxLayout() - - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Search for music (e.g., 'Artist - Song Title')") - self.search_input.setFixedHeight(40) - self.search_input.returnPressed.connect(self.perform_search) - self.search_input.setStyleSheet(""" - QLineEdit { - background: #404040; - border: 1px solid #606060; - border-radius: 20px; - padding: 0 15px; - color: #ffffff; - font-size: 12px; - } - QLineEdit:focus { - border: 1px solid #1db954; - } - """) - - self.search_btn = QPushButton("Search") - self.search_btn.setFixedSize(100, 40) - self.search_btn.clicked.connect(self.perform_search) - self.search_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 20px; - color: #000000; - font-size: 12px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:disabled { - background: #404040; - color: #666666; - } - """) - - search_layout.addWidget(self.search_input) - search_layout.addWidget(self.search_btn) - - # Search status - self.search_status = QLabel("Enter a search term and click Search") - self.search_status.setFont(QFont("Arial", 10)) - self.search_status.setStyleSheet("color: #b3b3b3;") - - # Search results - self.search_results_scroll = QScrollArea() - self.search_results_scroll.setWidgetResizable(True) - self.search_results_scroll.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - } - QScrollBar:vertical { - background: #404040; - width: 8px; - border-radius: 4px; - } - QScrollBar::handle:vertical { - background: #1db954; - border-radius: 4px; - } - """) - - self.search_results_widget = QWidget() - self.search_results_layout = QVBoxLayout(self.search_results_widget) - self.search_results_layout.setSpacing(5) - - # Just add stretch - no load more button needed with auto-scroll - self.search_results_layout.addStretch() - self.search_results_scroll.setWidget(self.search_results_widget) - - layout.addWidget(search_header) - layout.addLayout(search_layout) - layout.addWidget(self.search_status) - layout.addWidget(self.search_results_scroll) - - return section - - def perform_search(self): - query = self.search_input.text().strip() - if not query: - self.update_search_status("Please enter a search term", "#ffa500") - return - - if not self.soulseek_client: - self.update_search_status("Soulseek client not available", "#e22134") - return - - # Stop any existing search - if self.search_thread and self.search_thread.isRunning(): - self.search_thread.stop() - self.search_thread.wait(1000) # Wait up to 1 second - if self.search_thread.isRunning(): - self.search_thread.terminate() - - # Clear previous results and reset state - self.clear_search_results() - self.displayed_results = 0 - self.is_loading_more = False - self.currently_expanded_item = None # Reset expanded state - - # Reset filter to "all" and sort to "relevance", hide filter controls - self.current_filter = "all" - self.current_sort = "relevance" - self.reverse_order = False # Reset reverse order to normal - self.current_search_query = query # Store search query for relevance calculation - if hasattr(self, 'filter_buttons'): - for btn_key, btn in self.filter_buttons.items(): - self.update_filter_button_style(btn, btn_key == "all") - if hasattr(self, 'format_buttons'): - for btn_key, btn in self.format_buttons.items(): - self.update_filter_button_style(btn, btn_key == "all") - if hasattr(self, 'sort_buttons'): - for btn_key, btn in self.sort_buttons.items(): - self.update_filter_button_style(btn, btn_key == "relevance") - if hasattr(self, 'reverse_order_btn'): - self.reverse_order_btn.setText("↓") # Reset to down arrow - self.update_filter_button_style(self.reverse_order_btn, False) # Reset to inactive - self.filter_container.setVisible(False) - - # Enhanced searching state with animation - self.search_btn.setText("Searching...") - self.search_btn.setEnabled(False) - self.update_search_status(f"Searching for '{query}'... Results will appear as they are found", "#1db954") - - # Emit activity signal for search start - self.download_activity.emit("", "Search Started", f"Searching for '{query}'", "Now") - - # Show loading animations - self.start_search_animations() - - # Start new search thread - self.search_thread = SearchThread(self.soulseek_client, query) - self.search_thread.search_completed.connect(self.on_search_completed) - self.search_thread.search_failed.connect(self.on_search_failed) - self.search_thread.search_progress.connect(self.on_search_progress) - self.search_thread.search_results_partial.connect(self.on_search_results_partial) - self.search_thread.finished.connect(self.on_search_thread_finished) - self.search_thread.start() - - # Show cancel button and hide search button during search - self.cancel_search_btn.setVisible(True) - self.search_btn.setVisible(False) - - def cancel_search(self): - """Cancel the current search operation""" - if self.search_thread and self.search_thread.isRunning(): - # Stop the search thread - self.search_thread.stop() - self.search_thread.wait(1000) # Wait up to 1 second - if self.search_thread.isRunning(): - self.search_thread.terminate() - - # Reset UI state - self.search_btn.setText("Search") - self.search_btn.setEnabled(True) - self.search_btn.setVisible(True) - self.cancel_search_btn.setVisible(False) - - # Stop animations and update status - self.stop_search_animations() - self.update_search_status("Search cancelled", "#ffa500") - - def update_search_status(self, message, color="#ffffff"): - """Update search status with enhanced styling""" - self.search_status.setText(message) - - if color == "#1db954": # Success/searching - bg_color = "rgba(29, 185, 84, 0.15)" - border_color = "rgba(29, 185, 84, 0.3)" - elif color == "#ffa500": # Warning - bg_color = "rgba(255, 165, 0, 0.15)" - border_color = "rgba(255, 165, 0, 0.3)" - elif color == "#e22134": # Error - bg_color = "rgba(226, 33, 52, 0.15)" - border_color = "rgba(226, 33, 52, 0.3)" - else: # Default - bg_color = "rgba(100, 100, 100, 0.1)" - border_color = "rgba(100, 100, 100, 0.2)" - - self.search_status.setStyleSheet(f""" - color: {color}; - padding: 2px 8px; - """) - - def start_search_animations(self): - """Start all search loading animations""" - # Show and start status area animations - self.spinning_circle.setVisible(True) - self.spinning_circle.start_animation() - self.bouncing_dots.setVisible(True) - self.bouncing_dots.start_animation() - - # Show and start results area loading - self.results_loading_container.setVisible(True) - self.results_spinning_circle.start_animation() - - def stop_search_animations(self): - """Stop and hide all search loading animations""" - # Stop and hide status area animations - self.spinning_circle.stop_animation() - self.spinning_circle.setVisible(False) - self.bouncing_dots.stop_animation() - self.bouncing_dots.setVisible(False) - - # Stop and hide results area loading - self.results_spinning_circle.stop_animation() - self.results_loading_container.setVisible(False) - - def on_search_thread_finished(self): - """Clean up when search thread finishes""" - if self.search_thread: - self.search_thread.deleteLater() - self.search_thread = None - - def clear_search_results(self): - # Remove all result items except the stretch - for i in reversed(range(self.search_results_layout.count())): - item = self.search_results_layout.itemAt(i) - if item.widget(): - item.widget().deleteLater() - elif item.spacerItem(): - continue # Keep the stretch spacer - else: - self.search_results_layout.removeItem(item) - - def on_search_results_partial(self, tracks, albums, response_count): - """Handle progressive search results as they come in""" - # Combine tracks and albums into a single list for display (albums first, then tracks) - combined_results = albums + tracks - - # Initialize temp results if not exists - if not hasattr(self, '_temp_search_results'): - self._temp_search_results = [] - if not hasattr(self, '_temp_tracks'): - self._temp_tracks = [] - if not hasattr(self, '_temp_albums'): - self._temp_albums = [] - - # Store tracks and albums separately and combined - self._temp_tracks = tracks.copy() # Replace with full updated list - self._temp_albums = albums.copy() # Replace with full updated list - self._temp_search_results = combined_results.copy() - - # Update filtered results cache to match current filter and apply sorting - if hasattr(self, 'current_filter'): - if self.current_filter == "all": - filtered_results = combined_results.copy() - elif self.current_filter == "albums": - filtered_results = albums.copy() - elif self.current_filter == "singles": - filtered_results = tracks.copy() - else: - filtered_results = combined_results.copy() - else: - filtered_results = combined_results.copy() - - # Apply sorting to filtered results - self.current_filtered_results = self.sort_results(filtered_results) - - # Clear existing results and display the updated complete set - # This ensures proper sorting and no duplicates - self.clear_search_results() - self.displayed_results = 0 - - # Only display up to the current page limit - remaining_slots = self.results_per_page - results_to_show = combined_results[:remaining_slots] - - # Temporarily disable layout updates for smoother batch loading - self.search_results_widget.setUpdatesEnabled(False) - - for result in results_to_show: - if isinstance(result, AlbumResult): - # Create expandable album result item - result_item = AlbumResultItem(result) - result_item.album_download_requested.connect(self.start_album_download) - result_item.matched_album_download_requested.connect(self.start_matched_album_download) - result_item.track_download_requested.connect(self.start_download) # Individual track downloads - result_item.track_stream_requested.connect(lambda search_result, track_item: self.start_stream(search_result, track_item)) # Individual track streaming - else: - # Create individual track result item - result_item = SearchResultItem(result) - result_item.download_requested.connect(self.start_download) - result_item.stream_requested.connect(lambda search_result, item=result_item: self.start_stream(search_result, item)) - result_item.expansion_requested.connect(self.handle_expansion_request) - - # Insert before the stretch - insert_position = self.search_results_layout.count() - 1 - self.search_results_layout.insertWidget(insert_position, result_item) - - # Re-enable updates and force layout refresh - self.search_results_widget.setUpdatesEnabled(True) - self.search_results_widget.updateGeometry() - self.search_results_layout.update() - self.search_results_scroll.updateGeometry() - - self.displayed_results = len(results_to_show) - - # Show filter controls during live search when we have meaningful results - total_results = len(tracks) + len(albums) - should_show_filters = ( - # Show if we have both albums and tracks (diverse results) - (len(albums) > 0 and len(tracks) > 0) or - # Or if we have enough results to make filtering useful - total_results >= 5 - ) - - if should_show_filters and not self.filter_container.isVisible(): - self.filter_container.setVisible(True) - - # Update status message with real-time feedback - if self.displayed_results < self.results_per_page: - self.update_search_status(f"Found {total_results} results ({len(tracks)} tracks, {len(albums)} albums) from {response_count} users • Live updating...", "#1db954") - else: - self.update_search_status(f"Found {total_results} results ({len(tracks)} tracks, {len(albums)} albums) from {response_count} users • Showing first {self.results_per_page} (scroll for more)", "#1db954") - - def on_search_completed(self, results): - self.search_btn.setText("Search") - self.search_btn.setEnabled(True) - self.search_btn.setVisible(True) - self.cancel_search_btn.setVisible(False) - - # Stop loading animations - self.stop_search_animations() - - # Use the temp results that have been accumulating during live updates - if hasattr(self, '_temp_tracks') and hasattr(self, '_temp_albums'): - tracks = self._temp_tracks - albums = self._temp_albums - combined_results = self._temp_search_results - elif isinstance(results, tuple) and len(results) == 2: - # Fallback to final results if temp not available - tracks, albums = results - combined_results = albums + tracks - else: - # Fallback for old list format or empty results - tracks = results or [] - albums = [] - combined_results = results or [] - - # Store final results - self.search_results = combined_results - self.current_filtered_results = self.sort_results(combined_results) # Initialize with sorted results - self.track_results = tracks - self.album_results = albums - - total_results = len(combined_results) - - if total_results == 0: - if self.displayed_results == 0: - self.update_search_status("No results found • Try a different search term or artist name", "#ffa500") - else: - self.update_search_status(f"Search completed • Found {self.displayed_results} total results", "#1db954") - # Hide filter controls when no results - self.filter_container.setVisible(False) - return - - # Update status with album/track breakdown - album_count = len(albums) - track_count = len(tracks) - - status_parts = [] - if album_count > 0: - status_parts.append(f"{album_count} album{'s' if album_count != 1 else ''}") - if track_count > 0: - status_parts.append(f"{track_count} track{'s' if track_count != 1 else ''}") - - result_summary = " • ".join(status_parts) if status_parts else f"{total_results} results" - - # Show filter controls when we have results - self.filter_container.setVisible(True) - - # Emit activity signal for search completion - search_query = self.search_input.text().strip() - self.download_activity.emit("", "Search Complete", f"Found {total_results} results for '{search_query}'", "Now") - - # Update status based on whether there are more results to load - if self.displayed_results < total_results: - remaining = total_results - self.displayed_results - self.update_search_status(f"Search completed • Found {result_summary} • Showing first {self.displayed_results} (scroll down for {remaining} more)", "#1db954") - else: - self.update_search_status(f"Search completed • Found {result_summary}", "#1db954") - - def clear_search_results(self): - """Clear all search result items from the layout""" - # Remove all SearchResultItem and AlbumResultItem widgets (but keep stretch) - items_to_remove = [] - for i in range(self.search_results_layout.count()): - item = self.search_results_layout.itemAt(i) - if item and item.widget(): - widget = item.widget() - if isinstance(widget, (SearchResultItem, AlbumResultItem)): - items_to_remove.append(widget) - - for widget in items_to_remove: - self.search_results_layout.removeWidget(widget) - widget.deleteLater() - - def on_scroll_changed(self, value): - """Handle scroll changes to implement lazy loading""" - if self.is_loading_more or not self.current_filtered_results: - return - - scroll_bar = self.search_results_scroll.verticalScrollBar() - - # Check if we're near the bottom (90% scrolled) - if scroll_bar.maximum() > 0: - scroll_percentage = value / scroll_bar.maximum() - - if scroll_percentage >= 0.9 and self.displayed_results < len(self.current_filtered_results): - self.load_more_results() - - # In downloads.py, find and REPLACE the existing load_more_results method with this: - - def load_more_results(self): - """ - Prepares a batch of results and initiates the staggered loading process. - """ - if self.is_loading_more or not self.current_filtered_results: - return - - self.is_loading_more = True - - # Calculate how many more results to show - start_index = self.displayed_results - end_index = min(start_index + self.results_per_page, len(self.current_filtered_results)) - - # Get the batch of results to load and add them to our queue - results_to_add = self.current_filtered_results[start_index:end_index] - self._results_to_load_queue.extend(results_to_add) - - # Update status to show that we are loading more - self.update_search_status(f"Loading more results...", "#1db954") - - # Kick off the staggered loading process by loading the first item - if self._results_to_load_queue: - QTimer.singleShot(0, self._load_next_result_item) - else: - self.is_loading_more = False - - - def handle_expansion_request(self, requesting_item): - """Handle accordion-style expansion where only one item can be expanded at a time""" - # If there's a currently expanded item and it's not the requesting item, collapse it - if self.currently_expanded_item and self.currently_expanded_item != requesting_item: - try: - self.currently_expanded_item.set_expanded(False, animate=True) - except RuntimeError: - # Widget has been deleted, just clear the reference - self.currently_expanded_item = None - - # Toggle the requesting item - new_expanded_state = not requesting_item.is_expanded - requesting_item.set_expanded(new_expanded_state, animate=True) - - # Update tracking - if new_expanded_state: - self.currently_expanded_item = requesting_item - else: - self.currently_expanded_item = None - - def on_search_failed(self, error_msg): - self.search_btn.setText("Search") - self.search_btn.setEnabled(True) - self.search_btn.setVisible(True) - self.cancel_search_btn.setVisible(False) - - # Stop loading animations - self.stop_search_animations() - - self.update_search_status(f"Search failed: {error_msg}", "#e22134") - - def on_search_progress(self, message): - self.update_search_status(f"{message}", "#1db954") - - def start_download(self, search_result): - """Start downloading a search result using threaded approach""" - try: - # Extract track info for queue display - full_filename = search_result.filename - - # Extract just the filename part (without directory path) - import os - filename = os.path.basename(full_filename) - - # Use TrackResult's parsed metadata if available, otherwise parse filename - if hasattr(search_result, 'title') and search_result.title: - title = search_result.title - print(f"[DEBUG] Using TrackResult title: '{title}'") - else: - # Fallback: Parse title from filename - name_without_ext = filename - if '.' in name_without_ext: - name_without_ext = '.'.join(name_without_ext.split('.')[:-1]) - - # Check for track number prefix and remove it - import re - track_number_match = re.match(r'^(\d+)\.\s*(.+)', name_without_ext) - if track_number_match: - name_without_track_num = track_number_match.group(2) - else: - name_without_track_num = name_without_ext - - # Extract just the title (remove artist if present) - parts = name_without_track_num.split(' - ') - if len(parts) >= 2: - title = ' - '.join(parts[1:]).strip() # Everything after first " - " - else: - title = name_without_track_num.strip() - - print(f"[DEBUG] Parsed title from filename: '{title}'") - - # Use TrackResult's artist if available, otherwise parse or use username - if hasattr(search_result, 'artist') and search_result.artist: - artist = search_result.artist - print(f"[DEBUG] Using TrackResult artist: '{artist}'") - else: - # Fallback: Parse artist from filename or use uploader - name_without_ext = filename - if '.' in name_without_ext: - name_without_ext = '.'.join(name_without_ext.split('.')[:-1]) - - # Remove track number prefix - handle multiple formats - import re - name_without_track_num = name_without_ext - - # Try different track number patterns - track_patterns = [ - r'^(\d+)\.\s*(.+)', # "06. Artist - Track" - r'^(\d+)\s*-\s*(.+)', # "06 - Artist - Track" - r'^(\d+)\s+(.+)' # "06 Artist - Track" - ] - - for pattern in track_patterns: - match = re.match(pattern, name_without_ext) - if match: - name_without_track_num = match.group(2) - break - - # Extract artist (first part before " - ") - parts = name_without_track_num.split(' - ') - if len(parts) >= 2: - # First part is artist, second part is track title - artist = parts[0].strip() - # Verify this doesn't look like a track number - if re.match(r'^\d+$', artist.strip()): - # If first part is just a number, use username as fallback - artist = search_result.username - else: - artist = search_result.username - - print(f"[DEBUG] Parsed artist from filename: '{artist}'") - - # Final cleanup - ensure we have meaningful values - if not title or title == '': - title = filename # Ultimate fallback - if not artist or artist == '': - artist = search_result.username # Ultimate fallback - - print(f"[DEBUG] Extracted title info from '{full_filename}' -> title: '{title}', artist: '{artist}'") - - # Extract album context from search_result if available (for matched album downloads) - album_name = None - track_number = None - - if hasattr(search_result, 'album') and search_result.album: - album_name = search_result.album - print(f"[DEBUG] Found album context: '{album_name}'") - - if hasattr(search_result, 'track_number') and search_result.track_number: - track_number = search_result.track_number - print(f"[DEBUG] Found track number: {track_number}") - - # Generate a unique download ID for tracking and cancellation - import time - import uuid - timestamp = time.time() - unique_suffix = str(uuid.uuid4())[:8] # Short unique identifier - download_id = f"{search_result.username}_{filename}_{int(timestamp)}_{unique_suffix}" - - # Add to download queue immediately as "downloading" with album context - download_item = self.download_queue.add_download_item( - title=title, - artist=artist, - status="downloading", - progress=0, - file_size=search_result.size, - download_id=download_id, - username=search_result.username, - file_path=full_filename, # Store the full path for matching - soulseek_client=self.soulseek_client, - album=album_name, - track_number=track_number - ) - - print(f"[DEBUG] Created download item with album context: album='{album_name}', track_number={track_number}") - - # Emit activity signal for download start - self.download_activity.emit("", "Download Started", f"'{title}' by {artist}", "Now") - - # Create and start download thread - download_thread = DownloadThread(self.soulseek_client, search_result, download_item) - download_thread.download_completed.connect(self.on_download_completed, Qt.ConnectionType.QueuedConnection) - download_thread.download_failed.connect(self.on_download_failed, Qt.ConnectionType.QueuedConnection) - download_thread.download_progress.connect(self.on_download_progress, Qt.ConnectionType.QueuedConnection) - download_thread.finished.connect( - functools.partial(self.on_download_thread_finished, download_thread), - Qt.ConnectionType.QueuedConnection - ) - - # Track the thread - self.download_threads.append(download_thread) - - # Start the download - download_thread.start() - - # Download started - feedback will appear in download queue - - except Exception as e: - print(f"Failed to start download: {str(e)}") - - def start_album_download(self, album_result): - """Start downloading all tracks in an album""" - try: - print(f"Starting album download: {album_result.album_title} by {album_result.artist}") - - # First, find and disable all track download buttons for this album - self.disable_album_track_buttons(album_result) - - # Download each track in the album - for track in album_result.tracks: - self.start_download(track) - - print(f"Queued {len(album_result.tracks)} tracks for download from album: {album_result.album_title}") - - except Exception as e: - print(f"Failed to start album download: {str(e)}") - - def disable_album_track_buttons(self, album_result): - """Disable all track download buttons for an album to prevent duplicate downloads""" - # Find the AlbumResultItem that contains these tracks - for album_item in self.findChildren(AlbumResultItem): - if (album_item.album_result.album_title == album_result.album_title and - album_item.album_result.artist == album_result.artist): - - # Disable all track download buttons in this album - for track_item in album_item.track_items: - track_item.set_download_queued_state() - print(f"[DEBUG] Disabled {len(album_item.track_items)} track download buttons for album: {album_result.album_title}") - break - - def start_matched_download(self, search_result): - """Start a matched download for a single track using the new modal.""" - try: - if not self.spotify_client.is_authenticated(): - self.start_download(search_result) - return - - modal = SpotifyMatchingModal(search_result, self.spotify_client, self.matching_engine, self, is_album=False) - # Use a lambda to pass the original search_result to the handler - modal.match_confirmed.connect( - lambda artist, album: self._handle_match_confirmed(search_result, artist, album, is_album_download=False) - ) - - if modal.exec() == QDialog.DialogCode.Accepted: - print("Match confirmed via modal.") - elif hasattr(modal, 'skipped_matching') and modal.skipped_matching: - print("Matching skipped, proceeding with normal download.") - self.start_download(search_result) - else: - print("Match process cancelled by user.") - - except Exception as e: - print(f"Error in matched download process: {e}") - self.start_download(search_result) - - def start_matched_album_download(self, album_result): - """Start a matched download for a full album using the new modal.""" - try: - if not self.spotify_client.is_authenticated(): - self.start_album_download(album_result) - return - - # Use the first track as a reference for the modal - first_track = album_result.tracks[0] if album_result.tracks else None - if not first_track: - print("Cannot start matched download for an empty album.") - self.start_album_download(album_result) - return - - modal = SpotifyMatchingModal(first_track, self.spotify_client, self.matching_engine, self, is_album=True, album_result=album_result) - modal.match_confirmed.connect( - lambda artist, album: self._handle_match_confirmed(album_result, artist, album, is_album_download=True) - ) - - if modal.exec() == QDialog.DialogCode.Accepted: - print("Album match confirmed via modal.") - elif hasattr(modal, 'skipped_matching') and modal.skipped_matching: - print("Album matching skipped, proceeding with normal download.") - self.start_album_download(album_result) - else: - print("Album match process cancelled by user.") - - except Exception as e: - print(f"Error in matched album download process: {e}") - self.start_album_download(album_result) - - - def _handle_matched_download(self, search_result, artist: Artist): - """Handle the download after artist selection from modal""" - try: - print(f"Starting matched download for '{search_result.title}' by '{artist.name}'") - - # Store the selected artist metadata with the search result - search_result.matched_artist = artist - - # Start the download with normal process but enhanced with Spotify metadata - download_item = self._start_download_with_artist(search_result, artist) - - if download_item: - print(f"Successfully created matched download for '{download_item.title}'") - else: - print(f"Failed to create matched download, falling back to normal download") - self.start_download(search_result) - - except Exception as e: - print(f"Error handling matched download: {e}") - # Fallback to normal download - self.start_download(search_result) - - def _handle_match_confirmed(self, original_result, artist: Artist, album: Album, is_album_download=False): - """ - Handles the final confirmed match from the modal for both single tracks and full albums. - """ - try: - if is_album_download: - # This is a full album download - print(f"Confirmed album match: '{album.name}' by '{artist.name}'") - # Now, we process each track in the original album_result - for track in original_result.tracks: - track.matched_artist = artist - track.matched_album = album - track.album = album.name # Overwrite album name for consistency - self._start_download_with_artist(track, artist) - else: - # This is a single track download - print(f"Confirmed single track match: '{original_result.title}' -> Artist: '{artist.name}', Album: '{album.name}'") - original_result.matched_artist = artist - original_result.matched_album = album - - # For singles-only mode, don't overwrite with dummy album name - if album.id != "singles-dummy": - original_result.album = album.name - # If it's our dummy album, keep the original album name from the search result - - self._start_download_with_artist(original_result, artist) - - except Exception as e: - print(f"Error handling confirmed match: {e}") - # Fallback to normal download - if is_album_download: - self.start_album_download(original_result) - else: - self.start_download(original_result) - - - def _start_download_with_artist(self, search_result, artist: Artist): - """Start download and immediately assign matched artist - no race conditions""" - try: - # Extract track info for queue display (same as start_download) - full_filename = search_result.filename - import os - filename = os.path.basename(full_filename) - - # Use TrackResult's parsed metadata if available, otherwise parse filename - if hasattr(search_result, 'title') and search_result.title: - title = search_result.title - else: - # Fallback parsing logic (copied from start_download) - name_without_ext = filename - if '.' in name_without_ext: - name_without_ext = '.'.join(name_without_ext.split('.')[:-1]) - - import re - track_number_match = re.match(r'^(\d+)\.\s*(.+)', name_without_ext) - if track_number_match: - name_without_track_num = track_number_match.group(2) - else: - name_without_track_num = name_without_ext - - parts = name_without_track_num.split(' - ') - if len(parts) >= 2: - title = ' - '.join(parts[1:]).strip() - else: - title = name_without_track_num.strip() - - # Use TrackResult's artist if available, otherwise parse or use username - if hasattr(search_result, 'artist') and search_result.artist: - original_artist = search_result.artist - else: - # Same fallback logic as start_download - name_without_ext = filename - if '.' in name_without_ext: - name_without_ext = '.'.join(name_without_ext.split('.')[:-1]) - - import re - name_without_track_num = name_without_ext - - # Try different track number patterns - track_patterns = [ - r'^(\d+)\.\s*(.+)', # "06. Artist - Track" - r'^(\d+)\s*-\s*(.+)', # "06 - Artist - Track" - r'^(\d+)\s+(.+)' # "06 Artist - Track" - ] - - for pattern in track_patterns: - match = re.match(pattern, name_without_ext) - if match: - name_without_track_num = match.group(2) - break - - parts = name_without_track_num.split(' - ') - if len(parts) >= 2: - original_artist = parts[0].strip() - # Verify this doesn't look like a track number - if re.match(r'^\d+$', original_artist.strip()): - # If first part is just a number, use username as fallback - original_artist = search_result.username - else: - original_artist = search_result.username - - # Final cleanup - if not title or title == '': - title = filename - if not original_artist or original_artist == '': - original_artist = search_result.username - - # Extract album context - album_name = getattr(search_result, 'album', None) - track_number = getattr(search_result, 'track_number', None) - - # If no track number found, try to extract from filename - if not track_number: - track_number = self._extract_track_number_from_filename(filename, title) - if track_number: - print(f"Extracted track number from filename: {track_number}") - else: - print(f"Could not extract track number from filename: '{filename}'") - - # Generate download ID - import time - import uuid - timestamp = time.time() - unique_suffix = str(uuid.uuid4())[:8] # Short unique identifier - download_id = f"{search_result.username}_{filename}_{int(timestamp)}_{unique_suffix}" - - # Create download item with matched artist immediately - download_item = self.download_queue.add_download_item( - title=title, - artist=original_artist, - status="downloading", - progress=0, - file_size=search_result.size, - download_id=download_id, - username=search_result.username, - file_path=full_filename, - soulseek_client=self.soulseek_client, - album=album_name, - track_number=track_number - ) - - # Immediately assign the matched artist - no timing delays - if download_item: - download_item.matched_artist = artist - print(f"Matched artist '{artist.name}' assigned to download item '{download_item.title}'") - - # Emit activity signal for download start (with matched artist) - self.download_activity.emit("", "Download Started", f"'{title}' by {artist.name if artist else original_artist}", "Now") - - # Start the download thread - download_thread = DownloadThread(self.soulseek_client, search_result, download_item) - download_thread.download_completed.connect(self.on_download_completed, Qt.ConnectionType.QueuedConnection) - download_thread.download_failed.connect(self.on_download_failed, Qt.ConnectionType.QueuedConnection) - download_thread.download_progress.connect(self.on_download_progress, Qt.ConnectionType.QueuedConnection) - download_thread.finished.connect( - functools.partial(self.on_download_thread_finished, download_thread), - Qt.ConnectionType.QueuedConnection - ) - - self.download_threads.append(download_thread) - download_thread.start() - - return download_item - - except Exception as e: - print(f"Failed to start download with artist: {str(e)}") - return None - - def _ensure_album_consistency(self, download_items, artist: Artist, album_name: str): - """Ensure all download items have consistent album naming for proper grouping""" - try: - with self.album_cache_lock: - # Create cache key for this album - album_key = f"{artist.name}::{album_name}" - - # Store the definitive album name - self.album_name_cache[album_key] = album_name - - print(f"Cached album name: '{album_name}' for key: '{album_key}'") - - # Ensure all download items use the same album name - for download_item in download_items: - if hasattr(download_item, 'album'): - download_item.album = album_name - print(f" Set album name for '{download_item.title}': '{album_name}'") - - except Exception as e: - print(f"Error ensuring album consistency: {e}") - - def _assign_matched_artist_to_download_item(self, search_result, artist: Artist): - """Assign matched artist to the corresponding download item""" - try: - print(f"Looking for download item matching: '{search_result.title}' by '{search_result.artist}'") - print(f"Current download items: {len(self.download_queue.download_items)}") - - matched = False - # Find the download item for this search result - for i, download_item in enumerate(self.download_queue.download_items): - print(f" Item {i}: '{getattr(download_item, 'title', 'NO_TITLE')}' by '{getattr(download_item, 'artist', 'NO_ARTIST')}'") - - if (hasattr(download_item, 'title') and download_item.title == search_result.title and - hasattr(download_item, 'artist') and download_item.artist == search_result.artist): - download_item.matched_artist = artist - print(f"Assigned matched artist '{artist.name}' to download item '{download_item.title}'") - matched = True - break - - if not matched: - print(f"Could not find matching download item for '{search_result.title}' by '{search_result.artist}'") - # Try a more lenient search - for i, download_item in enumerate(self.download_queue.download_items): - if (hasattr(download_item, 'title') and - self.matching_engine.normalize_string(download_item.title) == self.matching_engine.normalize_string(search_result.title)): - download_item.matched_artist = artist - print(f"Assigned matched artist '{artist.name}' to download item '{download_item.title}' (lenient match)") - matched = True - break - - if not matched: - print(f"Still could not find matching download item - assignment failed") - - except Exception as e: - print(f"Error assigning matched artist to download item: {e}") - - def _handle_matched_album_download(self, album_result, artist: Artist): - """Handle the album download after artist selection from modal""" - # OPTIMIZATION v2: Use optimized version if enabled - if hasattr(self, '_use_optimized_systems') and self._use_optimized_systems: - return self._handle_matched_album_download_v2(album_result, artist) - - try: - print(f"Starting matched album download for '{album_result.album_title}' by '{artist.name}'") - print(f"Processing {len(album_result.tracks)} tracks with matched artist") - - # Store the selected artist metadata and album context with each track - print(f"Album context being set:") - print(f" Album result title: '{album_result.album_title}'") - print(f" Matched artist: '{artist.name}'") - - # Clean up the album title - remove "Album - Artist -" prefix if present - clean_album_title = self._clean_album_title(album_result.album_title, artist.name) - print(f" Cleaned album title: '{clean_album_title}'") - - for track_index, track in enumerate(album_result.tracks, 1): - track.matched_artist = artist - - # Preserve album context - this is CRITICAL for proper album detection - track.album = clean_album_title # Use cleaned album title - - # Extract original track number from filename instead of using sequential index - if hasattr(track, 'filename'): - import os - filename = os.path.basename(track.filename) - original_track_num = self._extract_track_number_from_filename(filename, track.title) - if original_track_num: - track.track_number = original_track_num - print(f" Preserved original track number: {original_track_num}") - else: - # Only use sequential as fallback if no original number found - track.track_number = track_index - print(f" Using fallback sequential track number: {track_index}") - else: - # Fallback to sequential numbering if no filename available - track.track_number = track_index - print(f" Using fallback sequential track number (no filename): {track_index}") - - # Clean up track title - remove artist prefix if present - clean_track_title = self._clean_track_title(track.title, artist.name) - track.title = clean_track_title - - print(f" Track {track_index}: '{clean_track_title}' -> Artist: '{artist.name}', Album: '{clean_album_title}', Track#: {track.track_number}") - - # Start downloading all tracks with matched artist immediately - import time - download_items = [] - for track_index, track in enumerate(album_result.tracks, 1): - print(f"Starting download {track_index}/{len(album_result.tracks)}: {track.title}") - download_item = self._start_download_with_artist(track, artist) - if download_item: - download_items.append(download_item) - # Small delay between downloads to avoid overwhelming Soulseek - time.sleep(0.1) - - print(f"Successfully queued {len(download_items)}/{len(album_result.tracks)} tracks with matched artist") - - # Pre-calculate and cache the album name to ensure consistency - if download_items: - self._ensure_album_consistency(download_items, artist, clean_album_title) - - print(f"Queued {len(album_result.tracks)} tracks for matched download from album: {album_result.album_title}") - print(f"All tracks have album context preserved: '{album_result.album_title}'") - - except Exception as e: - print(f"Error handling matched album download: {e}") - # Fallback to normal album download - self.start_album_download(album_result) - - def _handle_matched_album_download_v2(self, album_result, artist: Artist): - """OPTIMIZATION v2: Handle album download with non-blocking batch processing""" - try: - - # Set bulk operation flag for adaptive polling - self._bulk_operation_active = True - - # Clean up the album title - clean_album_title = self._clean_album_title(album_result.album_title, artist.name) - - # Prepare all tracks with metadata (no blocking operations) - prepared_tracks = [] - for track_index, track in enumerate(album_result.tracks, 1): - track.matched_artist = artist - track.album = clean_album_title - - # Extract track number without blocking - if hasattr(track, 'filename'): - import os - filename = os.path.basename(track.filename) - original_track_num = self._extract_track_number_from_filename(filename, track.title) - if original_track_num: - track.track_number = original_track_num - else: - track.track_number = track_index - else: - track.track_number = track_index - - prepared_tracks.append(track) - - # Use background thread pool for batch download initiation - def batch_download_tracks(): - download_items = [] - for track_index, track in enumerate(prepared_tracks, 1): - try: - download_item = self._start_download_with_artist(track, artist) - if download_item: - download_items.append(download_item) - # No sleep - let the system handle queuing naturally - except Exception as e: - print(f"Failed to start download for track {track.title}: {e}") - - # Ensure album consistency in background - if download_items: - self._ensure_album_consistency(download_items, artist, clean_album_title) - - # Reset bulk operation flag - self._bulk_operation_active = False - - print(f"Queued {len(download_items)}/{len(prepared_tracks)} tracks") - return download_items - - # Submit to optimized thread pool - self._optimized_api_pool.submit(batch_download_tracks) - - except Exception as e: - print(f"Optimized album download failed: {e}") - self._bulk_operation_active = False - # Fallback to original method - self._handle_matched_album_download(album_result, artist) - - def _handle_matched_album_download_with_album(self, album_result, artist: Artist, selected_album: Album): - """Handle the album download after both artist and album selection from modal""" - try: - print(f"Starting matched album download with FORCED album selection") - print(f" Original album: '{album_result.album_title}'") - print(f" Selected artist: '{artist.name}'") - print(f" Selected album: '{selected_album.name}'") - print(f" ALL tracks will be forced into: '{selected_album.name}'") - - # Fetch official track titles from Spotify album - print(f"Fetching official track titles from Spotify album...") - spotify_tracks = self._get_spotify_album_tracks(selected_album) - - download_items = [] - - # Process all tracks and FORCE them into the selected album - for track_index, track in enumerate(album_result.tracks, 1): - track.matched_artist = artist - track.album = selected_album.name # FORCE album name - track.matched_album = selected_album # Store the full album object - track._force_album_name = selected_album.name # Flag to prevent album detection override - track._force_album_mode = True # Flag to skip individual Spotify lookups - - # Extract original track number from filename - if hasattr(track, 'filename'): - import os - filename = os.path.basename(track.filename) - original_track_num = self._extract_track_number_from_filename(filename, track.title) - if original_track_num: - track.track_number = original_track_num - else: - track.track_number = track_index - - # Match to Spotify track title if available - spotify_title = self._match_track_to_spotify_title(track, spotify_tracks) - if spotify_title: - print(f" Track {track_index}: '{track.title}' -> Spotify title: '{spotify_title}'") - track._spotify_title = spotify_title # Store the official Spotify title - track._spotify_clean_title = spotify_title # This will be used for file naming - else: - print(f" Track {track_index}: '{track.title}' -> No Spotify match found, using original") - - print(f" FORCED into Album: {selected_album.name}") - - # Start individual track download with enhanced metadata - download_item = self._start_download_with_artist(track, artist) - if download_item: - # Also apply the forced album to the download item - download_item._force_album_name = selected_album.name - download_item._force_album_mode = True - - # Apply Spotify title to download item if available - if hasattr(track, '_spotify_clean_title'): - download_item._spotify_clean_title = track._spotify_clean_title - print(f"Applied Spotify title to download item: '{track._spotify_clean_title}'") - - download_items.append(download_item) - print(f"Successfully queued track: {track.title}") - else: - print(f"Failed to queue track: {track.title}") - - # Ensure all download items have consistent album information - for download_item in download_items: - if hasattr(download_item, 'matched_artist'): - download_item._force_album_name = selected_album.name - download_item._force_album_mode = True - - print(f"Queued {len(album_result.tracks)} tracks for matched download - ALL FORCED into album: {selected_album.name}") - - except Exception as e: - print(f"Error handling matched album download with album selection: {e}") - # Fallback to normal album download - self.start_album_download(album_result) - - def _assign_matched_artist_to_album_downloads(self, album_result, artist: Artist): - """Assign matched artist to all download items for an album""" - try: - print(f"Assigning matched artist '{artist.name}' to all album download items") - print(f"Album has {len(album_result.tracks)} tracks") - print(f"Current download queue has {len(self.download_queue.download_items)} items") - - assigned_count = 0 - - # Find download items for all tracks in this album - for track_idx, track in enumerate(album_result.tracks): - print(f"Looking for track {track_idx + 1}: '{track.title}' by '{track.artist}'") - - matched = False - for download_item in self.download_queue.download_items: - if (hasattr(download_item, 'title') and download_item.title == track.title and - hasattr(download_item, 'artist') and download_item.artist == track.artist): - download_item.matched_artist = artist - assigned_count += 1 - print(f" Assigned to: {download_item.title}") - matched = True - break - - if not matched: - print(f" Could not find download item for: {track.title}") - # Try a more lenient search - for download_item in self.download_queue.download_items: - if (hasattr(download_item, 'title') and - self.matching_engine.normalize_string(download_item.title) == self.matching_engine.normalize_string(track.title)): - download_item.matched_artist = artist - assigned_count += 1 - print(f" Assigned to: {download_item.title} (lenient match)") - matched = True - break - - if not matched: - print(f" Still could not find download item for: {track.title}") - - print(f"Successfully assigned matched artist to {assigned_count}/{len(album_result.tracks)} album tracks") - - # If we didn't assign to all tracks, let's try again with a longer delay - if assigned_count < len(album_result.tracks): - print(f"⏰ Some assignments failed, will retry in 1 second...") - QTimer.singleShot(1000, lambda: self._retry_album_assignment(album_result, artist, assigned_count)) - - except Exception as e: - print(f"Error assigning matched artist to album download items: {e}") - - def _retry_album_assignment(self, album_result, artist: Artist, previous_count: int): - """Retry assignment for album tracks that failed the first time""" - try: - print(f"Retrying album assignment for remaining tracks...") - new_assigned = 0 - - for track in album_result.tracks: - # Only try to assign if not already assigned - found_assigned = False - for download_item in self.download_queue.download_items: - if (hasattr(download_item, 'title') and download_item.title == track.title and - hasattr(download_item, 'matched_artist') and download_item.matched_artist): - found_assigned = True - break - - if not found_assigned: - # Try to find and assign - for download_item in self.download_queue.download_items: - if (hasattr(download_item, 'title') and - self.matching_engine.normalize_string(download_item.title) == self.matching_engine.normalize_string(track.title)): - download_item.matched_artist = artist - new_assigned += 1 - print(f" Retry assigned to: {download_item.title}") - break - - total_assigned = previous_count + new_assigned - print(f"Retry complete: {total_assigned}/{len(album_result.tracks)} tracks now assigned") - - except Exception as e: - print(f"Error in retry assignment: {e}") - - def _handle_modal_cancelled(self, search_result): - """Handle when modal is cancelled for single track downloads""" - print(f"Modal cancelled for track: {search_result.title}") - # Re-enable any disabled download buttons for this track - # Since track downloads don't disable buttons, this is mainly for consistency - - def _handle_album_modal_cancelled(self, album_result): - """Handle when modal is cancelled for album downloads - re-enable buttons""" - print(f"Album modal cancelled for: {album_result.album_title}") - - # Re-enable all track download buttons for this album - self._enable_album_track_buttons(album_result) - - def _enable_album_track_buttons(self, album_result): - """Re-enable all track download buttons for an album""" - try: - # Find the AlbumResultItem and re-enable its buttons - for i in range(self.search_results_layout.count()): - item = self.search_results_layout.itemAt(i) - if item and item.widget(): - widget = item.widget() - if hasattr(widget, 'album_result') and widget.album_result == album_result: - # Re-enable the matched download button - if hasattr(widget, 'matched_download_btn'): - widget.matched_download_btn.setText("Download w/ Matching") - widget.matched_download_btn.setEnabled(True) - - # Re-enable the regular download button - if hasattr(widget, 'download_btn'): - widget.download_btn.setText("⬇️ Download Album") - widget.download_btn.setEnabled(True) - - print(f"Re-enabled buttons for album: {album_result.album_title}") - break - except Exception as e: - print(f"Error re-enabling album buttons: {e}") - - - def _cleanup_empty_directories(self, download_path, moved_file_path): - """ - Clean up empty directories left after moving a file, ignoring hidden files. - Walks up the directory tree until it finds a non-empty folder or the root download path. - """ - import os - - try: - # Start with the directory that contained the moved file - # For you, this will be 'downloads/SomeAlbumFolder' - current_dir = os.path.dirname(moved_file_path) - - # This loop will continue as long as the directory is inside the main download path - while current_dir != download_path and current_dir.startswith(download_path): - # Check if the directory is empty, IGNORING hidden files like .DS_Store - is_empty = True - for entry in os.listdir(current_dir): - if not entry.startswith('.'): # This is the key fix - is_empty = False - break - - if is_empty: - print(f"Removing empty directory (ignoring hidden files): {current_dir}") - try: - os.rmdir(current_dir) - # After deleting, move up to the parent to check it too - current_dir = os.path.dirname(current_dir) - except OSError as e: - print(f"Warning: Could not remove directory {current_dir}: {e}") - break # Stop if we can't remove a directory - else: - # If the directory is not empty, the job is done. - print(f"Stopping cleanup at non-empty directory: {current_dir}") - break - - except Exception as e: - print(f"Warning: An error occurred during directory cleanup: {e}") - - - def _organize_matched_download(self, download_item, original_file_path: str) -> Optional[str]: - """Organize a matched download into the Transfer folder structure""" - try: - import os - import shutil - from pathlib import Path - - if not hasattr(download_item, 'matched_artist'): - print("No matched artist information found") - return None - - artist = download_item.matched_artist - print(f"Organizing download for artist: {artist.name}") - - # --- FIX: Get transfer directory from config instead of hardcoding --- - # OLD CODE: - # project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - # transfer_dir = os.path.join(project_root, 'Transfer') - - # NEW CODE: - transfer_dir = config_manager.get('soulseek.transfer_path', './Transfer') - os.makedirs(transfer_dir, exist_ok=True) - - # Create artist directory - artist_dir = os.path.join(transfer_dir, self._sanitize_filename(artist.name)) - os.makedirs(artist_dir, exist_ok=True) - - # Determine if this is a single or album track - album_info = self._detect_album_info(download_item, artist) - - # Resolve consistent album name for grouping tracks from same album - if album_info and album_info['is_album']: - print(f"\nSMART ALBUM GROUPING for track: '{download_item.title}'") - print(f" Original album: '{getattr(download_item, 'album', 'None')}'") - print(f" Detected album: '{album_info.get('album_name', 'None')}'") - - consistent_album_name = self._resolve_album_group(download_item, artist, album_info) - album_info['album_name'] = consistent_album_name - - print(f" Final album name: '{consistent_album_name}'") - print(f"Album grouping complete!\n") - - if album_info and album_info['is_album']: - # Album track structure: Transfer/ARTIST/ARTIST - ALBUM/TRACK# TRACK.ext - print(f"Creating album folder:") - print(f" Artist name: '{artist.name}'") - print(f" Album name from album_info: '{album_info['album_name']}'") - print(f" Original download item title: '{download_item.title}'") - - # Use the Spotify title information if available (most accurate for matched tracks) - clean_track_name = download_item.title - if hasattr(download_item, '_spotify_clean_title') and download_item._spotify_clean_title: - clean_track_name = download_item._spotify_clean_title - elif album_info.get('clean_track_name'): - clean_track_name = album_info['clean_track_name'] - - print(f" Clean track name to use: '{clean_track_name}'") - - album_folder_name = f"{self._sanitize_filename(artist.name)} - {self._sanitize_filename(album_info['album_name'])}" - album_dir = os.path.join(artist_dir, album_folder_name) - os.makedirs(album_dir, exist_ok=True) - - # Create track filename with number (just track number + clean title, NO artist) - file_ext = os.path.splitext(original_file_path)[1] - track_number = album_info.get('track_number', 1) - track_filename = f"{track_number:02d} - {self._sanitize_filename(clean_track_name)}{file_ext}" - new_file_path = os.path.join(album_dir, track_filename) - - print(f"Album folder created: '{album_folder_name}'") - print(f"Track filename: '{track_filename}'") - - else: - # Single track structure: Transfer/ARTIST/ARTIST - SINGLE/SINGLE.ext - # Use the Spotify title information if available (most accurate for matched tracks) - clean_track_name = download_item.title - if hasattr(download_item, '_spotify_clean_title') and download_item._spotify_clean_title: - clean_track_name = download_item._spotify_clean_title - elif album_info and album_info.get('clean_track_name'): - clean_track_name = album_info['clean_track_name'] - - print(f" Original download item title: '{download_item.title}'") - print(f" Clean track name to use: '{clean_track_name}'") - - single_folder_name = f"{self._sanitize_filename(artist.name)} - {self._sanitize_filename(clean_track_name)}" - single_dir = os.path.join(artist_dir, single_folder_name) - os.makedirs(single_dir, exist_ok=True) - - # Create single filename with clean track name - file_ext = os.path.splitext(original_file_path)[1] - single_filename = f"{self._sanitize_filename(clean_track_name)}{file_ext}" - new_file_path = os.path.join(single_dir, single_filename) - - print(f"Single track: {single_folder_name}/{single_filename}") - - # Check if source file exists, and try to find it if not - if not os.path.exists(original_file_path): - print(f"Source file not found: {original_file_path}") - - # Try to find the file using different methods - found_file = self._find_downloaded_file(original_file_path, download_item) - if found_file: - print(f"Found file at: {found_file}") - original_file_path = found_file - else: - print(f"Could not locate downloaded file anywhere") - return None - - # File organization and overwrite logic will be handled after metadata enhancement - - # 🆕 METADATA ENHANCEMENT - Enhance BEFORE moving to avoid race conditions - if self._enhance_file_metadata(original_file_path, download_item, artist, album_info): - print(f"Metadata enhanced with Spotify data") - else: - print(f"Metadata enhancement failed, using original tags") - - # Verify source file exists before attempting move - if not os.path.exists(original_file_path): - print(f"Source file not found: {original_file_path}") - return None - - # Explicit overwrite: check if file exists, remove it, then move - if os.path.isfile(new_file_path): - os.remove(new_file_path) - - # Move the enhanced file to destination - shutil.move(original_file_path, new_file_path) - - # Verify the move was successful - if not os.path.exists(new_file_path): - print(f"File move failed - destination file not found: {new_file_path}") - return None - - if os.path.exists(original_file_path): - try: - os.remove(original_file_path) - except Exception as cleanup_error: - print(f"Could not remove original file: {cleanup_error}") - - # Clean up any empty directories left in the downloads folder - try: - downloads_path = config_manager.get('soulseek.download_path', './Downloads') - self._cleanup_empty_directories(downloads_path, original_file_path) - except Exception as cleanup_error: - print(f"Could not clean up empty directories: {cleanup_error}") - - # Download cover art for both albums and singles - if album_info and album_info['is_album']: - # Album track - download to album directory - self._download_cover_art(artist, album_info, os.path.dirname(new_file_path)) - else: - # Single track - create minimal album_info for cover art download - single_album_info = self._create_single_track_album_info(download_item, artist) - if single_album_info: - self._download_cover_art(artist, single_album_info, os.path.dirname(new_file_path)) - - # Generate LRC lyrics file at final location (elegant addition) - self._generate_lrc_file(new_file_path, download_item, artist, album_info) - - print(f"Successfully organized matched download: {new_file_path}") - return new_file_path - - except Exception as e: - print(f"Error organizing matched download: {e}") - return None - - - - - def _sanitize_filename(self, filename: str) -> str: - """Sanitize filename for file system compatibility""" - import re - # Replace invalid characters with underscores - sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename) - # Remove multiple spaces and trim - sanitized = re.sub(r'\s+', ' ', sanitized).strip() - # Windows forbids trailing dots/spaces on files and folders - sanitized = sanitized.rstrip('. ') or '_' - # Windows reserved device names - if re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)', sanitized, re.IGNORECASE): - sanitized = '_' + sanitized - # Limit length to avoid filesystem issues - return sanitized[:200] if len(sanitized) > 200 else sanitized - - def _clean_album_title(self, album_title: str, artist_name: str) -> str: - """Clean up album title by removing common prefixes, suffixes, and artist redundancy""" - import re - - # Start with the original title - original = album_title.strip() - cleaned = original - print(f"Album Title Cleaning: '{original}' (artist: '{artist_name}')") - - # Remove "Album - " prefix - cleaned = re.sub(r'^Album\s*-\s*', '', cleaned, flags=re.IGNORECASE) - - # Remove artist name prefix if it appears at the beginning - # This handles cases like "Kendrick Lamar - good kid, m.A.A.d city" - artist_pattern = re.escape(artist_name) + r'\s*-\s*' - cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE) - - # Remove common Soulseek suffixes in square brackets and parentheses - # Examples: [Deluxe Edition] [2012] [320 Kbps] [Album+iTunes+Bonus Tracks] [F10] - # (Deluxe Edition) (2012) (320 Kbps) etc. - # Remove year patterns like [2012], (2020), etc. - cleaned = re.sub(r'\s*[\[\(]\d{4}[\]\)]\s*', ' ', cleaned) - - # Remove quality/format indicators - quality_patterns = [ - r'\s*[\[\(][0-9]+\s*kbps[\]\)]\s*', - r'\s*[\[\(][0-9]+k[\]\)]\s*', - r'\s*[\[\(]320[\]\)]\s*', - r'\s*[\[\(]flac[\]\)]\s*', - r'\s*[\[\(]mp3[\]\)]\s*', - r'\s*[\[\(]wav[\]\)]\s*', - r'\s*[\[\(]lossless[\]\)]\s*' - ] - for pattern in quality_patterns: - cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE) - - # Remove source/torrent indicators - source_patterns = [ - r'\s*[\[\(]album[\]\)]\s*', - r'\s*[\[\(]itunes[\]\)]\s*', - r'\s*[\[\(]spotify[\]\)]\s*', - r'\s*[\[\(]cd[\]\)]\s*', - r'\s*[\[\(]web[\]\)]\s*', - r'\s*[\[\(]f\d+[\]\)]\s*', # [F10], [F24], etc. - r'\s*[\[\(]bonus\s*tracks?[\]\)]\s*', - r'\s*[\[\(]remaster(ed)?[\]\)]\s*', - r'\s*[\[\(]clean[\]\)]\s*', - r'\s*[\[\(]explicit[\]\)]\s*', - r'\s*[\[\(]album\+itunes\+bonus\s*tracks?[\]\)]\s*', # [Album+iTunes+Bonus Tracks] - r'\s*[\[\(]itunes\+bonus[\]\)]\s*', - r'\s*[\[\(]bonus[\]\)]\s*' - ] - for pattern in source_patterns: - cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE) - - # Remove edition indicators but preserve meaningful ones like "Deluxe Edition" - # Only remove if they're standalone or with obvious non-meaningful text - cleaned = re.sub(r'\s*[\[\(](deluxe\s*edition|special\s*edition|expanded\s*edition)[\]\)]\s*', r' (\1)', cleaned, flags=re.IGNORECASE) - - # Clean up multiple spaces, leading/trailing spaces and dashes - cleaned = re.sub(r'\s+', ' ', cleaned) - cleaned = re.sub(r'^[-\s]+', '', cleaned) - cleaned = re.sub(r'[-\s]+$', '', cleaned) - cleaned = cleaned.strip() - - # If everything was removed, try a more conservative approach - if not cleaned or len(cleaned) < 3: - # Extract just the main album name before any brackets - fallback = re.split(r'[\[\(]', album_title)[0].strip() - # Remove artist prefix from fallback - fallback = re.sub(f'^{re.escape(artist_name)}\\s*-\\s*', '', fallback, flags=re.IGNORECASE) - cleaned = fallback.strip() if fallback.strip() else album_title - print(f"Album Title used fallback: '{fallback}'") - - print(f"Album Title Result: '{original}' -> '{cleaned}'") - return cleaned - - def _clean_track_title(self, track_title: str, artist_name: str) -> str: - """Clean up track title by removing artist prefix and other unwanted elements""" - import re - - # Start with the original title - original = track_title.strip() - cleaned = original - print(f"Track Title Cleaning: '{original}' (artist: '{artist_name}')") - - # Remove track numbers from the beginning if present - # Handles cases like "01 - Track Name", "1. Track Name", "01. Track Name" - cleaned = re.sub(r'^\d{1,2}[\.\s\-]+', '', cleaned) - - # Remove artist name prefix if it appears at the beginning - # This handles cases like "Kendrick Lamar - Track Name" - artist_pattern = re.escape(artist_name) + r'\s*-\s*' - cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE) - - # Remove album name prefix if it appears (e.g., "GNX - 01 - wacced out murals") - # Look for pattern: "WORD - NUMBER - actual_title" - cleaned = re.sub(r'^[A-Za-z0-9\.]+\s*-\s*\d{1,2}\s*-\s*', '', cleaned) - - # Remove common file quality indicators from track titles - quality_patterns = [ - r'\s*[\[\(][0-9]+\s*kbps[\]\)]\s*', - r'\s*[\[\(]flac[\]\)]\s*', - r'\s*[\[\(]mp3[\]\)]\s*', - r'\s*[\[\(]320[\]\)]\s*' - ] - for pattern in quality_patterns: - cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE) - - # Clean up any remaining extra spaces, dashes, or dots at the start/end - cleaned = re.sub(r'^[-\s\.]+', '', cleaned) - cleaned = re.sub(r'[-\s\.]+$', '', cleaned) - cleaned = re.sub(r'\s+', ' ', cleaned).strip() - - # If everything was removed, try more conservative approach - if not cleaned or len(cleaned) < 2: - # Remove just the most obvious prefixes - fallback = track_title.strip() - fallback = re.sub(r'^\d{1,2}[\.\s\-]+', '', fallback) # Remove track numbers - fallback = re.sub(f'^{re.escape(artist_name)}\\s*-\\s*', '', fallback, flags=re.IGNORECASE) - cleaned = fallback.strip() if fallback.strip() else track_title - print(f"Track Title used fallback: '{fallback}'") - - print(f"Track Title Result: '{original}' -> '{cleaned}'") - return cleaned - - def _resolve_album_group(self, download_item, artist: Artist, album_info: dict) -> str: - """ - Smart album grouping: Start with standard, upgrade to deluxe if ANY track is deluxe. - This ensures all tracks from the same album get the same folder name. - """ - try: - with self.album_cache_lock: - # Get the original album name from the download item (if it has one) - original_album = getattr(download_item, 'album', None) - detected_album = album_info.get('album_name', '') - - # Extract base album name (without edition indicators) - if album_info.get('spotify_track'): - # Use Spotify album name for base - base_album = self._get_base_album_name(detected_album) - elif original_album: - # Clean the original Soulseek album name - cleaned_original = self._clean_album_title(original_album, artist.name) - base_album = self._get_base_album_name(cleaned_original) - else: - base_album = self._get_base_album_name(detected_album) - - # Normalize the base name (handle case variations, etc.) - base_album = self._normalize_base_album_name(base_album, artist.name) - - # Create a key for this album group (artist + base album) - album_key = f"{artist.name}::{base_album}" - - # Check if we already have a cached result for this album - if album_key in self.album_name_cache: - cached_name = self.album_name_cache[album_key] - print(f"Using cached album name for '{album_key}': '{cached_name}'") - return cached_name - - print(f"Album grouping - Key: '{album_key}', Detected: '{detected_album}'") - - # Check if this track indicates a deluxe edition - is_deluxe_track = False - if album_info.get('spotify_track'): - is_deluxe_track = self._detect_deluxe_edition(detected_album) - elif original_album: - is_deluxe_track = self._detect_deluxe_edition(original_album) - - # Get current edition level for this album group (default to standard) - current_edition = self.album_editions.get(album_key, "standard") - - # SMART ALGORITHM: Upgrade to deluxe if this track is deluxe - if is_deluxe_track and current_edition == "standard": - print(f"UPGRADE: Album '{base_album}' upgraded from standard to deluxe!") - self.album_editions[album_key] = "deluxe" - current_edition = "deluxe" - - # Build final album name based on edition level - if current_edition == "deluxe": - final_album_name = f"{base_album} (Deluxe Edition)" - else: - final_album_name = base_album - - # Store the resolution in both caches - self.album_groups[album_key] = final_album_name - self.album_name_cache[album_key] = final_album_name - self.album_artists[album_key] = artist.name - - print(f"Album resolution: '{detected_album}' -> '{final_album_name}' (edition: {current_edition})") - - return final_album_name - - except Exception as e: - print(f"Error resolving album group: {e}") - return album_info.get('album_name', download_item.title) - - def _normalize_base_album_name(self, base_album: str, artist_name: str) -> str: - """ - Normalize the base album name to handle case variations and known corrections. - """ - import re - - # Apply known album corrections for consistent naming - normalized_lower = base_album.lower().strip() - - known_corrections = { - 'good kid maad city': 'good kid, m.A.A.d city', - 'good kid m.a.a.d city': 'good kid, m.A.A.d city', - 'good kid m.a.a.d. city': 'good kid, m.A.A.d city', - 'good kid m a a d city': 'good kid, m.A.A.d city', - 'good kid m.a.a.d city': 'good kid, m.A.A.d city' - } - - for key, correction in known_corrections.items(): - if key == normalized_lower: - print(f"Base album correction: '{base_album}' -> '{correction}'") - return correction - - # If no specific correction, return cleaned version - return base_album.strip() - - def _normalize_spotify_album_variants(self, album_name: str, artist_name: str) -> str: - """ - Normalize different Spotify album variants to a consistent name. - E.g., 'good kid, m.A.A.d city' and 'good kid, m.A.A.d city (Deluxe)' - should both resolve to 'good kid, m.A.A.d city (Deluxe Edition)' - """ - import re - - normalized = album_name.strip() - - # Convert various deluxe indicators to standard format - deluxe_patterns = [ - (r'\s*\(deluxe\)\s*$', ' (Deluxe Edition)'), - (r'\s*\[deluxe\]\s*$', ' (Deluxe Edition)'), - (r'\s*deluxe\s*$', ' (Deluxe Edition)'), - (r'\s*\(deluxe\s+edition\)\s*$', ' (Deluxe Edition)'), - (r'\s*\[deluxe\s+edition\]\s*$', ' (Deluxe Edition)') - ] - - for pattern, replacement in deluxe_patterns: - if re.search(pattern, normalized, re.IGNORECASE): - normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE) - break - - # Normalize case inconsistencies for known albums - # This handles cases like "good kid, m.A.A.d city" vs "Good Kid M.A.A.D City" - known_album_corrections = { - 'good kid maad city': 'good kid, m.A.A.d city', - 'good kid m.a.a.d city': 'good kid, m.A.A.d city', - 'good kid m.a.a.d. city': 'good kid, m.A.A.d city', - 'good kid m.a.a.d city': 'good kid, m.A.A.d city', - 'good kid m a a d city': 'good kid, m.A.A.d city' - } - - normalized_lower = normalized.lower() - for key, correction in known_album_corrections.items(): - if key in normalized_lower: - # Preserve any edition suffix - suffix = '' - if '(deluxe edition)' in normalized_lower: - suffix = ' (Deluxe Edition)' - elif '(deluxe)' in normalized_lower: - suffix = ' (Deluxe Edition)' - - normalized = correction + suffix - break - - print(f"Album variant normalization: '{album_name}' -> '{normalized}'") - return normalized - - def _detect_deluxe_edition(self, album_name: str) -> bool: - """ - Detect if an album name indicates a deluxe/special edition. - Returns True if it's a deluxe variant, False for standard. - """ - if not album_name: - return False - - album_lower = album_name.lower() - - # Check for deluxe indicators - deluxe_indicators = [ - 'deluxe', - 'deluxe edition', - 'special edition', - 'expanded edition', - 'extended edition', - 'bonus', - 'remastered', - 'anniversary', - 'collectors edition', - 'limited edition' - ] - - for indicator in deluxe_indicators: - if indicator in album_lower: - print(f"Detected deluxe edition: '{album_name}' contains '{indicator}'") - return True - - return False - - def _get_base_album_name(self, album_name: str) -> str: - """ - Extract the base album name without edition indicators. - E.g., 'good kid, m.A.A.d city (Deluxe Edition)' -> 'good kid, m.A.A.d city' - """ - import re - - # Remove common edition suffixes - base_name = album_name - - # Remove edition indicators in parentheses or brackets - base_name = re.sub(r'\s*[\[\(](deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited).*?[\]\)]\s*$', '', base_name, flags=re.IGNORECASE) - - # Remove standalone edition words at the end - base_name = re.sub(r'\s+(deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited)\s*(edition)?\s*$', '', base_name, flags=re.IGNORECASE) - - return base_name.strip() - - def _detect_album_info(self, download_item, artist: Artist) -> Optional[dict]: - """Detect if track is part of an album using Spotify API as primary source""" - try: - print(f"Album detection for '{download_item.title}' by '{artist.name}':") - print(f" Has album attr: {hasattr(download_item, 'album')}") - if hasattr(download_item, 'album'): - print(f" Album value: '{download_item.album}'") - - # CHECK FOR FORCED ALBUM MODE FIRST - if hasattr(download_item, '_force_album_mode') and download_item._force_album_mode: - print(f"FORCED ALBUM MODE DETECTED - Using forced album name") - forced_album = getattr(download_item, '_force_album_name', 'Unknown Album') - print(f" Forced album: '{forced_album}'") - - # Try to get album image URL from matched_album if available - album_image_url = None - if hasattr(download_item, 'matched_album') and download_item.matched_album: - album_image_url = download_item.matched_album.image_url - - return { - 'is_album': True, - 'album_name': forced_album, - 'track_number': getattr(download_item, 'track_number', 1), - 'clean_track_name': download_item.title, - 'confidence': 1.0, # 100% confidence since it's forced - 'source': 'forced_user_selection', - 'album_image_url': album_image_url - } - - # PRIORITY 1: Try album-aware search if we have album context - if hasattr(download_item, 'album') and download_item.album and download_item.album.strip() and download_item.album != "Unknown Album": - print(f"ALBUM-AWARE SEARCH: Looking for '{download_item.title}' in album '{download_item.album}'") - album_result = self._search_track_in_album_context(download_item, artist) - if album_result: - print(f"Found track in album context - using album classification") - return album_result - else: - print(f"Track not found in album context, falling back to individual search") - - # PRIORITY 2: Fallback to individual track search for clean metadata - print(f"Searching Spotify for individual track info (PRIORITY 2)...") - - # Clean the track title before searching - remove artist prefix - clean_title = self._clean_track_title(download_item.title, artist.name) - print(f"Cleaned title: '{download_item.title}' -> '{clean_title}'") - - # Search for the track by artist and cleaned title - query = f"artist:{artist.name} track:{clean_title}" - tracks = self.spotify_client.search_tracks(query, limit=5) - - # Find the best matching track - best_match = None - best_confidence = 0 - - if tracks: - for track in tracks: - # Calculate confidence based on artist and title similarity - artist_confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(artist.name), - self.matching_engine.normalize_string(track.artists[0]) - ) - title_confidence = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(clean_title), - self.matching_engine.normalize_string(track.name) - ) - - combined_confidence = (artist_confidence * 0.6 + title_confidence * 0.4) - - if combined_confidence > best_confidence and combined_confidence > 0.6: # Lower threshold for better matches - best_match = track - best_confidence = combined_confidence - - # If we found a good Spotify match, use it for clean metadata - if best_match and best_confidence > 0.6: - print(f"Found matching Spotify track: '{best_match.name}' - Album: '{best_match.album}' (confidence: {best_confidence:.2f})") - - # Get detailed track information using Spotify's track API - detailed_track = None - if hasattr(best_match, 'id') and best_match.id: - print(f"Getting detailed track info from Spotify API for track ID: {best_match.id}") - detailed_track = self.spotify_client.get_track_details(best_match.id) - - # Use detailed track data if available - if detailed_track: - print(f"Got detailed track data from Spotify API") - album_name = self._clean_album_title(detailed_track['album']['name'], artist.name) - clean_track_name = detailed_track['name'] # Use Spotify's clean track name - album_type = detailed_track['album'].get('album_type', 'album') - total_tracks = detailed_track['album'].get('total_tracks', 1) - spotify_track_number = detailed_track.get('track_number', 1) - - print(f"Spotify album info: '{album_name}' (type: {album_type}, total_tracks: {total_tracks}, track#: {spotify_track_number})") - print(f"Clean track name from Spotify: '{clean_track_name}'") - - # Enhanced album detection using detailed API data - is_album = ( - # Album type is 'album' (not 'single') - album_type == 'album' and - # Album has multiple tracks - total_tracks > 1 and - # Album name different from track name - self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(clean_track_name) and - # Album name is not just the artist name - self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(artist.name) - ) - - track_num = spotify_track_number - print(f"Using Spotify track number: {track_num}") - - # Store the clean Spotify track name for use in file organization (only if not already set) - if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title: - download_item._spotify_clean_title = clean_track_name - download_item._spotify_clean_album = album_name - - # Extract album image URL from detailed track data - album_image_url = None - if detailed_track.get('album', {}).get('images'): - # Get the largest image (first one in Spotify's array) - album_image_url = detailed_track['album']['images'][0]['url'] - - if is_album: - print(f"Spotify detection: Album track - '{album_name}'") - return { - 'is_album': True, - 'album_name': album_name, - 'track_number': track_num, - 'spotify_track': best_match, - 'clean_track_name': clean_track_name, - 'album_image_url': album_image_url - } - else: - print(f"Spotify detection: Single track - using clean track name") - return { - 'is_album': False, - 'album_name': clean_track_name, # Use clean track name for single structure - 'track_number': 1, - 'spotify_track': best_match, - 'clean_track_name': clean_track_name, - 'album_image_url': album_image_url - } - - else: - print(f"Could not get detailed track data, using basic Spotify search data") - album_name = self._clean_album_title(best_match.album, artist.name) - clean_track_name = best_match.name - - # Fallback album detection logic - is_album = ( - # Album name different from track name (indicates multi-track album) - self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(clean_track_name) and - # Album name doesn't contain "single" or similar terms - not any(term in album_name.lower() for term in ['single', 'ep']) and - # Album name is not just the artist name - self.matching_engine.normalize_string(album_name) != self.matching_engine.normalize_string(artist.name) - ) - - # Get track number from metadata or filename as fallback - track_num = self._extract_track_number(download_item) - - # Only set if not already set (preserve original Spotify title from modal) - if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title: - download_item._spotify_clean_title = clean_track_name - download_item._spotify_clean_album = album_name - - # Try to get album image URL from matched_album if available - album_image_url = None - if hasattr(download_item, 'matched_album') and download_item.matched_album: - album_image_url = download_item.matched_album.image_url - - return { - 'is_album': is_album, - 'album_name': album_name if is_album else clean_track_name, - 'track_number': track_num if is_album else 1, - 'spotify_track': best_match, - 'clean_track_name': clean_track_name, - 'album_image_url': album_image_url - } - - # PRIORITY 3: Fallback to Soulseek album context if Spotify search failed - print(f"No good Spotify match found (confidence: {best_confidence:.2f}), checking Soulseek album context...") - - if hasattr(download_item, 'album') and download_item.album and download_item.album != "Unknown Album": - clean_album = self._clean_album_title(download_item.album, artist.name) - clean_title = self._clean_track_title(download_item.title, artist.name) - track_num = self._extract_track_number(download_item) - - print(f"Using cleaned Soulseek album context: '{clean_album}' (cleaned from '{download_item.album}')") - print(f"Cleaned track title: '{clean_title}' (cleaned from '{download_item.title}')") - - # Only set if not already set (preserve original Spotify title from modal) - if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title: - download_item._spotify_clean_title = clean_title - download_item._spotify_clean_album = clean_album - - # Try to get album image URL from matched_album if available - album_image_url = None - if hasattr(download_item, 'matched_album') and download_item.matched_album: - album_image_url = download_item.matched_album.image_url - - return { - 'is_album': True, - 'album_name': clean_album, - 'track_number': track_num, - 'spotify_track': None, - 'clean_track_name': clean_title, - 'album_image_url': album_image_url - } - - # PRIORITY 4: Complete fallback - single track with cleaned title - print(f"No album context found, defaulting to single track structure with cleaned title") - clean_title = self._clean_track_title(download_item.title, artist.name) - - # Only set if not already set (preserve original Spotify title from modal) - if not hasattr(download_item, '_spotify_clean_title') or not download_item._spotify_clean_title: - download_item._spotify_clean_title = clean_title - - # Try to get album image URL from matched_album if available - album_image_url = None - if hasattr(download_item, 'matched_album') and download_item.matched_album: - album_image_url = download_item.matched_album.image_url - - return { - 'is_album': False, - 'album_name': clean_title, # Use clean track name as single name - 'track_number': 1, - 'spotify_track': None, - 'clean_track_name': clean_title, - 'album_image_url': album_image_url - } - - except Exception as e: - print(f"Error detecting album info: {e}") - # Emergency fallback to single structure with basic cleaning - clean_title = self._clean_track_title(download_item.title, artist.name) - - # Try to get album image URL from matched_album if available - album_image_url = None - if hasattr(download_item, 'matched_album') and download_item.matched_album: - album_image_url = download_item.matched_album.image_url - - return { - 'is_album': False, - 'album_name': clean_title, - 'track_number': 1, - 'spotify_track': None, - 'clean_track_name': clean_title, - 'album_image_url': album_image_url - } - - def _search_track_in_album_context(self, download_item, artist: Artist) -> Optional[dict]: - """Search for a track within its album context to avoid promotional single confusion""" - try: - album_name = download_item.album - track_title = download_item.title - - print(f"Album-aware search: '{track_title}' in album '{album_name}' by '{artist.name}'") - - # Clean the album name for better search results - clean_album = self._clean_album_title(album_name, artist.name) - clean_track = self._clean_track_title(track_title, artist.name) - - # Search for the specific album first - album_query = f"album:{clean_album} artist:{artist.name}" - print(f"Searching albums: {album_query}") - albums = self.spotify_client.search_albums(album_query, limit=5) - - if not albums: - print(f"No albums found for query: {album_query}") - return None - - # Check each album to see if our track is in it - for album in albums: - print(f"Checking album: '{album.name}' ({album.total_tracks} tracks)") - - # Get tracks from this album - album_tracks_data = self.spotify_client.get_album_tracks(album.id) - if not album_tracks_data or 'items' not in album_tracks_data: - print(f"Could not get tracks for album: {album.name}") - continue - - # Check if our track is in this album - for track_data in album_tracks_data['items']: - track_name = track_data['name'] - track_number = track_data['track_number'] - - # Calculate similarity between our track and this album track - similarity = self.matching_engine.similarity_score( - self.matching_engine.normalize_string(clean_track), - self.matching_engine.normalize_string(track_name) - ) - - # Use higher threshold for remix matching to ensure precision - is_remix = any(word in clean_track.lower() for word in ['remix', 'mix', 'edit', 'version']) - threshold = 0.9 if is_remix else 0.7 # Much stricter for remixes - - if similarity > threshold: - print(f"FOUND: '{track_name}' (track #{track_number}) matches '{clean_track}' (similarity: {similarity:.2f})") - print(f"Forcing album classification for track in '{album.name}'") - - # Return album info - force album classification! - return { - 'is_album': True, # Always true - we found it in an album! - 'album_name': album.name, - 'track_number': track_number, - 'clean_track_name': clean_track, # Use the ORIGINAL download title, not the database match - 'album_image_url': album.image_url, - 'confidence': similarity, - 'source': 'album_context_search' - } - - print(f"Track '{clean_track}' not found in album '{album.name}'") - - print(f"Track '{clean_track}' not found in any matching albums") - return None - - except Exception as e: - print(f"Error in album-aware search: {e}") - return None - - def _download_cover_art(self, artist: Artist, album_info: dict, target_dir: str): - """Download cover art for the album, prioritizing album artwork from Spotify""" - try: - import requests - import os - - cover_path = os.path.join(target_dir, "cover.jpg") - - # Skip if cover already exists - if os.path.exists(cover_path): - print("Cover art already exists") - return - - image_url = None - source_description = "" - - # Priority 1: Use album artwork from album_info if available - if album_info.get('album_image_url'): - image_url = album_info['album_image_url'] - source_description = f"album artwork for '{album_info.get('album_name', 'Unknown Album')}'" - print(f"Using album artwork from album_info") - - # Priority 2: Try to get album artwork via Spotify search if we have album name - elif album_info.get('album_name') and hasattr(self, 'spotify_client'): - try: - print(f"Searching Spotify for album artwork: '{album_info['album_name']}' by '{artist.name}'") - # Search for the specific album - search_query = f"album:{album_info['album_name']} artist:{artist.name}" - albums = self.spotify_client.search_albums(search_query, limit=1) - - if albums and len(albums) > 0: - album = albums[0] - if album.image_url: - image_url = album.image_url - source_description = f"Spotify album artwork for '{album.name}'" - print(f"Found album artwork via Spotify search") - else: - print(f"Album found but no image available") - else: - print(f"No album found in Spotify search") - except Exception as e: - print(f"Error searching Spotify for album artwork: {e}") - - # Priority 3: Fall back to artist image - if not image_url and artist.image_url: - image_url = artist.image_url - source_description = f"artist image for '{artist.name}'" - print(f"Falling back to artist image") - - # No image available - if not image_url: - print("No cover art available (no album artwork or artist image)") - return - - print(f"Downloading {source_description} from: {image_url}") - response = requests.get(image_url, timeout=10) - response.raise_for_status() - - with open(cover_path, 'wb') as f: - f.write(response.content) - - print(f"Cover art downloaded: {cover_path}") - - except Exception as e: - print(f"Error downloading cover art: {e}") - - def _create_single_track_album_info(self, download_item, artist: Artist) -> Optional[dict]: - """Create album_info dict for single track cover art download""" - try: - # Check if we have matched_album from Spotify matching - if hasattr(download_item, 'matched_album') and download_item.matched_album: - album = download_item.matched_album - print(f"Single track has matched album: '{album.name}' with image: {bool(album.image_url)}") - return { - 'album_name': album.name, - 'album_image_url': album.image_url, - 'is_album': False, # This is still a single track - 'track_number': 1, - 'clean_track_name': download_item.title - } - - # If no matched_album, try to detect from track title or existing album field - album_name = None - if hasattr(download_item, 'album') and download_item.album and download_item.album.strip(): - album_name = download_item.album.strip() - print(f"Using album name from download_item: '{album_name}'") - else: - print(f"No album information available for single track") - return None - - # Create basic album_info for cover art search - return { - 'album_name': album_name, - 'album_image_url': None, # Will be searched in _download_cover_art - 'is_album': False, # This is still a single track - 'track_number': 1, - 'clean_track_name': download_item.title - } - - except Exception as e: - print(f"Error creating single track album info: {e}") - return None - - def _generate_lrc_file(self, file_path: str, download_item, artist, album_info: dict) -> bool: - """ - Generate LRC lyrics file using LRClib API. - Elegant addition to post-processing - extracts metadata from existing context. - """ - try: - # Initialize lyrics client if not already done - if not hasattr(self, 'lyrics_client'): - self.lyrics_client = LyricsClient() - - if not self.lyrics_client: - return False - - # Get track metadata from download_item - track_name = getattr(download_item, 'title', 'Unknown Track') - - # Handle artist parameter (can be dict or object) - if isinstance(artist, dict): - artist_name = artist.get('name', 'Unknown Artist') - elif hasattr(artist, 'name'): - artist_name = artist.name - else: - artist_name = str(artist) if artist else 'Unknown Artist' - - album_name = None - duration_seconds = None - - # Get album name if available - if album_info and album_info.get('is_album'): - album_name = album_info.get('album_name') - - # Get duration from download_item if available - if hasattr(download_item, 'duration') and download_item.duration: - try: - duration_seconds = int(download_item.duration) - except (ValueError, TypeError): - pass - - # Generate LRC file using lyrics client - success = self.lyrics_client.create_lrc_file( - audio_file_path=file_path, - track_name=track_name, - artist_name=artist_name, - album_name=album_name, - duration_seconds=duration_seconds - ) - - if success: - print(f"LRC file generated for: {track_name}") - else: - print(f"No lyrics found for: {track_name}") - - return success - - except Exception as e: - print(f"Error generating LRC file for {file_path}: {e}") - return False - - def _extract_track_number(self, download_item, spotify_track=None) -> int: - """Extract track number from various sources""" - try: - print(f"Extracting track number for: '{download_item.title}'") - - # Method 1: Check if download_item has track_number attribute (explicit metadata) - if hasattr(download_item, 'track_number') and download_item.track_number: - track_num = int(download_item.track_number) - print(f" Found track_number attribute: {track_num}") - return track_num - - # Method 2: Parse from filename (e.g., "01. Track Name.mp3", "01 - Track Name.flac") - if hasattr(download_item, 'title'): - import re - # Look for patterns like "01. ", "01 ", "01-", "1. ", etc. - patterns = [ - r'^(\d{1,2})[\.\s\-]+', # "01. " or "01 " or "01-" - r'(\d{1,2})\s*[\.\-]\s*', # "01." or "01-" with optional spaces - ] - - for pattern in patterns: - match = re.match(pattern, download_item.title.strip()) - if match: - track_num = int(match.group(1)) - print(f" Parsed from title pattern '{pattern}': {track_num}") - return track_num - - # Method 3: Parse from filename if available - if hasattr(download_item, 'filename'): - import re - import os - # Get just the filename without extension and path - base_name = os.path.splitext(os.path.basename(download_item.filename))[0] - - patterns = [ - r'^(\d{1,2})[\.\s\-]+', # "01. " or "01 " or "01-" - r'(\d{1,2})\s*[\.\-]\s*', # "01." or "01-" with optional spaces - ] - - for pattern in patterns: - match = re.match(pattern, base_name.strip()) - if match: - track_num = int(match.group(1)) - print(f" Parsed from filename pattern '{pattern}': {track_num}") - return track_num - - # Method 4: Get from Spotify track data (would need album API call) - if spotify_track: - # This would require additional Spotify API call to get full album - # For now, we'll skip this but could be enhanced later - print(f" Spotify track data available but not implemented yet") - pass - - # Default to 1 if no track number found - print(f" No track number found, defaulting to 1") - return 1 - - except Exception as e: - print(f"Error extracting track number: {e}") - return 1 - - def _find_downloaded_file(self, original_file_path: str, download_item) -> Optional[str]: - """Try to find the downloaded file using various methods""" - try: - import os - import glob - from pathlib import Path - - print(f"Searching for downloaded file...") - print(f" Original path: {original_file_path}") - - # Get the download directory - download_dir = self.soulseek_client.download_path if self.soulseek_client else './downloads' - print(f" Download directory: {download_dir}") - - # Normalize path separators (convert Windows \\ to /) - normalized_path = original_file_path.replace('\\', '/') - - # Extract filename from the API path - api_filename = os.path.basename(normalized_path) - print(f" Looking for filename: {api_filename}") - - # Method 1: Try the exact path first (but normalized) - if os.path.exists(original_file_path): - print(f" Found exact path: {original_file_path}") - return original_file_path - - # Method 2: Search in the download directory recursively - search_patterns = [ - os.path.join(download_dir, "**", api_filename), - os.path.join(download_dir, "**", f"*{download_item.title}*"), - os.path.join(download_dir, "**", f"*{download_item.artist}*") - ] - - for pattern in search_patterns: - print(f" Searching pattern: {pattern}") - matches = glob.glob(pattern, recursive=True) - if matches: - # Filter for audio files - audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} - audio_matches = [f for f in matches if os.path.splitext(f)[1].lower() in audio_extensions] - - if audio_matches: - # Return the first match that exists and has reasonable size - for match in audio_matches: - if os.path.exists(match) and os.path.getsize(match) > 1024: # At least 1KB - print(f" Found match: {match}") - return match - - # Method 3: Look for recently modified files in download directory - print(f" Searching for recent files in download directory...") - recent_files = [] - audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} - - # Debug: List all files in download directory - print(f" Files in download directory:") - for root, dirs, files in os.walk(download_dir): - for file in files: - file_path = os.path.join(root, file) - rel_path = os.path.relpath(file_path, download_dir) - file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 - print(f" {rel_path} ({file_size} bytes)") - - if os.path.splitext(file)[1].lower() in audio_extensions: - # Check if file was modified recently (within last 5 minutes) - import time - if time.time() - os.path.getmtime(file_path) < 300: # 5 minutes - recent_files.append((file_path, os.path.getmtime(file_path))) - - # Sort by modification time (most recent first) - recent_files.sort(key=lambda x: x[1], reverse=True) - - for file_path, _ in recent_files[:5]: # Check top 5 most recent - print(f" Checking recent file: {file_path}") - # Simple filename matching - if (download_item.title.lower() in os.path.basename(file_path).lower() or - download_item.artist.lower() in os.path.basename(file_path).lower()): - print(f" Found recent match: {file_path}") - return file_path - - print(f" No matching files found") - return None - - except Exception as e: - print(f"Error searching for downloaded file: {e}") - return None - - def update_album_track_button_states(self, download_item, status): - """Update track download button states based on download progress""" - - # Find the track item that corresponds to this download - album_items_found = self.findChildren(AlbumResultItem) - - for album_item in album_items_found: - - for track_item in album_item.track_items: - track_title = track_item.track_result.title - track_artist = track_item.track_result.artist - - - # Match by track title and artist - if (track_title == download_item.title and track_artist == download_item.artist): - - - # Update button state based on download status - if status == 'downloading': - track_item.set_download_downloading_state() - print(f"[DEBUG] Set button to downloading state ()") - elif status in ['completed', 'finished']: - track_item.set_download_completed_state() - print(f"[DEBUG] Set button to completed state ()") - elif status in ['queued', 'initializing']: - track_item.set_download_queued_state() - print(f"[DEBUG] Set button to queued state ()") - elif status in ['failed', 'cancelled', 'canceled']: - track_item.reset_download_state() # Allow retry - print(f"[DEBUG] RESET button to downloadable state (⬇️) - track can now be downloaded again!") - else: - print(f"[DEBUG] Unknown status '{status}' - no button update performed") - - return - - - def start_stream(self, search_result, result_item=None): - """Start streaming a search result using StreamingThread or toggle if same track""" - try: - # Check if this is the same track that's currently playing - current_track_id = getattr(self, 'current_track_id', None) - new_track_id = f"{search_result.username}:{search_result.filename}" - - print(f"start_stream() called for: {search_result.filename}") - print(f"Current track ID: {current_track_id}") - print(f"New track ID: {new_track_id}") - print(f"Currently playing button: {self.currently_playing_button}") - print(f"Result item: {result_item}") - print(f"Button match: {self.currently_playing_button == result_item}") - print(f"Track ID match: {current_track_id == new_track_id}") - - if current_track_id == new_track_id and self.currently_playing_button == result_item: - # Same track clicked - toggle playback - print(f"Toggling playback for: {search_result.filename}") - - toggle_result = self.audio_player.toggle_playback() - print(f"toggle_playback() returned: {toggle_result}") - - if toggle_result: - # Now playing - result_item.set_playing_state() - self.track_resumed.emit() - print("Song card: Resumed playback") - else: - # Now paused - result_item.set_loading_state() # Use loading as "paused" state - self.track_paused.emit() - print("Song card: Paused playback") - - return - else: - print(f"🆕 Different track or button - starting new stream") - - print(f"Starting stream: {search_result.filename} from {search_result.username}") - - # Different track - stop current and start new - if self.currently_playing_button: - self.audio_player.stop_playback() - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - - # Stop any existing streaming threads AND cancel their downloads - self._stop_all_streaming_threads() - self._cancel_current_streaming_download_sync() - - # Track the new currently playing button and track - self.currently_playing_button = result_item - self.current_track_id = new_track_id - self.current_track_result = search_result - - # Clear Stream folder before starting new stream (release current file since we're switching) - self.clear_stream_folder(release_current_file=True) - - # Check if file is a valid audio type - audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav'] - filename_lower = search_result.filename.lower() - - is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions) - - if is_audio: - print(f"Streaming audio file: {search_result.filename}") - print(f" Quality: {search_result.quality}") - print(f" Size: {search_result.size // (1024*1024)}MB") - print(f" User: {search_result.username}") - - # Track current streaming download for potential cancellation - self.current_streaming_download = { - 'username': search_result.username, - 'filename': search_result.filename, - 'download_id': None # Will be set when download starts - } - print(f"Tracking new streaming download: {search_result.username}:{search_result.filename}") - - # Create and start streaming thread - streaming_thread = StreamingThread(self.soulseek_client, search_result) - streaming_thread.streaming_started.connect(self.on_streaming_started, Qt.ConnectionType.QueuedConnection) - streaming_thread.streaming_finished.connect(self.on_streaming_finished, Qt.ConnectionType.QueuedConnection) - streaming_thread.streaming_progress.connect(self.on_streaming_progress, Qt.ConnectionType.QueuedConnection) - streaming_thread.streaming_queued.connect(self.on_streaming_queued, Qt.ConnectionType.QueuedConnection) - streaming_thread.streaming_failed.connect(self.on_streaming_failed, Qt.ConnectionType.QueuedConnection) - streaming_thread.finished.connect( - functools.partial(self.on_streaming_thread_finished, streaming_thread), - Qt.ConnectionType.QueuedConnection - ) - - # Track the streaming thread - if not hasattr(self, 'streaming_threads'): - self.streaming_threads = [] - self.streaming_threads.append(streaming_thread) - - # Start the streaming - streaming_thread.start() - - else: - print(f"Cannot stream non-audio file: {search_result.filename}") - - except Exception as e: - print(f"Failed to start stream: {str(e)}") - - def on_streaming_started(self, message, search_result): - """Handle streaming start""" - print(f"Streaming started: {message}") - # Set button to loading state while file is being prepared - if self.currently_playing_button: - try: - self.currently_playing_button.set_loading_state() - except RuntimeError: - # Button was deleted, ignore - pass - - # Emit signal for media player loading animation - self.track_loading_started.emit(search_result) - - def on_streaming_finished(self, message, search_result): - """Handle streaming completion - start actual audio playback""" - print(f"Streaming finished: {message}") - - # Check if this streaming result is for the currently requested track - # Prevent old downloads from interrupting new songs - if hasattr(self, 'current_track_result') and self.current_track_result: - current_track_id = f"{self.current_track_result.username}:{self.current_track_result.filename}" - finished_track_id = f"{search_result.username}:{search_result.filename}" - - if current_track_id != finished_track_id: - print(f"Ignoring old streaming result for: {search_result.filename}") - print(f" Current track: {current_track_id}") - print(f" Finished track: {finished_track_id}") - return - - try: - # Find the stream file in the Stream folder - project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/ - stream_folder = os.path.join(project_root, 'Stream') - - # Find any audio file in the stream folder (should only be one) - stream_file = None - audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} - - for filename in os.listdir(stream_folder): - file_path = os.path.join(stream_folder, filename) - if (os.path.isfile(file_path) and - os.path.splitext(filename)[1].lower() in audio_extensions): - stream_file = file_path - break - - if stream_file and os.path.exists(stream_file): - # Start audio playback - success = self.audio_player.play_file(stream_file) - if success: - print(f"Started audio playback: {os.path.basename(stream_file)}") - # Set button to playing state - if self.currently_playing_button: - try: - self.currently_playing_button.set_playing_state() - except RuntimeError: - # Button was deleted, ignore - pass - # Emit track started signal for sidebar media player - if hasattr(self, 'current_track_result') and self.current_track_result: - self.track_loading_finished.emit(self.current_track_result) - self.track_started.emit(self.current_track_result) - else: - print(f"Failed to start audio playback") - # Reset button on failure - if self.currently_playing_button: - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.currently_playing_button = None - else: - print(f"Stream file not found in {stream_folder}") - # Reset button on failure - if self.currently_playing_button: - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.currently_playing_button = None - - except Exception as e: - print(f"Error starting audio playback: {e}") - # Reset button on error - if self.currently_playing_button: - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.currently_playing_button = None - - def on_streaming_progress(self, progress_percent, search_result): - """Handle streaming progress updates""" - print(f"Streaming progress: {progress_percent:.1f}% for {search_result.filename}") - - # Check if this progress is for the currently requested track - if hasattr(self, 'current_track_result') and self.current_track_result: - current_track_id = f"{self.current_track_result.username}:{self.current_track_result.filename}" - progress_track_id = f"{search_result.username}:{search_result.filename}" - - if current_track_id == progress_track_id: - # Emit progress signal for media player - self.track_loading_progress.emit(progress_percent, search_result) - else: - print(f"Ignoring progress for old streaming result: {search_result.filename}") - - def on_streaming_queued(self, queue_msg, search_result): - """Handle streaming queue state updates""" - print(f"Queue status: {queue_msg} for {search_result.filename}") - - # Check if this queue status is for the currently requested track - if hasattr(self, 'current_track_result') and self.current_track_result: - current_track_id = f"{self.current_track_result.username}:{self.current_track_result.filename}" - queued_track_id = f"{search_result.username}:{search_result.filename}" - - if current_track_id == queued_track_id: - # Show queue status in button - if self.currently_playing_button: - try: - self.currently_playing_button.set_queue_state() - except RuntimeError: - # Button was deleted, ignore - pass - print(f"Showing queue status for current track") - else: - print(f"Ignoring queue status for old streaming result: {search_result.filename}") - - def on_streaming_failed(self, error_msg, search_result): - """Handle streaming failure""" - print(f"Streaming failed: {error_msg}") - # Reset any play button that might be waiting - if self.currently_playing_button: - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.currently_playing_button = None - - def _stop_all_streaming_threads(self): - """Stop all active streaming threads to prevent old downloads from interrupting new streams""" - if hasattr(self, 'streaming_threads'): - print(f"Stopping {len(self.streaming_threads)} active streaming threads") - - for thread in self.streaming_threads[:]: # Use slice copy to avoid modification during iteration - try: - if thread.isRunning(): - print(f"Stopping streaming thread for: {getattr(thread.search_result, 'filename', 'unknown')}") - thread.stop() # Request stop - - # Give thread more time to stop gracefully (3 seconds) - if not thread.wait(3000): # Wait up to 3 seconds - print(f"Streaming thread taking longer to stop, giving more time...") - # Try one more time with longer wait - if not thread.wait(2000): # Additional 2 seconds - print(f"Force terminating unresponsive streaming thread") - thread.terminate() - thread.wait(1000) # Wait for termination - else: - print(f"Streaming thread stopped gracefully (delayed)") - else: - print(f"Streaming thread stopped gracefully") - - # Remove from list - if thread in self.streaming_threads: - self.streaming_threads.remove(thread) - - except Exception as e: - print(f"Error stopping streaming thread: {e}") - - print(f"All streaming threads stopped") - - async def _cancel_current_streaming_download(self): - """Cancel the current streaming download via slskd API to prevent queue clogging""" - if not hasattr(self, 'current_streaming_download') or not self.current_streaming_download: - return - - try: - username = self.current_streaming_download['username'] - filename = self.current_streaming_download['filename'] - print(f"Attempting to cancel streaming download: {username}:{os.path.basename(filename)}") - - # Find the download ID by searching current transfers - all_transfers = await self.soulseek_client._make_request('GET', 'transfers/downloads') - download_id = None - - if all_transfers: - # Flatten transfer data to find our download - for user_data in all_transfers: - if user_data.get('username') == username: - for directory in user_data.get('directories', []): - for file_data in directory.get('files', []): - if os.path.basename(file_data.get('filename', '')) == os.path.basename(filename): - download_id = file_data.get('id') - break - if download_id: - break - if download_id: - break - - if download_id: - print(f"Found streaming download ID: {download_id}") - # Cancel the download with remove=False (slskd won't allow remove=True for active downloads) - success = await self.soulseek_client.cancel_download(download_id, username, remove=False) - if success: - print(f"Successfully cancelled streaming download: {os.path.basename(filename)}") - else: - print(f"Failed to cancel streaming download: {os.path.basename(filename)}") - # Try without remove flag as fallback - try: - success = await self.soulseek_client.cancel_download(download_id, username, remove=False) - if success: - print(f"Cancelled streaming download with fallback method: {os.path.basename(filename)}") - except Exception as fallback_e: - print(f"Fallback cancellation also failed: {fallback_e}") - else: - print(f"Could not find download ID for streaming download: {os.path.basename(filename)}") - - except Exception as e: - print(f"Error cancelling streaming download: {e}") - # Continue with graceful fallback - don't let cancellation errors break streaming - print(f"Continuing with new stream despite cancellation error") - finally: - # Clean up any partial files from the cancelled streaming download - if hasattr(self, 'current_streaming_download') and self.current_streaming_download: - await self._cleanup_cancelled_streaming_files(self.current_streaming_download) - - # Clear tracking regardless of success to prevent stuck state - self.current_streaming_download = None - print(f"Cleared streaming download tracking") - - # Also clean up any completed streaming downloads to prevent queue clogging - await self._cleanup_completed_streaming_downloads() - - async def _cleanup_completed_streaming_downloads(self): - """Remove completed streaming downloads from slskd to prevent queue clogging""" - try: - print(f"Cleaning up completed streaming downloads...") - - # Get current transfers to find completed ones - all_transfers = await self.soulseek_client._make_request('GET', 'transfers/downloads') - completed_streaming_downloads = [] - - if all_transfers: - # Look for completed downloads that might be from streaming - for user_data in all_transfers: - username = user_data.get('username', '') - for directory in user_data.get('directories', []): - for file_data in directory.get('files', []): - state = file_data.get('state', '') - filename = file_data.get('filename', '') - download_id = file_data.get('id', '') - - # Check if this is a completed download - if ('Completed' in state and 'Succeeded' in state) and download_id: - # Consider audio files as potential streaming downloads - audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} - file_ext = os.path.splitext(filename)[1].lower() - - if file_ext in audio_extensions: - completed_streaming_downloads.append({ - 'id': download_id, - 'username': username, - 'filename': filename - }) - - # Remove completed streaming downloads (limit to prevent excessive cleanup) - max_cleanup = 5 # Only clean up 5 at a time to be conservative - for download in completed_streaming_downloads[:max_cleanup]: - try: - success = await self.soulseek_client.cancel_download( - download['id'], download['username'], remove=True - ) - if success: - print(f"Cleaned up completed streaming download: {os.path.basename(download['filename'])}") - else: - print(f"Failed to clean up: {os.path.basename(download['filename'])}") - except Exception as e: - print(f"Error cleaning up download {download['id']}: {e}") - - if completed_streaming_downloads: - print(f"Completed streaming download cleanup: {len(completed_streaming_downloads[:max_cleanup])} items removed") - else: - print(f"No completed streaming downloads found to clean up") - - except Exception as e: - print(f"Error during streaming download cleanup: {e}") - - async def _cleanup_cancelled_streaming_files(self, download_info): - """Clean up partial files from cancelled streaming downloads""" - try: - username = download_info.get('username', '') - filename = download_info.get('filename', '') - - if not username or not filename: - return - - print(f"Cleaning up cancelled streaming files for: {os.path.basename(filename)}") - - # Get downloads directory from config - from config.settings import config_manager - downloads_config = config_manager.get_downloads_config() - download_path = downloads_config.get('path', './downloads') - - # Look for partial/completed files in downloads directory - filename_base = os.path.splitext(os.path.basename(filename))[0] - - # Search for files that might match this download - for root, dirs, files in os.walk(download_path): - for file in files: - # Check if this file could be from our cancelled download - if (filename_base.lower() in file.lower() or - os.path.basename(filename).lower() == file.lower()): - - file_path = os.path.join(root, file) - try: - print(f"Removing cancelled streaming file: {file_path}") - os.remove(file_path) - - # Clean up empty directories - self._cleanup_empty_directories(download_path, file_path) - - except Exception as e: - print(f"Error removing file {file_path}: {e}") - - except Exception as e: - print(f"Error cleaning up cancelled streaming files: {e}") - - def _cancel_current_streaming_download_sync(self): - """Synchronous wrapper for cancelling current streaming download""" - if hasattr(self, 'current_streaming_download') and self.current_streaming_download: - # Use async event loop to run the cancellation - import asyncio - import threading - - try: - # Try to get existing event loop first - try: - loop = asyncio.get_running_loop() - # Loop is already running, we need to run in a thread - def run_in_thread(): - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - try: - new_loop.run_until_complete(self._cancel_current_streaming_download()) - finally: - new_loop.close() - - thread = threading.Thread(target=run_in_thread) - thread.start() - thread.join(timeout=5.0) # Wait max 5 seconds - return - except RuntimeError: - # No event loop in current thread - pass - - # Create and use new event loop - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self._cancel_current_streaming_download()) - finally: - loop.close() - - except Exception as e: - print(f"Error in sync streaming download cancellation: {e}") - print(f"Continuing with new stream despite sync cancellation error") - # Clear tracking as fallback to prevent stuck state - self.current_streaming_download = None - - def on_streaming_thread_finished(self, thread): - """Clean up when streaming thread finishes""" - try: - if hasattr(self, 'streaming_threads') and thread in self.streaming_threads: - self.streaming_threads.remove(thread) - - # Disconnect all signals to prevent stale connections - try: - thread.streaming_started.disconnect() - thread.streaming_finished.disconnect() - thread.streaming_failed.disconnect() - thread.finished.disconnect() - except Exception: - pass # Ignore if signals are already disconnected - - # Ensure thread is properly stopped before deletion - if thread.isRunning(): - thread.stop() - thread.wait(1000) # Wait up to 1 second - - # Use QTimer.singleShot for delayed cleanup - QTimer.singleShot(100, thread.deleteLater) - - except Exception as e: - print(f"Error cleaning up finished streaming thread: {e}") - - def on_audio_playback_finished(self): - """Handle when audio playback finishes""" - print("Audio playback completed") - # Reset the play button to play state - if self.currently_playing_button: - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.currently_playing_button = None - - # Emit track finished signal for sidebar media player - self.track_finished.emit() - - # Clear Stream folder when playback finishes (release file since playback is done) - self.clear_stream_folder(release_current_file=True) - - # Clear track state - self.current_track_id = None - self.current_track_result = None - - def on_audio_playback_error(self, error_msg): - """Handle audio playback errors""" - print(f"Audio playback error: {error_msg}") - # Reset the play button to play state - if self.currently_playing_button: - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.currently_playing_button = None - - # Emit track stopped signal for sidebar media player - self.track_stopped.emit() - - # Clear Stream folder when playback errors (release file since there's an error) - self.clear_stream_folder(release_current_file=True) - - # Clear track state - self.current_track_id = None - self.current_track_result = None - - def handle_sidebar_play_pause(self): - """Handle play/pause request from sidebar media player""" - # Use the actual QMediaPlayer state instead of manual flag - from PyQt6.QtMultimedia import QMediaPlayer - - current_state = self.audio_player.playbackState() - print(f"handle_sidebar_play_pause() - Current state: {current_state}") - print(f"handle_sidebar_play_pause() - Current source: {self.audio_player.source().toString()}") - - if current_state == QMediaPlayer.PlaybackState.PlayingState: - print("Sidebar: Pausing playback") - self.audio_player.pause() - # is_playing will be set automatically by _on_playback_state_changed - if self.currently_playing_button: - try: - self.currently_playing_button.set_loading_state() # Use as "paused" state - except RuntimeError: - # Button was deleted, ignore - pass - self.track_paused.emit() - print("Paused from sidebar") - else: - print("Sidebar: Attempting to resume/play") - self.audio_player.play() - # is_playing will be set automatically by _on_playback_state_changed - if self.currently_playing_button: - try: - self.currently_playing_button.set_playing_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.track_resumed.emit() - print("Resumed from sidebar") - - def handle_sidebar_stop(self): - """Handle stop request from sidebar media player""" - self.audio_player.stop_playback() - if self.currently_playing_button: - try: - self.currently_playing_button.reset_play_state() - except RuntimeError: - # Button was deleted, ignore - pass - self.currently_playing_button = None - - # Emit track stopped signal - self.track_stopped.emit() - - # Clear Stream folder when stopping (release file since user explicitly stopped) - self.clear_stream_folder(release_current_file=True) - - # Clear track state - self.current_track_id = None - self.current_track_result = None - print("Stopped from sidebar") - - def handle_sidebar_volume(self, volume): - """Handle volume change from sidebar media player""" - self.audio_player.audio_output.setVolume(volume) - print(f"Volume set to {int(volume * 100)}% from sidebar") - - def clear_stream_folder(self, release_current_file=True): - """Clear all files from the Stream folder to prevent playing wrong files - - Args: - release_current_file (bool): Whether to release the current audio file handle. - Set to False if you want to clear old files but keep current playback. - """ - try: - # Only release file handles if explicitly requested - if release_current_file and hasattr(self, 'audio_player') and self.audio_player: - self.audio_player.release_file() - print("Released audio player file handle before clearing") - - project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/ - stream_folder = os.path.join(project_root, 'Stream') - - if os.path.exists(stream_folder): - for filename in os.listdir(stream_folder): - file_path = os.path.join(stream_folder, filename) - if os.path.isfile(file_path): - try: - os.remove(file_path) - print(f"Cleared old stream file: {filename}") - except Exception as e: - print(f"Could not remove stream file {filename}: {e}") - - except Exception as e: - print(f"Error clearing stream folder: {e}") - - def on_download_completed(self, message, download_item): - """Handle successful download start (NOT completion)""" - print(f"Download started: {message}") - - # Extract download ID from message if available - if "Download started:" in message and download_item: - # Message format is "Download started: " - download_id_part = message.replace("Download started:", "").strip() - - # Check if this looks like a UUID (real download ID) vs filename - import re - uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - if re.match(uuid_pattern, download_id_part, re.IGNORECASE): - download_item.download_id = download_id_part - print(f"[DEBUG] Stored real download ID: {download_id_part}") - else: - print(f"[DEBUG] Using filename as download ID: {download_id_part}") - download_item.download_id = download_id_part - - # Set status to downloading, not completed! - download_item.status = "downloading" - download_item.progress = 0 - - def on_download_failed(self, error_msg, download_item): - """Handle download failure""" - print(f"Download failed: {error_msg}") - # Update download item status to failed - download_item.status = "failed" - download_item.progress = 0 - - # Emit activity signal for download failure - self.download_activity.emit("", "Download Failed", f"'{download_item.title}' - {error_msg}", "Now") - - # Error logged to console for debugging - - def on_download_progress(self, message, download_item): - """Handle download progress updates""" - print(f"Download progress: {message}") - # Extract progress percentage if available from message - # For now just show as downloading - download_item.status = "downloading" - - def on_download_thread_finished(self, thread): - """Clean up when download thread finishes""" - try: - if thread in self.download_threads: - self.download_threads.remove(thread) - - # Disconnect all signals to prevent stale connections - try: - thread.download_completed.disconnect() - thread.download_failed.disconnect() - thread.download_progress.disconnect() - thread.finished.disconnect() - except Exception: - pass # Ignore if signals are already disconnected - - # Ensure thread is properly stopped before deletion - if thread.isRunning(): - thread.stop() - thread.wait(1000) # Wait up to 1 second - - # Use QTimer.singleShot for delayed cleanup to ensure signal processing is complete - QTimer.singleShot(100, thread.deleteLater) - - except Exception as e: - print(f"Error cleaning up finished download thread: {e}") - - def _run_async_operation(self, async_func, *args, success_callback=None, error_callback=None): - """Helper method to run async operations safely with proper event loop management""" - import asyncio - import threading - - def run_operation(): - """Run the async operation in a separate thread with its own event loop""" - try: - # Create a fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Run the async operation - result = loop.run_until_complete(async_func(*args)) - - # Schedule success callback on main thread if provided - if success_callback: - QTimer.singleShot(0, lambda: success_callback(result)) - - return result - - except Exception as e: - print(f"[ERROR] Exception in async operation: {e}") - import traceback - traceback.print_exc() - - # Schedule error callback on main thread if provided - if error_callback: - # Capture the error in a closure to avoid lambda variable issues - def call_error_callback(error=e): - error_callback(error) - QTimer.singleShot(0, call_error_callback) - - return False - - finally: - # Always close the loop we created - try: - loop.close() - except Exception as close_e: - print(f"[WARNING] Error closing event loop: {close_e}") - - try: - # Run the operation in a separate thread - operation_thread = threading.Thread(target=run_operation, daemon=True) - operation_thread.start() - - except Exception as e: - print(f"[ERROR] Exception starting async operation thread: {e}") - import traceback - traceback.print_exc() - - def clear_completed_downloads(self): - """Clear completed and cancelled downloads from both slskd backend and local queues""" - print("[DEBUG] DownloadsPage.clear_completed_downloads() method called!") - print(f"[DEBUG] Current download queue stats:") - print(f"[DEBUG] - Active queue: {len(self.download_queue.active_queue.download_items)} items") - print(f"[DEBUG] - Finished queue: {len(self.download_queue.finished_queue.download_items)} items") - - if not self.soulseek_client: - print("[ERROR] No soulseek client available for clearing downloads") - return - - # Run async clear operation using threading to avoid event loop conflicts - import asyncio - import threading - - # Define UI update callback outside the thread (with proper self reference) - def update_ui_callback(): - """UI update callback that runs on main thread""" - print("[DEBUG] *** UI CALLBACK EXECUTED *** - Starting UI clear operations...") - try: - # Step 1: Clear local queues - print("[DEBUG] Step 1: Calling clear_local_queues_only()...") - self.download_queue.clear_local_queues_only() - print("[DEBUG] Step 1 completed successfully") - - # Step 2: Update download status - print("[DEBUG] Step 2: Calling update_download_status()...") - self.update_download_status() - print("[DEBUG] Step 2 completed successfully") - - print("[DEBUG] *** UI CALLBACK COMPLETED *** - All UI clear operations finished") - except Exception as e: - print(f"[ERROR] Exception in UI callback: {e}") - import traceback - traceback.print_exc() - # Even if there's an error, try to update the display - try: - print("[DEBUG] Attempting fallback queue update...") - if hasattr(self, 'download_queue'): - self.download_queue.update_tab_counts() - except Exception as fallback_e: - print(f"[ERROR] Fallback update also failed: {fallback_e}") - - def run_clear_operation(): - """Run the clear operation in a separate thread with its own event loop""" - success = False - try: - # Create a fresh event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - print("[DEBUG] Clearing all completed/cancelled downloads from slskd backend...") - success = loop.run_until_complete(self.soulseek_client.clear_all_completed_downloads()) - - if success: - print("[DEBUG] Successfully cleared completed/cancelled downloads from backend") - else: - print("[WARNING] Backend reported failure, but proceeding with UI clearing anyway") - print("[WARNING] (Web UI may have cleared successfully despite backend failure report)") - - except Exception as e: - print(f"[ERROR] Exception during clear completed downloads: {e}") - import traceback - traceback.print_exc() - finally: - # Always close the loop we created - try: - loop.close() - except Exception as close_e: - print(f"[WARNING] Error closing event loop: {close_e}") - - # CRITICAL: Use signal to communicate with main thread (thread-safe) - print("[DEBUG] Thread completed, emitting completion signal...") - self.clear_completed_finished.emit(success, update_ui_callback) - - try: - # Run the clear operation in a separate thread - clear_thread = threading.Thread(target=run_clear_operation, daemon=True) - clear_thread.start() - - except Exception as e: - print(f"[ERROR] Exception starting clear completed downloads thread: {e}") - import traceback - traceback.print_exc() - - def _handle_clear_completion(self, backend_success, ui_callback): - """Handle completion of clear operation on main thread""" - print(f"[DEBUG] _handle_clear_completion called on main thread - backend_success: {backend_success}") - - # ALWAYS clear UI regardless of backend success/failure - # This ensures UI stays in sync even if backend reports false negatives - print("[DEBUG] Executing UI callback on main thread...") - try: - ui_callback() - except Exception as e: - print(f"[ERROR] Exception executing UI callback: {e}") - import traceback - traceback.print_exc() - - def _handle_api_cleanup_completion(self, success, download_id, username): - """Handle completion of API cleanup operation on main thread""" - if success: - print(f"Successfully signaled completion for download {download_id}") - else: - print(f"Failed to signal completion for download {download_id}") - - def _on_download_completion_finished(self, download_item, organized_path): - """Handle successful completion of background download processing""" - print(f"Background processing completed for '{download_item.title}' -> {organized_path}") - - # Update the download item status and progress on main thread - download_item.update_status( - status='completed', - progress=100, - download_speed=0, - file_path=organized_path - ) - - # Move completed items to finished queue on main thread - print(f"[DEBUG] Moving completed download '{download_item.title}' to finished queue") - self.download_queue.move_to_finished(download_item) - - def _on_download_completion_error(self, download_item, error_message): - """Handle error in background download processing""" - print(f"Background processing failed for '{download_item.title}': {error_message}") - - # Still mark as completed but with original path, move to finished queue - download_item.update_status( - status='completed', - progress=100, - download_speed=0, - file_path=download_item.file_path # Keep original path on error - ) - - # Move to finished queue even on error - print(f"[DEBUG] Moving download '{download_item.title}' to finished queue after error") - self.download_queue.move_to_finished(download_item) - - def _update_adaptive_polling(self): - """OPTIMIZATION v2: Update polling frequency based on download activity""" - try: - active_downloads = len([item for item in self.download_queue.download_items - if item.status.lower() in ['downloading', 'queued', 'initializing']]) - - # Determine optimal polling mode - new_mode = 'idle' - if self._bulk_operation_active: - new_mode = 'bulk_pause' - elif active_downloads > 0: - new_mode = 'active' - - # Update timer if mode changed - if new_mode != self._current_polling_mode: - new_interval = self._polling_intervals[new_mode] - self.download_status_timer.setInterval(new_interval) - self._current_polling_mode = new_mode - - self._last_active_count = active_downloads - - except Exception as e: - pass # Silent adaptive polling failures - - def update_download_status_v2(self): - """OPTIMIZATION v2: Robust queue state management with thread safety""" - if not self.soulseek_client: - return - - # Use thread-safe access to download items - download_items = self._queue_manager.get_download_items_copy() - if not download_items: - return - - # Update adaptive polling - self._update_adaptive_polling() - - def handle_status_update_v2(transfers_data): - """Enhanced status update handler with robust state management""" - import time - try: - if not transfers_data: - return - - # Flatten transfers data efficiently - all_transfers = [] - for user_data in transfers_data: - if 'directories' in user_data: - for directory in user_data['directories']: - if 'files' in directory: - all_transfers.extend(directory['files']) - - - # Use thread-safe operations throughout - with self._queue_consistency_lock: - matched_transfer_ids = set() - - # Process each download item with improved matching - for download_item in download_items: - if download_item.status.lower() in ['completed', 'finished', 'cancelled', 'failed']: - continue - - # Enhanced ID-based matching - matching_transfer = self._find_matching_transfer_v2( - download_item, all_transfers, matched_transfer_ids - ) - - if matching_transfer: - matched_transfer_ids.add(matching_transfer.get('id')) - self._process_transfer_match_v2(download_item, matching_transfer) - else: - self._handle_missing_transfer_v2(download_item) - - # Update UI counters - self.download_queue.update_tab_counts() - - # Enhanced logging: periodic queue health summary - if not hasattr(self, '_last_queue_summary_time'): - self._last_queue_summary_time = 0 - if time.time() - self._last_queue_summary_time > 30: # Every 30 seconds - self._log_queue_health_summary(download_items) - self._last_queue_summary_time = time.time() - - except Exception as e: - print(f"[ERROR] Status update v2 failed: {e}") - import traceback - traceback.print_exc() - - # Create status thread with error handling - try: - status_thread = TransferStatusThread(self.soulseek_client) - status_thread.transfer_status_completed.connect(handle_status_update_v2) - status_thread.finished.connect(lambda: self._cleanup_status_thread_v2(status_thread)) - - # Track threads for cleanup - if not hasattr(self, '_active_status_threads_v2'): - self._active_status_threads_v2 = set() - self._active_status_threads_v2.add(status_thread) - - status_thread.start() - - except Exception as e: - print(f"[ERROR] Failed to start status update thread v2: {e}") - - def _log_queue_health_summary(self, download_items): - """Log periodic queue health summary for debugging stuck downloads""" - import time - - try: - if not download_items: - return - - # Count downloads by status - status_counts = {} - stuck_downloads = [] - - for item in download_items: - status = item.status.lower() - status_counts[status] = status_counts.get(status, 0) + 1 - - # Check for potentially stuck downloads - if hasattr(item, 'queue_start_time') and item.queue_start_time: - queue_age = time.time() - item.queue_start_time - if queue_age > 15: # More than 15 seconds in queue - stuck_downloads.append((item.title, queue_age)) - - # Check for items with high API missing counts - if hasattr(item, 'api_missing_count_v2') and item.api_missing_count_v2 > 1: - stuck_downloads.append((item.title, f"API missing {item.api_missing_count_v2} cycles")) - - # Log summary - print(f"Queue Health: {status_counts}") - if stuck_downloads: - print(f"Potentially stuck downloads: {len(stuck_downloads)}") - for title, issue in stuck_downloads[:3]: # Show first 3 - if isinstance(issue, str): - print(f" - {title}: {issue}") - else: - print(f" - {title}: queued for {issue:.1f}s") - - except Exception as e: - pass # Silent logging failures - - def _find_matching_transfer_v2(self, download_item, all_transfers, matched_ids): - """Enhanced transfer matching with better ID tracking""" - # Primary: ID-based matching - if hasattr(download_item, 'download_id') and download_item.download_id: - for transfer in all_transfers: - transfer_id = transfer.get('id') - if transfer_id == download_item.download_id and transfer_id not in matched_ids: - return transfer - - # Fallback: Enhanced filename matching (simplified for performance) - for transfer in all_transfers: - transfer_id = transfer.get('id') - if transfer_id in matched_ids: - continue - - filename = transfer.get('filename', '').lower() - title = download_item.title.lower() - - # Quick contains check for performance - if title in filename or any(word in filename for word in title.split() if len(word) > 3): - return transfer - - return None - - def _process_transfer_match_v2(self, download_item, transfer): - """Process matched transfer with atomic state updates and progressive timeout logic""" - import time - - state = transfer.get('state', 'Unknown') - progress = min(100, max(0, transfer.get('percentComplete', 0))) - - # Initialize queue tracking if needed - if not hasattr(download_item, 'queue_start_time'): - download_item.queue_start_time = None - - # Determine new status - if 'Completed' in state or 'Succeeded' in state: - new_status = 'completed' - progress = 100 - elif 'Cancelled' in state or 'Canceled' in state: - new_status = 'cancelled' - elif 'Failed' in state or 'Errored' in state: - new_status = 'failed' - elif 'InProgress' in state: - new_status = 'downloading' - else: - new_status = 'queued' - - # Track queue state transitions for progressive timeout - if new_status in ['queued', 'initializing'] and download_item.queue_start_time is None: - download_item.queue_start_time = time.time() - print(f"Download entered queue: {download_item.title}") - elif new_status in ['downloading', 'completed', 'cancelled', 'failed']: - if download_item.queue_start_time: - queue_duration = time.time() - download_item.queue_start_time - print(f"⏱️ Download '{download_item.title}' was in queue for {queue_duration:.1f}s") - download_item.queue_start_time = None # Reset queue timer - - # Atomic status update - self._queue_manager.atomic_state_transition( - download_item, new_status, - callback=lambda item, old, new: self._handle_status_change_v2(item, old, new, transfer) - ) - - # Update progress and metadata - download_item.update_status( - status=new_status, - progress=int(progress), - download_speed=int(transfer.get('averageSpeed', 0)), - file_path=transfer.get('filename', download_item.file_path) - ) - - # Reset API missing count when transfer is found and add enhanced logging - if hasattr(download_item, 'api_missing_count_v2') and download_item.api_missing_count_v2 > 0: - print(f"Download reconnected to API after {download_item.api_missing_count_v2} missing cycles: {download_item.title}") - download_item.api_missing_count_v2 = 0 - - # Update ID mapping if needed - transfer_id = transfer.get('id') - if transfer_id and (not hasattr(download_item, 'download_id') or download_item.download_id != transfer_id): - self._queue_manager.update_id_mapping_safe(download_item, transfer_id) - - def _handle_status_change_v2(self, download_item, old_status, new_status, transfer): - """Handle status change transitions""" - if new_status in ['completed', 'cancelled', 'failed']: - # Move to finished queue - self.download_queue.move_to_finished(download_item) - - # Emit specific activity signal for failures - if new_status == 'failed': - self.download_activity.emit("", "Download Failed", f"'{download_item.title}' by {download_item.artist}", "Now") - elif new_status == 'cancelled': - self.download_activity.emit("", "Download Cancelled", f"'{download_item.title}' by {download_item.artist}", "Now") - - # Cleanup API if needed - if new_status in ['cancelled', 'failed'] and hasattr(download_item, 'download_id'): - self._schedule_api_cleanup_v2(download_item) - - # Schedule fallback cleanup check for errored downloads - if new_status == 'failed': - self._schedule_fallback_cleanup_check() - - def _handle_missing_transfer_v2(self, download_item): - """Handle downloads missing from API with improved grace period and queue age tracking""" - import time - - # Initialize tracking attributes - if not hasattr(download_item, 'api_missing_count_v2'): - download_item.api_missing_count_v2 = 0 - if not hasattr(download_item, 'queue_start_time'): - download_item.queue_start_time = None - - # Track when downloads first enter queued state - current_status = download_item.status.lower() - if current_status in ['queued', 'initializing'] and download_item.queue_start_time is None: - download_item.queue_start_time = time.time() - print(f"Started tracking queue time for: {download_item.title}") - elif current_status not in ['queued', 'initializing']: - download_item.queue_start_time = None # Reset if no longer queued - - download_item.api_missing_count_v2 += 1 - - # Enhanced timeout logic with queue age consideration - queue_timeout_exceeded = False - if download_item.queue_start_time: - queue_age = time.time() - download_item.queue_start_time - queue_timeout = 180.0 # 3 minutes max in queue - if queue_age > queue_timeout: - queue_timeout_exceeded = True - print(f"⏰ Queue timeout exceeded: {download_item.title} stuck in queue for {queue_age:.1f}s") - - # Fail download if API missing for 3 cycles OR queue timeout exceeded - if download_item.api_missing_count_v2 >= 3 or queue_timeout_exceeded: - if queue_timeout_exceeded: - print(f"Download failed due to queue timeout: {download_item.title}") - else: - print(f"Download missing from API for {download_item.api_missing_count_v2} cycles: {download_item.title}") - self._queue_manager.atomic_state_transition(download_item, 'failed') - self.download_queue.move_to_finished(download_item) - - def _schedule_api_cleanup_v2(self, download_item): - """Schedule API cleanup with robust retry mechanism""" - def cleanup_task_with_retry(): - import time - import asyncio - - max_retries = 3 - retry_delays = [2, 5, 10] # Progressive delays in seconds - - for attempt in range(max_retries): - try: - if not (hasattr(download_item, 'download_id') and download_item.soulseek_client): - return - - # Wait before cleanup to let API stabilize - if attempt == 0: - time.sleep(1) # Initial delay for API stabilization - else: - time.sleep(retry_delays[attempt - 1]) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - success = loop.run_until_complete( - download_item.soulseek_client.cancel_download( - download_item.download_id, - getattr(download_item, 'username', ''), - remove=True - ) - ) - - if success: - return # Success, exit retry loop - else: - if attempt == max_retries - 1: - print(f"API cleanup failed after {max_retries} attempts: {download_item.title}") - - finally: - loop.close() - - except Exception as e: - if attempt == max_retries - 1: - print(f"API cleanup failed after {max_retries} attempts: {e}") - # Continue to next retry - - self._optimized_api_pool.submit(cleanup_task_with_retry) - - def _schedule_fallback_cleanup_check(self): - """Schedule a fallback cleanup check for persistent errored entries""" - def fallback_cleanup_task(): - import time - import asyncio - - # Wait longer before fallback cleanup - time.sleep(30) # 30 second delay - - try: - if not self.soulseek_client: - return - - # Get current transfers to find persistent errored entries - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - transfers_data = loop.run_until_complete( - self.soulseek_client.get_all_downloads() - ) - - if transfers_data: - # Find errored entries that should have been cleaned up - persistent_errors = [] - for user_data in transfers_data: - if 'directories' in user_data: - for directory in user_data['directories']: - if 'files' in directory: - for transfer in directory['files']: - state = transfer.get('state', '') - if any(error_state in state for error_state in ['Failed', 'Errored', 'Cancelled']): - persistent_errors.append(transfer) - - # Clean up persistent errors (max 5 at a time to be conservative) - for transfer in persistent_errors[:5]: - try: - success = loop.run_until_complete( - self.soulseek_client.cancel_download( - transfer.get('id'), - transfer.get('username', ''), - remove=True - ) - ) - if success: - print(f"Fallback cleanup successful for persistent error") - except Exception as e: - pass # Silent fallback failures - - finally: - loop.close() - - except Exception as e: - pass # Silent fallback failures - - # Schedule fallback cleanup - self._optimized_api_pool.submit(fallback_cleanup_task) - - def _cleanup_status_thread_v2(self, thread): - """Clean up status threads""" - try: - if hasattr(self, '_active_status_threads_v2') and thread in self._active_status_threads_v2: - self._active_status_threads_v2.remove(thread) - thread.deleteLater() - except Exception as e: - pass # Silent thread cleanup failures - - def enable_optimized_systems(self): - """Enable all v2 optimization systems""" - self._use_optimized_systems = True - print("Enabled optimized download systems") - - # Switch to optimized timer callback - self.download_status_timer.timeout.disconnect() - self.download_status_timer.timeout.connect(self.update_download_status_v2) - - # Update timer interval for initial adaptive mode - self.download_status_timer.setInterval(self._polling_intervals['active']) - - def disable_optimized_systems(self): - """Disable v2 optimizations and revert to original behavior""" - self._use_optimized_systems = False - print("Disabled optimizations, reverted to original system") - - # Revert to original timer callback - self.download_status_timer.timeout.disconnect() - self.download_status_timer.timeout.connect(self.update_download_status) - self.download_status_timer.setInterval(1000) # Back to 1 second - -# In class DownloadsPage, REPLACE your old update_download_status method with this one. - - def update_download_status(self): - """ - Starts the background worker to process download statuses without blocking the UI. - This method is called by the QTimer. - """ - if self._is_status_update_running or not self.soulseek_client: - return # Don't start a new worker if one is already processing. - - # Check if there are any active downloads to process - active_items = [item for item in self.download_queue.active_queue.download_items] - if not active_items: - self._is_status_update_running = False - return - - self._is_status_update_running = True - - # Create and start the background worker - worker = StatusProcessingWorker( - soulseek_client=self.soulseek_client, - download_items=active_items - ) - - # Connect signals from the worker to the main thread handler - worker.signals.completed.connect(self._handle_processed_status_updates) - worker.signals.error.connect(lambda e: print(f"Status Worker Error: {e}")) - - # The worker will automatically be cleaned up by the thread pool. - self.status_processing_pool.start(worker) - - - def _periodic_cleanup_check(self): - """Check for completed downloads and clean them up intelligently""" - if not self.soulseek_client: - return - - try: - # Clean up bulk downloads found in previous tick - if self.downloads_to_cleanup: - print(f"[CLEANUP] Bulk cleaning {len(self.downloads_to_cleanup)} completed downloads from backend") - self._cleanup_backend_downloads(self.downloads_to_cleanup) - self.downloads_to_cleanup.clear() - - # Clean up individual downloads found in previous tick (errored ones) - if self.individual_downloads_to_cleanup: - print(f"[CLEANUP] Individually cleaning {len(self.individual_downloads_to_cleanup)} errored downloads from backend") - self._cleanup_individual_downloads(self.individual_downloads_to_cleanup) - self.individual_downloads_to_cleanup.clear() - - # Find new completed downloads for next tick - self._find_completed_downloads_for_cleanup() - - except Exception as e: - print(f"[ERROR] Error in periodic cleanup: {e}") - - def _find_completed_downloads_for_cleanup(self): - """Find downloads that need cleanup in the next tick""" - try: - # Get current downloads from backend - async def check_backend_downloads(): - try: - result = await self.soulseek_client.get_all_downloads() - if result: - bulk_cleanup_states = {'Completed, Succeeded', 'Completed, Cancelled', 'Cancelled', 'Canceled'} - individual_cleanup_states = {'Completed, Errored', 'Failed', 'Errored'} - - new_bulk_cleanup = set() - new_individual_cleanup = [] - - for download in result: - if download.state in bulk_cleanup_states: - # These can be cleared with bulk clear operation - download_key = f"{download.username}:{download.id}" - new_bulk_cleanup.add(download_key) - elif download.state in individual_cleanup_states: - # These need individual removal calls - download_info = { - 'username': download.username, - 'id': download.id, - 'state': download.state - } - new_individual_cleanup.append(download_info) - - if new_bulk_cleanup or new_individual_cleanup: - print(f"[CLEANUP] Found {len(new_bulk_cleanup)} bulk + {len(new_individual_cleanup)} individual downloads needing cleanup") - self.downloads_to_cleanup.update(new_bulk_cleanup) - if new_individual_cleanup: - # Store individual cleanup items separately - if not hasattr(self, 'individual_downloads_to_cleanup'): - self.individual_downloads_to_cleanup = [] - self.individual_downloads_to_cleanup.extend(new_individual_cleanup) - except Exception as e: - print(f"[ERROR] Error checking backend downloads: {e}") - - # Run in background to avoid blocking UI - def run_check(): - import asyncio - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(check_backend_downloads()) - finally: - try: - loop.close() - except Exception: - pass - - from concurrent.futures import ThreadPoolExecutor - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit(run_check) - - except Exception as e: - print(f"[ERROR] Error finding downloads for cleanup: {e}") - - def _cleanup_backend_downloads(self, download_keys): - """Clean up specific downloads from backend""" - try: - def do_cleanup(): - try: - # Use the existing clear all completed downloads method - # This is simpler and more reliable than individual cleanup - success = self.soulseek_client.clear_all_completed_downloads() - if success: - print(f"[CLEANUP] Successfully cleared completed downloads from backend") - else: - print(f"[CLEANUP] Failed to clear completed downloads from backend") - except Exception as e: - print(f"[ERROR] Error during backend cleanup: {e}") - - # Run cleanup in background - from concurrent.futures import ThreadPoolExecutor - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit(do_cleanup) - - except Exception as e: - print(f"[ERROR] Error in cleanup backend downloads: {e}") - - def _cleanup_individual_downloads(self, download_infos): - """Clean up specific errored downloads individually using cancel_download with remove=True""" - try: - def do_individual_cleanup(): - try: - import asyncio - - async def cleanup_downloads(): - success_count = 0 - for download_info in download_infos: - try: - username = download_info['username'] - download_id = download_info['id'] - state = download_info['state'] - - print(f"[CLEANUP] Removing {state} download: {username}/{download_id}") - success = await self.soulseek_client.cancel_download( - download_id=download_id, - username=username, - remove=True - ) - - if success: - success_count += 1 - print(f"[CLEANUP] Successfully removed {state} download: {username}/{download_id}") - else: - print(f"[CLEANUP] Failed to remove {state} download: {username}/{download_id}") - - except Exception as e: - print(f"[ERROR] Error removing individual download {download_info}: {e}") - - print(f"[CLEANUP] Individual cleanup completed: {success_count}/{len(download_infos)} removed") - - # Run the async cleanup - asyncio.run(cleanup_downloads()) - - except Exception as e: - print(f"[ERROR] Error during individual cleanup: {e}") - - # Run cleanup in background - from concurrent.futures import ThreadPoolExecutor - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit(do_individual_cleanup) - - except Exception as e: - print(f"[ERROR] Error in cleanup individual downloads: {e}") - - - def cleanup_all_threads(self): - """Stop and cleanup all active threads""" - try: - # Stop download status timer first - if hasattr(self, 'download_status_timer'): - self.download_status_timer.stop() - - # Stop search thread - if self.search_thread and self.search_thread.isRunning(): - self.search_thread.stop() - self.search_thread.wait(2000) # Wait up to 2 seconds - if self.search_thread.isRunning(): - self.search_thread.terminate() - self.search_thread.wait(1000) - self.search_thread.deleteLater() - self.search_thread = None - - # Stop explore thread - if self.explore_thread and self.explore_thread.isRunning(): - self.explore_thread.stop() - self.explore_thread.wait(2000) # Wait up to 2 seconds - if self.explore_thread.isRunning(): - self.explore_thread.terminate() - self.explore_thread.wait(1000) - self.explore_thread.deleteLater() - self.explore_thread = None - - # Stop session thread - if self.session_thread and self.session_thread.isRunning(): - self.session_thread.stop() - self.session_thread.wait(2000) # Wait up to 2 seconds - if self.session_thread.isRunning(): - self.session_thread.terminate() - self.session_thread.wait(1000) - self.session_thread.deleteLater() - self.session_thread = None - - # CRITICAL FIX: Stop all status update threads - for status_thread in self.status_update_threads[:]: # Copy list to avoid modification during iteration - try: - # Disconnect signals first - try: - status_thread.status_updated.disconnect() - status_thread.finished.disconnect() - except Exception: - pass # Ignore if signals are already disconnected - - if status_thread.isRunning(): - status_thread.stop() - status_thread.wait(2000) # Wait up to 2 seconds - if status_thread.isRunning(): - status_thread.terminate() - status_thread.wait(1000) - status_thread.deleteLater() - except Exception as e: - print(f"Error cleaning up status update thread: {e}") - - self.status_update_threads.clear() - - # Stop all download threads with proper cleanup - for download_thread in self.download_threads[:]: # Copy list to avoid modification during iteration - try: - # Disconnect signals first - try: - download_thread.download_completed.disconnect() - download_thread.download_failed.disconnect() - download_thread.download_progress.disconnect() - download_thread.finished.disconnect() - except Exception: - pass # Ignore if signals are already disconnected - - if download_thread.isRunning(): - download_thread.stop() - download_thread.wait(2000) # Wait up to 2 seconds - if download_thread.isRunning(): - download_thread.terminate() - download_thread.wait(1000) - download_thread.deleteLater() - except Exception as e: - print(f"Error cleaning up download thread: {e}") - - self.download_threads.clear() - - # Stop all API cleanup threads - for cleanup_thread in self.api_cleanup_threads[:]: # Copy list to avoid modification during iteration - try: - # Disconnect signals first - try: - cleanup_thread.cleanup_completed.disconnect() - cleanup_thread.finished.disconnect() - except Exception: - pass # Ignore if signals are already disconnected - - if cleanup_thread.isRunning(): - cleanup_thread.wait(2000) # Wait up to 2 seconds for completion - if cleanup_thread.isRunning(): - cleanup_thread.terminate() - cleanup_thread.wait(1000) - cleanup_thread.deleteLater() - except Exception as e: - print(f"Error cleaning up API cleanup thread: {e}") - - self.api_cleanup_threads.clear() - - except Exception as e: - print(f"Error during thread cleanup: {e}") - - def closeEvent(self, event): - """Handle widget close event""" - self.cleanup_all_threads() - super().closeEvent(event) - - def __del__(self): - """Destructor - ensure cleanup happens even if closeEvent isn't called""" - try: - self.cleanup_all_threads() - except: - pass # Ignore errors during destruction - - def on_paths_updated(self, key: str, value: str): - """Handle settings path updates for immediate effect""" - # No action needed - paths are fetched dynamically via config_manager.get() - # This method exists for future extensibility if caching is added later - pass - - def create_controls_section(self): - section = QWidget() - layout = QVBoxLayout(section) - layout.setSpacing(20) - - # Download controls - controls_frame = QFrame() - controls_frame.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - controls_layout = QVBoxLayout(controls_frame) - controls_layout.setContentsMargins(20, 20, 20, 20) - controls_layout.setSpacing(15) - - # Controls title - controls_title = QLabel("Download Controls") - controls_title.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - controls_title.setStyleSheet("color: #ffffff;") - - # Pause/Resume button - pause_btn = QPushButton("Pause Downloads") - pause_btn.setFixedHeight(40) - pause_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 20px; - color: #000000; - font-size: 12px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - """) - - # Clear completed button - clear_btn = QPushButton("Clear Completed") - clear_btn.setFixedHeight(35) - clear_btn.clicked.connect(self.clear_completed_downloads) # Connect to the clearing method - clear_btn.setStyleSheet(""" - QPushButton { - background: transparent; - border: 1px solid #e22134; - border-radius: 17px; - color: #e22134; - font-size: 11px; - font-weight: bold; - } - QPushButton:hover { - background: rgba(226, 33, 52, 0.1); - } - """) - - controls_layout.addWidget(controls_title) - controls_layout.addWidget(pause_btn) - controls_layout.addWidget(clear_btn) - - # Download stats - stats_frame = QFrame() - stats_frame.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - stats_layout = QVBoxLayout(stats_frame) - stats_layout.setContentsMargins(20, 20, 20, 20) - stats_layout.setSpacing(15) - - # Stats title - stats_title = QLabel("Download Statistics") - stats_title.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - stats_title.setStyleSheet("color: #ffffff;") - - # Stats items - stats_items = [ - ("Total Downloads", "247"), - ("Completed", "238"), - ("Failed", "4"), - ("In Progress", "2"), - ("Queued", "3") - ] - - stats_layout.addWidget(stats_title) - - for label, value in stats_items: - item_layout = QHBoxLayout() - - label_widget = QLabel(label) - label_widget.setFont(QFont("Arial", 11)) - label_widget.setStyleSheet("color: #b3b3b3;") - - value_widget = QLabel(value) - value_widget.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - value_widget.setStyleSheet("color: #ffffff;") - - item_layout.addWidget(label_widget) - item_layout.addStretch() - item_layout.addWidget(value_widget) - - stats_layout.addLayout(item_layout) - - layout.addWidget(controls_frame) - layout.addWidget(stats_frame) - layout.addStretch() - - return section - - def create_missing_tracks_section(self): - section = QFrame() - section.setFixedHeight(250) - section.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - layout = QVBoxLayout(section) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Header - header_layout = QHBoxLayout() - - title_label = QLabel("Missing Tracks") - title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #ffffff;") - - count_label = QLabel("23 tracks") - count_label.setFont(QFont("Arial", 11)) - count_label.setStyleSheet("color: #b3b3b3;") - - download_all_btn = QPushButton("Download All") - download_all_btn.setFixedSize(150, 35) - download_all_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 17px; - color: #000000; - font-size: 11px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - """) - - header_layout.addWidget(title_label) - header_layout.addWidget(count_label) - header_layout.addStretch() - header_layout.addWidget(download_all_btn) - - # Missing tracks scroll area - missing_scroll = QScrollArea() - missing_scroll.setWidgetResizable(True) - missing_scroll.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - } - QScrollBar:vertical { - background: #404040; - width: 6px; - border-radius: 3px; - } - QScrollBar::handle:vertical { - background: #1db954; - border-radius: 3px; - } - """) - - missing_widget = QWidget() - missing_layout = QVBoxLayout(missing_widget) - missing_layout.setSpacing(8) - missing_layout.setContentsMargins(0, 0, 0, 0) - - # Sample missing tracks with playlist info - missing_tracks = [ - ("Song Title 1", "Artist Name 1", "Liked Songs"), - ("Another Track", "Different Artist", "Road Trip Mix"), - ("Cool Song", "Band Name", "Workout Playlist"), - ("Missing Hit", "Popular Artist", "Discover Weekly"), - ("Rare Track", "Indie Artist", "Chill Vibes") - ] - - for track_title, artist, playlist in missing_tracks: - track_item = self.create_missing_track_item(track_title, artist, playlist) - missing_layout.addWidget(track_item) - - missing_layout.addStretch() - missing_scroll.setWidget(missing_widget) - - layout.addLayout(header_layout) - layout.addWidget(missing_scroll) - - return section - - def create_missing_track_item(self, track_title: str, artist: str, playlist: str): - item = QFrame() - item.setFixedHeight(45) - item.setStyleSheet(""" - QFrame { - background: #333333; - border-radius: 6px; - border: 1px solid #404040; - } - QFrame:hover { - background: #3a3a3a; - border: 1px solid #1db954; - } - """) - - layout = QHBoxLayout(item) - layout.setContentsMargins(12, 8, 12, 8) - layout.setSpacing(10) - - # Track info - info_layout = QVBoxLayout() - info_layout.setSpacing(2) - - track_label = QLabel(f"{track_title} - {artist}") - track_label.setFont(QFont("Arial", 10, QFont.Weight.Medium)) - track_label.setStyleSheet("color: #ffffff;") - - playlist_label = QLabel(f"from: {playlist}") - playlist_label.setFont(QFont("Arial", 9)) - playlist_label.setStyleSheet("color: #1db954;") - - info_layout.addWidget(track_label) - info_layout.addWidget(playlist_label) - - # Download button - download_btn = QPushButton("") - download_btn.setFixedSize(30, 30) - download_btn.setStyleSheet(""" - QPushButton { - background: rgba(29, 185, 84, 0.2); - border: 1px solid #1db954; - border-radius: 15px; - color: #1db954; - font-size: 12px; - } - QPushButton:hover { - background: #1db954; - color: #000000; - } - """) - - layout.addLayout(info_layout) - layout.addStretch() - layout.addWidget(download_btn) - - return item - - def _extract_track_number_from_filename(self, filename: str, title: str = None) -> Optional[int]: - """Extract track number from filename or title""" - try: - import re - import os - - # Try extracting from title first if available - if title: - patterns = [ - r'^(\d{1,2})[\.\s\-_]+', # "01. " or "01 " or "01-" or "01_" - r'^(\d{1,2})\s*[\.\-_]\s*', # "01." or "01-" or "01_" with optional spaces - r'^(\d{1,2})\s+', # "01 " (space only) - r'^(\d{1,2})[\)\]\}]\s*', # "01) " or "01] " or "01} " - r'^\[(\d{1,2})\]', # "[01]" bracket format - r'^\((\d{1,2})\)', # "(01)" parenthesis format - ] - - for pattern in patterns: - match = re.match(pattern, title.strip()) - if match: - track_num = int(match.group(1)) - print(f" Found track number in title '{title}': {track_num}") - return track_num - - # Try extracting from filename - base_name = os.path.splitext(filename)[0] # Remove extension - - patterns = [ - r'^(\d{1,2})[\.\s\-_]+', # "01. " or "01 " or "01-" or "01_" - r'^(\d{1,2})\s*[\.\-_]\s*', # "01." or "01-" or "01_" with optional spaces - r'^(\d{1,2})\s+', # "01 " (space only) - r'^(\d{1,2})[\)\]\}]\s*', # "01) " or "01] " or "01} " - r'^\[(\d{1,2})\]', # "[01]" bracket format - r'^\((\d{1,2})\)', # "(01)" parenthesis format - r'^Track\s*(\d{1,2})', # "Track 01" or "Track01" - r'^T(\d{1,2})', # "T01" format - ] - - for pattern in patterns: - match = re.match(pattern, base_name.strip()) - if match: - track_num = int(match.group(1)) - print(f" Found track number in filename '{filename}': {track_num}") - return track_num - - print(f" No track number found in filename: '{filename}'") - return None - - except Exception as e: - print(f"Error extracting track number from filename: {e}") - return None - - def _get_spotify_album_tracks(self, selected_album: Album) -> List[dict]: - """Fetch all tracks from the selected Spotify album""" - try: - print(f"Fetching tracks from Spotify album: {selected_album.name}") - tracks_data = self.spotify_client.get_album_tracks(selected_album.id) - - if tracks_data and 'items' in tracks_data: - tracks = [] - for track_data in tracks_data['items']: - tracks.append({ - 'name': track_data['name'], - 'track_number': track_data['track_number'], - 'duration_ms': track_data['duration_ms'], - 'id': track_data['id'] - }) - print(f"Found {len(tracks)} tracks in Spotify album") - return tracks - else: - print(f"No tracks found in Spotify album") - return [] - - except Exception as e: - print(f"Error fetching Spotify album tracks: {e}") - return [] - - def _match_track_to_spotify_title(self, track, spotify_tracks: List[dict]) -> Optional[str]: - """Match a downloaded track to a Spotify track title using similarity scoring""" - try: - if not spotify_tracks: - return None - - original_title = track.title - print(f"Matching track: '{original_title}'") - - # Clean the original title by removing track number prefixes - import re - cleaned_original = original_title - track_num_match = re.match(r'^(\d+)\s*[\.\-_]\s*(.+)', cleaned_original.strip()) - if track_num_match: - cleaned_original = track_num_match.group(2).strip() - print(f" Cleaned title (removed track number): '{cleaned_original}'") - - best_match = None - best_score = 0.0 - - # Try matching by track number first (most reliable) - if hasattr(track, 'track_number') and track.track_number: - for spotify_track in spotify_tracks: - if spotify_track['track_number'] == track.track_number: - print(f"Matched by track number {track.track_number}: '{spotify_track['name']}'") - return spotify_track['name'] - - # Fallback to title similarity matching using cleaned titles - for spotify_track in spotify_tracks: - # Normalize both titles for comparison (use cleaned original) - normalized_original = self.matching_engine.normalize_string(cleaned_original) - normalized_spotify = self.matching_engine.normalize_string(spotify_track['name']) - - print(f" Comparing: '{normalized_original}' vs '{normalized_spotify}'") - - # Calculate similarity score - score = self.matching_engine.similarity_score(normalized_original, normalized_spotify) - - if score > best_score: - best_score = score - best_match = spotify_track - print(f" New best match ({score:.2f}): '{spotify_track['name']}'") - - # Only return match if confidence is high enough - if best_match and best_score >= 0.6: # 60% similarity threshold - print(f"Matched by title similarity ({best_score:.2f}): '{best_match['name']}'") - return best_match['name'] - else: - print(f"No good title match found (best score: {best_score:.2f})") - return None - - except Exception as e: - print(f"Error matching track to Spotify title: {e}") - return None - - # In downloads.py, add this new method inside the DownloadsPage class (e.g., after load_more_results) - - def _load_next_result_item(self): - """ - Processes one item from the loading queue and schedules the next. - This breaks up the work into small, non-blocking chunks. - """ - if not self._results_to_load_queue: - self.is_loading_more = False - return - - # Pop one result from the front of the queue - result = self._results_to_load_queue.pop(0) - - # Create the appropriate widget for the result - if isinstance(result, AlbumResult): - result_item = AlbumResultItem(result) - result_item.album_download_requested.connect(self.start_album_download) - result_item.matched_album_download_requested.connect(self.start_matched_album_download) - result_item.track_download_requested.connect(self.start_download) - result_item.track_stream_requested.connect(lambda r, i=result_item: self.start_stream(r, i)) - else: - result_item = SearchResultItem(result) - result_item.download_requested.connect(self.start_download) - result_item.stream_requested.connect(lambda r, i=result_item: self.start_stream(r, i)) - result_item.expansion_requested.connect(self.handle_expansion_request) - - # Insert the newly created widget into the layout - insert_position = self.search_results_layout.count() - 1 - self.search_results_layout.insertWidget(insert_position, result_item) - self.displayed_results += 1 - - # If there are more items in the queue, schedule the next one immediately - if self._results_to_load_queue: - QTimer.singleShot(0, self._load_next_result_item) - else: - # All items for this batch are loaded - self.is_loading_more = False - # Update the status message now that loading is complete - total_filtered = len(self.current_filtered_results) - if self.displayed_results < total_filtered: - remaining = total_filtered - self.displayed_results - self.update_search_status(f"Showing {self.displayed_results} of {total_filtered} results (scroll for {remaining} more)", "#1db954") - else: - self.update_search_status(f"Showing all {total_filtered} results", "#1db954") - - # In class DownloadsPage, add this new method - - def _handle_processed_status_updates(self, results): - """ - This runs on the main thread and applies updates from the background worker. - It crucially updates download_id and username before moving items to ensure - API cleanup works correctly. - """ - # Create a lookup for active items by their memory ID for fast access - items_by_id = {id(item): item for item in self.download_queue.active_queue.download_items} - - items_to_move = [] - - # Batch UI updates to prevent multiple repaints - self.download_queue.setUpdatesEnabled(False) - - for result in results: - download_item = items_by_id.get(result['widget_id']) - if not download_item: - continue - - # **THE CRITICAL FIX IS HERE:** - # Update the item with the real transfer ID and username from the API. - # This ensures that when move_to_finished is called, it has the correct data for API cleanup. - if result.get('transfer_id'): - download_item.download_id = result['transfer_id'] - if result.get('username'): - download_item.username = result['username'] - - # Update the item's visual status - download_item.update_status( - status=result['status'], - progress=result['progress'], - download_speed=result['speed'], - file_path=result['path'] - ) - - action = result.get('action') - new_status = result['status'] - - if new_status == 'completed' and action == 'process_matched_completion': - # This is a matched download that just finished. - if download_item.mark_completion_processed(): - worker = DownloadCompletionWorker( - download_item=download_item, - absolute_file_path=result['path'], - organize_func=self._organize_matched_download - ) - worker.signals.completed.connect(self._on_download_completion_finished) - worker.signals.error.connect(self._on_download_completion_error) - self.completion_thread_pool.start(worker) - - elif new_status in ['completed', 'failed', 'cancelled']: - # For regular completed items or any failed/cancelled item. - if download_item not in items_to_move: - items_to_move.append(download_item) - - # Now, move all the items that need moving in one go - for item in items_to_move: - self.download_queue.move_to_finished(item) - - # Re-enable UI updates and trigger a single repaint - self.download_queue.setUpdatesEnabled(True) - self.download_queue.update() - self.download_queue.update_tab_counts() - - # Allow the next worker to run - self._is_status_update_running = False - - # ===================================== - # METADATA ENHANCEMENT SYSTEM - # ===================================== - - def _enhance_file_metadata(self, file_path: str, download_item, artist: Artist, album_info: dict) -> bool: - """ - Core function to enhance audio file metadata using Spotify data - - Args: - file_path: Path to the audio file in Transfer folder - download_item: Original search result with attached Spotify metadata - artist: Matched Spotify Artist object - album_info: Album detection results with track numbering - - Returns: - bool: Success/failure of metadata enhancement - """ - try: - # Check if metadata enhancement is enabled - if not config_manager.get('metadata_enhancement.enabled', True): - print("Metadata enhancement disabled in config") - return True - - print(f"Enhancing metadata for: {os.path.basename(file_path)}") - - # Load the audio file - audio_file = MutagenFile(file_path) - if audio_file is None: - print(f"Could not load audio file with Mutagen: {file_path}") - return False - - # Extract comprehensive metadata from Spotify - metadata = self._extract_spotify_metadata(download_item, artist, album_info) - if not metadata: - print(f"Could not extract Spotify metadata, preserving original tags") - return True - - # Determine file format and apply appropriate tags - file_format = self._detect_audio_format(file_path, audio_file) - success = False - - if file_format == 'mp3': - print(f"Applying ID3 tags for {file_path}") - success = self._apply_id3_tags(audio_file, metadata, file_path) - elif file_format == 'flac': - print(f"Applying FLAC tags for {file_path}") - success = self._apply_flac_tags(audio_file, metadata, file_path) - elif file_format in ['mp4', 'm4a']: - print(f"Applying MP4 tags for {file_path}") - success = self._apply_mp4_tags(audio_file, metadata, file_path) - elif file_format == 'ogg': - print(f"Applying OGG tags for {file_path}") - success = self._apply_ogg_tags(audio_file, metadata, file_path) - else: - print(f"Unsupported audio format for metadata enhancement: {file_format}") - return True - - if success: - # Optionally embed album art - if config_manager.get('metadata_enhancement.embed_album_art', True): - self._embed_album_art_metadata(file_path, audio_file, metadata, file_format) - - print(f"Metadata enhanced with Spotify data") - return True - else: - print(f"Metadata enhancement failed, original tags preserved") - return False - - except Exception as e: - print(f"Error enhancing metadata for {file_path}: {e}") - return False - - def _extract_spotify_metadata(self, download_item, artist: Artist, album_info: dict) -> dict: - """ - Extract comprehensive metadata from Spotify objects - - Returns complete metadata dictionary with: - - Basic tags (title, artist, album, year, track#) - - Advanced tags (genres, album artist, total tracks) - - Plex-specific optimizations - - Album art URL for embedding - - Spotify IDs for future enhancements - """ - try: - metadata = {} - - # Debug: Log what we're working with - print(f"Extracting metadata for: {download_item.title}") - print(f" - Artist: {artist.name if artist else 'None'}") - print(f" - Album info: {album_info}") - print(f" - Has _spotify_clean_title: {hasattr(download_item, '_spotify_clean_title')}") - print(f" - Has matched_album: {hasattr(download_item, 'matched_album')}") - - if not artist: - print(f"No artist provided for metadata extraction") - return {} - - # Basic track information - metadata['title'] = getattr(download_item, '_spotify_clean_title', download_item.title) - metadata['artist'] = artist.name - metadata['album_artist'] = artist.name # Critical for Plex - - # Album information - if album_info and album_info.get('is_album'): - metadata['album'] = album_info.get('album_name', 'Unknown Album') - metadata['track_number'] = album_info.get('track_number', 1) - metadata['total_tracks'] = album_info.get('total_tracks', 1) - if album_info.get('disc_number'): - metadata['disc_number'] = album_info['disc_number'] - else: - # Single track - metadata['album'] = metadata['title'] # For singles, album = title - metadata['track_number'] = 1 - metadata['total_tracks'] = 1 - - # Release date - if hasattr(download_item, 'matched_album') and download_item.matched_album: - if hasattr(download_item.matched_album, 'release_date'): - metadata['date'] = download_item.matched_album.release_date[:4] if download_item.matched_album.release_date else None - - # Genre information from artist - if hasattr(artist, 'genres') and artist.genres: - # Use first genre or combine multiple - if len(artist.genres) == 1: - metadata['genre'] = artist.genres[0] - else: - # Combine up to 3 genres - metadata['genre'] = ', '.join(artist.genres[:3]) - - # Album art URL - if hasattr(download_item, 'matched_album') and download_item.matched_album: - if hasattr(download_item.matched_album, 'image_url') and download_item.matched_album.image_url: - metadata['album_art_url'] = download_item.matched_album.image_url - - # Spotify IDs for future enhancements - metadata['spotify_artist_id'] = artist.id if hasattr(artist, 'id') else None - if hasattr(download_item, 'matched_album') and download_item.matched_album: - metadata['spotify_album_id'] = getattr(download_item.matched_album, 'id', None) - - print(f"Extracted metadata summary:") - print(f" - Title: {metadata.get('title')}") - print(f" - Artist: {metadata.get('artist')}") - print(f" - Album: {metadata.get('album')}") - print(f" - Track #: {metadata.get('track_number')}") - print(f" - Date: {metadata.get('date')}") - print(f" - Genre: {metadata.get('genre')}") - print(f" - Album art: {'Yes' if metadata.get('album_art_url') else 'No'}") - print(f" - Total fields: {len(metadata)}") - - # Special debugging for problematic tracks - if metadata.get('title') == 'Tell Me What You Want' or metadata.get('track_number') == 13: - print(f"[DEBUG] Special track detected - Tell Me What You Want:") - print(f" - Full metadata dict: {metadata}") - print(f" - Album info dict: {album_info}") - print(f" - Artist object: {artist}") - if hasattr(download_item, 'matched_album'): - print(f" - matched_album: {download_item.matched_album}") - else: - print(f" - No matched_album attribute") - - return metadata - - except Exception as e: - print(f"Error extracting Spotify metadata: {e}") - import traceback - traceback.print_exc() - return {} - - def _detect_audio_format(self, file_path: str, audio_file) -> str: - """Detect the audio format for appropriate tag handling""" - try: - file_ext = os.path.splitext(file_path)[1].lower() - - # Direct extension mapping - format_map = { - '.mp3': 'mp3', - '.flac': 'flac', - '.m4a': 'm4a', - '.mp4': 'mp4', - '.ogg': 'ogg', - '.oga': 'ogg' - } - - if file_ext in format_map: - return format_map[file_ext] - - # Fallback to mutagen detection - if hasattr(audio_file, 'mime'): - mime_type = audio_file.mime[0] if audio_file.mime else '' - if 'mp3' in mime_type or 'mpeg' in mime_type: - return 'mp3' - elif 'flac' in mime_type: - return 'flac' - elif 'mp4' in mime_type or 'm4a' in mime_type: - return 'mp4' - elif 'ogg' in mime_type: - return 'ogg' - - return 'unknown' - - except Exception as e: - print(f"Could not detect audio format: {e}") - return 'unknown' - - def _apply_id3_tags(self, audio_file, metadata: dict, file_path: str) -> bool: - """Handle MP3 ID3v2.4 tags with full Unicode support""" - try: - # Ensure ID3 tags exist - if not hasattr(audio_file, 'tags') or audio_file.tags is None: - audio_file.add_tags() - - tags = audio_file.tags - - # Basic tags - tags.setall('TIT2', [TIT2(encoding=3, text=metadata.get('title', ''))]) # Title - tags.setall('TPE1', [TPE1(encoding=3, text=metadata.get('artist', ''))]) # Artist - tags.setall('TPE2', [TPE2(encoding=3, text=metadata.get('album_artist', ''))]) # Album Artist - tags.setall('TALB', [TALB(encoding=3, text=metadata.get('album', ''))]) # Album - - # Date - if metadata.get('date'): - tags.setall('TDRC', [TDRC(encoding=3, text=metadata['date'])]) - - # Track number - track_text = f"{metadata.get('track_number', 1)}/{metadata.get('total_tracks', 1)}" - tags.setall('TRCK', [TRCK(encoding=3, text=track_text)]) - - # Genre - if metadata.get('genre'): - tags.setall('TCON', [TCON(encoding=3, text=metadata['genre'])]) - - # Disc number - if metadata.get('disc_number'): - tags.setall('TPOS', [TPOS(encoding=3, text=str(metadata['disc_number']))]) - - # Spotify IDs for future reference - if metadata.get('spotify_artist_id'): - tags.setall('TXXX:Spotify Artist ID', [TXXX(encoding=3, desc='Spotify Artist ID', text=metadata['spotify_artist_id'])]) - if metadata.get('spotify_album_id'): - tags.setall('TXXX:Spotify Album ID', [TXXX(encoding=3, desc='Spotify Album ID', text=metadata['spotify_album_id'])]) - - - - audio_file.save() - return True - - except Exception as e: - print(f"Error applying ID3 tags: {e}") - return False - - def _apply_flac_tags(self, audio_file, metadata: dict, file_path: str) -> bool: - """Handle FLAC Vorbis comments for lossless files""" - try: - print(f"Applying FLAC tags with {len(metadata)} metadata fields") - - # Read existing tags first to preserve non-empty values - existing_title = audio_file.get('TITLE', [''])[0] if audio_file.get('TITLE') else '' - existing_artist = audio_file.get('ARTIST', [''])[0] if audio_file.get('ARTIST') else '' - existing_album = audio_file.get('ALBUM', [''])[0] if audio_file.get('ALBUM') else '' - existing_date = audio_file.get('DATE', [''])[0] if audio_file.get('DATE') else '' - existing_genre = audio_file.get('GENRE', [''])[0] if audio_file.get('GENRE') else '' - - print(f" - Existing tags: Title='{existing_title}', Artist='{existing_artist}', Album='{existing_album}'") - - # Only update if we have non-empty Spotify data OR if existing tag is empty - if metadata.get('title') and metadata.get('title').strip(): - audio_file['TITLE'] = metadata['title'] - print(f" - Updated TITLE: '{existing_title}' → '{metadata['title']}'") - elif existing_title: - print(f" - Preserving existing TITLE: '{existing_title}' (Spotify data empty)") - - if metadata.get('artist') and metadata.get('artist').strip(): - audio_file['ARTIST'] = metadata['artist'] - print(f" - Updated ARTIST: '{existing_artist}' → '{metadata['artist']}'") - elif existing_artist: - print(f" - Preserving existing ARTIST: '{existing_artist}' (Spotify data empty)") - - if metadata.get('album_artist') and metadata.get('album_artist').strip(): - audio_file['ALBUMARTIST'] = metadata['album_artist'] - elif not audio_file.get('ALBUMARTIST'): - audio_file['ALBUMARTIST'] = metadata.get('album_artist', '') - - if metadata.get('album') and metadata.get('album').strip(): - audio_file['ALBUM'] = metadata['album'] - print(f" - Updated ALBUM: '{existing_album}' → '{metadata['album']}'") - elif existing_album: - print(f" - Preserving existing ALBUM: '{existing_album}' (Spotify data empty)") - - # Date - only update if we have a date and existing is empty OR we have better data - if metadata.get('date') and metadata.get('date').strip(): - audio_file['DATE'] = metadata['date'] - print(f" - Updated DATE: '{existing_date}' → '{metadata['date']}'") - elif existing_date: - print(f" - Preserving existing DATE: '{existing_date}' (Spotify data empty)") - - # Track number - always update since this comes from album detection - audio_file['TRACKNUMBER'] = str(metadata.get('track_number', 1)) - audio_file['TRACKTOTAL'] = str(metadata.get('total_tracks', 1)) - - # Genre - only update if we have genre data and existing is empty OR we have better data - if metadata.get('genre') and metadata.get('genre').strip(): - audio_file['GENRE'] = metadata['genre'] - print(f" - Updated GENRE: '{existing_genre}' → '{metadata['genre']}'") - elif existing_genre: - print(f" - Preserving existing GENRE: '{existing_genre}' (Spotify data empty)") - - # Disc number - if metadata.get('disc_number'): - audio_file['DISCNUMBER'] = str(metadata['disc_number']) - - # Spotify IDs - if metadata.get('spotify_artist_id'): - audio_file['SPOTIFY_ARTIST_ID'] = metadata['spotify_artist_id'] - if metadata.get('spotify_album_id'): - audio_file['SPOTIFY_ALBUM_ID'] = metadata['spotify_album_id'] - - - print(f" - Saving FLAC file with enhanced metadata...") - audio_file.save() - print(f" - FLAC tags saved successfully") - return True - - except Exception as e: - print(f"Error applying FLAC tags: {e}") - import traceback - traceback.print_exc() - return False - - def _apply_mp4_tags(self, audio_file, metadata: dict, file_path: str) -> bool: - """Handle MP4/M4A iTunes-style tags""" - try: - # Basic tags - audio_file['\xa9nam'] = [metadata.get('title', '')] # Title - audio_file['\xa9ART'] = [metadata.get('artist', '')] # Artist - audio_file['aART'] = [metadata.get('album_artist', '')] # Album Artist - audio_file['\xa9alb'] = [metadata.get('album', '')] # Album - - # Date - if metadata.get('date'): - audio_file['\xa9day'] = [metadata['date']] - - # Track number - track_num = metadata.get('track_number', 1) - total_tracks = metadata.get('total_tracks', 1) - audio_file['trkn'] = [(track_num, total_tracks)] - - # Genre - if metadata.get('genre'): - audio_file['\xa9gen'] = [metadata['genre']] - - # Disc number - if metadata.get('disc_number'): - audio_file['disk'] = [(metadata['disc_number'], 0)] - - # Spotify IDs (using custom tags) - if metadata.get('spotify_artist_id'): - audio_file['----:com.apple.iTunes:Spotify Artist ID'] = [metadata['spotify_artist_id'].encode('utf-8')] - if metadata.get('spotify_album_id'): - audio_file['----:com.apple.iTunes:Spotify Album ID'] = [metadata['spotify_album_id'].encode('utf-8')] - - - - audio_file.save() - return True - - except Exception as e: - print(f"Error applying MP4 tags: {e}") - return False - - def _apply_ogg_tags(self, audio_file, metadata: dict, file_path: str) -> bool: - """Handle OGG Vorbis comments""" - try: - # Basic tags - audio_file['TITLE'] = metadata.get('title', '') - audio_file['ARTIST'] = metadata.get('artist', '') - audio_file['ALBUMARTIST'] = metadata.get('album_artist', '') - audio_file['ALBUM'] = metadata.get('album', '') - - # Date - if metadata.get('date'): - audio_file['DATE'] = metadata['date'] - - # Track number - audio_file['TRACKNUMBER'] = str(metadata.get('track_number', 1)) - audio_file['TRACKTOTAL'] = str(metadata.get('total_tracks', 1)) - - # Genre - if metadata.get('genre'): - audio_file['GENRE'] = metadata['genre'] - - # Disc number - if metadata.get('disc_number'): - audio_file['DISCNUMBER'] = str(metadata['disc_number']) - - # Spotify IDs - if metadata.get('spotify_artist_id'): - audio_file['SPOTIFY_ARTIST_ID'] = metadata['spotify_artist_id'] - if metadata.get('spotify_album_id'): - audio_file['SPOTIFY_ALBUM_ID'] = metadata['spotify_album_id'] - - audio_file.save() - return True - - except Exception as e: - print(f"Error applying OGG tags: {e}") - return False - - def _embed_album_art_metadata(self, file_path: str, audio_file, metadata: dict, file_format: str) -> bool: - """Download and embed high-quality Spotify album art""" - try: - if not metadata.get('album_art_url'): - print("No album art URL available for embedding") - return True - - print(f"Downloading album art for embedding...") - - # Download album art - album_art_url = metadata['album_art_url'] - response = urllib.request.urlopen(album_art_url, timeout=10) - image_data = response.read() - - if not image_data: - print("Failed to download album art data") - return False - - # Determine image format - image_format = 'image/jpeg' # Spotify typically uses JPEG - - # Embed based on format - if file_format == 'mp3': - if not hasattr(audio_file, 'tags') or audio_file.tags is None: - audio_file.add_tags() - - audio_file.tags.setall('APIC', [APIC( - encoding=3, # UTF-8 - mime=image_format, - type=3, # Cover (front) - desc='Cover', - data=image_data - )]) - - elif file_format == 'flac': - picture = Picture() - picture.data = image_data - picture.type = 3 # Cover (front) - picture.mime = image_format - picture.width = 640 - picture.height = 640 - picture.depth = 24 - audio_file.add_picture(picture) - - elif file_format in ['mp4', 'm4a']: - if image_format == 'image/jpeg': - audio_file['covr'] = [MP4Cover(image_data, imageformat=MP4Cover.FORMAT_JPEG)] - else: - audio_file['covr'] = [MP4Cover(image_data, imageformat=MP4Cover.FORMAT_PNG)] - - # Save with embedded art - audio_file.save() - print(f"Album art successfully embedded") - return True - - except Exception as e: - print(f"Error embedding album art: {e}") - return False - - def cleanup_resources(self): - """Clean up resources when page is destroyed""" - try: - # Shutdown thread pools - if hasattr(self, 'api_thread_pool'): - self.api_thread_pool.shutdown(wait=False) - print("API thread pool shutdown") - - if hasattr(self, 'completion_thread_pool'): - self.completion_thread_pool.waitForDone(3000) # Wait up to 3 seconds for completion - print("Completion thread pool shutdown") - except Exception as e: - print(f"Error during resource cleanup: {e}") \ No newline at end of file diff --git a/ui/pages/settings.py b/ui/pages/settings.py deleted file mode 100644 index c26f9e63..00000000 --- a/ui/pages/settings.py +++ /dev/null @@ -1,3487 +0,0 @@ -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QFrame, QPushButton, QLineEdit, QComboBox, - QCheckBox, QSpinBox, QTextEdit, QGroupBox, QFormLayout, QMessageBox, QSizePolicy, QScrollArea) -from PyQt6.QtCore import Qt, QThread, pyqtSignal -from PyQt6.QtGui import QFont, QIcon -from config.settings import config_manager -from utils.logging_config import get_logger -import requests - -logger = get_logger("settings") - -class PlexDetectionThread(QThread): - progress_updated = pyqtSignal(int, str) # progress value, current url - detection_completed = pyqtSignal(str) # found_url (empty if not found) - - def __init__(self): - super().__init__() - self.cancelled = False - - def cancel(self): - self.cancelled = True - - def run(self): - import requests - import socket - import ipaddress - import subprocess - import platform - from concurrent.futures import ThreadPoolExecutor, as_completed - - def get_network_info(): - """Get comprehensive network information with subnet detection""" - try: - # Get local IP using socket method - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - - # Try to get actual subnet mask - try: - if platform.system() == "Windows": - # Windows: Use netsh to get subnet info - result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'], - capture_output=True, text=True, timeout=3) - # Parse output for subnet mask (simplified) - subnet_mask = "255.255.255.0" # Default fallback - else: - # Linux/Mac: Try to parse network interfaces - result = subprocess.run(['ip', 'route', 'show'], - capture_output=True, text=True, timeout=3) - subnet_mask = "255.255.255.0" # Default fallback - except: - subnet_mask = "255.255.255.0" # Default /24 - - # Calculate network range - network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False) - return str(network.network_address), str(network.netmask), local_ip, network - - except Exception as e: - # Fallback to original method - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - - # Default to /24 network - network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False) - return str(network.network_address), "255.255.255.0", local_ip, network - - def test_plex_server(ip, port=32400): - """Test if a Plex server is running at the given IP and port""" - try: - url = f"http://{ip}:{port}/web/index.html" - response = requests.get(url, timeout=2, allow_redirects=True) - - # Check for Plex-specific indicators - if response.status_code == 200: - # Check if it's actually Plex - if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers): - return f"http://{ip}:{port}" - - # Also try the API endpoint - api_url = f"http://{ip}:{port}/identity" - api_response = requests.get(api_url, timeout=1) - if api_response.status_code == 200 and 'MediaContainer' in api_response.text: - return f"http://{ip}:{port}" - - except: - pass - return None - - try: - network_addr, netmask, local_ip, network = get_network_info() - - # Build list of IPs to test - test_ips = [] - - # Priority 1: Test localhost first - if not self.cancelled: - self.progress_updated.emit(5, "http://localhost:32400") - localhost_result = test_plex_server("localhost") - if localhost_result: - self.detection_completed.emit(localhost_result) - return - - # Priority 2: Test local IP - if not self.cancelled: - self.progress_updated.emit(10, f"http://{local_ip}:32400") - local_result = test_plex_server(local_ip) - if local_result: - self.detection_completed.emit(local_result) - return - - # Priority 3: Test common IPs (router gateway, etc.) - common_ips = [ - local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway - local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway - local_ip.rsplit('.', 1)[0] + '.100', # Common static IP - ] - - progress = 15 - for ip in common_ips: - if self.cancelled: - break - - self.progress_updated.emit(progress, f"http://{ip}:32400") - result = test_plex_server(ip) - if result: - self.detection_completed.emit(result) - return - progress += 5 - - # Priority 4: Scan the network range (limited to reasonable size) - network_hosts = list(network.hosts()) - if len(network_hosts) > 50: - # Limit scan to reasonable size for performance - step = max(1, len(network_hosts) // 50) - network_hosts = network_hosts[::step] - - progress_step = max(1, (85 - progress) // len(network_hosts)) - - # Use ThreadPoolExecutor for concurrent scanning - with ThreadPoolExecutor(max_workers=10) as executor: - # Submit all tasks - future_to_ip = {executor.submit(test_plex_server, str(ip)): str(ip) - for ip in network_hosts} - - try: - for future in as_completed(future_to_ip): - if self.cancelled: - # Cancel all pending futures - for f in future_to_ip: - if not f.done(): - f.cancel() - break - - ip = future_to_ip[future] - progress = min(95, progress + progress_step) - self.progress_updated.emit(progress, f"http://{ip}:32400") - - try: - result = future.result() - if result: - # Cancel all pending futures before returning - for f in future_to_ip: - if not f.done(): - f.cancel() - self.detection_completed.emit(result) - return - except: - pass - finally: - # Ensure executor is properly shutdown - # Use wait=False if cancelled to avoid blocking - executor.shutdown(wait=not self.cancelled) - - # If we get here, no Plex server was found - self.progress_updated.emit(100, "Scan complete") - self.detection_completed.emit("") # Empty string = not found - - except Exception as e: - print(f"Plex detection error: {e}") - self.detection_completed.emit("") # Empty string = not found - -class SlskdDetectionThread(QThread): - progress_updated = pyqtSignal(int, str) # progress value, current url - detection_completed = pyqtSignal(str) # found_url (empty if not found) - - def __init__(self): - super().__init__() - self.cancelled = False - - def cancel(self): - self.cancelled = True - - def run(self): - import requests - import socket - import ipaddress - import subprocess - import platform - from concurrent.futures import ThreadPoolExecutor, as_completed - - def get_network_info(): - """Get comprehensive network information with subnet detection""" - try: - # Get local IP using socket method - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - - # Try to get actual subnet mask - try: - if platform.system() == "Windows": - # Windows: Use netsh to get subnet info - result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'], - capture_output=True, text=True, timeout=3) - # Parse output for subnet mask (simplified) - subnet_mask = "255.255.255.0" # Default fallback - else: - # Linux/Mac: Try to parse network interfaces - result = subprocess.run(['ip', 'route', 'show'], - capture_output=True, text=True, timeout=3) - subnet_mask = "255.255.255.0" # Default fallback - except: - subnet_mask = "255.255.255.0" # Default /24 - - # Calculate network range - network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False) - return str(network.network_address), str(network.netmask), local_ip, network - - except Exception as e: - # Fallback to original method - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - - ip_parts = local_ip.split('.') - network_base = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.0" - network = ipaddress.IPv4Network(f"{network_base}/24", strict=False) - return network_base, "255.255.255.0", local_ip, network - except: - return None, None, None, None - - def get_active_ips_from_arp(): - """Get active IP addresses from ARP table""" - active_ips = set() - try: - if platform.system() == "Windows": - result = subprocess.run(['arp', '-a'], capture_output=True, text=True, timeout=5) - else: - result = subprocess.run(['arp', '-a'], capture_output=True, text=True, timeout=5) - - # Parse ARP output for IP addresses - import re - ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' - ips = re.findall(ip_pattern, result.stdout) - active_ips.update(ips) - except: - pass - return active_ips - - def generate_comprehensive_targets(network_info): - """Generate comprehensive list of scan targets with priorities""" - if not network_info[3]: # network object - return [] - - network, local_ip = network_info[3], network_info[2] - targets = [] - - # Enhanced port list for slskd detection - slskd_ports = [5030, 5031, 8080, 3000, 9000, 38477, 2416] - - # Priority 1: Infrastructure IPs (router, DNS, etc.) - infrastructure_ips = [1, 2, 254, 253] - for host_num in infrastructure_ips: - try: - ip = str(network.network_address + host_num) - if ip != local_ip and ip in network: - for port in slskd_ports: - targets.append((f"http://{ip}:{port}", 1)) # Priority 1 - except: - continue - - # Priority 2: Get active IPs from ARP table - active_ips = get_active_ips_from_arp() - for ip in active_ips: - try: - if ipaddress.IPv4Address(ip) in network and ip != local_ip: - for port in slskd_ports: - targets.append((f"http://{ip}:{port}", 2)) # Priority 2 - except: - continue - - # Priority 3: Common static IP ranges - static_ranges = [ - range(100, 201), # .100-.200 (common static) - range(10, 100), # .10-.99 (DHCP range) - range(201, 254), # .201-.253 (high static) - ] - - for ip_range in static_ranges: - for host_num in ip_range: - try: - ip = str(network.network_address + host_num) - if ip != local_ip and ip in network: - # Only add if not already in active IPs (avoid duplicates) - if ip not in active_ips: - for port in [5030, 5031, 8080]: # Limit ports for full sweep - targets.append((f"http://{ip}:{port}", 3)) # Priority 3 - except: - continue - - # Sort by priority and return - targets.sort(key=lambda x: x[1]) - return [target[0] for target in targets] - - def test_url_enhanced(url, timeout=2): - """Enhanced URL testing with slskd-specific validation""" - try: - # Test main API endpoint - response = requests.get(f"{url}/api/v0/session", timeout=timeout) - if response.status_code in [200, 401]: - # Additional validation: check if it's really slskd - try: - app_response = requests.get(f"{url}/api/v0/application", timeout=1) - if app_response.status_code == 200: - data = app_response.json() - if 'name' in data and 'slskd' in data.get('name', '').lower(): - return url, 'verified' - except: - pass - return url, 'probable' - except requests.exceptions.ConnectionError: - pass - except requests.exceptions.Timeout: - pass - except Exception: - pass - return None, None - - def parallel_scan(targets, max_workers=15): - """Scan targets in parallel with progressive timeout""" - found_url = None - completed_count = 0 - - # Split into batches for better progress reporting - batch_size = max(1, len(targets) // 10) # 10 progress updates - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - # Submit all tasks - future_to_url = { - executor.submit(test_url_enhanced, target): target - for target in targets - } - - try: - # Process completed tasks - for future in as_completed(future_to_url): - if self.cancelled: - # Cancel remaining futures - for f in future_to_url: - if not f.done(): - f.cancel() - break - - completed_count += 1 - progress = int((completed_count / len(targets)) * 100) - current_url = future_to_url[future] - - # Update progress - self.progress_updated.emit(progress, f"Scanning {current_url.split('//')[1]}") - - # Check result - try: - result_url, confidence = future.result() - if result_url: - found_url = result_url - self.progress_updated.emit(100, f"Found: {result_url}") - - # Cancel remaining futures for faster completion - for f in future_to_url: - if not f.done(): - f.cancel() - break - except: - continue - finally: - # Ensure executor is properly shutdown - # Use wait=False if cancelled to avoid blocking - executor.shutdown(wait=not self.cancelled) - - return found_url - - # Main detection logic - found_url = None - - # Phase 1: Test local candidates first (fast) - self.progress_updated.emit(5, "Checking local machine...") - local_candidates = [ - "http://localhost:5030", - "http://127.0.0.1:5030", - "http://localhost:5031", - "http://127.0.0.1:5031", - "http://localhost:8080", - "http://127.0.0.1:8080", - "http://localhost:3000", - "http://127.0.0.1:3000" - ] - - for url in local_candidates: - if self.cancelled: - break - result_url, confidence = test_url_enhanced(url, timeout=1) - if result_url: - found_url = result_url - break - - # Phase 2: Network scanning if not found locally - if not found_url and not self.cancelled: - self.progress_updated.emit(10, "Analyzing network...") - - network_info = get_network_info() - if network_info[0]: # If we got network info - targets = generate_comprehensive_targets(network_info) - - if targets: - self.progress_updated.emit(15, f"Scanning {len(targets)} network targets...") - found_url = parallel_scan(targets) - - # Emit completion - if not self.cancelled: - self.detection_completed.emit(found_url or "") - -class ServiceTestThread(QThread): - test_completed = pyqtSignal(str, bool, str) # service, success, message - - def __init__(self, service_type, test_config): - super().__init__() - self.service_type = service_type - self.test_config = test_config - - def run(self): - """Run the service test in background thread""" - try: - if self.service_type == "spotify": - success, message = self._test_spotify() - elif self.service_type == "tidal": - success, message = self._test_tidal() - elif self.service_type == "plex": - success, message = self._test_plex() - elif self.service_type == "jellyfin": - success, message = self._test_jellyfin() - elif self.service_type == "navidrome": - success, message = self._test_navidrome() - elif self.service_type == "soulseek": - success, message = self._test_soulseek() - else: - success, message = False, "Unknown service type" - - self.test_completed.emit(self.service_type, success, message) - - except Exception as e: - self.test_completed.emit(self.service_type, False, f"Test failed: {str(e)}") - - def _test_spotify(self): - """Test Spotify connection""" - try: - from core.spotify_client import SpotifyClient - - # Basic validation first - if not self.test_config.get('client_id') or not self.test_config.get('client_secret'): - return False, "Please enter both Client ID and Client Secret" - - # Save temporarily to test - original_client_id = config_manager.get('spotify.client_id') - original_client_secret = config_manager.get('spotify.client_secret') - - config_manager.set('spotify.client_id', self.test_config['client_id']) - config_manager.set('spotify.client_secret', self.test_config['client_secret']) - - # Test connection with timeout protection - try: - client = SpotifyClient() - - # Check if client was created successfully (has sp object) - if client.sp is None: - message = "Failed to create Spotify client.\nCheck your credentials." - success = False - else: - # Try a simple auth check with timeout - try: - # This will trigger OAuth flow - user needs to complete it - if client.is_authenticated(): - user_info = client.get_user_info() - username = user_info.get('display_name', 'Unknown') if user_info else 'Unknown' - message = f"Spotify connection successful!\nConnected as: {username}" - success = True - else: - message = "Spotify authentication failed.\nPlease complete the OAuth flow in your browser." - success = False - except Exception as auth_e: - message = f"Spotify authentication failed:\n{str(auth_e)}" - success = False - - except Exception as client_e: - message = f"Failed to create Spotify client:\n{str(client_e)}" - success = False - - # Restore original values - config_manager.set('spotify.client_id', original_client_id) - config_manager.set('spotify.client_secret', original_client_secret) - - return success, message - - except Exception as e: - # Restore original values even on exception - try: - config_manager.set('spotify.client_id', original_client_id) - config_manager.set('spotify.client_secret', original_client_secret) - except: - pass - return False, f"Spotify test failed:\n{str(e)}" - - def _test_tidal(self): - """Test Tidal connection""" - try: - from core.tidal_client import TidalClient - - # Basic validation first - if not self.test_config.get('client_id') or not self.test_config.get('client_secret'): - return False, "Please enter both Client ID and Client Secret" - - # Save temporarily to test - original_client_id = config_manager.get('tidal.client_id') - original_client_secret = config_manager.get('tidal.client_secret') - - config_manager.set('tidal.client_id', self.test_config['client_id']) - config_manager.set('tidal.client_secret', self.test_config['client_secret']) - - # Test connection with timeout protection - try: - client = TidalClient() - - # Test authentication - this will trigger OAuth flow if needed - if client.is_authenticated() or client._ensure_valid_token(): - user_info = client.get_user_info() - username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User' - message = f"Tidal connection successful!\nConnected as: {username}\nOAuth flow completed." - success = True - else: - message = "Tidal authentication failed.\nPlease complete the OAuth flow in your browser.\nCheck your credentials and redirect URI." - success = False - - except Exception as client_e: - message = f"Failed to create Tidal client:\n{str(client_e)}" - success = False - - # Restore original values - config_manager.set('tidal.client_id', original_client_id) - config_manager.set('tidal.client_secret', original_client_secret) - - return success, message - - except Exception as e: - # Restore original values even on exception - try: - config_manager.set('tidal.client_id', original_client_id) - config_manager.set('tidal.client_secret', original_client_secret) - except: - pass - return False, f"Tidal test failed:\n{str(e)}" - - def _test_plex(self): - """Test Plex connection""" - try: - from core.plex_client import PlexClient - - # Save temporarily to test - original_base_url = config_manager.get('plex.base_url') - original_token = config_manager.get('plex.token') - - config_manager.set('plex.base_url', self.test_config['base_url']) - config_manager.set('plex.token', self.test_config['token']) - - # Test connection - client = PlexClient() - if client.is_connected(): - server_name = client.server.friendlyName if client.server else 'Unknown' - message = f"Plex connection successful!\nServer: {server_name}" - success = True - else: - message = "Plex connection failed.\nCheck your server URL and token." - success = False - - # Restore original values - config_manager.set('plex.base_url', original_base_url) - config_manager.set('plex.token', original_token) - - return success, message - - except Exception as e: - return False, f"Plex test failed:\n{str(e)}" - - def _test_jellyfin(self): - """Test Jellyfin connection""" - try: - import requests - - base_url = self.test_config['base_url'] - api_key = self.test_config['api_key'] - - if not base_url: - return False, "Please enter Jellyfin server URL" - - if not api_key: - return False, "Please enter Jellyfin API key" - - # Clean URL - remove trailing slash - if base_url.endswith('/'): - base_url = base_url[:-1] - - # Test connection with system info endpoint - headers = {'X-Emby-Token': api_key} if api_key else {} - test_url = f"{base_url}/System/Info" - - response = requests.get(test_url, headers=headers, timeout=5) - - if response.status_code == 200: - data = response.json() - server_name = data.get('ServerName', 'Unknown') - version = data.get('Version', 'Unknown') - message = f"Jellyfin connection successful!\nServer: {server_name}\nVersion: {version}" - return True, message - elif response.status_code == 401: - return False, "Jellyfin authentication failed.\nCheck your API key." - else: - return False, f"Jellyfin connection failed.\nHTTP {response.status_code}: {response.text}" - - except requests.exceptions.Timeout: - return False, "Jellyfin connection timeout.\nCheck your server URL." - except requests.exceptions.ConnectionError: - return False, "Cannot connect to Jellyfin server.\nCheck your server URL and network." - except Exception as e: - return False, f"Jellyfin test failed:\n{str(e)}" - - def _test_navidrome(self): - """Test Navidrome connection""" - try: - import requests - import hashlib - import secrets - - base_url = self.test_config['base_url'] - username = self.test_config['username'] - password = self.test_config['password'] - - if not base_url: - return False, "Please enter Navidrome server URL" - - if not username: - return False, "Please enter Navidrome username" - - if not password: - return False, "Please enter Navidrome password" - - # Clean URL - remove trailing slash - if base_url.endswith('/'): - base_url = base_url[:-1] - - # Generate authentication parameters for Subsonic API - salt = secrets.token_hex(8) - token = hashlib.md5((password + salt).encode()).hexdigest() - - # Test connection with ping endpoint - params = { - 'u': username, - 't': token, - 's': salt, - 'v': '1.16.1', - 'c': 'SoulSync', - 'f': 'json' - } - - test_url = f"{base_url}/rest/ping" - response = requests.get(test_url, params=params, timeout=10) - - if response.status_code == 200: - data = response.json() - subsonic_response = data.get('subsonic-response', {}) - - if subsonic_response.get('status') == 'ok': - version = subsonic_response.get('version', 'Unknown') - message = f"Navidrome connection successful!\nSubsonic API Version: {version}" - return True, message - elif subsonic_response.get('status') == 'failed': - error = subsonic_response.get('error', {}) - error_message = error.get('message', 'Unknown error') - return False, f"Navidrome authentication failed:\n{error_message}" - else: - return False, "Unexpected response from Navidrome server" - else: - return False, f"Navidrome connection failed.\nHTTP {response.status_code}: {response.text}" - - except requests.exceptions.Timeout: - return False, "Navidrome connection timeout.\nCheck your server URL." - except requests.exceptions.ConnectionError: - return False, "Cannot connect to Navidrome server.\nCheck your server URL and network." - except Exception as e: - return False, f"Navidrome test failed:\n{str(e)}" - - def _test_soulseek(self): - """Test Soulseek connection""" - try: - import requests - - slskd_url = self.test_config['slskd_url'] - api_key = self.test_config['api_key'] - - if not slskd_url: - return False, ("Please enter slskd URL\n\n" - "slskd is a headless Soulseek client that provides an HTTP API.\n" - "Download from: https://github.com/slskd/slskd") - - # Test API endpoint - headers = {} - if api_key: - headers['X-API-Key'] = api_key - - response = requests.get(f"{slskd_url}/api/v0/session", headers=headers, timeout=5) - - if response.status_code == 200: - return True, "Soulseek connection successful!\nslskd is responding." - elif response.status_code == 401: - return False, ("Invalid API key\n\n" - "Please check your slskd API key in the configuration.") - else: - return False, (f"Soulseek connection failed\nHTTP {response.status_code}\n\n" - "slskd is running but returned an error.") - - except requests.exceptions.ConnectionError as e: - if "refused" in str(e).lower(): - return False, ("Cannot connect to slskd\n\n" - "slskd appears to not be running on the specified URL.\n\n" - "To fix this:\n" - "1. Install slskd from: https://github.com/slskd/slskd\n" - "2. Start slskd service\n" - "3. Ensure it's running on the correct port (default: 5030)") - else: - return False, f"Network error:\n{str(e)}" - except requests.exceptions.Timeout: - return False, ("Connection timed out\n\n" - "slskd is not responding. Check if it's running and accessible.") - except requests.exceptions.RequestException as e: - return False, f"Request failed:\n{str(e)}" - except Exception as e: - return False, f"Unexpected error:\n{str(e)}" - -class JellyfinDetectionThread(QThread): - progress_updated = pyqtSignal(int, str) # progress value, current url - detection_completed = pyqtSignal(str) # found_url (empty if not found) - - def __init__(self): - super().__init__() - self.cancelled = False - - def cancel(self): - self.cancelled = True - - def run(self): - import requests - import socket - import ipaddress - import subprocess - import platform - from concurrent.futures import ThreadPoolExecutor, as_completed - - def get_network_info(): - """Get comprehensive network information with subnet detection""" - try: - # Get local IP using socket method - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - - # Parse network info - network = ipaddress.ip_network(f"{local_ip}/24", strict=False) - return { - 'local_ip': local_ip, - 'network': network, - 'subnet': str(network.network_address) + "/24" - } - except Exception as e: - print(f"Error getting network info: {e}") - return {'local_ip': '127.0.0.1', 'network': None, 'subnet': '127.0.0.1/32'} - - try: - # Test common Jellyfin URLs first - common_urls = [ - "http://localhost:8096", - "http://127.0.0.1:8096", - "http://jellyfin:8096" - ] - - network_info = get_network_info() - local_ip = network_info['local_ip'] - - # Add local IP variations - if local_ip != '127.0.0.1': - common_urls.extend([ - f"http://{local_ip}:8096", - f"https://{local_ip}:8920" # HTTPS port - ]) - - # Test common URLs first - for i, url in enumerate(common_urls): - if self.cancelled: - break - - progress = int((i / len(common_urls)) * 50) # First 50% for common URLs - self.progress_updated.emit(progress, url) - - if self.test_jellyfin_url(url): - self.detection_completed.emit(url) - return - - # If common URLs fail, scan network subnet - if network_info['network'] and not self.cancelled: - network = network_info['network'] - hosts_to_scan = list(network.hosts())[:50] # Limit to first 50 hosts - - def test_host(host_ip): - if self.cancelled: - return None - - test_urls = [ - f"http://{host_ip}:8096", - f"https://{host_ip}:8920" - ] - - for url in test_urls: - if self.cancelled: - break - if self.test_jellyfin_url(url, timeout=2): # Shorter timeout for network scan - return url - return None - - # Test hosts in parallel - with ThreadPoolExecutor(max_workers=10) as executor: - future_to_host = {executor.submit(test_host, str(host)): host for host in hosts_to_scan} - - for i, future in enumerate(as_completed(future_to_host)): - if self.cancelled: - break - - progress = 50 + int((i / len(hosts_to_scan)) * 50) # Remaining 50% - host = future_to_host[future] - self.progress_updated.emit(progress, f"Scanning {host}...") - - result = future.result() - if result: - self.detection_completed.emit(result) - return - - # Nothing found - self.detection_completed.emit("") - - except Exception as e: - print(f"Jellyfin detection error: {e}") - self.detection_completed.emit("") # Empty string = not found - - def test_jellyfin_url(self, url, timeout=5): - """Test if a URL hosts a Jellyfin server""" - try: - import requests - - # Test the system/info endpoint which is available without auth - response = requests.get(f"{url}/System/Info", timeout=timeout, verify=False) - - if response.status_code == 200: - data = response.json() - # Check if it's actually Jellyfin - if 'ServerName' in data or 'Version' in data: - return True - - except Exception: - pass - - # Fallback: try to get the web interface - try: - import requests - response = requests.get(url, timeout=timeout, verify=False) - if response.status_code == 200: - content = response.text.lower() - # Look for Jellyfin-specific content - if 'jellyfin' in content or 'emby' in content: - return True - except Exception: - pass - - return False - -class NavidromeDetectionThread(QThread): - progress_updated = pyqtSignal(int, str) # progress value, current url - detection_completed = pyqtSignal(str) # found_url (empty if not found) - - def __init__(self): - super().__init__() - self.cancelled = False - - def cancel(self): - self.cancelled = True - - def run(self): - import requests - import socket - import ipaddress - - def get_network_info(): - """Get comprehensive network information with subnet detection""" - try: - # Get local IP using socket method - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - - # Parse network info - network = ipaddress.ip_network(f"{local_ip}/24", strict=False) - return { - 'local_ip': local_ip, - 'network': network, - 'subnet': str(network.network_address) + "/24" - } - except Exception as e: - print(f"Error getting network info: {e}") - return {'local_ip': '127.0.0.1', 'network': None, 'subnet': '127.0.0.1/32'} - - try: - # Test common Navidrome URLs first - common_urls = [ - "http://localhost:4533", - "http://127.0.0.1:4533", - "http://navidrome:4533" - ] - - network_info = get_network_info() - local_ip = network_info['local_ip'] - - # Add local IP with common ports - common_urls.extend([ - f"http://{local_ip}:4533" - ]) - - total_hosts = len(common_urls) - current_host = 0 - - # Test common URLs first - for url in common_urls: - if self.cancelled: - break - - current_host += 1 - self.progress_updated.emit(int((current_host / total_hosts) * 100), url) - - if self.test_navidrome_url(url): - self.detection_completed.emit(url) - return - - # If no common URLs worked, signal not found - self.detection_completed.emit("") - - except Exception as e: - print(f"Navidrome detection error: {e}") - self.detection_completed.emit("") - - def test_navidrome_url(self, url, timeout=5): - """Test if URL hosts a Navidrome server by checking for ping endpoint""" - try: - # Test Navidrome ping endpoint - ping_url = f"{url.rstrip('/')}/rest/ping" - print(f"Testing Navidrome at: {ping_url}") - - response = requests.get(ping_url, params={ - 'u': 'test', - 't': 'test', - 's': 'test', - 'v': '1.16.1', - 'c': 'SoulSync', - 'f': 'json' - }, timeout=timeout) - - print(f"Response status: {response.status_code}") - - # Navidrome should return status 401 or 403 for invalid credentials, not 404 - if response.status_code in [200, 401, 403]: - try: - data = response.json() - print(f"Response data: {data}") - # Check if it's a valid Subsonic API response - if 'subsonic-response' in data: - print(f"Found Navidrome server at {url}") - return True - except Exception as e: - print(f"JSON parse error: {e}") - - # Also try a simple GET to the root to see if it's at least a web server - try: - root_response = requests.get(url, timeout=timeout) - if root_response.status_code == 200 and 'navidrome' in root_response.text.lower(): - print(f"Found Navidrome web interface at {url}") - return True - except: - pass - - return False - - except Exception as e: - print(f"Error testing {url}: {e}") - return False - -class SettingsGroup(QGroupBox): - def __init__(self, title: str, parent=None): - super().__init__(title, parent) - self.setStyleSheet(""" - QGroupBox { - background: #282828; - border: 1px solid #404040; - border-radius: 8px; - font-size: 14px; - font-weight: bold; - color: #ffffff; - padding-top: 15px; - margin-top: 10px; - } - QGroupBox::title { - subcontrol-origin: margin; - left: 10px; - padding: 0 5px 0 5px; - } - """) - -class SettingsPage(QWidget): - settings_changed = pyqtSignal(str, str) # Signal for when settings paths change - - def __init__(self, parent=None): - super().__init__(parent) - self.config_manager = None - self.form_inputs = {} - self.test_thread = None - self.test_buttons = {} - self.detection_thread = None - self.detection_dialog = None - self.setup_ui() - self.load_config_values() - - def set_toast_manager(self, toast_manager): - """Set the toast manager for showing notifications""" - self.toast_manager = toast_manager - - def on_test_completed(self, service, success, message): - """Handle test completion from background thread""" - # Re-enable the test button - if service in self.test_buttons: - button = self.test_buttons[service] - button.setEnabled(True) - button.setText(f"Test {service.title()}") - - # Show result message - if success: - QMessageBox.information(self, "Success", message) - else: - if "Configuration Required" in message or "enter slskd URL" in message: - QMessageBox.warning(self, "Configuration Required", message) - else: - QMessageBox.critical(self, "Test Failed", message) - - # Clean up thread - if self.test_thread: - self.test_thread.deleteLater() - self.test_thread = None - - def start_service_test(self, service_type, test_config): - """Start a service test in background thread""" - # Don't start new test if one is already running - if self.test_thread and self.test_thread.isRunning(): - return - - # Update button state - if service_type in self.test_buttons: - button = self.test_buttons[service_type] - button.setEnabled(False) - button.setText("Testing...") - - # Start test thread - self.test_thread = ServiceTestThread(service_type, test_config) - self.test_thread.test_completed.connect(self.on_test_completed) - self.test_thread.start() - - def setup_ui(self): - self.setStyleSheet(""" - SettingsPage { - background: #191414; - } - """) - - # Main container layout - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - - # Create scroll area - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll_area.setStyleSheet(""" - QScrollArea { - background: #191414; - border: none; - } - QScrollBar:vertical { - background: #282828; - width: 12px; - border-radius: 6px; - } - QScrollBar::handle:vertical { - background: #535353; - min-height: 20px; - border-radius: 6px; - } - QScrollBar::handle:vertical:hover { - background: #727272; - } - """) - - # Create scrollable content widget - scroll_content = QWidget() - scroll_content.setStyleSheet("background: #191414;") - content_layout = QVBoxLayout(scroll_content) - content_layout.setContentsMargins(20, 16, 20, 20) - content_layout.setSpacing(16) - - # Header - header = self.create_header() - content_layout.addWidget(header) - - # Settings content - settings_layout = QHBoxLayout() - settings_layout.setSpacing(24) - - # Left column - left_column = self.create_left_column() - settings_layout.addWidget(left_column) - - # Right column - right_column = self.create_right_column() - settings_layout.addWidget(right_column) - - content_layout.addLayout(settings_layout) - content_layout.addStretch() - - # Save button - self.save_btn = QPushButton("Save Settings") - self.save_btn.setFixedHeight(45) - self.save_btn.clicked.connect(self.save_settings) - self.save_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 22px; - color: #000000; - font-size: 14px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - """) - - content_layout.addWidget(self.save_btn) - - # Set scroll area content and add to main layout - scroll_area.setWidget(scroll_content) - main_layout.addWidget(scroll_area) - - def load_config_values(self): - """Load current configuration values into form inputs""" - try: - # Load Spotify config - spotify_config = config_manager.get_spotify_config() - self.client_id_input.setText(spotify_config.get('client_id', '')) - self.client_secret_input.setText(spotify_config.get('client_secret', '')) - - # Load Tidal config - tidal_config = config_manager.get('tidal', {}) - self.tidal_client_id_input.setText(tidal_config.get('client_id', '')) - self.tidal_client_secret_input.setText(tidal_config.get('client_secret', '')) - - # Load Plex config - plex_config = config_manager.get_plex_config() - self.plex_url_input.setText(plex_config.get('base_url', '')) - self.plex_token_input.setText(plex_config.get('token', '')) - - # Load Jellyfin config - jellyfin_config = config_manager.get_jellyfin_config() - self.jellyfin_url_input.setText(jellyfin_config.get('base_url', '')) - self.jellyfin_api_key_input.setText(jellyfin_config.get('api_key', '')) - - # Load Navidrome config - navidrome_config = config_manager.get_navidrome_config() - self.navidrome_url_input.setText(navidrome_config.get('base_url', '')) - self.navidrome_username_input.setText(navidrome_config.get('username', '')) - self.navidrome_password_input.setText(navidrome_config.get('password', '')) - - # Initialize server selection - active_server = config_manager.get_active_media_server() - self.pending_server_change = None - self.update_server_toggle_styles(active_server) - - # Show/hide appropriate containers based on active server - self.plex_container.hide() - self.jellyfin_container.hide() - self.navidrome_container.hide() - - if active_server == 'plex': - self.plex_container.show() - elif active_server == 'jellyfin': - self.jellyfin_container.show() - elif active_server == 'navidrome': - self.navidrome_container.show() - - # Load Soulseek config - soulseek_config = config_manager.get_soulseek_config() - self.slskd_url_input.setText(soulseek_config.get('slskd_url', '')) - self.api_key_input.setText(soulseek_config.get('api_key', '')) - self.download_path_input.setText(soulseek_config.get('download_path', './downloads')) - self.transfer_path_input.setText(soulseek_config.get('transfer_path', './Transfer')) - - # Load database config - database_config = config_manager.get('database', {}) - if hasattr(self, 'max_workers_combo'): - max_workers = database_config.get('max_workers', 5) - # Find the index of the current value in the combo box - index = self.max_workers_combo.findText(str(max_workers)) - if index >= 0: - self.max_workers_combo.setCurrentIndex(index) - - # Load logging config (read-only display) - logging_config = config_manager.get_logging_config() - if hasattr(self, 'log_level_display'): - self.log_level_display.setText(logging_config.get('level', 'DEBUG')) - - if hasattr(self, 'log_path_display'): - self.log_path_display.setText(logging_config.get('path', 'logs/app.log')) - - # Load metadata enhancement settings - metadata_config = config_manager.get('metadata_enhancement', {}) - if hasattr(self, 'metadata_enabled_checkbox'): - self.metadata_enabled_checkbox.setChecked(metadata_config.get('enabled', True)) - if hasattr(self, 'embed_album_art_checkbox'): - self.embed_album_art_checkbox.setChecked(metadata_config.get('embed_album_art', True)) - - # Load playlist sync settings - playlist_sync_config = config_manager.get('playlist_sync', {}) - if hasattr(self, 'create_backup_checkbox'): - self.create_backup_checkbox.setChecked(playlist_sync_config.get('create_backup', True)) - - except Exception as e: - QMessageBox.warning(self, "Error", f"Failed to load configuration: {e}") - - def save_settings(self): - """Save current form values to configuration""" - try: - # Save Spotify settings - config_manager.set('spotify.client_id', self.client_id_input.text()) - config_manager.set('spotify.client_secret', self.client_secret_input.text()) - - # Save Tidal settings - config_manager.set('tidal.client_id', self.tidal_client_id_input.text()) - config_manager.set('tidal.client_secret', self.tidal_client_secret_input.text()) - - # Save Plex settings - config_manager.set('plex.base_url', self.plex_url_input.text()) - config_manager.set('plex.token', self.plex_token_input.text()) - - # Save Jellyfin settings - config_manager.set('jellyfin.base_url', self.jellyfin_url_input.text()) - config_manager.set('jellyfin.api_key', self.jellyfin_api_key_input.text()) - - # Save Navidrome settings - config_manager.set('navidrome.base_url', self.navidrome_url_input.text()) - config_manager.set('navidrome.username', self.navidrome_username_input.text()) - config_manager.set('navidrome.password', self.navidrome_password_input.text()) - - # Save pending server change if any - if hasattr(self, 'pending_server_change') and self.pending_server_change: - config_manager.set_active_media_server(self.pending_server_change) - logger.info(f"Server changed to {self.pending_server_change} - restart required") - - # Save Soulseek settings - config_manager.set('soulseek.slskd_url', self.slskd_url_input.text()) - config_manager.set('soulseek.api_key', self.api_key_input.text()) - config_manager.set('soulseek.download_path', self.download_path_input.text()) - config_manager.set('soulseek.transfer_path', self.transfer_path_input.text()) - - # Save Database settings - if hasattr(self, 'max_workers_combo'): - max_workers = int(self.max_workers_combo.currentText()) - config_manager.set('database.max_workers', max_workers) - - # Emit signals for path changes to update other pages immediately - self.settings_changed.emit('soulseek.download_path', self.download_path_input.text()) - self.settings_changed.emit('soulseek.transfer_path', self.transfer_path_input.text()) - - # Emit signals for service configuration changes to reinitialize clients - self.settings_changed.emit('spotify.client_id', self.client_id_input.text()) - self.settings_changed.emit('spotify.client_secret', self.client_secret_input.text()) - self.settings_changed.emit('tidal.client_id', self.tidal_client_id_input.text()) - self.settings_changed.emit('tidal.client_secret', self.tidal_client_secret_input.text()) - self.settings_changed.emit('plex.base_url', self.plex_url_input.text()) - self.settings_changed.emit('plex.token', self.plex_token_input.text()) - self.settings_changed.emit('soulseek.slskd_url', self.slskd_url_input.text()) - self.settings_changed.emit('soulseek.api_key', self.api_key_input.text()) - - # Show success message - QMessageBox.information(self, "Success", "Settings saved successfully!") - - # Update button text temporarily - original_text = self.save_btn.text() - self.save_btn.setText("Saved!") - self.save_btn.setStyleSheet(""" - QPushButton { - background: #1aa34a; - border: none; - border-radius: 22px; - color: #ffffff; - font-size: 14px; - font-weight: bold; - } - """) - - # Reset button after 2 seconds - from PyQt6.QtCore import QTimer - QTimer.singleShot(2000, lambda: self.reset_save_button(original_text)) - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to save settings: {e}") - - def reset_save_button(self, original_text): - """Reset save button to original state""" - self.save_btn.setText(original_text) - self.save_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 22px; - color: #000000; - font-size: 14px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - """) - - def test_spotify_connection(self): - """Test Spotify API connection in background thread""" - test_config = { - 'client_id': self.client_id_input.text(), - 'client_secret': self.client_secret_input.text() - } - self.start_service_test('spotify', test_config) - - def test_tidal_connection(self): - """Test Tidal API connection in background thread""" - test_config = { - 'client_id': self.tidal_client_id_input.text(), - 'client_secret': self.tidal_client_secret_input.text() - } - self.start_service_test('tidal', test_config) - - def authenticate_tidal(self): - """Manually trigger Tidal OAuth authentication""" - try: - from core.tidal_client import TidalClient - - # Make sure we have the current settings - config_manager.set('tidal.client_id', self.tidal_client_id_input.text()) - config_manager.set('tidal.client_secret', self.tidal_client_secret_input.text()) - - # Create client and authenticate - client = TidalClient() - - self.tidal_auth_btn.setText("Authenticating...") - self.tidal_auth_btn.setEnabled(False) - - if client.authenticate(): - QMessageBox.information(self, "Success", "Tidal authentication successful!\nYou can now use Tidal playlists.") - self.tidal_auth_btn.setText("Authenticated") - else: - QMessageBox.warning(self, "Authentication Failed", "Tidal authentication failed.\nPlease check your credentials and try again.") - self.tidal_auth_btn.setText("Authenticate") - - self.tidal_auth_btn.setEnabled(True) - - except Exception as e: - self.tidal_auth_btn.setText("Authenticate") - self.tidal_auth_btn.setEnabled(True) - QMessageBox.critical(self, "Error", f"Failed to authenticate with Tidal:\n{str(e)}") - - def test_active_server_connection(self): - """Test the currently active (or pending) media server connection""" - # Determine which server to test (pending change takes priority) - active_server = getattr(self, 'pending_server_change', None) or config_manager.get_active_media_server() - - if active_server == 'plex': - test_config = { - 'base_url': self.plex_url_input.text(), - 'token': self.plex_token_input.text() - } - self.start_service_test('plex', test_config) - elif active_server == 'jellyfin': - test_config = { - 'base_url': self.jellyfin_url_input.text(), - 'api_key': self.jellyfin_api_key_input.text() - } - self.start_service_test('jellyfin', test_config) - elif active_server == 'navidrome': - test_config = { - 'base_url': self.navidrome_url_input.text(), - 'username': self.navidrome_username_input.text(), - 'password': self.navidrome_password_input.text() - } - self.start_service_test('navidrome', test_config) - else: - logger.warning(f"Unknown active server type: {active_server}") - - def test_plex_connection(self): - """Test Plex server connection in background thread""" - test_config = { - 'base_url': self.plex_url_input.text(), - 'token': self.plex_token_input.text() - } - self.start_service_test('plex', test_config) - - def test_soulseek_connection(self): - """Test Soulseek slskd connection in background thread""" - test_config = { - 'slskd_url': self.slskd_url_input.text(), - 'api_key': self.api_key_input.text() - } - self.start_service_test('soulseek', test_config) - - def auto_detect_plex(self): - """Auto-detect Plex server URL using background thread""" - # Don't start new detection if one is already running - if self.detection_thread and self.detection_thread.isRunning(): - return - - # Create animated loading dialog - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton - from PyQt6.QtCore import QTimer, QPropertyAnimation, QRect - from PyQt6.QtGui import QPainter, QColor - - self.detection_dialog = QDialog(self) - self.detection_dialog.setWindowTitle("Auto-detecting Plex Server") - self.detection_dialog.setModal(True) - self.detection_dialog.setFixedSize(400, 180) - self.detection_dialog.setWindowFlags(self.detection_dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) - - # Apply dark theme styling - self.detection_dialog.setStyleSheet(""" - QDialog { - background-color: #282828; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - } - QLabel { - color: #ffffff; - font-size: 14px; - } - QPushButton { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - padding: 8px 16px; - font-size: 11px; - } - QPushButton:hover { - background-color: #505050; - } - """) - - layout = QVBoxLayout(self.detection_dialog) - layout.setSpacing(20) - layout.setContentsMargins(20, 20, 20, 20) - - # Title label - title_label = QLabel("Searching for Plex servers...") - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(title_label) - - # Status label - self.status_label = QLabel("Checking local machine...") - self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.status_label.setStyleSheet("color: #ffffff; font-size: 12px; background: transparent;") - layout.addWidget(self.status_label) - - # Animated loading bar container - loading_container = QLabel() - loading_container.setFixedHeight(8) - loading_container.setStyleSheet(""" - QLabel { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - } - """) - layout.addWidget(loading_container) - - # Animated orange bar for Plex - self.loading_bar = QLabel(loading_container) - self.loading_bar.setFixedHeight(6) - self.loading_bar.setStyleSheet(""" - background-color: #e5a00d; - border-radius: 3px; - border: none; - """) - - # Start animation - self.loading_animation = QPropertyAnimation(self.loading_bar, b"geometry") - self.loading_animation.setDuration(1500) # 1.5 seconds - self.loading_animation.setStartValue(QRect(1, 1, 0, 6)) - self.loading_animation.setEndValue(QRect(1, 1, loading_container.width() - 2, 6)) - self.loading_animation.setLoopCount(-1) # Infinite loop - self.loading_animation.start() - - # Cancel button - button_layout = QHBoxLayout() - button_layout.addStretch() - - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.cancel_detection) - button_layout.addWidget(cancel_btn) - - layout.addLayout(button_layout) - - # Start Plex detection thread - self.detection_thread = PlexDetectionThread() - self.detection_thread.progress_updated.connect(self.on_detection_progress, Qt.ConnectionType.QueuedConnection) - self.detection_thread.detection_completed.connect(self.on_plex_detection_completed, Qt.ConnectionType.QueuedConnection) - self.detection_thread.start() - - self.detection_dialog.show() - - def auto_detect_slskd(self): - """Auto-detect slskd URL using background thread""" - # Don't start new detection if one is already running - if self.detection_thread and self.detection_thread.isRunning(): - return - - # Create animated loading dialog - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton - from PyQt6.QtCore import QTimer, QPropertyAnimation, QRect - from PyQt6.QtGui import QPainter, QColor - - self.detection_dialog = QDialog(self) - self.detection_dialog.setWindowTitle("Auto-detecting slskd") - self.detection_dialog.setModal(True) - self.detection_dialog.setFixedSize(400, 180) - self.detection_dialog.setWindowFlags(self.detection_dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) - - # Apply dark theme styling - self.detection_dialog.setStyleSheet(""" - QDialog { - background-color: #282828; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - } - QLabel { - color: #ffffff; - font-size: 14px; - } - QPushButton { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - padding: 8px 16px; - font-size: 11px; - } - QPushButton:hover { - background-color: #505050; - } - """) - - layout = QVBoxLayout(self.detection_dialog) - layout.setSpacing(20) - layout.setContentsMargins(20, 20, 20, 20) - - # Title label - title_label = QLabel("Searching for slskd instances...") - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(title_label) - - # Status label - self.status_label = QLabel("Checking local machine...") - self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.status_label.setStyleSheet("color: #ffffff; font-size: 12px; background: transparent;") - layout.addWidget(self.status_label) - - # Animated loading bar container - loading_container = QLabel() - loading_container.setFixedHeight(8) - loading_container.setStyleSheet(""" - QLabel { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - } - """) - layout.addWidget(loading_container) - - # Animated green bar - self.loading_bar = QLabel(loading_container) - self.loading_bar.setFixedHeight(6) - self.loading_bar.setStyleSheet(""" - background-color: #1db954; - border-radius: 3px; - border: none; - """) - - # Start animation - self.loading_animation = QPropertyAnimation(self.loading_bar, b"geometry") - self.loading_animation.setDuration(1500) # 1.5 seconds - self.loading_animation.setStartValue(QRect(1, 1, 0, 6)) - self.loading_animation.setEndValue(QRect(1, 1, loading_container.width() - 2, 6)) - self.loading_animation.setLoopCount(-1) # Infinite loop - self.loading_animation.start() - - # Cancel button - button_layout = QHBoxLayout() - button_layout.addStretch() - - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.cancel_detection) - button_layout.addWidget(cancel_btn) - - layout.addLayout(button_layout) - - # Start detection thread - self.detection_thread = SlskdDetectionThread() - self.detection_thread.progress_updated.connect(self.on_detection_progress, Qt.ConnectionType.QueuedConnection) - self.detection_thread.detection_completed.connect(self.on_detection_completed, Qt.ConnectionType.QueuedConnection) - self.detection_thread.start() - - self.detection_dialog.show() - - def cancel_detection(self): - """Cancel the ongoing detection""" - if self.detection_thread: - # Set cancellation flag first - self.detection_thread.cancel() - - # If thread is still running, terminate it - if self.detection_thread.isRunning(): - self.detection_thread.quit() - # Don't wait too long during cancellation to avoid blocking UI - if not self.detection_thread.wait(500): # Wait only 500ms - # Force terminate if it doesn't respond - self.detection_thread.terminate() - self.detection_thread.wait() - - self.detection_thread.deleteLater() - self.detection_thread = None - - # Close dialog - if hasattr(self, 'detection_dialog') and self.detection_dialog: - if hasattr(self, 'loading_animation'): - self.loading_animation.stop() - self.detection_dialog.close() - self.detection_dialog = None - - def on_detection_progress(self, progress_value, current_url): - """Handle progress updates from detection thread""" - if hasattr(self, 'status_label') and self.status_label: - if "localhost" in current_url or "127.0.0.1" in current_url: - self.status_label.setText("Checking local machine...") - else: - self.status_label.setText("Checking network...") - - def on_plex_detection_completed(self, found_url): - """Handle Plex detection completion""" - # Stop animation and close dialog - if hasattr(self, 'loading_animation'): - self.loading_animation.stop() - - if hasattr(self, 'detection_dialog') and self.detection_dialog: - self.detection_dialog.close() - self.detection_dialog = None - - # Properly cleanup thread - if self.detection_thread: - if self.detection_thread.isRunning(): - self.detection_thread.quit() - self.detection_thread.wait(1000) # Wait up to 1 second for thread to finish - self.detection_thread.deleteLater() - self.detection_thread = None - - if found_url: - self.plex_url_input.setText(found_url) - self.show_plex_success_dialog(found_url) - else: - QMessageBox.warning(self, "Auto-detect Failed", - "Could not find Plex server running on local machine or network.\n\n" - "Please ensure Plex Media Server is running and try:\n" - "• Check if Plex Media Server service is started\n" - "• Verify firewall allows access to Plex port (32400)\n" - "• Enter the URL manually if on a different network\n\n" - "Common URLs:\n" - "• http://localhost:32400 (local default)\n" - "• http://192.168.1.100:32400 (network example)") - - def on_detection_completed(self, found_url): - """Handle slskd detection completion""" - # Stop animation and close dialog - if hasattr(self, 'loading_animation'): - self.loading_animation.stop() - - if hasattr(self, 'detection_dialog') and self.detection_dialog: - self.detection_dialog.close() - self.detection_dialog = None - - # Properly cleanup thread - if self.detection_thread: - if self.detection_thread.isRunning(): - self.detection_thread.quit() - self.detection_thread.wait(1000) # Wait up to 1 second for thread to finish - self.detection_thread.deleteLater() - self.detection_thread = None - - if found_url: - self.slskd_url_input.setText(found_url) - self.show_success_dialog(found_url) - else: - QMessageBox.warning(self, "Auto-detect Failed", - "Could not find slskd running on local machine or network.\n\n" - "Please ensure slskd is running and try:\n" - "• Check if slskd service is started\n" - "• Verify firewall allows access to slskd port\n" - "• Enter the URL manually if on a different network\n\n" - "Common URLs:\n" - "• http://localhost:5030 (local default)\n" - "• http://192.168.1.100:5030 (network example)") - - def show_plex_success_dialog(self, found_url): - """Show custom Plex success dialog with copy functionality""" - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit - from PyQt6.QtCore import Qt - from PyQt6.QtGui import QClipboard - - dialog = QDialog(self) - dialog.setWindowTitle("Plex Auto-detect Success") - dialog.setModal(True) - dialog.setFixedSize(380, 160) - dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) - - # Apply dark theme styling - dialog.setStyleSheet(""" - QDialog { - background-color: #282828; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - } - QLabel { - color: #ffffff; - font-size: 12px; - } - QTextEdit { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - font-size: 11px; - font-family: 'Courier New', monospace; - padding: 8px; - } - QPushButton { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - padding: 6px 12px; - font-size: 11px; - min-width: 50px; - min-height: 28px; - } - QPushButton:hover { - background-color: #505050; - } - #copyButton { - background-color: #e5a00d; - border: 1px solid #e5a00d; - color: #000000; - font-weight: bold; - min-height: 28px; - } - #copyButton:hover { - background-color: #f5b00d; - } - """) - - layout = QVBoxLayout(dialog) - layout.setSpacing(8) - layout.setContentsMargins(15, 15, 15, 15) - - # Success message - location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network" - success_label = QLabel(f"Found Plex server running {location_type}!") - success_label.setStyleSheet("color: #e5a00d; font-size: 13px; font-weight: bold;") - success_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(success_label) - - # URL display with copy functionality - url_label = QLabel("Detected URL:") - layout.addWidget(url_label) - - url_container = QHBoxLayout() - url_container.setSpacing(5) - - url_display = QTextEdit() - url_display.setPlainText(found_url) - url_display.setReadOnly(True) - url_display.setFixedHeight(30) - url_display.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - url_container.addWidget(url_display) - - copy_btn = QPushButton("Copy") - copy_btn.setObjectName("copyButton") - copy_btn.setFixedSize(55, 30) - copy_btn.clicked.connect(lambda: self.copy_to_clipboard(found_url, copy_btn)) - url_container.addWidget(copy_btn) - - layout.addLayout(url_container) - - # Info text - info_label = QLabel("URL automatically filled in settings above.") - info_label.setStyleSheet("color: #ffffff; font-size: 9px; font-style: italic; background: transparent;") - info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(info_label) - - # OK button - button_layout = QHBoxLayout() - button_layout.addStretch() - - ok_btn = QPushButton("OK") - ok_btn.setFixedSize(60, 28) - ok_btn.clicked.connect(dialog.accept) - ok_btn.setDefault(True) - button_layout.addWidget(ok_btn) - - layout.addLayout(button_layout) - - dialog.exec() - - def show_jellyfin_success_dialog(self, found_url): - """Show custom Jellyfin success dialog with copy functionality""" - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit - from PyQt6.QtCore import Qt - from PyQt6.QtGui import QClipboard - - dialog = QDialog(self) - dialog.setWindowTitle("Jellyfin Auto-detect Success") - dialog.setModal(True) - dialog.setFixedSize(380, 160) - dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) - - # Apply dark theme styling - dialog.setStyleSheet(""" - QDialog { - background-color: #282828; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - } - QLabel { - color: #ffffff; - font-size: 12px; - } - QTextEdit { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - font-size: 11px; - font-family: 'Courier New', monospace; - padding: 8px; - } - QPushButton { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - padding: 6px 12px; - font-size: 11px; - min-width: 50px; - min-height: 28px; - } - QPushButton:hover { - background-color: #505050; - } - #copyButton { - background-color: #aa5cc3; - border: 1px solid #aa5cc3; - color: #ffffff; - font-weight: bold; - min-height: 28px; - } - #copyButton:hover { - background-color: #ba6cd3; - } - """) - - layout = QVBoxLayout(dialog) - layout.setSpacing(8) - layout.setContentsMargins(15, 15, 15, 15) - - # Success message - location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network" - success_label = QLabel(f"Found Jellyfin server running {location_type}!") - success_label.setStyleSheet("color: #aa5cc3; font-size: 13px; font-weight: bold;") - success_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(success_label) - - # URL display with copy functionality - url_label = QLabel("Detected URL:") - layout.addWidget(url_label) - - url_container = QHBoxLayout() - url_container.setSpacing(5) - - url_display = QTextEdit() - url_display.setPlainText(found_url) - url_display.setReadOnly(True) - url_display.setFixedHeight(30) - url_display.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - url_container.addWidget(url_display) - - copy_btn = QPushButton("Copy") - copy_btn.setObjectName("copyButton") - copy_btn.setFixedSize(55, 30) - copy_btn.clicked.connect(lambda: self.copy_to_clipboard(found_url, copy_btn)) - url_container.addWidget(copy_btn) - - layout.addLayout(url_container) - - # Info text - info_label = QLabel("URL automatically filled in settings above.") - info_label.setStyleSheet("color: #ffffff; font-size: 9px; font-style: italic; background: transparent;") - info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(info_label) - - # OK button - button_layout = QHBoxLayout() - button_layout.addStretch() - - ok_btn = QPushButton("OK") - ok_btn.setFixedSize(60, 28) - ok_btn.clicked.connect(dialog.accept) - ok_btn.setDefault(True) - button_layout.addWidget(ok_btn) - - layout.addLayout(button_layout) - - dialog.exec() - - def show_success_dialog(self, found_url): - """Show custom slskd success dialog with copy functionality""" - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit - from PyQt6.QtCore import Qt - from PyQt6.QtGui import QClipboard - - dialog = QDialog(self) - dialog.setWindowTitle("Auto-detect Success") - dialog.setModal(True) - dialog.setFixedSize(380, 160) - dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) - - # Apply dark theme styling - dialog.setStyleSheet(""" - QDialog { - background-color: #282828; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - } - QLabel { - color: #ffffff; - font-size: 12px; - } - QTextEdit { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - font-size: 11px; - font-family: 'Courier New', monospace; - padding: 8px; - } - QPushButton { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - padding: 6px 12px; - font-size: 11px; - min-width: 50px; - min-height: 28px; - } - QPushButton:hover { - background-color: #505050; - } - #copyButton { - background-color: #1db954; - border: 1px solid #1db954; - color: #000000; - font-weight: bold; - min-height: 28px; - } - #copyButton:hover { - background-color: #1ed760; - } - """) - - layout = QVBoxLayout(dialog) - layout.setSpacing(8) - layout.setContentsMargins(15, 15, 15, 15) - - # Success message - location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network" - success_label = QLabel(f"Found slskd running {location_type}!") - success_label.setStyleSheet("color: #1db954; font-size: 13px; font-weight: bold;") - success_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(success_label) - - # URL display with copy functionality - url_label = QLabel("Detected URL:") - layout.addWidget(url_label) - - url_container = QHBoxLayout() - url_container.setSpacing(5) - - url_display = QTextEdit() - url_display.setPlainText(found_url) - url_display.setReadOnly(True) - url_display.setFixedHeight(30) - url_display.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - url_container.addWidget(url_display) - - copy_btn = QPushButton("Copy") - copy_btn.setObjectName("copyButton") - copy_btn.setFixedSize(55, 30) - copy_btn.clicked.connect(lambda: self.copy_to_clipboard(found_url, copy_btn)) - url_container.addWidget(copy_btn) - - layout.addLayout(url_container) - - # Info text - info_label = QLabel("URL automatically filled in settings above.") - info_label.setStyleSheet("color: #ffffff; font-size: 9px; font-style: italic; background: transparent;") - info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(info_label) - - # OK button - button_layout = QHBoxLayout() - button_layout.addStretch() - - ok_btn = QPushButton("OK") - ok_btn.setFixedSize(60, 28) - ok_btn.clicked.connect(dialog.accept) - ok_btn.setDefault(True) - button_layout.addWidget(ok_btn) - - layout.addLayout(button_layout) - - dialog.exec() - - def copy_to_clipboard(self, text, button): - """Copy text to clipboard and show feedback""" - from PyQt6.QtWidgets import QApplication - from PyQt6.QtCore import QTimer - - clipboard = QApplication.clipboard() - clipboard.setText(text) - - # Show feedback - original_text = button.text() - button.setText("Copied!") - button.setEnabled(False) - - # Reset button after 1 second with safe reference check - def safe_reset(): - try: - if button and not button.isHidden(): # Check if button still exists and is valid - button.setText(original_text) - button.setEnabled(True) - except RuntimeError: - # Button was deleted, ignore silently - pass - - QTimer.singleShot(1000, safe_reset) - - def browse_download_path(self): - """Open a directory dialog to select download path""" - from PyQt6.QtWidgets import QFileDialog - - current_path = self.download_path_input.text() - selected_path = QFileDialog.getExistingDirectory( - self, - "Select Download Directory", - current_path if current_path else ".", - QFileDialog.Option.ShowDirsOnly - ) - - if selected_path: - self.download_path_input.setText(selected_path) - - def browse_transfer_path(self): - """Open a directory dialog to select transfer path""" - from PyQt6.QtWidgets import QFileDialog - - current_path = self.transfer_path_input.text() - selected_path = QFileDialog.getExistingDirectory( - self, - "Select Transfer Directory", - current_path if current_path else ".", - QFileDialog.Option.ShowDirsOnly - ) - - if selected_path: - self.transfer_path_input.setText(selected_path) - - def create_header(self): - header = QWidget() - layout = QVBoxLayout(header) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) - - # Title - title_label = QLabel("Settings") - title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #ffffff; background: transparent;") - - # Subtitle - subtitle_label = QLabel("Configure your music sync and download preferences") - subtitle_label.setFont(QFont("Arial", 14)) - subtitle_label.setStyleSheet("color: #ffffff; background: transparent;") - - layout.addWidget(title_label) - layout.addWidget(subtitle_label) - - return header - - def create_left_column(self): - column = QWidget() - layout = QVBoxLayout(column) - layout.setSpacing(18) - - # API Configuration - api_group = SettingsGroup("API Configuration") - api_layout = QVBoxLayout(api_group) - api_layout.setContentsMargins(16, 20, 16, 16) - api_layout.setSpacing(12) - - # Spotify settings - spotify_frame = QFrame() - spotify_frame.setStyleSheet(""" - QFrame { - background: #333333; - border: 1px solid #444444; - border-radius: 8px; - padding: 8px; - } - """) - spotify_layout = QVBoxLayout(spotify_frame) - spotify_layout.setSpacing(8) - - spotify_title = QLabel("Spotify") - spotify_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - spotify_title.setStyleSheet("color: #1db954;") - spotify_layout.addWidget(spotify_title) - - # Client ID - client_id_label = QLabel("Client ID:") - client_id_label.setStyleSheet(self.get_label_style(11)) - spotify_layout.addWidget(client_id_label) - - self.client_id_input = QLineEdit() - self.client_id_input.setStyleSheet(self.get_input_style()) - self.client_id_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['spotify.client_id'] = self.client_id_input - spotify_layout.addWidget(self.client_id_input) - - # Client Secret - client_secret_label = QLabel("Client Secret:") - client_secret_label.setStyleSheet(self.get_label_style(11)) - spotify_layout.addWidget(client_secret_label) - - self.client_secret_input = QLineEdit() - self.client_secret_input.setEchoMode(QLineEdit.EchoMode.Password) - self.client_secret_input.setStyleSheet(self.get_input_style()) - self.client_secret_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['spotify.client_secret'] = self.client_secret_input - spotify_layout.addWidget(self.client_secret_input) - - # Callback URL info - callback_info_label = QLabel("Required Redirect URI:") - callback_info_label.setStyleSheet("color: #ffffff; font-size: 11px; margin-top: 8px; background: transparent;") - spotify_layout.addWidget(callback_info_label) - - callback_url_label = QLabel("http://127.0.0.1:8888/callback") - callback_url_label.setStyleSheet(""" - color: #1db954; - font-size: 11px; - font-family: 'Courier New', monospace; - background-color: rgba(29, 185, 84, 0.1); - border: 1px solid rgba(29, 185, 84, 0.3); - border-radius: 4px; - padding: 6px 8px; - margin-bottom: 8px; - """) - callback_url_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - spotify_layout.addWidget(callback_url_label) - - # Helper text - helper_text = QLabel("Add this URL to your Spotify app's 'Redirect URIs' in the Spotify Developer Dashboard") - helper_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;") - helper_text.setWordWrap(True) - spotify_layout.addWidget(helper_text) - - # Tidal settings - tidal_frame = QFrame() - tidal_frame.setStyleSheet(""" - QFrame { - background: #333333; - border: 1px solid #444444; - border-radius: 8px; - padding: 8px; - } - """) - tidal_layout = QVBoxLayout(tidal_frame) - tidal_layout.setSpacing(8) - - tidal_title = QLabel("Tidal") - tidal_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - tidal_title.setStyleSheet("color: #ff6600;") - tidal_layout.addWidget(tidal_title) - - # Client ID - tidal_client_id_label = QLabel("Client ID:") - tidal_client_id_label.setStyleSheet(self.get_label_style(11)) - tidal_layout.addWidget(tidal_client_id_label) - - self.tidal_client_id_input = QLineEdit() - self.tidal_client_id_input.setStyleSheet(self.get_input_style()) - self.tidal_client_id_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['tidal.client_id'] = self.tidal_client_id_input - tidal_layout.addWidget(self.tidal_client_id_input) - - # Client Secret - tidal_client_secret_label = QLabel("Client Secret:") - tidal_client_secret_label.setStyleSheet(self.get_label_style(11)) - tidal_layout.addWidget(tidal_client_secret_label) - - self.tidal_client_secret_input = QLineEdit() - self.tidal_client_secret_input.setEchoMode(QLineEdit.EchoMode.Password) - self.tidal_client_secret_input.setStyleSheet(self.get_input_style()) - self.tidal_client_secret_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['tidal.client_secret'] = self.tidal_client_secret_input - tidal_layout.addWidget(self.tidal_client_secret_input) - - # Helper text for Tidal - tidal_helper_text = QLabel("Configure Tidal API credentials for playlist sync functionality") - tidal_helper_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;") - tidal_helper_text.setWordWrap(True) - tidal_layout.addWidget(tidal_helper_text) - - # OAuth info - oauth_info_label = QLabel("Required Redirect URI:") - oauth_info_label.setStyleSheet("color: #ffffff; font-size: 11px; margin-top: 8px; background: transparent;") - tidal_layout.addWidget(oauth_info_label) - - oauth_url_label = QLabel("http://127.0.0.1:8889/tidal/callback") - oauth_url_label.setStyleSheet(""" - color: #ff6600; - font-size: 11px; - font-family: 'Courier New', monospace; - background: #2a2a2a; - border: 1px solid #444444; - border-radius: 4px; - padding: 6px 8px; - margin-bottom: 8px; - """) - oauth_url_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - tidal_layout.addWidget(oauth_url_label) - - # Authenticate button - self.tidal_auth_btn = QPushButton("Authenticate") - self.tidal_auth_btn.setFixedHeight(30) - self.tidal_auth_btn.setStyleSheet(""" - QPushButton { - background: #ff6600; - border: none; - border-radius: 15px; - color: #ffffff; - font-size: 11px; - font-weight: bold; - margin-top: 8px; - } - QPushButton:hover { - background: #ff7700; - } - QPushButton:pressed { - background: #e55500; - } - """) - self.tidal_auth_btn.clicked.connect(self.authenticate_tidal) - tidal_layout.addWidget(self.tidal_auth_btn) - - # Server Selection Toggle Buttons - server_selection_container = QWidget() - server_selection_container.setStyleSheet("background: transparent;") - server_selection_layout = QVBoxLayout(server_selection_container) - server_selection_layout.setContentsMargins(0, 12, 0, 12) - server_selection_layout.setSpacing(8) - - # Server selection title - server_title = QLabel("Media Server Source") - server_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - server_title.setStyleSheet("color: #ffffff; background: transparent;") - server_selection_layout.addWidget(server_title) - - # Toggle buttons container - toggle_container = QHBoxLayout() - toggle_container.setSpacing(8) - - # Plex toggle button - self.plex_toggle_button = QPushButton() - self.plex_toggle_button.setFixedHeight(40) - self.plex_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.plex_toggle_button.clicked.connect(lambda: self.select_media_server('plex')) - - # Jellyfin toggle button - self.jellyfin_toggle_button = QPushButton() - self.jellyfin_toggle_button.setFixedHeight(40) - self.jellyfin_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.jellyfin_toggle_button.clicked.connect(lambda: self.select_media_server('jellyfin')) - - # Navidrome toggle button - self.navidrome_toggle_button = QPushButton() - self.navidrome_toggle_button.setFixedHeight(40) - self.navidrome_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.navidrome_toggle_button.clicked.connect(lambda: self.select_media_server('navidrome')) - - toggle_container.addWidget(self.plex_toggle_button) - toggle_container.addWidget(self.jellyfin_toggle_button) - toggle_container.addWidget(self.navidrome_toggle_button) - server_selection_layout.addLayout(toggle_container) - - # Restart warning (initially hidden) - self.restart_warning_frame = QLabel("Server change requires restart - Save settings then restart SoulSync") - self.restart_warning_frame.setStyleSheet(""" - color: #ffc107; - font-size: 11px; - font-weight: bold; - background: transparent; - margin: 8px 0px 4px 0px; - """) - self.restart_warning_frame.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.restart_warning_frame.hide() - server_selection_layout.addWidget(self.restart_warning_frame) - - # Media Server Settings Container - self.plex_container = QWidget() - self.plex_container.setStyleSheet("background: transparent;") - plex_container_layout = QVBoxLayout(self.plex_container) - plex_container_layout.setContentsMargins(0, 0, 0, 0) - plex_container_layout.setSpacing(0) - - # Plex settings - plex_frame = QFrame() - plex_frame.setStyleSheet(""" - QFrame { - background: #333333; - border: 1px solid #444444; - border-radius: 8px; - padding: 8px; - } - """) - plex_layout = QVBoxLayout(plex_frame) - plex_layout.setSpacing(8) - - plex_title = QLabel("Plex") - plex_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - plex_title.setStyleSheet("color: #e5a00d;") - plex_layout.addWidget(plex_title) - - # Server URL - plex_url_label = QLabel("Server URL:") - plex_url_label.setStyleSheet(self.get_label_style(11)) - plex_layout.addWidget(plex_url_label) - - plex_url_input_layout = QHBoxLayout() - self.plex_url_input = QLineEdit() - self.plex_url_input.setStyleSheet(self.get_input_style()) - self.plex_url_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['plex.base_url'] = self.plex_url_input - - plex_detect_btn = QPushButton("Auto-detect") - plex_detect_btn.setFixedSize(80, 30) - plex_detect_btn.clicked.connect(self.auto_detect_plex) - plex_detect_btn.setStyleSheet(self.get_test_button_style()) - - plex_url_input_layout.addWidget(self.plex_url_input) - plex_url_input_layout.addWidget(plex_detect_btn) - plex_layout.addLayout(plex_url_input_layout) - - # Token - plex_token_label = QLabel("Token:") - plex_token_label.setStyleSheet(self.get_label_style(11)) - plex_layout.addWidget(plex_token_label) - - self.plex_token_input = QLineEdit() - self.plex_token_input.setEchoMode(QLineEdit.EchoMode.Password) - self.plex_token_input.setStyleSheet(self.get_input_style()) - self.plex_token_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['plex.token'] = self.plex_token_input - plex_layout.addWidget(self.plex_token_input) - - # Add Plex frame to its container - plex_container_layout.addWidget(plex_frame) - - # Jellyfin Settings Container - self.jellyfin_container = QWidget() - self.jellyfin_container.setStyleSheet("background: transparent;") - jellyfin_container_layout = QVBoxLayout(self.jellyfin_container) - jellyfin_container_layout.setContentsMargins(0, 0, 0, 0) - jellyfin_container_layout.setSpacing(0) - - # Jellyfin settings - jellyfin_frame = QFrame() - jellyfin_frame.setStyleSheet(""" - QFrame { - background: #333333; - border: 1px solid #444444; - border-radius: 8px; - padding: 8px; - } - """) - jellyfin_layout = QVBoxLayout(jellyfin_frame) - jellyfin_layout.setSpacing(8) - - jellyfin_title = QLabel("Jellyfin") - jellyfin_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - jellyfin_title.setStyleSheet("color: #aa5cc3;") # Jellyfin purple color - jellyfin_layout.addWidget(jellyfin_title) - - # Server URL - jellyfin_url_label = QLabel("Server URL:") - jellyfin_url_label.setStyleSheet(self.get_label_style(11)) - jellyfin_layout.addWidget(jellyfin_url_label) - - jellyfin_url_input_layout = QHBoxLayout() - self.jellyfin_url_input = QLineEdit() - self.jellyfin_url_input.setStyleSheet(self.get_input_style()) - self.jellyfin_url_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['jellyfin.base_url'] = self.jellyfin_url_input - - jellyfin_detect_btn = QPushButton("Auto-detect") - jellyfin_detect_btn.setFixedSize(80, 30) - jellyfin_detect_btn.clicked.connect(self.auto_detect_jellyfin) - jellyfin_detect_btn.setStyleSheet(self.get_test_button_style()) - - jellyfin_url_input_layout.addWidget(self.jellyfin_url_input) - jellyfin_url_input_layout.addWidget(jellyfin_detect_btn) - jellyfin_layout.addLayout(jellyfin_url_input_layout) - - # API Key - jellyfin_api_key_label = QLabel("API Key:") - jellyfin_api_key_label.setStyleSheet(self.get_label_style(11)) - jellyfin_layout.addWidget(jellyfin_api_key_label) - - self.jellyfin_api_key_input = QLineEdit() - self.jellyfin_api_key_input.setEchoMode(QLineEdit.EchoMode.Password) - self.jellyfin_api_key_input.setStyleSheet(self.get_input_style()) - self.jellyfin_api_key_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['jellyfin.api_key'] = self.jellyfin_api_key_input - jellyfin_layout.addWidget(self.jellyfin_api_key_input) - - # Add Jellyfin frame to its container - jellyfin_container_layout.addWidget(jellyfin_frame) - - # Navidrome Settings Container - self.navidrome_container = QWidget() - self.navidrome_container.setStyleSheet("background: transparent;") - navidrome_container_layout = QVBoxLayout(self.navidrome_container) - navidrome_container_layout.setContentsMargins(0, 0, 0, 0) - navidrome_container_layout.setSpacing(0) - - # Navidrome settings - navidrome_frame = QFrame() - navidrome_frame.setStyleSheet(""" - QFrame { - background: #333333; - border: 1px solid #444444; - border-radius: 8px; - padding: 8px; - } - """) - navidrome_layout = QVBoxLayout(navidrome_frame) - navidrome_layout.setSpacing(8) - - navidrome_title = QLabel("Navidrome") - navidrome_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - navidrome_title.setStyleSheet("color: #ff6b6b;") # Navidrome red color - navidrome_layout.addWidget(navidrome_title) - - # Server URL - navidrome_url_label = QLabel("Server URL:") - navidrome_url_label.setStyleSheet(self.get_label_style(11)) - navidrome_layout.addWidget(navidrome_url_label) - - navidrome_url_input_layout = QHBoxLayout() - self.navidrome_url_input = QLineEdit() - self.navidrome_url_input.setStyleSheet(self.get_input_style()) - self.navidrome_url_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['navidrome.base_url'] = self.navidrome_url_input - - navidrome_detect_btn = QPushButton("Auto-detect") - navidrome_detect_btn.setFixedSize(80, 30) - navidrome_detect_btn.clicked.connect(self.auto_detect_navidrome) - navidrome_detect_btn.setStyleSheet(self.get_test_button_style()) - - navidrome_url_input_layout.addWidget(self.navidrome_url_input) - navidrome_url_input_layout.addWidget(navidrome_detect_btn) - navidrome_layout.addLayout(navidrome_url_input_layout) - - # Username - navidrome_username_label = QLabel("Username:") - navidrome_username_label.setStyleSheet(self.get_label_style(11)) - navidrome_layout.addWidget(navidrome_username_label) - - self.navidrome_username_input = QLineEdit() - self.navidrome_username_input.setStyleSheet(self.get_input_style()) - self.navidrome_username_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['navidrome.username'] = self.navidrome_username_input - navidrome_layout.addWidget(self.navidrome_username_input) - - # Password - navidrome_password_label = QLabel("Password:") - navidrome_password_label.setStyleSheet(self.get_label_style(11)) - navidrome_layout.addWidget(navidrome_password_label) - - self.navidrome_password_input = QLineEdit() - self.navidrome_password_input.setEchoMode(QLineEdit.EchoMode.Password) - self.navidrome_password_input.setStyleSheet(self.get_input_style()) - self.navidrome_password_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['navidrome.password'] = self.navidrome_password_input - navidrome_layout.addWidget(self.navidrome_password_input) - - # Add Navidrome frame to its container - navidrome_container_layout.addWidget(navidrome_frame) - - # Soulseek settings - soulseek_frame = QFrame() - soulseek_frame.setStyleSheet(""" - QFrame { - background: #333333; - border: 1px solid #444444; - border-radius: 8px; - padding: 8px; - } - """) - soulseek_layout = QVBoxLayout(soulseek_frame) - soulseek_layout.setSpacing(8) - - soulseek_title = QLabel("Soulseek") - soulseek_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - soulseek_title.setStyleSheet("color: #5dade2;") - soulseek_layout.addWidget(soulseek_title) - - # slskd URL - slskd_url_label = QLabel("slskd URL:") - slskd_url_label.setStyleSheet(self.get_label_style(11)) - soulseek_layout.addWidget(slskd_url_label) - - url_input_layout = QHBoxLayout() - self.slskd_url_input = QLineEdit() - self.slskd_url_input.setStyleSheet(self.get_input_style()) - self.slskd_url_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['soulseek.slskd_url'] = self.slskd_url_input - - detect_btn = QPushButton("Auto-detect") - detect_btn.setFixedSize(80, 30) - detect_btn.clicked.connect(self.auto_detect_slskd) - detect_btn.setStyleSheet(self.get_test_button_style()) - - url_input_layout.addWidget(self.slskd_url_input) - url_input_layout.addWidget(detect_btn) - soulseek_layout.addLayout(url_input_layout) - - # API Key - api_key_label = QLabel("API Key:") - api_key_label.setStyleSheet(self.get_label_style(11)) - soulseek_layout.addWidget(api_key_label) - - self.api_key_input = QLineEdit() - self.api_key_input.setPlaceholderText("Enter your slskd API key") - self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password) - self.api_key_input.setStyleSheet(self.get_input_style()) - self.api_key_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.form_inputs['soulseek.api_key'] = self.api_key_input - soulseek_layout.addWidget(self.api_key_input) - - api_layout.addWidget(spotify_frame) - api_layout.addWidget(tidal_frame) - api_layout.addWidget(server_selection_container) - api_layout.addWidget(self.plex_container) - api_layout.addWidget(self.jellyfin_container) - api_layout.addWidget(self.navidrome_container) - api_layout.addWidget(soulseek_frame) - - # Test connections - test_layout = QHBoxLayout() - test_layout.setSpacing(12) - - self.test_buttons['spotify'] = QPushButton("Test Spotify") - self.test_buttons['spotify'].setFixedHeight(30) - self.test_buttons['spotify'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.test_buttons['spotify'].clicked.connect(self.test_spotify_connection) - self.test_buttons['spotify'].setStyleSheet(self.get_test_button_style()) - - self.test_buttons['tidal'] = QPushButton("Test Tidal") - self.test_buttons['tidal'].setFixedHeight(30) - self.test_buttons['tidal'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.test_buttons['tidal'].clicked.connect(self.test_tidal_connection) - self.test_buttons['tidal'].setStyleSheet(self.get_test_button_style()) - - self.test_buttons['server'] = QPushButton("Test Server") - self.test_buttons['server'].setFixedHeight(30) - self.test_buttons['server'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.test_buttons['server'].clicked.connect(self.test_active_server_connection) - self.test_buttons['server'].setStyleSheet(self.get_test_button_style()) - - self.test_buttons['soulseek'] = QPushButton("Test Soulseek") - self.test_buttons['soulseek'].setFixedHeight(30) - self.test_buttons['soulseek'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.test_buttons['soulseek'].clicked.connect(self.test_soulseek_connection) - self.test_buttons['soulseek'].setStyleSheet(self.get_test_button_style()) - - test_layout.addWidget(self.test_buttons['spotify']) - test_layout.addWidget(self.test_buttons['tidal']) - test_layout.addWidget(self.test_buttons['server']) - test_layout.addWidget(self.test_buttons['soulseek']) - - api_layout.addLayout(test_layout) - - - layout.addWidget(api_group) - layout.addStretch() - - return column - - def create_right_column(self): - column = QWidget() - layout = QVBoxLayout(column) - layout.setSpacing(18) - - # Download Settings - download_group = SettingsGroup("Download Settings") - download_layout = QVBoxLayout(download_group) - download_layout.setContentsMargins(16, 20, 16, 16) - download_layout.setSpacing(12) - - # Download path - path_container = QVBoxLayout() - path_label = QLabel("Slskd Download Dir:") - path_label.setStyleSheet(self.get_label_style(12)) - path_container.addWidget(path_label) - - path_input_layout = QHBoxLayout() - self.download_path_input = QLineEdit("./downloads") - self.download_path_input.setStyleSheet(self.get_input_style()) - self.download_path_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - browse_btn = QPushButton("Browse") - browse_btn.setFixedSize(70, 30) - browse_btn.clicked.connect(self.browse_download_path) - browse_btn.setStyleSheet(self.get_test_button_style()) - - path_input_layout.addWidget(self.download_path_input) - path_input_layout.addWidget(browse_btn) - path_container.addLayout(path_input_layout) - - # Transfer folder path - transfer_path_container = QVBoxLayout() - transfer_path_label = QLabel("Matched Transfer Dir (Plex Music Dir?):") - transfer_path_label.setStyleSheet(self.get_label_style(12)) - transfer_path_container.addWidget(transfer_path_label) - - transfer_input_layout = QHBoxLayout() - self.transfer_path_input = QLineEdit("./Transfer") - self.transfer_path_input.setStyleSheet(self.get_input_style()) - self.transfer_path_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - transfer_browse_btn = QPushButton("Browse") - transfer_browse_btn.setFixedSize(70, 30) - transfer_browse_btn.clicked.connect(self.browse_transfer_path) - transfer_browse_btn.setStyleSheet(self.get_test_button_style()) - - transfer_input_layout.addWidget(self.transfer_path_input) - transfer_input_layout.addWidget(transfer_browse_btn) - transfer_path_container.addLayout(transfer_input_layout) - - download_layout.addLayout(path_container) - download_layout.addLayout(transfer_path_container) - - # Database Settings - database_group = SettingsGroup("Database Settings") - database_layout = QVBoxLayout(database_group) - database_layout.setContentsMargins(16, 20, 16, 16) - database_layout.setSpacing(12) - - # Max Workers - workers_layout = QHBoxLayout() - workers_label = QLabel("Concurrent Workers:") - workers_label.setStyleSheet(self.get_label_style(12)) - - self.max_workers_combo = QComboBox() - self.max_workers_combo.addItems(["3", "4", "5", "6", "7", "8", "9", "10"]) - self.max_workers_combo.setCurrentText("5") # Default value - self.max_workers_combo.setStyleSheet(self.get_combo_style()) - self.max_workers_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - workers_layout.addWidget(workers_label) - workers_layout.addWidget(self.max_workers_combo) - - # Help text for workers - workers_help = QLabel("Number of parallel threads for database updates. Higher values = faster updates but more server load.") - workers_help.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;") - workers_help.setWordWrap(True) - - database_layout.addLayout(workers_layout) - database_layout.addWidget(workers_help) - - # Metadata Enhancement Settings - metadata_group = SettingsGroup("Metadata Enhancement") - metadata_layout = QVBoxLayout(metadata_group) - metadata_layout.setContentsMargins(16, 20, 16, 16) - metadata_layout.setSpacing(12) - - # Enable metadata enhancement checkbox - self.metadata_enabled_checkbox = QCheckBox("Enable metadata enhancement with Spotify data") - self.metadata_enabled_checkbox.setChecked(True) - self.metadata_enabled_checkbox.setStyleSheet(""" - QCheckBox { - color: #ffffff; - font-size: 12px; - spacing: 8px; - background: transparent; - } - QCheckBox::indicator { - width: 16px; - height: 16px; - border-radius: 3px; - border: 2px solid #606060; - background-color: #404040; - } - QCheckBox::indicator:checked { - background-color: #1db954; - border-color: #1db954; - image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzLjUgNC41TDYuNSAxMS41TDIuNSA3LjUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=); - } - QCheckBox::indicator:hover { - border-color: #1db954; - } - """) - self.form_inputs['metadata_enhancement.enabled'] = self.metadata_enabled_checkbox - - # Embed album art checkbox - self.embed_album_art_checkbox = QCheckBox("Embed high-quality album art from Spotify") - self.embed_album_art_checkbox.setChecked(True) - self.embed_album_art_checkbox.setStyleSheet(self.metadata_enabled_checkbox.styleSheet()) - self.form_inputs['metadata_enhancement.embed_album_art'] = self.embed_album_art_checkbox - - - # Supported formats display - supported_formats_layout = QHBoxLayout() - formats_label = QLabel("Supported Formats:") - formats_label.setStyleSheet(self.get_label_style(12)) - - formats_display = QLabel("MP3, FLAC, MP4/M4A, OGG") - formats_display.setStyleSheet(""" - color: #ffffff; - font-size: 11px; - background: transparent; - border: none; - """) - - supported_formats_layout.addWidget(formats_label) - supported_formats_layout.addWidget(formats_display) - - # Help text - help_text = QLabel("Automatically enhances downloaded tracks with accurate Spotify metadata including artist, album, track numbers, genres, and release dates. Perfect for Plex libraries!") - help_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;") - help_text.setWordWrap(True) - - metadata_layout.addWidget(self.metadata_enabled_checkbox) - metadata_layout.addWidget(self.embed_album_art_checkbox) - metadata_layout.addLayout(supported_formats_layout) - metadata_layout.addWidget(help_text) - - # Playlist Sync Settings - playlist_sync_group = SettingsGroup("Playlist Sync") - playlist_sync_layout = QVBoxLayout(playlist_sync_group) - playlist_sync_layout.setContentsMargins(16, 20, 16, 16) - playlist_sync_layout.setSpacing(12) - - # Create backup checkbox - self.create_backup_checkbox = QCheckBox("Create backup of existing playlists before sync") - self.create_backup_checkbox.setChecked(True) - self.create_backup_checkbox.setStyleSheet(""" - QCheckBox { - color: #ffffff; - font-size: 12px; - spacing: 8px; - background: transparent; - } - QCheckBox::indicator { - width: 16px; - height: 16px; - border-radius: 3px; - border: 2px solid #606060; - background-color: #404040; - } - QCheckBox::indicator:checked { - background-color: #1db954; - border-color: #1db954; - image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzLjUgNC41TDYuNSAxMS41TDIuNSA3LjUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=); - } - QCheckBox::indicator:hover { - border-color: #1db954; - } - """) - - # Help text for playlist sync - playlist_help_text = QLabel("When enabled, existing Plex playlists will be backed up as '[Playlist Name] Backup' before being overwritten during sync. Only one backup per playlist is maintained.") - playlist_help_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;") - playlist_help_text.setWordWrap(True) - - playlist_sync_layout.addWidget(self.create_backup_checkbox) - playlist_sync_layout.addWidget(playlist_help_text) - - # Add to form inputs for saving - self.form_inputs['playlist_sync.create_backup'] = self.create_backup_checkbox - - # Logging Settings - logging_group = SettingsGroup("Logging Settings") - logging_layout = QVBoxLayout(logging_group) - logging_layout.setContentsMargins(16, 20, 16, 16) - logging_layout.setSpacing(12) - - # Log level (read-only) - log_level_layout = QHBoxLayout() - log_level_label = QLabel("Log Level:") - log_level_label.setStyleSheet(self.get_label_style(12)) - - self.log_level_display = QLabel("DEBUG") - self.log_level_display.setStyleSheet(""" - color: #ffffff; - font-size: 11px; - background: transparent; - border: none; - """) - self.log_level_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - log_level_layout.addWidget(log_level_label) - log_level_layout.addWidget(self.log_level_display) - - # Log file path (read-only) - log_path_container = QVBoxLayout() - log_path_label = QLabel("Log File Path:") - log_path_label.setStyleSheet(self.get_label_style(12)) - log_path_container.addWidget(log_path_label) - - self.log_path_display = QLabel("logs/app.log") - self.log_path_display.setStyleSheet(""" - color: #1db954; - font-size: 11px; - font-family: 'Courier New', monospace; - background-color: rgba(29, 185, 84, 0.1); - border: 1px solid rgba(29, 185, 84, 0.3); - border-radius: 4px; - padding: 6px 8px; - """) - self.log_path_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - log_path_container.addWidget(self.log_path_display) - - logging_layout.addLayout(log_level_layout) - logging_layout.addLayout(log_path_container) - - layout.addWidget(download_group) - layout.addWidget(database_group) - layout.addWidget(metadata_group) - layout.addWidget(playlist_sync_group) - layout.addWidget(logging_group) - layout.addStretch() # Push content to top, prevent stretching - - return column - - def get_input_style(self): - return """ - QLineEdit { - background: #404040; - border: 1px solid #606060; - border-radius: 4px; - padding: 8px; - color: #ffffff; - font-size: 11px; - } - QLineEdit:focus { - border: 1px solid #1db954; - } - """ - - def select_media_server(self, server_type: str): - """Handle media server selection toggle""" - try: - current_server = config_manager.get_active_media_server() - - if server_type != current_server: - # Show restart warning - self.restart_warning_frame.show() - - # Update the pending server change (but don't make it active yet) - self.pending_server_change = server_type - else: - # Hide restart warning if selecting the current server - self.restart_warning_frame.hide() - self.pending_server_change = None - - # Update toggle button styles - self.update_server_toggle_styles(server_type) - - # Show/hide appropriate containers - self.plex_container.hide() - self.jellyfin_container.hide() - self.navidrome_container.hide() - - if server_type == 'plex': - self.plex_container.show() - elif server_type == 'jellyfin': - self.jellyfin_container.show() - elif server_type == 'navidrome': - self.navidrome_container.show() - - except Exception as e: - logger.error(f"Error selecting media server: {e}") - - def update_server_toggle_styles(self, active_server=None): - """Update the visual styles of server toggle buttons""" - if active_server is None: - active_server = getattr(self, 'pending_server_change', None) or config_manager.get_active_media_server() - - from PyQt6.QtGui import QIcon, QPixmap - from PyQt6.QtCore import QSize, Qt - import requests - import os - from pathlib import Path - - def download_and_cache_logo(url, cache_filename, size=32): - """Download logo and cache it locally, return QIcon""" - cache_dir = Path("ui/assets") - cache_dir.mkdir(exist_ok=True) - cache_path = cache_dir / cache_filename - - # Download if not cached - if not cache_path.exists(): - try: - logger.info(f"Downloading logo from {url}") - response = requests.get(url, timeout=10) - if response.status_code == 200: - with open(cache_path, 'wb') as f: - f.write(response.content) - logger.info(f"Logo cached at {cache_path}") - else: - logger.warning(f"Failed to download logo: HTTP {response.status_code}") - return QIcon() - except Exception as e: - logger.warning(f"Error downloading logo from {url}: {e}") - return QIcon() - - # Load from cache - try: - pixmap = QPixmap(str(cache_path)) - if not pixmap.isNull(): - # Scale to desired size while maintaining aspect ratio - scaled_pixmap = pixmap.scaled( - size, size, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation - ) - return QIcon(scaled_pixmap) - else: - logger.warning(f"Could not load cached logo from {cache_path}") - return QIcon() - except Exception as e: - logger.warning(f"Error loading cached logo: {e}") - return QIcon() - - # Cache and load the exact logos you provided - if not hasattr(self, '_cached_plex_icon'): - self._cached_plex_icon = download_and_cache_logo( - "https://wiki.mrmc.tv/images/c/cf/Plex_icon.png", - "plex_icon.png", - 32 - ) - - if not hasattr(self, '_cached_jellyfin_icon'): - self._cached_jellyfin_icon = download_and_cache_logo( - "https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Jellyfin_-_icon-transparent.svg/2048px-Jellyfin_-_icon-transparent.svg.png", - "jellyfin_icon.png", - 32 - ) - - if not hasattr(self, '_cached_navidrome_icon'): - self._cached_navidrome_icon = download_and_cache_logo( - "https://raw.githubusercontent.com/navidrome/navidrome/master/resources/logo-192x192.png", - "navidrome_icon.png", - 32 - ) - # Fallback to a simple text-based icon if download fails - if self._cached_navidrome_icon.isNull(): - logger.warning("Navidrome icon download failed, creating fallback icon") - self._cached_navidrome_icon = self._create_fallback_icon("N", "#ff6b6b") - - plex_icon = self._cached_plex_icon - jellyfin_icon = self._cached_jellyfin_icon - navidrome_icon = self._cached_navidrome_icon - - # Active button styles with appropriate colors - active_plex_style = """ - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(229, 160, 13, 0.8), - stop:1 rgba(199, 140, 11, 0.9)); - border: 2px solid rgba(229, 160, 13, 1); - border-radius: 8px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(229, 160, 13, 0.9), - stop:1 rgba(199, 140, 11, 1.0)); - } - """ - - active_jellyfin_style = """ - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(170, 92, 195, 0.8), - stop:1 rgba(150, 82, 175, 0.9)); - border: 2px solid rgba(170, 92, 195, 1); - border-radius: 8px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(170, 92, 195, 0.9), - stop:1 rgba(150, 82, 175, 1.0)); - } - """ - - active_navidrome_style = """ - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 107, 107, 0.8), - stop:1 rgba(235, 87, 87, 0.9)); - border: 2px solid rgba(255, 107, 107, 1); - border-radius: 8px; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(255, 107, 107, 0.9), - stop:1 rgba(235, 87, 87, 1.0)); - } - """ - - # Inactive button style - inactive_style = """ - QPushButton { - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 8px; - } - QPushButton:hover { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.5); - } - """ - - # Set icons, text, and styles - self.plex_toggle_button.setIcon(plex_icon) - self.plex_toggle_button.setIconSize(QSize(28, 28)) - self.plex_toggle_button.setText("Plex") - - self.jellyfin_toggle_button.setIcon(jellyfin_icon) - self.jellyfin_toggle_button.setIconSize(QSize(28, 28)) - self.jellyfin_toggle_button.setText("Jellyfin") - - self.navidrome_toggle_button.setIcon(navidrome_icon) - self.navidrome_toggle_button.setIconSize(QSize(28, 28)) - self.navidrome_toggle_button.setText("Navidrome") - - # Debug: Check if icons are properly loaded - if navidrome_icon.isNull(): - logger.warning("Navidrome icon failed to load!") - else: - logger.info("Navidrome icon loaded successfully") - - # Reset all buttons to inactive first - self.plex_toggle_button.setStyleSheet(inactive_style) - self.jellyfin_toggle_button.setStyleSheet(inactive_style) - self.navidrome_toggle_button.setStyleSheet(inactive_style) - - # Set the active server button style - if active_server == 'plex': - self.plex_toggle_button.setStyleSheet(active_plex_style) - elif active_server == 'jellyfin': - self.jellyfin_toggle_button.setStyleSheet(active_jellyfin_style) - elif active_server == 'navidrome': - self.navidrome_toggle_button.setStyleSheet(active_navidrome_style) - - def _create_fallback_icon(self, text, color): - """Create a simple text-based fallback icon""" - from PyQt6.QtGui import QPixmap, QPainter, QFont, QColor - from PyQt6.QtCore import Qt - - # Create a 32x32 pixmap - pixmap = QPixmap(32, 32) - pixmap.fill(Qt.GlobalColor.transparent) - - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Draw background circle - painter.setBrush(QColor(color)) - painter.setPen(Qt.PenStyle.NoPen) - painter.drawEllipse(2, 2, 28, 28) - - # Draw text - painter.setPen(QColor("white")) - font = QFont("Arial", 14, QFont.Weight.Bold) - painter.setFont(font) - painter.drawText(0, 0, 32, 32, Qt.AlignmentFlag.AlignCenter, text) - - painter.end() - return QIcon(pixmap) - - def auto_detect_jellyfin(self): - """Auto-detect Jellyfin server URL using background thread""" - # Don't start new detection if one is already running - if self.detection_thread and self.detection_thread.isRunning(): - return - - # Create animated loading dialog - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton - from PyQt6.QtCore import QTimer, QPropertyAnimation, QRect - from PyQt6.QtGui import QPainter, QColor - - self.detection_dialog = QDialog(self) - self.detection_dialog.setWindowTitle("Auto-detecting Jellyfin Server") - self.detection_dialog.setModal(True) - self.detection_dialog.setFixedSize(400, 180) - self.detection_dialog.setWindowFlags(self.detection_dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) - - # Apply dark theme styling - self.detection_dialog.setStyleSheet(""" - QDialog { - background-color: #282828; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - } - QLabel { - color: #ffffff; - font-size: 14px; - } - QPushButton { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - padding: 8px 16px; - font-size: 11px; - } - QPushButton:hover { - background-color: #505050; - } - """) - - layout = QVBoxLayout(self.detection_dialog) - layout.setSpacing(20) - layout.setContentsMargins(20, 20, 20, 20) - - # Title label - title_label = QLabel("Searching for Jellyfin servers...") - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(title_label) - - # Status label - self.status_label = QLabel("Checking local machine...") - self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.status_label.setStyleSheet("color: #ffffff; font-size: 12px; background: transparent;") - layout.addWidget(self.status_label) - - # Animated loading bar container - loading_container = QLabel() - loading_container.setFixedHeight(8) - loading_container.setStyleSheet(""" - QLabel { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - } - """) - layout.addWidget(loading_container) - - # Animated purple bar for Jellyfin - self.loading_bar = QLabel(loading_container) - self.loading_bar.setFixedHeight(6) - self.loading_bar.setStyleSheet(""" - background-color: #aa5cc3; - border-radius: 3px; - border: none; - """) - - # Start animation - self.loading_animation = QPropertyAnimation(self.loading_bar, b"geometry") - self.loading_animation.setDuration(1500) # 1.5 seconds - self.loading_animation.setStartValue(QRect(1, 1, 0, 6)) - self.loading_animation.setEndValue(QRect(1, 1, loading_container.width() - 2, 6)) - self.loading_animation.setLoopCount(-1) # Infinite loop - self.loading_animation.start() - - # Cancel button - button_layout = QHBoxLayout() - button_layout.addStretch() - - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.cancel_detection) - button_layout.addWidget(cancel_btn) - - layout.addLayout(button_layout) - - # Start Jellyfin detection thread - self.detection_thread = JellyfinDetectionThread() - self.detection_thread.progress_updated.connect(self.on_detection_progress, Qt.ConnectionType.QueuedConnection) - self.detection_thread.detection_completed.connect(self.on_jellyfin_detection_completed, Qt.ConnectionType.QueuedConnection) - self.detection_thread.start() - - self.detection_dialog.show() - - def auto_detect_navidrome(self): - """Auto-detect Navidrome server URL using background thread""" - # Don't start new detection if one is already running - if self.detection_thread and self.detection_thread.isRunning(): - return - - # Create animated loading dialog - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton - from PyQt6.QtCore import QTimer, QPropertyAnimation, QRect - from PyQt6.QtGui import QPainter, QColor - - self.detection_dialog = QDialog(self) - self.detection_dialog.setWindowTitle("Auto-detecting Navidrome Server") - self.detection_dialog.setModal(True) - self.detection_dialog.setFixedSize(400, 180) - self.detection_dialog.setWindowFlags(self.detection_dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) - - # Apply dark theme styling - self.detection_dialog.setStyleSheet(""" - QDialog { - background-color: #282828; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - } - QLabel { - color: #ffffff; - font-size: 14px; - } - QPushButton { - background-color: #404040; - border: 1px solid #606060; - border-radius: 4px; - color: #ffffff; - padding: 8px 16px; - font-size: 11px; - } - QPushButton:hover { - background-color: #505050; - } - """) - - layout = QVBoxLayout(self.detection_dialog) - layout.setSpacing(20) - - # Status text - status_label = QLabel("Scanning network for Navidrome servers...") - status_label.setWordWrap(True) - layout.addWidget(status_label) - - # Cancel button - button_layout = QHBoxLayout() - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.cancel_detection) - button_layout.addStretch() - button_layout.addWidget(cancel_btn) - layout.addLayout(button_layout) - - # Start Navidrome detection thread - self.detection_thread = NavidromeDetectionThread() - self.detection_thread.progress_updated.connect(self.on_detection_progress, Qt.ConnectionType.QueuedConnection) - self.detection_thread.detection_completed.connect(self.on_navidrome_detection_completed, Qt.ConnectionType.QueuedConnection) - self.detection_thread.start() - - self.detection_dialog.show() - - def on_navidrome_detection_completed(self, found_url): - """Handle Navidrome detection completion""" - # Stop animation and close dialog - if hasattr(self, 'loading_animation'): - self.loading_animation.stop() - - if hasattr(self, 'detection_dialog') and self.detection_dialog: - self.detection_dialog.close() - self.detection_dialog = None - - # Properly cleanup thread - if self.detection_thread: - if self.detection_thread.isRunning(): - self.detection_thread.quit() - self.detection_thread.wait(1000) # Wait up to 1 second - self.detection_thread = None - - if found_url: - self.navidrome_url_input.setText(found_url) - # Show success toast - from ui.components.toast_manager import ToastManager - toast_manager = ToastManager(self) - toast_manager.show_toast(f"Navidrome server detected: {found_url}", "success", 4000) - else: - # Show error toast - from ui.components.toast_manager import ToastManager - toast_manager = ToastManager(self) - toast_manager.show_toast("No Navidrome servers found on the network", "error", 4000) - - def on_jellyfin_detection_completed(self, found_url): - """Handle Jellyfin detection completion""" - # Stop animation and close dialog - if hasattr(self, 'loading_animation'): - self.loading_animation.stop() - - if hasattr(self, 'detection_dialog') and self.detection_dialog: - self.detection_dialog.close() - self.detection_dialog = None - - # Properly cleanup thread - if self.detection_thread: - if self.detection_thread.isRunning(): - self.detection_thread.quit() - self.detection_thread.wait(1000) # Wait up to 1 second for thread to finish - self.detection_thread.deleteLater() - self.detection_thread = None - - if found_url: - self.jellyfin_url_input.setText(found_url) - self.show_jellyfin_success_dialog(found_url) - logger.info(f"Jellyfin auto-detection successful: {found_url}") - else: - from PyQt6.QtWidgets import QMessageBox - msg = QMessageBox(self) - msg.setWindowTitle("No Jellyfin Server Found") - msg.setText("Could not find a Jellyfin server on your network.\n\nPlease enter your server URL manually (e.g., http://localhost:8096)") - msg.setIcon(QMessageBox.Icon.Information) - msg.exec() - logger.info("Jellyfin auto-detection failed - no server found") - - - def get_combo_style(self): - return """ - QComboBox { - background: #404040; - border: 1px solid #606060; - border-radius: 4px; - padding: 8px; - color: #ffffff; - font-size: 11px; - min-width: 100px; - } - QComboBox:focus { - border: 1px solid #1db954; - } - QComboBox::drop-down { - border: none; - } - """ - - def get_spin_style(self): - return """ - QSpinBox { - background: #404040; - border: 1px solid #606060; - border-radius: 4px; - padding: 8px; - color: #ffffff; - font-size: 11px; - min-width: 80px; - } - QSpinBox:focus { - border: 1px solid #1db954; - } - """ - - def get_checkbox_style(self): - return """ - QCheckBox { - color: #ffffff; - font-size: 12px; - } - QCheckBox::indicator { - width: 16px; - height: 16px; - border-radius: 8px; - border: 2px solid #b3b3b3; - background: transparent; - } - QCheckBox::indicator:checked { - background: #1db954; - border: 2px solid #1db954; - } - """ - - def get_test_button_style(self): - return """ - QPushButton { - background: transparent; - border: 1px solid #1db954; - border-radius: 15px; - color: #1db954; - font-size: 10px; - font-weight: bold; - } - QPushButton:hover { - background: rgba(29, 185, 84, 0.1); - } - """ - - def get_label_style(self, font_size=12): - """Get consistent label style without background""" - return f""" - QLabel {{ - color: #ffffff; - font-size: {font_size}px; - background: transparent; - border: none; - }} - """ \ No newline at end of file diff --git a/ui/pages/sync.py b/ui/pages/sync.py deleted file mode 100644 index 9e70c1f1..00000000 --- a/ui/pages/sync.py +++ /dev/null @@ -1,9920 +0,0 @@ -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QFrame, QPushButton, QListWidget, QListWidgetItem, - QProgressBar, QTextEdit, QCheckBox, QComboBox, - QScrollArea, QSizePolicy, QMessageBox, QDialog, - QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QLineEdit, QTabWidget) -from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QRunnable, QThreadPool, QObject -from PyQt6.QtGui import QFont, QBrush, QColor -import os -import json -from datetime import datetime -from dataclasses import dataclass -from typing import List, Optional -from core.soulseek_client import TrackResult -import re -import asyncio -import time -from core.matching_engine import MusicMatchingEngine -from core.wishlist_service import get_wishlist_service -from ui.components.toast_manager import ToastType -from database.music_database import get_database -from core.plex_scan_manager import PlexScanManager -from utils.logging_config import get_logger -import yt_dlp -from core.spotify_client import Track, Playlist -from core.tidal_client import TidalClient - -logger = get_logger("sync") - -# Define constants for storage -STORAGE_DIR = "storage" -STATUS_FILE = os.path.join(STORAGE_DIR, "sync_status.json") - -class EllipsisLabel(QLabel): - """A label that shows ellipsis for long text and tooltip on hover""" - def __init__(self, text="", parent=None): - super().__init__(text, parent) - self.full_text = text - self.setText(text) - - def setText(self, text): - self.full_text = text - # Set elided text with ellipsis - try: - fm = self.fontMetrics() - widget_width = self.width() - # Use a minimum width if widget isn't sized yet - if widget_width <= 0: - widget_width = 200 # Default fallback width - elided_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, widget_width - 10) - super().setText(elided_text) - - # Set tooltip to show full text if it's elided - if elided_text != text: - self.setToolTip(text) - else: - self.setToolTip("") # Clear tooltip if text fits - except Exception as e: - # Fallback to just setting the text if ellipsis calculation fails - logger.debug(f"EllipsisLabel setText error: {e}") - super().setText(text) - self.setToolTip(text) - - def resizeEvent(self, event): - """Handle resize events to recalculate ellipsis""" - super().resizeEvent(event) - # Re-elide text with new width - if self.full_text: - fm = self.fontMetrics() - elided_text = fm.elidedText(self.full_text, Qt.TextElideMode.ElideRight, self.width() - 10) - super().setText(elided_text) - - # Update tooltip - if elided_text != self.full_text: - self.setToolTip(self.full_text) - else: - self.setToolTip("") - -def load_sync_status(): - """Loads the sync status from the JSON file.""" - if not os.path.exists(STATUS_FILE): - return {} - try: - with open(STATUS_FILE, 'r') as f: - # Return empty dict if file is empty - content = f.read() - if not content: - return {} - return json.loads(content) - except (json.JSONDecodeError, FileNotFoundError): - # If file is corrupted or not found, return an empty dict - print(f"Warning: Could not read or parse {STATUS_FILE}. Starting with a clean slate.") - return {} - -def save_sync_status(data): - """Saves the sync status to the JSON file.""" - try: - os.makedirs(STORAGE_DIR, exist_ok=True) - with open(STATUS_FILE, 'w') as f: - json.dump(data, f, indent=4) - except Exception as e: - print(f"Error saving sync status to {STATUS_FILE}: {e}") - -def clean_track_name_for_search(track_name): - """ - Intelligently cleans a track name for searching by removing noise while preserving important version information. - Removes: (feat. Artist), (Explicit), (Clean), etc. - Keeps: (Extended Version), (Live), (Acoustic), (Remix), etc. - """ - if not track_name or not isinstance(track_name, str): - return track_name - - cleaned_name = track_name - - # Define patterns to REMOVE (noise that doesn't affect track identity) - remove_patterns = [ - r'\s*\(explicit\)', # (Explicit) - r'\s*\(clean\)', # (Clean) - r'\s*\(radio\s*edit\)', # (Radio Edit) - r'\s*\(radio\s*version\)', # (Radio Version) - r'\s*\(feat\.?\s*[^)]+\)', # (feat. Artist) or (ft. Artist) - r'\s*\(ft\.?\s*[^)]+\)', # (ft Artist) - r'\s*\(featuring\s*[^)]+\)', # (featuring Artist) - r'\s*\(with\s*[^)]+\)', # (with Artist) - r'\s*\[[^\]]*explicit[^\]]*\]', # [Explicit] in brackets - r'\s*\[[^\]]*clean[^\]]*\]', # [Clean] in brackets - ] - - # Apply removal patterns - for pattern in remove_patterns: - cleaned_name = re.sub(pattern, '', cleaned_name, flags=re.IGNORECASE).strip() - - # PRESERVE important version information (do NOT remove these) - # These patterns are intentionally NOT in the remove list: - # - (Extended Version), (Extended), (Long Version) - # - (Live), (Live Version), (Concert) - # - (Acoustic), (Acoustic Version) - # - (Remix), (Club Mix), (Dance Mix) - # - (Remastered), (Remaster) - # - (Demo), (Studio Version) - # - (Instrumental) - # - Album/year info like (2023), (Deluxe Edition) - - # If cleaning results in an empty string, return the original track name - if not cleaned_name.strip(): - return track_name - - # Log cleaning if significant changes were made - if cleaned_name != track_name: - print(f"Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'") - - return cleaned_name - -def clean_youtube_track_title(title, artist_name=None): - """ - Aggressively clean YouTube track titles by removing video noise and extracting clean track names - - Examples: - 'No Way Jose (Official Music Video)' → 'No Way Jose' - 'bbno$ - mary poppins (official music video)' → 'mary poppins' - 'Beyond (From "Moana 2") (Official Video) ft. Rachel House' → 'Beyond' - 'Temporary (feat. Skylar Grey) [Official Music Video]' → 'Temporary' - 'ALL MY LOVE (Directors\' Cut)' → 'ALL MY LOVE' - 'Espresso Macchiato | Estonia 🇪🇪 | Official Music Video | #Eurovision2025' → 'Espresso Macchiato' - """ - import re - - if not title: - return title - - original_title = title - - # FIRST: Remove artist name if it appears at the start with a dash - # Handle formats like "LITTLE BIG - MOUSTACHE" → "MOUSTACHE" - if artist_name: - # Create a regex pattern to match artist name at the beginning followed by dash - # Use word boundaries and case-insensitive matching for better accuracy - artist_pattern = r'^' + re.escape(artist_name.strip()) + r'\s*[-–—]\s*' - cleaned_title = re.sub(artist_pattern, '', title, flags=re.IGNORECASE).strip() - - # Debug logging for artist removal - if cleaned_title != title: - print(f"Removed artist from title: '{title}' -> '{cleaned_title}' (artist: '{artist_name}')") - - title = cleaned_title - - # Remove content in brackets/braces of any type SECOND (before general dash removal) - title = re.sub(r'【[^】]*】', '', title) # Japanese brackets - title = re.sub(r'\s*\([^)]*\)', '', title) # Parentheses - removes everything after first ( - title = re.sub(r'\s*\(.*$', '', title) # Remove everything after lone ( (unmatched parentheses) - title = re.sub(r'\[[^\]]*\]', '', title) # Square brackets - title = re.sub(r'\{[^}]*\}', '', title) # Curly braces - title = re.sub(r'<[^>]*>', '', title) # Angle brackets - - # Remove everything after a dash (often album or extra info) - title = re.sub(r'\s*-\s*.*$', '', title) - - # Remove everything after pipes (|) - often used for additional context - title = re.split(r'\s*\|\s*', title)[0].strip() - - # Remove common video/platform noise - noise_patterns = [ - r'\bapple\s+music\b', - r'\bfull\s+video\b', - r'\bmusic\s+video\b', - r'\bofficial\s+video\b', - r'\bofficial\s+music\s+video\b', - r'\bofficial\b', - r'\bcensored\s+version\b', - r'\buncensored\s+version\b', - r'\bexplicit\s+version\b', - r'\blive\s+version\b', - r'\bversion\b', - r'\btopic\b', - r'\baudio\b', - r'\blyrics?\b', - r'\blyric\s+video\b', - r'\bwith\s+lyrics?\b', - r'\bvisuali[sz]er\b', - r'\bmv\b', - r'\bdirectors?\s+cut\b', - r'\bremaster(ed)?\b', - r'\bremix\b' - ] - - for pattern in noise_patterns: - title = re.sub(pattern, '', title, flags=re.IGNORECASE) - - # Remove artist name from title if present - if artist_name: - # Try removing exact artist name - title = re.sub(rf'\b{re.escape(artist_name)}\b', '', title, flags=re.IGNORECASE) - # Try removing artist name with common separators - title = re.sub(rf'\b{re.escape(artist_name)}\s*[-–—:]\s*', '', title, flags=re.IGNORECASE) - title = re.sub(rf'^{re.escape(artist_name)}\s*[-–—:]\s*', '', title, flags=re.IGNORECASE) - - # Remove all quotes and other punctuation - title = re.sub(r'["\'''""„‚‛‹›«»]', '', title) - - # Remove featured artist patterns (after removing parentheses) - feat_patterns = [ - r'\s+feat\.?\s+.+$', # " feat Artist" at end - r'\s+ft\.?\s+.+$', # " ft Artist" at end - r'\s+featuring\s+.+$', # " featuring Artist" at end - r'\s+with\s+.+$', # " with Artist" at end - ] - - for pattern in feat_patterns: - title = re.sub(pattern, '', title, flags=re.IGNORECASE).strip() - - # Clean up whitespace and punctuation - title = re.sub(r'\s+', ' ', title).strip() - title = re.sub(r'^[-–—:,.\s]+|[-–—:,.\s]+$', '', title).strip() - - # If we cleaned too much, return original - if not title.strip() or len(title.strip()) < 2: - title = original_title - - if title != original_title: - print(f"YouTube title cleaned: '{original_title}' → '{title}'") - - return title - -def clean_youtube_artist(artist_string): - """ - Clean YouTube artist strings to get primary artist name - - Examples: - 'Yung Gravy, bbno$ (BABY GRAVY)' → 'Yung Gravy' - 'Y2K, bbno$' → 'Y2K' - 'LITTLE BIG' → 'LITTLE BIG' - 'Artist "Nickname" Name' → 'Artist Nickname Name' - 'ArtistVEVO' → 'Artist' - """ - import re - - if not artist_string: - return artist_string - - original_artist = artist_string - - # Remove all quotes - they're usually not part of artist names - artist_string = artist_string.replace('"', '').replace("'", '').replace(''', '').replace(''', '').replace('"', '').replace('"', '') - - # Remove anything in parentheses (often group/label names) - artist_string = re.sub(r'\s*\([^)]*\)', '', artist_string).strip() - - # Remove anything in brackets (often additional info) - artist_string = re.sub(r'\s*\[[^\]]*\]', '', artist_string).strip() - - # Remove common YouTube channel suffixes - channel_suffixes = [ - r'\s*-\s*Topic\s*$', # YouTube auto-generated "Topic" channels - r'\s*VEVO\s*$', - r'\s*Music\s*$', - r'\s*Official\s*$', - r'\s*Records\s*$', - r'\s*Entertainment\s*$', - r'\s*TV\s*$', - r'\s*Channel\s*$' - ] - - for suffix in channel_suffixes: - artist_string = re.sub(suffix, '', artist_string, flags=re.IGNORECASE).strip() - - # Split on common separators and take the first artist - separators = [',', '&', ' and ', ' x ', ' X ', ' feat.', ' ft.', ' featuring', ' with', ' vs ', ' vs.'] - - for sep in separators: - if sep in artist_string: - parts = artist_string.split(sep) - artist_string = parts[0].strip() - break - - # Clean up extra whitespace and punctuation - artist_string = re.sub(r'\s+', ' ', artist_string).strip() - artist_string = re.sub(r'^\-\s*|\s*\-$', '', artist_string).strip() # Remove leading/trailing dashes - artist_string = re.sub(r'^,\s*|\s*,$', '', artist_string).strip() # Remove leading/trailing commas - - # If we cleaned too much, return original - if not artist_string.strip(): - artist_string = original_artist - - if artist_string != original_artist: - print(f"YouTube artist cleaned: '{original_artist}' → '{artist_string}'") - - return artist_string - -def parse_youtube_playlist(url): - """ - Parse a YouTube Music playlist URL and extract track information using yt-dlp - Uses flat playlist extraction to avoid rate limits and get all tracks - Returns a list of track dictionaries compatible with our Track structure - """ - try: - # Configure yt-dlp options for flat playlist extraction (avoids rate limits) - ydl_opts = { - 'quiet': True, - 'no_warnings': True, - 'extract_flat': True, # Only extract basic info, no individual video metadata - 'flat_playlist': True, # Extract all playlist entries without hitting API for each video - 'skip_download': True, # Don't download, just extract IDs and basic info - # Remove all limits to get complete playlist - } - - tracks = [] - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - # Extract playlist info - playlist_info = ydl.extract_info(url, download=False) - - if not playlist_info: - raise Exception("Could not extract playlist information") - - # Get playlist entries - entries = playlist_info.get('entries', []) - - if not entries: - raise Exception("No tracks found in playlist") - - # Extract playlist title - playlist_title = playlist_info.get('title', 'YouTube Playlist') - print(f"Found {len(entries)} tracks in YouTube playlist: '{playlist_title}'") - print(f"Playlist info keys: {list(playlist_info.keys())}") - if 'playlist_count' in playlist_info: - print(f"Reported playlist count: {playlist_info['playlist_count']}") - if 'n_entries' in playlist_info: - print(f"Reported n_entries: {playlist_info['n_entries']}") - print(f"Actual entries length: {len(entries)}") - - # Convert each entry to our Track format - for i, entry in enumerate(entries): - if not entry: # Skip None entries - continue - - try: - # Extract title and uploader - raw_title = entry.get('title', f'Unknown Track {i+1}') - raw_uploader = entry.get('uploader', 'Unknown Artist') - duration = entry.get('duration', 0) - - # Start with uploader as default artist - artists = [clean_youtube_artist(raw_uploader)] - track_name = raw_title - - # Try to extract artist and track from title patterns - # Pattern 1: "LITTLE BIG – HARDCORE AMERICAN COWBOY" (artist in title) - if ' – ' in raw_title or ' - ' in raw_title: - separator = ' – ' if ' – ' in raw_title else ' - ' - parts = raw_title.split(separator, 1) - if len(parts) == 2: - potential_artist = clean_youtube_artist(parts[0].strip()) - potential_track = clean_youtube_track_title(parts[1].strip(), potential_artist) - - # Use the artist from title if it looks valid - if potential_artist and len(potential_artist) > 1: - artists = [potential_artist] - track_name = potential_track - else: - track_name = clean_youtube_track_title(raw_title) - else: - track_name = clean_youtube_track_title(raw_title) - - # Pattern 2: "Track by Artist" or "Track : Artist" - elif ' by ' in raw_title.lower(): - parts = raw_title.lower().split(' by ', 1) - if len(parts) == 2: - track_name = clean_youtube_track_title(raw_title[:len(parts[0])].strip()) - potential_artist = clean_youtube_artist(raw_title[len(parts[0]) + 4:].strip()) - if potential_artist and len(potential_artist) > 1: - artists = [potential_artist] - - elif ': ' in raw_title and len(raw_title.split(': ')) == 2: - parts = raw_title.split(': ', 1) - potential_artist = clean_youtube_artist(parts[0].strip()) - potential_track = clean_youtube_track_title(parts[1].strip(), potential_artist) - - if potential_artist and len(potential_artist) > 1: - artists = [potential_artist] - track_name = potential_track - else: - track_name = clean_youtube_track_title(raw_title) - - else: - # No clear pattern, just clean the title - track_name = clean_youtube_track_title(raw_title) - - # Final cleanup - track_name = track_name.strip() - artists = [artist.strip() for artist in artists if artist.strip()] - if not artists or not artists[0]: - artists = ['Unknown Artist'] - - # Create track dict compatible with our Track structure - track_data = { - 'id': entry.get('id', f'youtube_{i}'), - 'name': track_name, - 'artists': artists, - 'album': 'YouTube Music', # Default album name - 'duration_ms': duration * 1000 if duration else 0, - 'popularity': 0, # YouTube doesn't provide popularity - 'preview_url': None, - 'external_urls': {'youtube': entry.get('webpage_url', '')}, - # Store original uncleaned data for fallback searches - 'raw_title': raw_title, - 'raw_uploader': raw_uploader - } - - tracks.append(track_data) - - # Log the parsing result for debugging - if track_name != raw_title or artists[0] != raw_uploader: - print(f"Parsed: '{raw_title}' by '{raw_uploader}' → '{track_name}' by '{artists[0]}'") - - except Exception as e: - print(f"Error processing track {i}: {e}") - continue - - print(f"Successfully processed {len(tracks)} tracks out of {len(entries)} entries") - if len(tracks) != len(entries): - skipped = len(entries) - len(tracks) - print(f"Skipped {skipped} tracks due to processing errors") - - return tracks, playlist_title - - except Exception as e: - print(f"Error parsing YouTube playlist: {e}") - raise e - -def create_youtube_playlist_object(tracks_data, playlist_url, playlist_title=None): - """ - Create a Playlist object from YouTube tracks data that's compatible - with the existing DownloadMissingTracksModal - """ - try: - # Convert track dictionaries to Track objects - tracks = [] - for track_data in tracks_data: - track = Track( - id=track_data['id'], - name=track_data['name'], - artists=track_data['artists'], - album=track_data['album'], - duration_ms=track_data['duration_ms'], - popularity=track_data['popularity'], - preview_url=track_data['preview_url'], - external_urls=track_data['external_urls'] - ) - - # Add raw uncleaned data for fallback searches - if 'raw_title' in track_data and 'raw_uploader' in track_data: - track.raw_title = track_data['raw_title'] - track.raw_uploader = track_data['raw_uploader'] - - tracks.append(track) - - # Create playlist object - # Use provided playlist title or fall back to generic name - if playlist_title: - playlist_name = playlist_title - else: - playlist_name = f"YouTube Playlist ({len(tracks)} tracks)" - - playlist = Playlist( - id=f"youtube_{hash(playlist_url)}", # Generate unique ID from URL - name=playlist_name, - description=f"Imported from YouTube Music: {playlist_url}", - owner="YouTube Music", - public=True, - collaborative=False, - tracks=tracks, - total_tracks=len(tracks) - ) - - return playlist - - except Exception as e: - print(f"Error creating YouTube playlist object: {e}") - raise e - -@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) # total_tracks - track_analyzed = pyqtSignal(int, object) # track_index, TrackAnalysisResult - analysis_completed = pyqtSignal(list) # List[TrackAnalysisResult] - analysis_failed = pyqtSignal(str) # error_message - -class PlaylistTrackAnalysisWorker(QRunnable): - """Background worker to analyze playlist tracks against media library""" - - def __init__(self, playlist_tracks, media_client, server_type="plex"): - super().__init__() - self.playlist_tracks = playlist_tracks - self.media_client = media_client # Can be plex_client or jellyfin_client - self.server_type = server_type - self.signals = PlaylistTrackAnalysisWorkerSignals() - self._cancelled = False - # Instantiate the matching engine once per worker for efficiency - self.matching_engine = MusicMatchingEngine() - - def cancel(self): - """Cancel the analysis operation""" - self._cancelled = True - - def run(self): - """Analyze each track in the playlist""" - try: - if self._cancelled: - return - - self.signals.analysis_started.emit(len(self.playlist_tracks)) - results = [] - - # Check if media server is connected - server_connected = False - try: - if self.media_client: - server_connected = self.media_client.is_connected() - except Exception as e: - print(f"{self.server_type.title()} connection check failed: {e}") - server_connected = False - - for i, track in enumerate(self.playlist_tracks): - if self._cancelled: - return - - result = TrackAnalysisResult( - spotify_track=track, - exists_in_plex=False - ) - - if server_connected: - # Check if track exists in media server - try: - match, confidence = self._check_track_in_library(track) - # Use the 0.8 confidence threshold - if match and confidence >= 0.8: - result.exists_in_plex = True # Keep existing field name for compatibility - result.plex_match = match # Keep existing field name for compatibility - result.confidence = confidence - except Exception as e: - result.error_message = f"{self.server_type.title()} 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: - import traceback - traceback.print_exc() - self.signals.analysis_failed.emit(str(e)) - - def _check_track_in_library(self, spotify_track): - """ - Check if a Spotify track exists in the database by searching for each artist and - stopping as soon as a confident match is found. - Now uses local database instead of media server API for much faster performance. - """ - try: - original_title = spotify_track.name - - # Get database instance - db = get_database() - - # --- Generate conservative title variations (preserve meaningful differences) --- - title_variations = [original_title] - - # Only add cleaned version if it removes clear noise (not meaningful content like remixes) - cleaned_for_search = clean_track_name_for_search(original_title) - if cleaned_for_search.lower() != original_title.lower(): - title_variations.append(cleaned_for_search) - - # Use matching engine's conservative clean_title (no longer strips remixes/versions) - base_title = self.matching_engine.clean_title(original_title) - if base_title.lower() not in [t.lower() for t in title_variations]: - title_variations.append(base_title) - - # DO NOT strip content after dashes - this removes important remix/version info - - unique_title_variations = list(dict.fromkeys(title_variations)) - - # --- Search for each artist with each title variation --- - 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 - - # Use database check_track_exists method with consistent thresholds and active server filter - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server) - - if db_track and confidence >= 0.7: - print(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}") - - # Convert database track to format compatible with existing code - # Create a mock Plex track object for compatibility - 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 - - print(f"No database match found for '{original_title}' by any of the artists {artists_to_search}") - 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 TrackDownloadWorkerSignals(QObject): - """Signals for track download worker""" - download_started = pyqtSignal(int, int, str) # download_index, track_index, download_id - download_failed = pyqtSignal(int, int, str) # download_index, track_index, error_message - -class TrackDownloadWorker(QRunnable): - """Background worker to download individual tracks via Soulseek""" - - def __init__(self, spotify_track, soulseek_client, download_index, track_index, quality_preference=None): - super().__init__() - self.spotify_track = spotify_track - self.soulseek_client = soulseek_client - self.download_index = download_index - self.track_index = track_index - self.quality_preference = quality_preference or 'flac' - self.signals = TrackDownloadWorkerSignals() - self._cancelled = False - - def cancel(self): - """Cancel the download operation""" - self._cancelled = True - - def run(self): - """Download the track via Soulseek""" - try: - if self._cancelled or not self.soulseek_client: - return - - # Create search queries - prioritize artist + track for better accuracy - track_name = self.spotify_track.name - artist_name = self.spotify_track.artists[0] if self.spotify_track.artists else "" - - search_queries = [] - # Try artist + track first (more specific, less false matches) - if artist_name: - search_queries.append(f"{artist_name} {track_name}") - # Fallback to track name only if artist search fails - search_queries.append(track_name) - - download_id = None - - # Try each search query until we find a download - for query in search_queries: - if self._cancelled: - return - - print(f"Searching Soulseek: {query}") - - # Use the async method (need to run in sync context) - import asyncio - 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, self.quality_preference) - ) - if download_id: - break # Success - stop trying other queries - finally: - loop.close() - - if download_id: - self.signals.download_started.emit(self.download_index, self.track_index, download_id) - else: - self.signals.download_failed.emit(self.download_index, self.track_index, "No search results found") - - except Exception as e: - self.signals.download_failed.emit(self.download_index, self.track_index, str(e)) - -class SyncStatusProcessingWorkerSignals(QObject): - """Defines the signals available from the SyncStatusProcessingWorker.""" - completed = pyqtSignal(list) - error = pyqtSignal(str) - -class SyncStatusProcessingWorker(QRunnable): - """ - Runs download status processing in a background thread for the sync modal. - It checks the slskd API to provide a reliable status, with fallbacks. - This implementation is based on the working logic from downloads.py to restore live 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 - # This worker no longer performs filesystem checks, so it doesn't need transfers_directory. - - def run(self): - """The main logic of the background worker.""" - try: - import asyncio - import os - 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 = [] - - # --- FIX: More robustly parse the transfers data --- - # Errored/finished downloads might not be nested inside 'directories'. - # This checks for a 'files' list at both the user and directory levels. - all_transfers = [] - for user_data in transfers_data: - # Check for files directly under the user object - if 'files' in user_data and isinstance(user_data['files'], list): - all_transfers.extend(user_data['files']) - # Also check for files nested inside directories - 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 - - # Step 1: Try to match by the original download ID. - if item_data.get('download_id'): - matching_transfer = transfers_by_id.get(item_data['download_id']) - - # Step 2: If no match by ID, fall back to an exact filename match. - 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 - print(f"ℹ️ Found download for '{expected_basename}' by exact filename match.") - break - - if matching_transfer: - state = matching_transfer.get('state', 'Unknown') - progress = matching_transfer.get('percentComplete', 0) - - # Determine status with correct priority (Errored/Cancelled before Completed) - 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: - # If not found in the API, it might have failed or been cancelled. - # Use a grace period before marking as failed. - item_data['api_missing_count'] = item_data.get('api_missing_count', 0) + 1 - if item_data['api_missing_count'] >= 3: - expected_filename = os.path.basename(item_data['file_path']) - print(f"Download failed (missing from API after 3 checks): {expected_filename}") - payload = {'widget_id': item_data['widget_id'], 'status': 'failed'} - results.append(payload) - - self.signals.completed.emit(results) - except Exception as e: - import traceback - traceback.print_exc() - self.signals.error.emit(str(e)) - -class PlaylistLoaderThread(QThread): - playlist_loaded = pyqtSignal(object) # Single playlist - loading_finished = pyqtSignal(int) # Total count - loading_failed = pyqtSignal(str) # Error message - progress_updated = pyqtSignal(str) # Progress text - - def __init__(self, spotify_client): - super().__init__() - self.spotify_client = spotify_client - - def run(self): - try: - self.progress_updated.emit("Connecting to Spotify...") - if not self.spotify_client or not self.spotify_client.is_authenticated(): - self.loading_failed.emit("Spotify not authenticated") - return - - self.progress_updated.emit("Fetching playlists...") - playlists = self.spotify_client.get_user_playlists_metadata_only() - - for i, playlist in enumerate(playlists): - self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}") - self.playlist_loaded.emit(playlist) - self.msleep(20) # Reduced delay for faster but visible progressive loading - - self.loading_finished.emit(len(playlists)) - - except Exception as e: - self.loading_failed.emit(str(e)) - -class TidalPlaylistLoaderThread(QThread): - playlist_loaded = pyqtSignal(object) # Single playlist - loading_finished = pyqtSignal(int) # Total count - loading_failed = pyqtSignal(str) # Error message - progress_updated = pyqtSignal(str) # Progress text - - def __init__(self, tidal_client): - super().__init__() - self.tidal_client = tidal_client - - def run(self): - try: - self.progress_updated.emit("Connecting to Tidal...") - if not self.tidal_client: - self.loading_failed.emit("Tidal client not available") - return - - # Try to ensure authentication (will trigger OAuth if needed) - if not self.tidal_client.is_authenticated(): - self.progress_updated.emit("Authenticating with Tidal...") - if not self.tidal_client._ensure_valid_token(): - self.loading_failed.emit("Tidal authentication failed. Please check your settings and complete OAuth flow.") - return - - self.progress_updated.emit("Fetching playlists...") - playlists = self.tidal_client.get_user_playlists_metadata_only() - - for i, playlist in enumerate(playlists): - self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}") - self.playlist_loaded.emit(playlist) - self.msleep(20) # Reduced delay for faster but visible progressive loading - - self.loading_finished.emit(len(playlists)) - - except Exception as e: - self.loading_failed.emit(str(e)) - -class TrackLoadingWorkerSignals(QObject): - """Signals for async track loading worker""" - tracks_loaded = pyqtSignal(str, list) # playlist_id, tracks - loading_failed = pyqtSignal(str, str) # playlist_id, error_message - loading_started = pyqtSignal(str) # playlist_id - -class TrackLoadingWorker(QRunnable): - """Async worker for loading playlist tracks (following downloads.py pattern)""" - - def __init__(self, spotify_client, playlist_id, playlist_name): - super().__init__() - self.spotify_client = spotify_client - self.playlist_id = playlist_id - self.playlist_name = playlist_name - self.signals = TrackLoadingWorkerSignals() - self._cancelled = False - - def cancel(self): - """Cancel the worker operation""" - self._cancelled = True - - def run(self): - """Load tracks in background thread""" - logger.info(f"TrackLoadingWorker starting for playlist {self.playlist_id}") - try: - if self._cancelled: - logger.info(f"TrackLoadingWorker cancelled before starting for playlist {self.playlist_id}") - return - - logger.info(f"Emitting loading_started signal for playlist {self.playlist_id}") - self.signals.loading_started.emit(self.playlist_id) - - if self._cancelled: - logger.info(f"TrackLoadingWorker cancelled after loading_started for playlist {self.playlist_id}") - return - - # Fetch tracks from Spotify API - logger.info(f"Fetching tracks from Spotify API for playlist {self.playlist_id}") - tracks = self.spotify_client._get_playlist_tracks(self.playlist_id) - logger.info(f"Successfully fetched {len(tracks) if tracks else 0} tracks for playlist {self.playlist_id}") - - if self._cancelled: - logger.info(f"TrackLoadingWorker cancelled after fetching tracks for playlist {self.playlist_id}") - return - - # Emit success signal - logger.info(f"Emitting tracks_loaded signal for playlist {self.playlist_id} with {len(tracks) if tracks else 0} tracks") - self.signals.tracks_loaded.emit(self.playlist_id, tracks) - logger.info(f"TrackLoadingWorker completed successfully for playlist {self.playlist_id}") - - except Exception as e: - logger.error(f"TrackLoadingWorker failed for playlist {self.playlist_id}: {e}") - if not self._cancelled: - # Emit error signal only if not cancelled - logger.info(f"Emitting loading_failed signal for playlist {self.playlist_id}") - self.signals.loading_failed.emit(self.playlist_id, str(e)) - else: - logger.info(f"TrackLoadingWorker was cancelled, not emitting error signal for playlist {self.playlist_id}") - -class SyncWorkerSignals(QObject): - """Signals for sync worker""" - progress = pyqtSignal(object) # SyncProgress - finished = pyqtSignal(object, object) # SyncResult, snapshot_id (can be None) - error = pyqtSignal(str) - -class SyncWorker(QRunnable): - """Background worker for playlist synchronization with real-time progress callbacks""" - - def __init__(self, playlist, sync_service, progress_callback=None): - super().__init__() - self.playlist = playlist - self.sync_service = sync_service - self.progress_callback = progress_callback - self.signals = SyncWorkerSignals() - self._cancelled = False - - # Connect progress callback - if progress_callback: - self.signals.progress.connect(progress_callback) - - def cancel(self): - """Cancel the sync operation""" - self._cancelled = True - if hasattr(self.sync_service, 'cancel_sync'): - self.sync_service.cancel_sync() - - # Clear the progress callback to stop further progress updates - if hasattr(self.sync_service, 'clear_progress_callback'): - self.sync_service.clear_progress_callback(self.playlist.name) - - # Log the cancellation request - print(f"DEBUG: SyncWorker.cancel() called for playlist {getattr(self.playlist, 'name', 'unknown')}") - - def run(self): - """Execute the sync operation""" - snapshot_id = None # Define snapshot_id in the outer scope - try: - if self._cancelled: - return - - # Set up progress callback for sync service - def on_progress(progress): - print(f"SyncWorker progress callback called! total={progress.total_tracks}, matched={progress.matched_tracks}") - if not self._cancelled: - print(f"Emitting progress signal to parent page") - self.signals.progress.emit(progress) - else: - print(f"Sync was cancelled, not emitting signal") - - print(f"Setting up progress callback for playlist: '{self.playlist.name}'") - self.sync_service.set_progress_callback(on_progress, self.playlist.name) - - # Create new event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - # Run sync with playlist object - result = loop.run_until_complete( - self.sync_service.sync_playlist(self.playlist, download_missing=False) - ) - - # --- THE FIX --- - # After sync, fetch the new snapshot_id directly from Spotify - # to ensure we have the most up-to-date value. - try: - if hasattr(self.sync_service, 'spotify_client') and self.sync_service.spotify_client: - # Assuming a synchronous method to get a single playlist's metadata - updated_playlist = self.sync_service.spotify_client.get_playlist(self.playlist.id) - if updated_playlist: - snapshot_id = updated_playlist.snapshot_id - print(f"DEBUG: Successfully fetched new snapshot_id: {snapshot_id}") - else: - print(f"WARNING: get_playlist returned None for {self.playlist.name}") - else: - print("WARNING: Could not get snapshot_id, spotify_client not found on sync_service.") - except Exception as e: - print(f"WARNING: Could not fetch updated snapshot_id for {self.playlist.name}: {e}") - - if not self._cancelled: - # Emit the result and the (potentially new) snapshot_id - self.signals.finished.emit(result, snapshot_id) - - finally: - loop.close() - - except Exception as e: - if not self._cancelled: - self.signals.error.emit(str(e)) - -class PlaylistDetailsModal(QDialog): - def __init__(self, playlist, parent=None): - super().__init__(parent) - self.playlist = playlist - self.parent_page = parent - self.spotify_client = parent.spotify_client if parent else None - - # Thread management - self.active_workers = [] - self.fallback_pools = [] - self.is_closing = False - - # Sync state tracking - self.is_syncing = False - self.sync_worker = None - self.sync_status_widget = None - self.sync_button = None - - # Clear existing tracks BEFORE setup_ui to prevent synchronous population - if self.spotify_client: - self.playlist.tracks = [] - - self.setup_ui() - - # Restore sync state if playlist is currently syncing - self.restore_sync_state() - - # Load tracks asynchronously if not already loaded - if not self.playlist.tracks and self.spotify_client: - # Check cache first - if hasattr(parent, 'track_cache') and playlist.id in parent.track_cache: - self.playlist.tracks = parent.track_cache[playlist.id] - self.refresh_track_table() - else: - self.load_tracks_async() - - def closeEvent(self, event): - """Clean up threads and resources when modal is closed""" - self.is_closing = True - self.cleanup_workers() - super().closeEvent(event) - - def cleanup_workers(self): - """Clean up all active workers and thread pools (except sync workers)""" - # Cancel active workers first, but skip sync workers to allow background sync - for worker in self.active_workers: - try: - # Don't cancel sync workers - they should continue in background - if hasattr(worker, 'cancel') and not isinstance(worker, SyncWorker): - worker.cancel() - except (RuntimeError, AttributeError): - pass - - # Disconnect signals from active workers to prevent race conditions (except sync workers) - for worker in self.active_workers: - try: - # Don't disconnect sync worker signals - they need to continue updating playlist items - if hasattr(worker, 'signals') and not isinstance(worker, SyncWorker): - # Disconnect track loading worker signals - try: - worker.signals.tracks_loaded.disconnect(self.on_tracks_loaded) - except (RuntimeError, TypeError): - pass - try: - worker.signals.loading_failed.disconnect(self.on_tracks_loading_failed) - except (RuntimeError, TypeError): - pass - - # Disconnect playlist analysis worker signals - try: - worker.signals.analysis_started.disconnect(self.on_analysis_started) - except (RuntimeError, TypeError): - pass - try: - worker.signals.track_analyzed.disconnect(self.on_track_analyzed) - except (RuntimeError, TypeError): - pass - try: - worker.signals.analysis_completed.disconnect(self.on_analysis_completed) - except (RuntimeError, TypeError): - pass - try: - worker.signals.analysis_failed.disconnect(self.on_analysis_failed) - except (RuntimeError, TypeError): - pass - except (RuntimeError, AttributeError): - # Signal may already be disconnected or worker deleted - pass - - # Clean up fallback thread pools with timeout - for pool in self.fallback_pools: - try: - pool.clear() # Cancel pending workers - if not pool.waitForDone(2000): # Wait 2 seconds max - # Force termination if workers don't finish gracefully - pool.clear() - except (RuntimeError, AttributeError): - pass - - # Clear tracking lists - self.active_workers.clear() - self.fallback_pools.clear() - - def setup_ui(self): - self.setWindowTitle(f"Playlist Details - {self.playlist.name}") - - # Make modal responsive to screen size - from PyQt6.QtWidgets import QApplication - screen = QApplication.primaryScreen().geometry() - modal_width = min(1200, int(screen.width() * 0.9)) - modal_height = min(800, int(screen.height() * 0.9)) - self.resize(modal_width, modal_height) - - # Center the modal on screen - self.move((screen.width() - modal_width) // 2, (screen.height() - modal_height) // 2) - - self.setStyleSheet(""" - QDialog { - background: #191414; - color: #ffffff; - } - """) - - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(20, 20, 20, 20) # Reduced margins for smaller screens - main_layout.setSpacing(16) - - # Header section (fixed height) - header = self.create_header() - main_layout.addWidget(header, 0) # stretch factor 0 - fixed size - - # Track list section (expandable) - track_list = self.create_track_list() - main_layout.addWidget(track_list, 1) # stretch factor 1 - takes available space - - # Button section (fixed height, always visible) - button_widget = QWidget() - button_layout = self.create_buttons() - button_widget.setLayout(button_layout) - main_layout.addWidget(button_widget, 0) # stretch factor 0 - fixed size - - def create_header(self): - header = QFrame() - header.setFixedHeight(120) - header.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 16px; - border: 1px solid #404040; - } - """) - - layout = QVBoxLayout(header) - layout.setContentsMargins(32, 24, 32, 24) - layout.setSpacing(12) - - # Playlist name - larger, more prominent - name_label = QLabel(self.playlist.name) - name_label.setFont(QFont("SF Pro Display", 24, QFont.Weight.Bold)) - name_label.setStyleSheet("color: #ffffff; border: none; background: transparent;") - - # Playlist info in a more compact horizontal layout - info_layout = QHBoxLayout() - info_layout.setSpacing(24) - - # Track count with icon-like styling - track_count = QLabel(f"{self.playlist.total_tracks} tracks") - track_count.setFont(QFont("SF Pro Text", 14, QFont.Weight.Medium)) - track_count.setStyleSheet("color: #b3b3b3; border: none; background: transparent;") - - # Owner with subtle separator - owner = QLabel(f"by {self.playlist.owner}") - owner.setFont(QFont("SF Pro Text", 14)) - owner.setStyleSheet("color: #b3b3b3; border: none; background: transparent;") - - # Status with accent color - visibility = "Public" if self.playlist.public else "Private" - if self.playlist.collaborative: - visibility = "Collaborative" - status = QLabel(visibility) - status.setFont(QFont("SF Pro Text", 14, QFont.Weight.Medium)) - status.setStyleSheet(""" - color: #1db954; - border: none; - background: rgba(29, 185, 84, 0.1); - padding: 4px 12px; - border-radius: 12px; - """) - - info_layout.addWidget(track_count) - info_layout.addWidget(owner) - info_layout.addWidget(status) - info_layout.addStretch() - - # Sync status display (hidden by default) - self.sync_status_widget = self.create_sync_status_display() - info_layout.addWidget(self.sync_status_widget) - - layout.addWidget(name_label) - layout.addLayout(info_layout) - - return header - - def create_sync_status_display(self): - """Create sync status display widget (hidden by default)""" - sync_status = QFrame() - sync_status.setStyleSheet(""" - QFrame { - background: rgba(29, 185, 84, 0.1); - border: 1px solid rgba(29, 185, 84, 0.3); - border-radius: 12px; - } - """) - sync_status.setMinimumHeight(36) # Ensure adequate height - sync_status.hide() # Hidden by default - - layout = QHBoxLayout(sync_status) - layout.setContentsMargins(12, 8, 12, 8) # Increased margins for better text visibility - layout.setSpacing(12) - - # Total tracks - self.total_tracks_label = QLabel("0") - self.total_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - self.total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;") - - # Matched tracks - self.matched_tracks_label = QLabel("0") - self.matched_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;") - - # Failed tracks - self.failed_tracks_label = QLabel("0") - self.failed_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;") - - # Percentage - self.percentage_label = QLabel("0%") - self.percentage_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Bold)) - self.percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;") - - layout.addWidget(self.total_tracks_label) - - # Separator 1 - sep1 = QLabel("/") - sep1.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - sep1.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep1) - - layout.addWidget(self.matched_tracks_label) - - # Separator 2 - sep2 = QLabel("/") - sep2.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - sep2.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep2) - - layout.addWidget(self.failed_tracks_label) - - # Separator 3 - sep3 = QLabel("/") - sep3.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - sep3.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep3) - - layout.addWidget(self.percentage_label) - - return sync_status - - def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0): - """Update sync status display""" - if self.sync_status_widget: - self.total_tracks_label.setText(f"{total_tracks}") - self.matched_tracks_label.setText(f"{matched_tracks}") - self.failed_tracks_label.setText(f"{failed_tracks}") - - if total_tracks > 0: - processed_tracks = matched_tracks + failed_tracks - percentage = int((processed_tracks / total_tracks) * 100) - self.percentage_label.setText(f"{percentage}%") - else: - self.percentage_label.setText("0%") - - def set_sync_button_state(self, is_syncing): - """Update sync button appearance based on sync state""" - if self.sync_button: - if is_syncing: - # Change to Cancel Sync with red styling - self.sync_button.setText("Cancel Sync") - self.sync_button.setStyleSheet(""" - QPushButton { - background: #e22134; - border: none; - border-radius: 22px; - color: #ffffff; - font-size: 13px; - font-weight: 600; - font-family: 'SF Pro Text'; - } - QPushButton:hover { - background: #f44336; - } - QPushButton:pressed { - background: #c62828; - } - """) - else: - # Change back to Sync This Playlist with green styling - self.sync_button.setText("Sync This Playlist") - self.sync_button.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 22px; - color: #ffffff; - font-size: 13px; - font-weight: 600; - font-family: 'SF Pro Text'; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:pressed { - background: #169c46; - } - """) - - def restore_sync_state(self): - """Restore sync state when modal is reopened""" - # Check if sync is ongoing for this playlist - if self.parent_page and self.parent_page.is_playlist_syncing(self.playlist.id): - self.is_syncing = True - self.set_sync_button_state(True) - - # Find playlist item to get current progress - playlist_item = self.parent_page.find_playlist_item_widget(self.playlist.id) - if playlist_item: - # Show sync status widget with current progress - if self.sync_status_widget: - self.sync_status_widget.show() - self.update_sync_status( - playlist_item.sync_total_tracks, - playlist_item.sync_matched_tracks, - playlist_item.sync_failed_tracks - ) - - def create_track_list(self): - container = QFrame() - container.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 16px; - border: 1px solid #404040; - } - """) - - layout = QVBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Track table with professional styling - self.track_table = QTableWidget() - self.track_table.setColumnCount(4) - self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Album", "Duration"]) - - # Set initial row count (may be 0 if tracks not loaded yet) - track_count = len(self.playlist.tracks) if self.playlist.tracks else 1 - self.track_table.setRowCount(track_count) - - # Professional table styling - self.track_table.setStyleSheet(""" - QTableWidget { - background: #282828; - border: none; - border-radius: 16px; - gridline-color: transparent; - color: #ffffff; - font-size: 11px; - selection-background-color: rgba(29, 185, 84, 0.2); - } - QTableWidget::item { - padding: 12px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - background: transparent; - } - QTableWidget::item:hover { - background: rgba(255, 255, 255, 0.02); - } - QTableWidget::item:selected { - background: rgba(29, 185, 84, 0.15); - color: #ffffff; - } - QHeaderView { - background: transparent; - border: none; - } - QHeaderView::section { - background: transparent; - color: #b3b3b3; - padding: 12px 16px; - border: none; - border-bottom: 2px solid rgba(255, 255, 255, 0.1); - font-weight: 600; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.5px; - } - QHeaderView::section:hover { - background: rgba(255, 255, 255, 0.02); - } - """) - - # Populate table with proper styling - if self.playlist.tracks: - for row, track in enumerate(self.playlist.tracks): - # Track name with ellipsis label - track_label = EllipsisLabel(track.name) - track_label.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium)) - track_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") - self.track_table.setCellWidget(row, 0, track_label) - - # Artist(s) with ellipsis label - artists = ", ".join(track.artists) - artist_label = EllipsisLabel(artists) - artist_label.setFont(QFont("SF Pro Text", 11)) - artist_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") - self.track_table.setCellWidget(row, 1, artist_label) - - # Album with ellipsis label - album_label = EllipsisLabel(track.album) - album_label.setFont(QFont("SF Pro Text", 11)) - album_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") - self.track_table.setCellWidget(row, 2, album_label) - - # Duration with standard item (doesn't need scrolling) - duration = self.format_duration(track.duration_ms) - duration_item = QTableWidgetItem(duration) - duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - duration_item.setFont(QFont("SF Mono", 10)) - self.track_table.setItem(row, 3, duration_item) - else: - # Show placeholder while tracks are being loaded - placeholder_item = QTableWidgetItem("Loading tracks...") - placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(0, 0, placeholder_item) - self.track_table.setSpan(0, 0, 1, 4) - - # Professional column configuration - header = self.track_table.horizontalHeader() - header.setVisible(True) - header.show() - header.setStretchLastSection(False) - header.setHighlightSections(False) - header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) - - # Calculate available width (modal is 1200px, account for margins) - available_width = 1136 # 1200 - 64px margins - - # Professional proportional widths - track_width = int(available_width * 0.35) # ~398px - artist_width = int(available_width * 0.28) # ~318px - album_width = int(available_width * 0.28) # ~318px - duration_width = 100 # Fixed 100px - - # Apply column widths with proper resize modes - header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) - header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) - header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) - header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) - - self.track_table.setColumnWidth(0, track_width) - self.track_table.setColumnWidth(1, artist_width) - self.track_table.setColumnWidth(2, album_width) - self.track_table.setColumnWidth(3, duration_width) - - # Set minimum widths for professional look - header.setMinimumSectionSize(120) - - # Hide row numbers and configure table behavior - self.track_table.verticalHeader().setVisible(False) - self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.track_table.setAlternatingRowColors(False) - - # Set uniform row height to accommodate the labels properly - self.track_table.verticalHeader().setDefaultSectionSize(40) # Height for each row - - layout.addWidget(self.track_table) - - return container - - def create_buttons(self): - button_layout = QHBoxLayout() - button_layout.setSpacing(16) - button_layout.setContentsMargins(0, 0, 0, 0) - - # Close button with subtle styling - close_btn = QPushButton("Close") - close_btn.setFixedSize(100, 44) - close_btn.clicked.connect(self.close) - close_btn.setStyleSheet(""" - QPushButton { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 22px; - color: #ffffff; - font-size: 13px; - font-weight: 600; - font-family: 'SF Pro Text'; - } - QPushButton:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.15); - } - QPushButton:pressed { - background: rgba(255, 255, 255, 0.02); - } - """) - - # Download missing tracks button with outline style - download_btn = QPushButton("Download Missing Tracks") - download_btn.setFixedSize(200, 44) - download_btn.setStyleSheet(""" - QPushButton { - background: transparent; - border: 1px solid #1db954; - border-radius: 22px; - color: #1db954; - font-size: 13px; - font-weight: 600; - font-family: 'SF Pro Text'; - } - QPushButton:hover { - background: rgba(29, 185, 84, 0.08); - border-color: #1ed760; - color: #1ed760; - } - QPushButton:pressed { - background: rgba(29, 185, 84, 0.15); - } - """) - - # Sync button with primary styling (store reference for state management) - self.sync_button = QPushButton("Sync This Playlist") - self.sync_button.setFixedSize(160, 44) - self.sync_button.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 22px; - color: #ffffff; - font-size: 13px; - font-weight: 600; - font-family: 'SF Pro Text'; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:pressed { - background: #169c46; - } - """) - - # Connect button signals - download_btn.clicked.connect(self.on_download_missing_tracks_clicked) - self.sync_button.clicked.connect(self.on_sync_playlist_clicked) - - button_layout.addStretch() - button_layout.addWidget(close_btn) - button_layout.addWidget(download_btn) - button_layout.addWidget(self.sync_button) - - return button_layout - - def format_duration(self, duration_ms): - """Convert milliseconds to MM:SS format""" - seconds = duration_ms // 1000 - minutes = seconds // 60 - seconds = seconds % 60 - return f"{minutes}:{seconds:02d}" - - def on_download_missing_tracks_clicked(self): - """Handle Download Missing Tracks button click""" - print("Download Missing Tracks button clicked!") - - if not self.playlist or not self.playlist.tracks: - QMessageBox.warning(self, "Error", "Playlist tracks not loaded") - return - - playlist_item_widget = self.parent_page.find_playlist_item_widget(self.playlist.id) - if not playlist_item_widget: - QMessageBox.critical(self, "Error", "Could not find the associated playlist item on the main page.") - return - - print("Creating DownloadMissingTracksModal...") - modal = DownloadMissingTracksModal(self.playlist, playlist_item_widget, self.parent_page, self.parent_page.downloads_page) - - playlist_item_widget.download_modal = modal - - # --- FIX: Connect the cleanup signal immediately upon creation. --- - # This ensures that when the modal closes for any reason, the SyncPage - # is notified and can run its cleanup logic. - modal.process_finished.connect( - lambda: self.parent_page.on_download_process_finished(self.playlist.id) - ) - - self.accept() - modal.show() - - def find_playlist_item_from_sync_modal(self): - """Find the PlaylistItem widget for this playlist from sync modal""" - if not hasattr(self.parent_page, 'current_playlists'): - return None - - # Look through the parent page's playlist items - for i in range(self.parent_page.playlist_layout.count()): - item = self.parent_page.playlist_layout.itemAt(i) - if item and item.widget() and isinstance(item.widget(), PlaylistItem): - playlist_item = item.widget() - if playlist_item.playlist and playlist_item.playlist.id == self.playlist.id: - return playlist_item - return None - - def on_sync_playlist_clicked(self): - """Handle Sync This Playlist button click""" - if self.is_syncing: - # Cancel sync - self.cancel_sync() - return - - if not self.playlist: - QMessageBox.warning(self, "Error", "No playlist selected") - return - - if not self.playlist.tracks: - QMessageBox.warning(self, "Error", "Playlist tracks not loaded") - return - - # Check if sync service is available - if not hasattr(self.parent_page, 'sync_service'): - # Create sync service if not available - from services.sync_service import PlaylistSyncService - self.parent_page.sync_service = PlaylistSyncService( - self.parent_page.spotify_client, - self.parent_page.plex_client, - self.parent_page.soulseek_client, - getattr(self.parent_page, 'jellyfin_client', None), - getattr(self.parent_page, 'navidrome_client', None) - ) - - # Start sync - self.start_sync() - - def start_sync(self): - """Start playlist sync operation via parent page""" - if self.parent_page and self.parent_page.start_playlist_sync(self.playlist): - self.is_syncing = True - - # Update Tidal card state to syncing (matches YouTube workflow) - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): - print(f"Updating Tidal card state to syncing for playlist_id: {self.playlist_id}") - if hasattr(self.parent_page, 'update_tidal_card_phase'): - self.parent_page.update_tidal_card_phase(self.playlist_id, 'syncing') - - # Update YouTube card state to syncing (existing logic) - if hasattr(self, 'youtube_url'): - print(f"Updating YouTube card state to syncing for URL: {self.youtube_url}") - if hasattr(self.parent_page, 'update_youtube_card_phase'): - self.parent_page.update_youtube_card_phase(self.youtube_url, 'syncing') - - # Update modal UI state - self.set_sync_button_state(True) - - # Show sync status widget - if self.sync_status_widget: - self.sync_status_widget.show() - self.update_sync_status(len(self.playlist.tracks), 0, 0) - - def cancel_sync(self): - """Cancel ongoing sync operation via parent page""" - if self.parent_page and self.parent_page.cancel_playlist_sync(self.playlist.id): - self.is_syncing = False - - # Update modal UI state - self.set_sync_button_state(False) - - # Hide sync status widget - if self.sync_status_widget: - self.sync_status_widget.hide() - - def on_sync_progress(self, playlist_id, progress): - """Handle sync progress updates (called from parent page)""" - if playlist_id == self.playlist.id: - # Update modal status display - self.update_sync_status( - progress.total_tracks, - progress.matched_tracks, - progress.failed_tracks - ) - - def on_sync_finished(self, playlist_id, result): - """Handle sync completion (called from parent page)""" - if playlist_id == self.playlist.id: - self.is_syncing = False - - # Update button state - self.set_sync_button_state(False) - - # Update final status - self.update_sync_status( - result.total_tracks, - result.matched_tracks, - result.failed_tracks - ) - - def on_sync_error(self, playlist_id, error_msg): - """Handle sync error (called from parent page)""" - if playlist_id == self.playlist.id: - self.is_syncing = False - - # Update button state - self.set_sync_button_state(False) - - # Hide sync status widget - if self.sync_status_widget: - self.sync_status_widget.hide() - - # Show error message - QMessageBox.critical(self, "Sync Failed", f"Sync failed: {error_msg}") - - def start_playlist_missing_tracks_download(self): - """Start the process of downloading missing tracks from playlist""" - track_count = len(self.playlist.tracks) - - # Start analysis worker - self.start_track_analysis() - - # Show analysis started message - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = active_server.title() - QMessageBox.information(self, "Analysis Started", - f"Starting analysis of {track_count} tracks.\nChecking {server_name} library for existing tracks...") - - def start_track_analysis(self): - """Start background track analysis against media library""" - # Create analysis worker - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - if active_server == "plex": - media_client = getattr(self.parent_page, 'plex_client', None) - else: # jellyfin - media_client = getattr(self.parent_page, 'jellyfin_client', None) - - worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, media_client, active_server) - - # Connect signals - 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) - - # Track worker for cleanup - self.active_workers.append(worker) - - # Submit to thread pool - if hasattr(self.parent_page, 'thread_pool'): - self.parent_page.thread_pool.start(worker) - else: - # Create and track fallback thread pool - thread_pool = QThreadPool() - self.fallback_pools.append(thread_pool) - thread_pool.start(worker) - - def on_analysis_started(self, total_tracks): - """Handle analysis started signal""" - # Get server name for log message - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = active_server.title() if active_server else "Plex" - except: - server_name = "Plex" - - print(f"Started analyzing {total_tracks} tracks against {server_name} library") - - def on_track_analyzed(self, track_index, result): - """Handle individual track analysis completion""" - track = result.spotify_track - if result.exists_in_plex: - print(f"Track {track_index}: '{track.name}' by {track.artists[0]} EXISTS in Plex (confidence: {result.confidence:.2f})") - else: - print(f"Track {track_index}: '{track.name}' by {track.artists[0]} MISSING from Plex - will download") - - def on_analysis_completed(self, results): - """Handle analysis completion and start downloads for missing tracks""" - missing_tracks = [r for r in results if not r.exists_in_plex] - existing_tracks = [r for r in results if r.exists_in_plex] - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = active_server.title() if active_server else "Plex" - except: - server_name = "Plex" - print(f"Analysis complete: {len(missing_tracks)} missing, {len(existing_tracks)} existing") - - if not missing_tracks: - QMessageBox.information(self, "Analysis Complete", - f"All tracks already exist in {server_name} library!\nNo downloads needed.") - return - - # Show results to user - message = f"Analysis complete!\n\n" - # Get server name for display - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = active_server.title() if active_server else "Plex" - except: - server_name = "Plex" - - message += f"Tracks already in {server_name}: {len(existing_tracks)}\n" - message += f"Tracks to download: {len(missing_tracks)}\n\n" - message += "Ready to start downloading missing tracks?" - - reply = QMessageBox.question(self, "Start Downloads?", message, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - - if reply == QMessageBox.StandardButton.Yes: - self.start_missing_track_downloads(missing_tracks) - - def on_analysis_failed(self, error_message): - """Handle analysis failure""" - QMessageBox.critical(self, "Analysis Failed", f"Failed to analyze tracks: {error_message}") - - def start_missing_track_downloads(self, missing_tracks): - """Start downloading the missing tracks""" - # TODO: Implement Soulseek search and download queueing - # For now, just show what would be downloaded - track_list = [] - for result in missing_tracks: - track = result.spotify_track - artist = track.artists[0] if track.artists else "Unknown Artist" - track_list.append(f"• {track.name} by {artist}") - - message = f"Would download {len(missing_tracks)} tracks:\n\n" - message += "\n".join(track_list[:10]) # Show first 10 - if len(track_list) > 10: - message += f"\n... and {len(track_list) - 10} more" - - QMessageBox.information(self, "Downloads Queued", message) - - def load_tracks_async(self): - """Load tracks asynchronously using worker thread""" - if not self.spotify_client: - return - - # Show loading state in track table - if hasattr(self, 'track_table'): - self.track_table.setRowCount(1) - loading_item = QTableWidgetItem("Loading tracks...") - loading_item.setFlags(loading_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(0, 0, loading_item) - self.track_table.setSpan(0, 0, 1, 4) - - # Create and submit worker to thread pool - worker = TrackLoadingWorker(self.spotify_client, self.playlist.id, self.playlist.name) - worker.signals.tracks_loaded.connect(self.on_tracks_loaded) - worker.signals.loading_failed.connect(self.on_tracks_loading_failed) - - # Track active worker for cleanup - self.active_workers.append(worker) - - # Submit to parent's thread pool if available, otherwise create one - if hasattr(self.parent_page, 'thread_pool'): - self.parent_page.thread_pool.start(worker) - else: - # Create and track fallback thread pool - thread_pool = QThreadPool() - self.fallback_pools.append(thread_pool) - thread_pool.start(worker) - - def on_tracks_loaded(self, playlist_id, tracks): - """Handle successful track loading""" - logger.info(f"Tracks loaded signal received: playlist_id={playlist_id}, tracks_count={len(tracks) if tracks else 0}") - - # Log validation state - playlist_match = playlist_id == self.playlist.id - not_closing = not self.is_closing - not_hidden = not self.isHidden() - has_table = hasattr(self, 'track_table') - - logger.info(f"Validation state: playlist_match={playlist_match}, not_closing={not_closing}, not_hidden={not_hidden}, has_table={has_table}") - - # Validate modal state before processing - if (playlist_match and not_closing and not_hidden and has_table): - logger.info(f"Processing tracks for playlist {self.playlist.name}") - - self.playlist.tracks = tracks - - # Cache tracks in parent for future use - if hasattr(self.parent_page, 'track_cache'): - self.parent_page.track_cache[playlist_id] = tracks - logger.info(f"Cached {len(tracks)} tracks for playlist {playlist_id}") - - # Refresh the track table - try: - self.refresh_track_table() - logger.info(f"Successfully refreshed track table with {len(tracks)} tracks") - except Exception as e: - logger.error(f"Error refreshing track table: {e}") - else: - logger.warning(f"Skipping track loading due to validation failure for playlist {playlist_id}") - - def on_tracks_loading_failed(self, playlist_id, error_message): - """Handle track loading failure""" - logger.error(f"Track loading failed for playlist {playlist_id}: {error_message}") - - # Validate modal state before processing - if (playlist_id == self.playlist.id and - not self.is_closing and - not self.isHidden() and - hasattr(self, 'track_table')): - logger.info(f"Displaying error message in track table") - self.track_table.setRowCount(1) - error_item = QTableWidgetItem(f"Error loading tracks: {error_message}") - error_item.setFlags(error_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(0, 0, error_item) - self.track_table.setSpan(0, 0, 1, 4) - else: - logger.warning(f"Cannot display error message due to modal state validation failure") - - def refresh_track_table(self): - """Refresh the track table with loaded tracks""" - logger.info(f"refresh_track_table called for playlist {self.playlist.name}") - - if not hasattr(self, 'track_table'): - logger.error("No track_table attribute found") - return - - # Limit tracks to prevent UI blocking on large playlists - total_tracks = len(self.playlist.tracks) - display_limit = 100 - tracks_to_show = self.playlist.tracks[:display_limit] - - logger.info(f"Setting track table row count to {len(tracks_to_show)} (total tracks: {total_tracks})") - - self.track_table.setRowCount(len(tracks_to_show)) - self.track_table.clearSpans() # Remove any spans from loading state - - # Populate table with limited tracks - logger.info(f"Populating track table with {len(tracks_to_show)} tracks") - for row, track in enumerate(tracks_to_show): - try: - logger.debug(f"Processing track {row+1}/{len(tracks_to_show)}: {track.name} by {', '.join(track.artists) if track.artists else 'Unknown'}") - - # Track name with ellipsis label - track_label = EllipsisLabel(track.name) - track_label.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium)) - track_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") - self.track_table.setCellWidget(row, 0, track_label) - logger.debug(f"Set track name widget for row {row}") - - # Artist(s) with ellipsis label - artists = ", ".join(track.artists) if track.artists else "Unknown Artist" - artist_label = EllipsisLabel(artists) - artist_label.setFont(QFont("SF Pro Text", 11)) - artist_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") - self.track_table.setCellWidget(row, 1, artist_label) - logger.debug(f"Set artist widget for row {row}") - - # Album with ellipsis label - album_name = track.album if track.album else "Unknown Album" - album_label = EllipsisLabel(album_name) - album_label.setFont(QFont("SF Pro Text", 11)) - album_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") - self.track_table.setCellWidget(row, 2, album_label) - logger.debug(f"Set album widget for row {row}") - - # Duration with standard item (doesn't need scrolling) - duration = self.format_duration(track.duration_ms) - duration_item = QTableWidgetItem(duration) - duration_item.setFlags(duration_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - duration_item.setFont(QFont("SF Mono", 10)) - self.track_table.setItem(row, 3, duration_item) - logger.debug(f"Set duration item for row {row}") - - logger.debug(f"Completed track {row+1}/{len(tracks_to_show)}") - - except Exception as e: - logger.error(f"Error processing track {row+1}: {e}") - logger.error(f"Track data: name='{track.name if hasattr(track, 'name') else 'N/A'}', artists='{track.artists if hasattr(track, 'artists') else 'N/A'}', album='{track.album if hasattr(track, 'album') else 'N/A'}'") - # Continue with next track rather than failing completely - continue - - logger.info(f"Finished populating all {len(tracks_to_show)} tracks") - - # Add info message if tracks were limited - if total_tracks > display_limit: - # Update the modal title to show track count info - if hasattr(self, 'setWindowTitle'): - original_title = f"Playlist Details - {self.playlist.name}" - self.setWindowTitle(f"{original_title} (Showing {display_limit} of {total_tracks:,} tracks)") - - # Also show a subtle message at the bottom of the table - print(f"Playlist Details: Showing first {display_limit} of {total_tracks:,} tracks for better performance") - -class PlaylistItem(QFrame): - view_details_clicked = pyqtSignal(object) # Signal to emit playlist object - - def __init__(self, name: str, track_count: int, sync_status: str, playlist=None, parent=None): - super().__init__(parent) - self.name = name - self.track_count = track_count - self.sync_status = sync_status - self.playlist = playlist - self.is_selected = False - self.download_modal = None - - # Sync state tracking - self.is_syncing = False - self.sync_total_tracks = 0 - self.sync_matched_tracks = 0 - self.sync_failed_tracks = 0 - self.sync_status_widget = None - - # Selection state tracking - self._pending_click = False - - self.setup_ui() - - def on_checkbox_clicked(self): - """Handle direct checkbox click - use same debounced logic""" - print(f"Direct checkbox click for {self.name}") - self.toggle_selection() - - def update_selection_style(self): - """Update visual style based on selection state""" - if self.is_selected: - self.setStyleSheet(""" - PlaylistItem { - background: rgba(29, 185, 84, 0.1); - border-radius: 8px; - border: 2px solid #1db954; - } - PlaylistItem:hover { - background: rgba(29, 185, 84, 0.15); - border: 2px solid #1ed760; - } - """) - else: - self.setStyleSheet(""" - PlaylistItem { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - PlaylistItem:hover { - background: #333333; - border: 1px solid #1db954; - } - """) - - def setup_ui(self): - self.setFixedHeight(80) - self.setStyleSheet(""" - PlaylistItem { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - PlaylistItem:hover { - background: #333333; - border: 1px solid #1db954; - } - """) - - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - - layout = QHBoxLayout(self) - layout.setContentsMargins(20, 15, 20, 15) - layout.setSpacing(15) - - self.checkbox = QCheckBox() - self.checkbox.clicked.connect(self.on_checkbox_clicked) - self.checkbox.setStyleSheet(""" - QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 9px; - border: 2px solid #b3b3b3; - background: transparent; - } - QCheckBox::indicator:checked { - background: #1db954; - border: 2px solid #1db954; - } - QCheckBox::indicator:checked:hover { - background: #1ed760; - } - """) - - content_layout = QVBoxLayout() - content_layout.setSpacing(5) - - name_label = QLabel(self.name) - name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - name_label.setStyleSheet("color: #ffffff;") - - info_layout = QHBoxLayout() - info_layout.setSpacing(20) - - track_label = QLabel(f"{self.track_count} tracks") - track_label.setFont(QFont("Arial", 10)) - track_label.setStyleSheet("color: #b3b3b3;") - - # **FIX**: Renamed this to `sync_status_label` to avoid conflicts - self.sync_status_label = QLabel(self.sync_status) - self.sync_status_label.setFont(QFont("Arial", 10)) - if "Synced" in self.sync_status: - self.sync_status_label.setStyleSheet("color: #1db954;") - elif self.sync_status == "Needs Sync": - self.sync_status_label.setStyleSheet("color: #ffa500;") - else: - self.sync_status_label.setStyleSheet("color: #e22134;") - - info_layout.addWidget(track_label) - info_layout.addWidget(self.sync_status_label) - info_layout.addStretch() - - content_layout.addWidget(name_label) - content_layout.addLayout(info_layout) - - self.action_btn = QPushButton("Sync / Download") - self.action_btn.setFixedSize(120, 30) - self.action_btn.clicked.connect(self.on_action_clicked) - self.action_btn.setStyleSheet(""" - QPushButton { - background: transparent; - border: 1px solid #1db954; - border-radius: 15px; - color: #1db954; - font-size: 10px; - font-weight: bold; - } - QPushButton:hover { - background: #1db954; - color: #000000; - } - """) - - # **FIX**: Renamed this to `operation_status_button` to avoid conflicts - self.operation_status_button = QPushButton() - self.operation_status_button.setFixedSize(120, 30) - self.operation_status_button.setStyleSheet(""" - QPushButton { - background: #1db954; - border: 1px solid #169441; - border-radius: 15px; - color: #000000; - font-size: 10px; - font-weight: bold; - padding: 5px; - text-align: center; - } - QPushButton:hover { - background: #1ed760; - - } - """) - self.operation_status_button.clicked.connect(self.on_status_clicked) - self.operation_status_button.hide() - - self.download_modal = None - self.sync_status_widget = self.create_compact_sync_status() - - layout.addWidget(self.checkbox) - layout.addLayout(content_layout) - layout.addStretch() - layout.addWidget(self.sync_status_widget) - layout.addWidget(self.action_btn) - layout.addWidget(self.operation_status_button) - - self.installEventFilter(self) - for child in self.findChildren(QWidget): - if child != self.action_btn and child != self.operation_status_button: - child.installEventFilter(self) - - def eventFilter(self, source, event): - """Filter events to handle clicks anywhere on the item""" - if event.type() == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton: - # **FIX**: Updated to check for the correctly named button - if source == self.action_btn or source == self.operation_status_button: - return False - - print(f"Event filter caught click on {source} in playlist {self.name}") - self.toggle_selection() - return True - - return super().eventFilter(source, event) - - def toggle_selection(self): - """Toggle the selection state of this playlist item immediately""" - if self._pending_click: - return - - self._pending_click = True - - sync_page = self - while sync_page and not isinstance(sync_page, SyncPage): - sync_page = sync_page.parent() - - if sync_page and self.playlist and self.playlist.id: - currently_selected = self.playlist.id in sync_page.selected_playlists - sync_page.toggle_playlist_selection(self.playlist.id) - new_state = self.playlist.id in sync_page.selected_playlists - self.is_selected = new_state - - self.checkbox.blockSignals(True) - self.checkbox.setChecked(new_state) - self.checkbox.blockSignals(False) - - self.update_selection_style() - print(f"Processed click for {self.name}: {currently_selected} -> {new_state}") - else: - print(f"Could not process click for {self.name} - missing sync page or playlist ID") - - QTimer.singleShot(25, lambda: setattr(self, '_pending_click', False)) - - def mousePressEvent(self, event): - """Handle direct clicks on the playlist item background""" - if event.button() == Qt.MouseButton.LeftButton: - print(f"Direct click on playlist item: {self.name}") - self.toggle_selection() - super().mousePressEvent(event) - - def sync_selection_state(self): - """Synchronize selection state with parent SyncPage (call when needed)""" - sync_page = self - while sync_page and not isinstance(sync_page, SyncPage): - sync_page = sync_page.parent() - - if sync_page and self.playlist and self.playlist.id: - actual_selected = self.playlist.id in sync_page.selected_playlists - - if self.is_selected != actual_selected: - print(f"Syncing state for {self.name}: {self.is_selected} -> {actual_selected}") - self.is_selected = actual_selected - - self.checkbox.blockSignals(True) - self.checkbox.setChecked(actual_selected) - self.checkbox.blockSignals(False) - - self.update_selection_style() - - def create_compact_sync_status(self): - """Create compact sync status display for playlist item""" - sync_status = QFrame() - sync_status.setFixedHeight(36) - sync_status.setStyleSheet(""" - QFrame { - background: rgba(29, 185, 84, 0.1); - border: 1px solid rgba(29, 185, 84, 0.3); - border-radius: 15px; - } - """) - sync_status.hide() - - layout = QHBoxLayout(sync_status) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(6) - - self.item_total_tracks_label = QLabel("0") - self.item_total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.item_total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;") - - self.item_matched_tracks_label = QLabel("0") - self.item_matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.item_matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;") - - self.item_failed_tracks_label = QLabel("0") - self.item_failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.item_failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;") - - self.item_percentage_label = QLabel("0%") - self.item_percentage_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Bold)) - self.item_percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;") - - layout.addWidget(self.item_total_tracks_label) - - item_sep1 = QLabel("/") - item_sep1.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - item_sep1.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(item_sep1) - - layout.addWidget(self.item_matched_tracks_label) - - item_sep2 = QLabel("/") - item_sep2.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - item_sep2.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(item_sep2) - - layout.addWidget(self.item_failed_tracks_label) - - item_sep3 = QLabel("/") - item_sep3.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - item_sep3.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(item_sep3) - - layout.addWidget(self.item_percentage_label) - - return sync_status - - def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0): - """Update sync status display for playlist item""" - self.sync_total_tracks = total_tracks - self.sync_matched_tracks = matched_tracks - self.sync_failed_tracks = failed_tracks - - if self.sync_status_widget and hasattr(self, 'item_total_tracks_label'): - self.item_total_tracks_label.setText(f"{total_tracks}") - self.item_matched_tracks_label.setText(f"{matched_tracks}") - self.item_failed_tracks_label.setText(f"{failed_tracks}") - - if total_tracks > 0: - processed_tracks = matched_tracks + failed_tracks - percentage = int((processed_tracks / total_tracks) * 100) - self.item_percentage_label.setText(f"{percentage}%") - else: - self.item_percentage_label.setText("0%") - - if total_tracks > 0 or self.is_syncing: - self.sync_status_widget.show() - else: - self.sync_status_widget.hide() - - def show_operation_status(self, status_text="View Progress"): - """Changes the button to show an operation is in progress.""" - # **FIX**: Updated to use the correctly named button - self.operation_status_button.setText(status_text) - self.operation_status_button.show() - self.action_btn.hide() - - def hide_operation_status(self): - """Resets the button to its default state.""" - # **FIX**: Updated to use the correctly named button - self.operation_status_button.hide() - self.action_btn.show() - - def on_action_clicked(self): - """If a download is in progress, show the modal. Otherwise, open details.""" - if self.download_modal: - self.download_modal.show() - self.download_modal.activateWindow() - else: - self.view_details_clicked.emit(self.playlist) - - def update_operation_status(self, status_text): - """Update the operation status text""" - # **FIX**: Updated to use the correctly named button - self.operation_status_button.setText(status_text) - - def set_download_modal(self, modal): - """Store reference to the download modal""" - self.download_modal = modal - - def update_sync_status_text(self, new_status): - """Update the sync status text and style the label accordingly""" - self.sync_status = new_status - if hasattr(self, 'sync_status_label'): - self.sync_status_label.setText(new_status) - - # Update color based on status - if "Synced" in new_status: - self.sync_status_label.setStyleSheet("color: #1db954;") - elif new_status == "Needs Sync": - self.sync_status_label.setStyleSheet("color: #ffa500;") - else: - self.sync_status_label.setStyleSheet("color: #e22134;") - - def on_status_clicked(self): - """Handle status button click - reopen modal""" - if self.download_modal and not self.download_modal.isVisible(): - self.download_modal.show() - self.download_modal.activateWindow() - self.download_modal.raise_() - -class TidalPlaylistCard(QFrame): - """Tidal playlist card with persistent state tracking across all phases (matches YouTube workflow)""" - card_clicked = pyqtSignal(str, str) # Signal: (playlist_id, phase) - - def __init__(self, playlist_id: str, playlist_name: str = "Loading...", track_count: int = 0, parent=None): - super().__init__(parent) - self.playlist_id = playlist_id - self.playlist_name = playlist_name - self.track_count = track_count - self.phase = "discovering" # discovering, discovery_complete, syncing, sync_complete, downloading, download_complete - self.progress_data = {'total': 0, 'matched': 0, 'failed': 0} - - # Modal references - self.discovery_modal = None - self.download_modal = None - - # State data - self.playlist_data = None - self.discovered_tracks = [] - - self.setup_ui() - self.update_display() - - def setup_ui(self): - self.setFixedHeight(80) - self.setStyleSheet(""" - TidalPlaylistCard { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - TidalPlaylistCard:hover { - background: #333333; - border: 1px solid #ff6600; - } - """) - - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - - layout = QHBoxLayout(self) - layout.setContentsMargins(20, 15, 20, 15) - layout.setSpacing(15) - - # Tidal icon indicator - tidal_icon = QLabel("") - tidal_icon.setFixedSize(24, 24) - tidal_icon.setStyleSheet(""" - QLabel { - color: #ff6600; - font-size: 16px; - font-weight: bold; - background: transparent; - text-align: center; - border-radius: 12px; - border: 1px solid #ff6600; - } - """) - tidal_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Content layout - content_layout = QVBoxLayout() - content_layout.setSpacing(4) - - # Playlist name - self.name_label = EllipsisLabel(self.playlist_name) - self.name_label.setStyleSheet(""" - QLabel { - color: #ffffff; - font-size: 14px; - font-weight: bold; - background: transparent; - } - """) - - # Info row (track count + phase) - info_layout = QHBoxLayout() - info_layout.setSpacing(8) - info_layout.setContentsMargins(0, 0, 0, 0) - - # Track count - self.track_label = QLabel(f"{self.track_count} tracks") - self.track_label.setStyleSheet(""" - QLabel { - color: #b3b3b3; - font-size: 11px; - background: transparent; - } - """) - - # Phase indicator - self.phase_label = QLabel(self.get_phase_text()) - self.phase_label.setStyleSheet(""" - QLabel { - color: #ff6600; - font-size: 11px; - background: transparent; - font-weight: bold; - } - """) - - info_layout.addWidget(self.track_label) - info_layout.addWidget(self.phase_label) - info_layout.addStretch() - - content_layout.addWidget(self.name_label) - content_layout.addLayout(info_layout) - - # Progress widget (hidden by default, shown during syncing/downloading) - self.progress_widget = self.create_progress_display() - self.progress_widget.hide() - - # Action button - self.action_btn = QPushButton("Discover Matches") - self.action_btn.setFixedSize(120, 30) - self.action_btn.setStyleSheet(""" - QPushButton { - background: #ff6600; - border: none; - border-radius: 15px; - color: #ffffff; - font-size: 10px; - font-weight: bold; - } - QPushButton:hover { - background: #ff7700; - } - QPushButton:pressed { - background: #e55500; - } - """) - self.action_btn.clicked.connect(self.on_action_clicked) - - layout.addWidget(tidal_icon) - layout.addLayout(content_layout) - layout.addWidget(self.progress_widget) - layout.addStretch() - layout.addWidget(self.action_btn) - - def create_progress_display(self): - """Create sync status display widget like YouTubePlaylistCard""" - sync_status = QFrame() - sync_status.setFixedHeight(30) - sync_status.setStyleSheet(""" - QFrame { - background: rgba(0, 0, 0, 0.3); - border-radius: 15px; - border: 1px solid rgba(255, 255, 255, 0.1); - } - """) - - layout = QHBoxLayout(sync_status) - layout.setContentsMargins(12, 6, 12, 6) - layout.setSpacing(8) - - # Create labels for progress display - self.total_tracks_label = QLabel(f"{self.progress_data['total']}") - self.total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.total_tracks_label.setStyleSheet("color: #b3b3b3; background: transparent;") - - self.matched_tracks_label = QLabel(f"{self.progress_data['matched']}") - self.matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent;") - - self.failed_tracks_label = QLabel(f"{self.progress_data['failed']}") - self.failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent;") - - layout.addWidget(self.total_tracks_label) - layout.addWidget(self.matched_tracks_label) - layout.addWidget(self.failed_tracks_label) - layout.addStretch() - - return sync_status - - def get_phase_text(self): - """Get display text for current phase""" - phase_texts = { - "discovering": "Ready to discover", - "discovery_complete": "Discovery complete", - "syncing": "Finding matches...", - "sync_complete": "Sync complete", - "downloading": "Downloading...", - "download_complete": "Download complete" - } - return phase_texts.get(self.phase, self.phase) - - def get_action_text(self): - """Get text for action button based on current phase""" - action_texts = { - "discovering": "Discover Matches", - "discovery_complete": "View Results", - "syncing": "View Progress", - "sync_complete": "Download Missing", - "downloading": "View Downloads", - "download_complete": "View Downloads" - } - return action_texts.get(self.phase, "Discover Matches") - - def update_phase_style(self): - """Update styling based on current phase""" - if self.phase in ['discovering']: - self.phase_label.setStyleSheet("color: #ff6600; font-size: 11px; background: transparent; font-weight: bold;") - elif self.phase in ['discovery_complete', 'sync_complete']: - self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;") - elif self.phase in ['syncing', 'downloading']: - self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;") - elif self.phase in ['download_complete']: - self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;") - - def update_display(self): - """Update all display elements based on current state""" - self.name_label.setText(self.playlist_name) - self.track_label.setText(f"{self.track_count} tracks") - self.phase_label.setText(self.get_phase_text()) - self.action_btn.setText(self.get_action_text()) - self.update_phase_style() - - def set_phase(self, phase: str): - """Update the current phase and refresh display""" - self.phase = phase - self.update_display() - - # Show/hide progress widget based on phase - if phase in ['syncing', 'downloading', 'sync_complete']: - print(f"Tidal card phase set to {phase} - showing progress widget") - self.progress_widget.show() - self.action_btn.hide() - # For syncing phase, initialize with current progress data - if phase == 'syncing': - # Ensure we show some initial progress data - if self.progress_data['total'] == 0: - # Initialize with track count if available - self.progress_data['total'] = self.track_count - self.total_tracks_label.setText(f"{self.progress_data['total']}") - self.matched_tracks_label.setText(f"{self.progress_data['matched']}") - self.failed_tracks_label.setText(f"{self.progress_data['failed']}") - # For sync_complete, hide progress after a delay to show final results - elif phase == 'sync_complete': - from PyQt6.QtCore import QTimer - QTimer.singleShot(5000, lambda: self.progress_widget.hide() if self.phase == 'sync_complete' else None) - QTimer.singleShot(5000, lambda: self.action_btn.show() if self.phase == 'sync_complete' else None) - else: - self.progress_widget.hide() - self.action_btn.show() - - def update_playlist_info(self, name: str, track_count: int): - """Update playlist information and refresh display""" - self.playlist_name = name - self.track_count = track_count - self.update_display() - - def update_progress(self, total: int, matched: int, failed: int): - """Update progress data and refresh progress display""" - self.progress_data = {'total': total, 'matched': matched, 'failed': failed} - if self.progress_widget.isVisible(): - self.total_tracks_label.setText(f"{total}") - self.matched_tracks_label.setText(f"{matched}") - self.failed_tracks_label.setText(f"{failed}") - - def on_action_clicked(self): - """Handle action button click - emit signal with current phase""" - self.card_clicked.emit(self.playlist_id, self.phase) - - def mousePressEvent(self, event): - """Handle card clicks""" - if event.button() == Qt.MouseButton.LeftButton: - self.card_clicked.emit(self.playlist_id, self.phase) - super().mousePressEvent(event) - -class YouTubePlaylistCard(QFrame): - """YouTube playlist card with persistent state tracking across all phases""" - card_clicked = pyqtSignal(str, str) # Signal: (url, phase) - - def __init__(self, url: str, playlist_name: str = "Loading...", track_count: int = 0, parent=None): - super().__init__(parent) - self.url = url - self.playlist_name = playlist_name - self.track_count = track_count - self.phase = "discovering" # discovering, discovery_complete, syncing, sync_complete, downloading, download_complete - self.progress_data = {'total': 0, 'matched': 0, 'failed': 0} - - # Modal references - self.discovery_modal = None - self.download_modal = None - - # State data - self.playlist_data = None - self.discovered_tracks = [] - - self.setup_ui() - self.update_display() - - def setup_ui(self): - self.setFixedHeight(80) - self.setStyleSheet(""" - YouTubePlaylistCard { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - YouTubePlaylistCard:hover { - background: #333333; - border: 1px solid #ff0000; - } - """) - - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - - layout = QHBoxLayout(self) - layout.setContentsMargins(20, 15, 20, 15) - layout.setSpacing(15) - - # YouTube icon indicator - yt_icon = QLabel("") - yt_icon.setFixedSize(24, 24) - yt_icon.setStyleSheet(""" - QLabel { - color: #ff0000; - font-size: 16px; - font-weight: bold; - background: transparent; - text-align: center; - border-radius: 12px; - border: 1px solid #ff0000; - } - """) - yt_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Content layout - content_layout = QVBoxLayout() - content_layout.setSpacing(5) - - self.name_label = QLabel(self.playlist_name) - self.name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - self.name_label.setStyleSheet("color: #ffffff;") - - info_layout = QHBoxLayout() - info_layout.setSpacing(20) - - self.track_label = QLabel(f"{self.track_count} tracks") - self.track_label.setFont(QFont("Arial", 10)) - self.track_label.setStyleSheet("color: #b3b3b3;") - - self.phase_label = QLabel(self.get_phase_text()) - self.phase_label.setFont(QFont("Arial", 10)) - self.update_phase_style() - - info_layout.addWidget(self.track_label) - info_layout.addWidget(self.phase_label) - info_layout.addStretch() - - content_layout.addWidget(self.name_label) - content_layout.addLayout(info_layout) - - # Progress status widget (similar to PlaylistItem) - self.progress_widget = self.create_progress_display() - self.progress_widget.hide() # Initially hidden - - # Action button - self.action_btn = QPushButton(self.get_action_text()) - self.action_btn.setFixedSize(120, 30) - self.action_btn.clicked.connect(self.on_action_clicked) - self.action_btn.setStyleSheet(""" - QPushButton { - background: transparent; - border: 1px solid #ff0000; - border-radius: 15px; - color: #ff0000; - font-size: 10px; - font-weight: bold; - } - QPushButton:hover { - background: #ff0000; - color: #ffffff; - } - """) - - layout.addWidget(yt_icon) - layout.addLayout(content_layout) - layout.addWidget(self.progress_widget) - layout.addWidget(self.action_btn) - - def create_progress_display(self): - """Create sync status display widget like PlaylistItem""" - sync_status = QFrame() - sync_status.setFixedHeight(30) - sync_status.setStyleSheet(""" - QFrame { - background: rgba(0, 0, 0, 0.3); - border-radius: 15px; - border: 1px solid rgba(255, 255, 255, 0.1); - } - """) - - layout = QHBoxLayout(sync_status) - layout.setContentsMargins(12, 6, 12, 6) - layout.setSpacing(8) - - # Create labels for progress display - self.total_tracks_label = QLabel(f"{self.progress_data['total']}") - self.total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.total_tracks_label.setStyleSheet("color: #b3b3b3; background: transparent; border: none;") - layout.addWidget(self.total_tracks_label) - - sep1 = QLabel("/") - sep1.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - sep1.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep1) - - self.matched_tracks_label = QLabel(f"{self.progress_data['matched']}") - self.matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;") - layout.addWidget(self.matched_tracks_label) - - sep2 = QLabel("/") - sep2.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - sep2.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep2) - - self.failed_tracks_label = QLabel(f"{self.progress_data['failed']}") - self.failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;") - layout.addWidget(self.failed_tracks_label) - - sep3 = QLabel("/") - sep3.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - sep3.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep3) - - self.percentage_label = QLabel("0%") - self.percentage_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - self.percentage_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") - layout.addWidget(self.percentage_label) - - return sync_status - - def get_phase_text(self): - """Get display text for current phase""" - phase_texts = { - 'discovering': 'Discovering tracks...', - 'discovery_complete': 'Discovery complete', - 'syncing': 'Syncing...', - 'sync_complete': 'Sync complete', - 'downloading': 'Downloading...', - 'download_complete': 'Complete' - } - return phase_texts.get(self.phase, self.phase) - - def get_action_text(self): - """Get action button text based on phase""" - action_texts = { - 'discovering': 'View Progress', - 'discovery_complete': 'View Details', - 'syncing': 'View Progress', - 'sync_complete': 'Download Missing', - 'downloading': 'View Downloads', - 'download_complete': 'View Results' - } - return action_texts.get(self.phase, 'Open') - - def update_phase_style(self): - """Update phase label color based on current phase""" - phase_colors = { - 'discovering': '#ffa500', # Orange - 'discovery_complete': '#1db954', # Green - 'syncing': '#ffa500', # Orange - 'sync_complete': '#1db954', # Green - 'downloading': '#ffa500', # Orange - 'download_complete': '#1db954' # Green - } - color = phase_colors.get(self.phase, '#b3b3b3') - self.phase_label.setStyleSheet(f"color: {color};") - - def update_display(self): - """Update all display elements based on current state""" - self.name_label.setText(self.playlist_name) - self.track_label.setText(f"{self.track_count} tracks") - self.phase_label.setText(self.get_phase_text()) - self.action_btn.setText(self.get_action_text()) - self.update_phase_style() - - def set_phase(self, phase: str): - """Update the current phase and refresh display""" - self.phase = phase - self.update_display() - - # Show/hide progress widget based on phase - if phase in ['syncing', 'downloading', 'sync_complete']: - print(f"Card phase set to {phase} - showing progress widget") - self.progress_widget.show() - self.action_btn.hide() - # For syncing phase, initialize with current progress data - if phase == 'syncing': - # Ensure we show some initial progress data - if self.progress_data['total'] == 0: - # Initialize with track count if available - self.progress_data['total'] = self.track_count - self.total_tracks_label.setText(f"{self.progress_data['total']}") - self.matched_tracks_label.setText(f"{self.progress_data['matched']}") - self.failed_tracks_label.setText(f"{self.progress_data['failed']}") - # For sync_complete, hide progress after a delay to show final results - elif phase == 'sync_complete': - from PyQt6.QtCore import QTimer - QTimer.singleShot(5000, lambda: self.progress_widget.hide() if self.phase == 'sync_complete' else None) - QTimer.singleShot(5000, lambda: self.action_btn.show() if self.phase == 'sync_complete' else None) - else: - print(f"Card phase set to {phase} - hiding progress widget") - self.progress_widget.hide() - self.action_btn.show() - - def update_progress(self, total=None, matched=None, failed=None): - """Update progress data and display""" - print(f"Card update_progress called: total={total}, matched={matched}, failed={failed}, phase={self.phase}") - - if total is not None: - self.progress_data['total'] = total - if matched is not None: - self.progress_data['matched'] = matched - if failed is not None: - self.progress_data['failed'] = failed - - # Update labels - self.total_tracks_label.setText(f"{self.progress_data['total']}") - self.matched_tracks_label.setText(f"{self.progress_data['matched']}") - self.failed_tracks_label.setText(f"{self.progress_data['failed']}") - - # Ensure progress widget is visible when progress is being updated - # This ensures live status display is always shown during active operations - if self.phase in ['syncing', 'downloading']: - print(f"Card in {self.phase} phase - ensuring progress widget is visible") - self.progress_widget.show() - self.action_btn.hide() - else: - print(f"Card not in active phase ({self.phase}) - progress widget state unchanged") - - # Calculate percentage - total = self.progress_data['total'] - if total > 0: - processed = self.progress_data['matched'] + self.progress_data['failed'] - percentage = int((processed / total) * 100) - self.percentage_label.setText(f"{percentage}%") - else: - self.percentage_label.setText("0%") - - def update_playlist_info(self, name: str, track_count: int): - """Update playlist name and track count""" - self.playlist_name = name - self.track_count = track_count - self.update_display() - - def set_playlist_data(self, data): - """Store discovered playlist data""" - self.playlist_data = data - if hasattr(data, 'tracks'): - self.discovered_tracks = data.tracks - self.track_count = len(data.tracks) - self.update_display() - - def on_action_clicked(self): - """Handle action button click - emit signal with current phase""" - self.card_clicked.emit(self.url, self.phase) - - def mousePressEvent(self, event): - """Handle card clicks""" - if event.button() == Qt.MouseButton.LeftButton: - self.card_clicked.emit(self.url, self.phase) - super().mousePressEvent(event) - -class SyncOptionsPanel(QFrame): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - self.setStyleSheet(""" - SyncOptionsPanel { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(15) - - # Title - title_label = QLabel("Sync Options") - title_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #ffffff;") - - # Download missing tracks option - self.download_missing = QCheckBox("Download missing tracks from Soulseek") - self.download_missing.setChecked(True) - self.download_missing.setStyleSheet(""" - QCheckBox { - color: #ffffff; - font-size: 11px; - } - QCheckBox::indicator { - width: 16px; - height: 16px; - border-radius: 8px; - border: 2px solid #b3b3b3; - background: transparent; - } - QCheckBox::indicator:checked { - background: #1db954; - border: 2px solid #1db954; - } - """) - - layout.addWidget(title_label) - layout.addWidget(self.download_missing) - -class SyncPage(QWidget): - # Signals for dashboard activity tracking - sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time - database_updated_externally = pyqtSignal() - - def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, navidrome_client=None, tidal_client=None, parent=None): - super().__init__(parent) - self.spotify_client = spotify_client - self.plex_client = plex_client - self.jellyfin_client = jellyfin_client - self.navidrome_client = navidrome_client - self.soulseek_client = soulseek_client - self.tidal_client = tidal_client or TidalClient() - self.downloads_page = downloads_page - self.sync_statuses = load_sync_status() - self.current_playlists = [] - self.playlist_loader = None - self.current_tidal_playlists = [] - self.tidal_playlist_loader = None - self.active_download_processes = {} - # Track cache for performance - self.track_cache = {} # playlist_id -> tracks - - # Sync worker management - self.active_sync_workers = {} # playlist_id -> SyncWorker (for individual modal syncs) - self.sequential_sync_worker = None # Current sequential sync worker - - # Selection tracking - self.selected_playlists = set() # Set of selected playlist IDs - self.sequential_sync_queue = [] # Queue for sequential syncing - self.is_sequential_syncing = False - - # Thread pool for async operations (like downloads.py) - self.thread_pool = QThreadPool() - self.thread_pool.setMaxThreadCount(3) # Limit concurrent Spotify API calls - - # YouTube playlist tracking - self.active_youtube_processes = {} # URL -> modal instance - self.youtube_worker = None # Current parsing worker - self.youtube_status_widgets = {} # playlist_id -> status widget - - # YouTube playlist download modal references for reopening - self.active_youtube_download_modals = {} # playlist_id -> modal instance - - # YouTube playlist card hub system - self.youtube_playlist_states = {} # url -> {phase, data, card, modals} - self.youtube_cards = {} # url -> YouTubePlaylistCard instance - self.youtube_cards_container = None # Container for all YouTube cards - - # Tidal playlist card hub system (identical to YouTube) - self.tidal_playlist_states = {} # playlist_id -> {phase, data, card, modals} - self.tidal_cards = {} # playlist_id -> TidalPlaylistCard instance - self.tidal_cards_container = None # Container for all Tidal cards - - # Initialize unified media scan manager - self.scan_manager = None - try: - from core.media_scan_manager import MediaScanManager - self.scan_manager = MediaScanManager(delay_seconds=60) - # Add automatic incremental database update after scan completion - self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed) - logger.info("MediaScanManager initialized for SyncPage") - except Exception as e: - logger.error(f"Failed to initialize MediaScanManager: {e}") - - self.setup_ui() - - # Don't auto-load on startup, but do auto-load when page becomes visible - self.show_initial_state() - self.playlists_loaded = False - - def set_toast_manager(self, toast_manager): - """Set the toast manager for showing notifications""" - self.toast_manager = toast_manager - - def _on_media_scan_completed(self): - """Callback triggered when media 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 - from config.settings import config_manager - - # Get the active media client - active_server = config_manager.get_active_media_server() - if active_server == "jellyfin": - media_client = getattr(self, 'jellyfin_client', None) - else: - media_client = getattr(self, 'plex_client', None) - - # Check if we should run incremental update - if not media_client or not media_client.is_connected(): - logger.debug(f"{active_server.upper()} 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(f"Starting automatic incremental database update after {active_server.upper()} scan") - self._start_automatic_incremental_update() - - except Exception as e: - logger.error(f"Error in media 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 - self._auto_database_worker = DatabaseUpdateWorker( - self.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") - - # Emit the signal to notify the dashboard to refresh its statistics - self.database_updated_externally.emit() - logger.info("Emitted signal to refresh dashboard database statistics after auto update") - - # 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 _update_and_save_sync_status(self, playlist_id, result, snapshot_id): - """Updates the sync status for a given playlist and saves to file.""" - # THE FIX: This function will now run even if there are failed tracks, - # ensuring the sync time and snapshot_id are always recorded. - playlist_obj = next((p for p in self.current_playlists if p.id == playlist_id), None) - - if playlist_obj: - now = datetime.now() - self.sync_statuses[playlist_id] = { - 'name': playlist_obj.name, - 'owner': playlist_obj.owner, - 'snapshot_id': snapshot_id, - 'last_synced': now.isoformat() - } - save_sync_status(self.sync_statuses) - - # This now targets the correct label for real-time UI updates - playlist_item = self.find_playlist_item_widget(playlist_id) - if playlist_item and hasattr(playlist_item, 'sync_status_label'): - new_status_text = f"Synced: {now.strftime('%b %d, %H:%M')}" - playlist_item.sync_status_label.setText(new_status_text) - playlist_item.sync_status_label.setStyleSheet("color: #1db954;") - - def is_playlist_syncing(self, playlist_id): - """Check if a playlist is currently syncing""" - return playlist_id in self.active_sync_workers - - def get_playlist_sync_worker(self, playlist_id): - """Get the sync worker for a playlist if it exists""" - return self.active_sync_workers.get(playlist_id) - - def start_playlist_sync(self, playlist): - """Start sync for a playlist (called from modal)""" - if playlist.id in self.active_sync_workers: - # Already syncing - return False - - # Create sync service if not available - if not hasattr(self, 'sync_service'): - from services.sync_service import PlaylistSyncService - self.sync_service = PlaylistSyncService( - self.spotify_client, - self.plex_client, - self.soulseek_client, - getattr(self, 'jellyfin_client', None), - getattr(self, 'navidrome_client', None) - ) - - # Create sync worker - sync_worker = SyncWorker( - playlist=playlist, - sync_service=self.sync_service - ) - - # Connect worker signals - sync_worker.signals.finished.connect(lambda result, sid: self.on_sync_finished(playlist.id, result, sid)) - - sync_worker.signals.error.connect(lambda error: self.on_sync_error(playlist.id, error)) - sync_worker.signals.progress.connect(lambda progress: self.on_sync_progress(playlist.id, progress)) - - # Store the worker - self.active_sync_workers[playlist.id] = sync_worker - - # Emit activity signal for sync start - self.sync_activity.emit("", "Sync Started", f"Syncing playlist '{playlist.name}'", "Now") - - # Show toast notification for sync start - if hasattr(self, 'toast_manager') and self.toast_manager: - track_count = len(playlist.tracks) if hasattr(playlist, 'tracks') else 0 - if track_count > 0: - self.toast_manager.show_toast(f"Starting sync for '{playlist.name}' ({track_count} tracks)", ToastType.INFO) - - # Start the worker - self.thread_pool.start(sync_worker) - - # Update playlist item status - playlist_item = self.find_playlist_item_widget(playlist.id) - if playlist_item: - playlist_item.is_syncing = True - playlist_item.update_sync_status(len(playlist.tracks), 0, 0) - - # Log start - if hasattr(self, 'log_area'): - self.log_area.append(f"Starting sync for playlist: {playlist.name}") - - # Update refresh button state since we now have an active sync - self.update_refresh_button_state() - - return True - - def start_sequential_playlist_sync(self, playlist): - """Start sync for a playlist as part of sequential sync (separate from individual syncs)""" - # Create sync service if not available - if not hasattr(self, 'sync_service'): - from services.sync_service import PlaylistSyncService - self.sync_service = PlaylistSyncService( - self.spotify_client, - self.plex_client, - self.soulseek_client, - getattr(self, 'jellyfin_client', None), - getattr(self, 'navidrome_client', None) - ) - - # Create sync worker for sequential sync - sync_worker = SyncWorker( - playlist=playlist, - sync_service=self.sync_service - ) - - # Connect worker signals for sequential sync - sync_worker.signals.finished.connect(lambda result, sid: self.on_sequential_sync_finished(playlist.id, result, sid)) - sync_worker.signals.error.connect(lambda error: self.on_sequential_sync_error(playlist.id, error)) - sync_worker.signals.progress.connect(lambda progress: self.on_sync_progress(playlist.id, progress)) - - # Store the sequential sync worker - self.sequential_sync_worker = sync_worker - - # Start the worker - self.thread_pool.start(sync_worker) - - # Update playlist item status - playlist_item = self.find_playlist_item_widget(playlist.id) - if playlist_item: - playlist_item.is_syncing = True - playlist_item.update_sync_status(len(playlist.tracks), 0, 0) - - # Log start - if hasattr(self, 'log_area'): - self.log_area.append(f"Starting sequential sync for playlist: {playlist.name}") - - # Show toast notification for sequential sync start - if hasattr(self, 'toast_manager') and self.toast_manager: - track_count = len(playlist.tracks) if hasattr(playlist, 'tracks') else 0 - if track_count > 0: - self.toast_manager.show_toast(f"Starting sequential sync for '{playlist.name}' ({track_count} tracks)", ToastType.INFO) - - return True - - def toggle_playlist_selection(self, playlist_id): - """Toggle selection state of a playlist""" - if playlist_id in self.selected_playlists: - self.selected_playlists.remove(playlist_id) - print(f"Deselected playlist: {playlist_id}") - else: - self.selected_playlists.add(playlist_id) - print(f"Selected playlist: {playlist_id}") - - print(f"Total selected: {len(self.selected_playlists)}") - self.update_selection_ui() - - def update_selection_ui(self): - """Update the selection info label and button state""" - selected_count = len(self.selected_playlists) - - print(f"Updating UI with {selected_count} selected playlists, sequential syncing: {self.is_sequential_syncing}, individual syncs: {len(self.active_sync_workers)}") - - if selected_count == 0: - self.selection_info.setText("Select playlists to sync") - self.start_sync_btn.setEnabled(False) - print("Button disabled - no selection") - elif self.has_active_operations(): - # Don't change button state during any active operations - print(f"Active operations in progress - keeping button as is") - elif selected_count == 1: - self.selection_info.setText("1 playlist selected") - self.start_sync_btn.setEnabled(True) - print("Button enabled - 1 playlist") - else: - self.selection_info.setText(f"{selected_count} playlists selected") - self.start_sync_btn.setEnabled(True) - print(f"Button enabled - {selected_count} playlists") - - def start_selected_playlist_sync(self): - """Start syncing all selected playlists sequentially""" - if not self.selected_playlists or self.is_sequential_syncing: - return - - # Don't allow sequential sync if individual syncs are already running - if self.active_sync_workers: - print(f"DEBUG: Cannot start sequential sync - {len(self.active_sync_workers)} individual syncs are running") - return - - # Get selected playlist objects - selected_playlist_objects = [] - for playlist_item in self.get_all_playlist_items(): - if playlist_item.playlist.id in self.selected_playlists: - selected_playlist_objects.append(playlist_item.playlist) - - if not selected_playlist_objects: - return - - # Start sequential sync - self.sequential_sync_queue = selected_playlist_objects.copy() - self.is_sequential_syncing = True - self.start_sync_btn.setText("Syncing...") - self.start_sync_btn.setEnabled(False) - - # Disable refresh button during sequential sync - self.update_refresh_button_state() - - # Start first sync - self.process_next_in_sync_queue() - - def process_next_in_sync_queue(self): - """Process the next playlist in the sequential sync queue.""" - print(f"DEBUG: process_next_in_sync_queue - queue length: {len(self.sequential_sync_queue)}, is_syncing: {self.is_sequential_syncing}") - - if self.sequential_sync_queue and self.is_sequential_syncing: - # Get next playlist to sync - next_playlist = self.sequential_sync_queue.pop(0) - print(f"DEBUG: Starting sync for next playlist: {next_playlist.name}") - - # Start sync for this playlist - if not self.start_sequential_playlist_sync(next_playlist): - # If sync failed to start, immediately process the next one - print("DEBUG: Sync failed to start, moving to next playlist") - self.process_next_in_sync_queue() - else: - # If queue is empty or sync was cancelled, call the final completion handler - print("DEBUG: Sequential sync queue is empty or syncing stopped - calling completion handler.") - self.on_sequential_sync_complete() - - def on_sequential_sync_complete(self): - """Handle completion of the entire sequential sync process.""" - # Ensure this runs only once at the very end - if not self.is_sequential_syncing: - return - - print("DEBUG: Sequential sync process complete. Resetting all states.") - self.is_sequential_syncing = False - self.sequential_sync_queue.clear() - self.sequential_sync_worker = None # Ensure worker is cleared - - # Reset the button text and state authoritatively - self.start_sync_btn.setText("Start Sync") - - # Update the entire UI based on the new, correct state - self.update_selection_ui() - self.update_refresh_button_state() - - def on_sequential_sync_finished(self, playlist_id, result, snapshot_id): - """Handle completion of individual playlist in sequential sync""" - print(f"DEBUG: Sequential sync finished for playlist {playlist_id}") - - # Clear sequential sync worker - self.sequential_sync_worker = None - - # Update playlist item status - playlist_item = self.find_playlist_item_widget(playlist_id) - if playlist_item: - playlist_item.is_syncing = False - playlist_item.update_sync_status( - result.total_tracks, - result.matched_tracks, - result.failed_tracks - ) - - # Hide status widget after completion with delay - QTimer.singleShot(3000, lambda: playlist_item.sync_status_widget.hide() if playlist_item.sync_status_widget else None) - - # Update any open modals - self.update_open_modals_completion(playlist_id, result) - - # Pass the snapshot_id to the save function - self._update_and_save_sync_status(playlist_id, result, snapshot_id) - - # Log completion - if hasattr(self, 'log_area'): - success_rate = result.success_rate - msg = f"Sequential sync complete: {result.synced_tracks}/{result.total_tracks} tracks synced ({success_rate:.1f}%)" - if result.failed_tracks > 0: - msg += f", {result.failed_tracks} failed" - self.log_area.append(msg) - - # Show toast notification for sequential sync completion - if hasattr(self, 'toast_manager') and self.toast_manager: - playlist_item = self.find_playlist_item_widget(playlist_id) - playlist_name = playlist_item.name if playlist_item else "Unknown Playlist" - if result.failed_tracks > 0: - self.toast_manager.show_toast(f"'{playlist_name}' sync completed: {result.matched_tracks}/{result.total_tracks} tracks, {result.failed_tracks} failed", ToastType.WARNING) - else: - self.toast_manager.show_toast(f"'{playlist_name}' sync completed: {result.matched_tracks} tracks added", ToastType.SUCCESS) - - # **THE FIX**: Defer processing the next item to allow the event loop to catch up. - # This ensures UI updates (like the status label) are processed before moving on. - if self.is_sequential_syncing: - print(f"DEBUG: Scheduling next playlist in sequence.") - QTimer.singleShot(10, self.process_next_in_sync_queue) - - def on_sequential_sync_error(self, playlist_id, error_msg): - """Handle error in individual playlist during sequential sync""" - print(f"DEBUG: Sequential sync error for playlist {playlist_id}: {error_msg}") - - # Clear sequential sync worker - self.sequential_sync_worker = None - - # Update playlist item status - playlist_item = self.find_playlist_item_widget(playlist_id) - if playlist_item: - playlist_item.is_syncing = False - if playlist_item.sync_status_widget: - playlist_item.sync_status_widget.hide() - - # Update any open modals - self.update_open_modals_error(playlist_id, error_msg) - - # Log error - if hasattr(self, 'log_area'): - self.log_area.append(f"Sequential sync failed: {error_msg}") - - # Show toast notification for sequential sync error - if hasattr(self, 'toast_manager') and self.toast_manager: - playlist_item = self.find_playlist_item_widget(playlist_id) - playlist_name = playlist_item.name if playlist_id else "Unknown Playlist" - self.toast_manager.show_toast(f"Sequential sync failed for '{playlist_name}': {error_msg}", ToastType.ERROR) - - # **THE FIX**: Defer processing the next item to allow the event loop to catch up. - if self.is_sequential_syncing: - print(f"DEBUG: Scheduling next playlist in sequence despite error.") - QTimer.singleShot(10, self.process_next_in_sync_queue) - - def get_all_playlist_items(self): - """Get all PlaylistItem widgets from the playlist layout""" - playlist_items = [] - for i in range(self.playlist_layout.count()): - item = self.playlist_layout.itemAt(i) - widget = item.widget() - if isinstance(widget, PlaylistItem): - playlist_items.append(widget) - return playlist_items - - def cancel_playlist_sync(self, playlist_id): - """Cancel sync for a playlist""" - if playlist_id in self.active_sync_workers: - worker = self.active_sync_workers[playlist_id] - worker.cancel() - - # Remove from active workers - del self.active_sync_workers[playlist_id] - - # Update playlist item status - playlist_item = self.find_playlist_item_widget(playlist_id) - if playlist_item: - playlist_item.is_syncing = False - if playlist_item.sync_status_widget: - playlist_item.sync_status_widget.hide() - - # Log cancellation - if hasattr(self, 'log_area'): - self.log_area.append(f"Sync cancelled for playlist") - - return True - return False - - def on_sync_progress(self, playlist_id, progress): - """Handle sync progress updates""" - print(f"PARENT PAGE on_sync_progress called! playlist_id={playlist_id}") - print(f"Progress: total={progress.total_tracks}, matched={progress.matched_tracks}, failed={progress.failed_tracks}") - - # Update playlist item status (for Spotify playlists) - playlist_item = self.find_playlist_item_widget(playlist_id) - if playlist_item: - print(f"Found playlist item widget, updating status") - playlist_item.update_sync_status( - progress.total_tracks, - progress.matched_tracks, - progress.failed_tracks - ) - else: - print(f"No playlist item widget found for playlist_id: {playlist_id}") - - # Update YouTube card progress (for YouTube playlists) - # Find the YouTube card by matching playlist IDs - youtube_card_updated = False - print(f"Searching for YouTube card with playlist_id: {playlist_id}") - for url, state in self.youtube_playlist_states.items(): - playlist_data = state.get('playlist_data') - if playlist_data and hasattr(playlist_data, 'id'): - print(f"Checking YouTube card: URL={url}, stored playlist_id={playlist_data.id}") - if playlist_data.id == playlist_id: - print(f"Found matching YouTube card for playlist_id: {playlist_id}, updating progress") - self.update_youtube_card_progress( - url, - total=progress.total_tracks, - matched=progress.matched_tracks, - failed=progress.failed_tracks - ) - youtube_card_updated = True - break - else: - print(f"Playlist ID mismatch: {playlist_data.id} != {playlist_id}") - else: - print(f"YouTube card state missing playlist_data or id: URL={url}") - - if not youtube_card_updated: - print(f"No matching YouTube card found for playlist_id: {playlist_id}") - - # Update Tidal card progress (for Tidal playlists) - # Find the Tidal card by matching playlist IDs - tidal_card_updated = False - print(f"Searching for Tidal card with playlist_id: {playlist_id}") - for tidal_playlist_id, state in self.tidal_playlist_states.items(): - playlist_data = state.get('playlist_data') - if playlist_data and hasattr(playlist_data, 'id'): - print(f"Checking Tidal card: tidal_playlist_id={tidal_playlist_id}, stored playlist_id={playlist_data.id}") - if playlist_data.id == playlist_id: - print(f"Found matching Tidal card for playlist_id: {playlist_id}, updating progress") - # Update card progress display - if tidal_playlist_id in self.tidal_cards: - card = self.tidal_cards[tidal_playlist_id] - card.update_progress( - total=progress.total_tracks, - matched=progress.matched_tracks, - failed=progress.failed_tracks - ) - tidal_card_updated = True - break - else: - print(f"Playlist ID mismatch: {playlist_data.id} != {playlist_id}") - else: - print(f"Tidal card state missing playlist_data or id: tidal_playlist_id={tidal_playlist_id}") - - if not tidal_card_updated: - print(f"No matching Tidal card found for playlist_id: {playlist_id}") - - if not playlist_item and not youtube_card_updated and not tidal_card_updated: - print(f"No playlist widget, YouTube card, OR Tidal card found for playlist_id: {playlist_id}") - - # Update any open modal for this playlist - print(f"About to call update_open_modals_progress") - self.update_open_modals_progress(playlist_id, progress) - - def on_sync_finished(self, playlist_id, result, snapshot_id): - """Handle sync completion""" - # Remove from active workers - if playlist_id in self.active_sync_workers: - del self.active_sync_workers[playlist_id] - - # Update playlist item status (for Spotify playlists) - playlist_item = self.find_playlist_item_widget(playlist_id) - playlist_name = "Unknown Playlist" - - if playlist_item: - playlist_item.is_syncing = False - playlist_item.update_sync_status( - result.total_tracks, - result.matched_tracks, - result.failed_tracks - ) - playlist_name = playlist_item.name - # Hide status widget after completion with delay - QTimer.singleShot(3000, lambda: playlist_item.sync_status_widget.hide() if playlist_item.sync_status_widget else None) - - # Update YouTube card status (for YouTube playlists) - youtube_card_updated = False - for url, state in self.youtube_playlist_states.items(): - playlist_data = state.get('playlist_data') - if playlist_data and hasattr(playlist_data, 'id') and playlist_data.id == playlist_id: - print(f"YouTube sync finished for playlist_id: {playlist_id}, updating card to sync_complete") - self.update_youtube_card_phase(url, 'sync_complete') - self.update_youtube_card_progress( - url, - total=result.total_tracks, - matched=result.matched_tracks, - failed=result.failed_tracks - ) - playlist_name = playlist_data.name - youtube_card_updated = True - break - - # Update Tidal card status (for Tidal playlists) - tidal_card_updated = False - for tidal_playlist_id, state in self.tidal_playlist_states.items(): - playlist_data = state.get('playlist_data') - if playlist_data and hasattr(playlist_data, 'id') and playlist_data.id == playlist_id: - print(f"Tidal sync finished for playlist_id: {playlist_id}, updating card to sync_complete") - self.update_tidal_card_phase(tidal_playlist_id, 'sync_complete') - # Also update card progress display - if tidal_playlist_id in self.tidal_cards: - card = self.tidal_cards[tidal_playlist_id] - card.update_progress( - total=result.total_tracks, - matched=result.matched_tracks, - failed=result.failed_tracks - ) - playlist_name = playlist_data.name - tidal_card_updated = True - break - - # Update any open modals - self.update_open_modals_completion(playlist_id, result) - - # Pass the snapshot_id to the save function - self._update_and_save_sync_status(playlist_id, result, snapshot_id) - - # Emit activity signal for sync completion - success_msg = f"Completed: {result.matched_tracks}/{result.total_tracks} tracks" - self.sync_activity.emit("", "Sync Complete", f"'{playlist_name}' - {success_msg}", "Now") - - # Show toast notification for sync completion - if hasattr(self, 'toast_manager') and self.toast_manager: - wishlist_count = getattr(result, 'wishlist_added_count', 0) - - if result.failed_tracks > 0: - msg = f"Sync completed: {result.matched_tracks}/{result.total_tracks} tracks added, {result.failed_tracks} failed" - if wishlist_count > 0: - msg += f". {wishlist_count} track{'s' if wishlist_count > 1 else ''} added to wishlist" - self.toast_manager.show_toast(msg, ToastType.WARNING) - else: - msg = f"Sync completed: {result.matched_tracks} tracks added to queue" - if wishlist_count > 0: - msg += f". {wishlist_count} missing track{'s' if wishlist_count > 1 else ''} added to wishlist" - self.toast_manager.show_toast(msg, ToastType.SUCCESS) - - # Continue sequential sync if in progress - if self.is_sequential_syncing: - print(f"DEBUG: Sync finished for {playlist_id}, continuing sequential sync") - self.process_next_in_sync_queue() - else: - print(f"DEBUG: Sync finished for {playlist_id}, not in sequential sync mode") - - # Update refresh button state since a sync completed - self.update_refresh_button_state() - - # Log completion - if hasattr(self, 'log_area'): - success_rate = result.success_rate - msg = f"Sync complete: {result.synced_tracks}/{result.total_tracks} tracks synced ({success_rate:.1f}%)" - if result.failed_tracks > 0: - msg += f", {result.failed_tracks} failed" - self.log_area.append(msg) - - def on_sync_error(self, playlist_id, error_msg): - """Handle sync error""" - # Remove from active workers - if playlist_id in self.active_sync_workers: - del self.active_sync_workers[playlist_id] - - # Update playlist item status - playlist_item = self.find_playlist_item_widget(playlist_id) - if playlist_item: - playlist_item.is_syncing = False - if playlist_item.sync_status_widget: - playlist_item.sync_status_widget.hide() - - # Update any open modals - self.update_open_modals_error(playlist_id, error_msg) - - # Emit activity signal for sync error - playlist_name = playlist_item.name if playlist_item else "Unknown Playlist" - self.sync_activity.emit("", "Sync Failed", f"'{playlist_name}' - {error_msg}", "Now") - - # Show toast notification for sync error - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.show_toast(f"Sync failed for '{playlist_name}': {error_msg}", ToastType.ERROR) - - # Continue sequential sync if in progress (even on error) - if self.is_sequential_syncing: - self.process_next_in_sync_queue() - - # Update refresh button state since a sync completed (with error) - self.update_refresh_button_state() - - # Log error - if hasattr(self, 'log_area'): - self.log_area.append(f"Sync failed: {error_msg}") - - def update_open_modals_progress(self, playlist_id, progress): - """Update any open modals for this playlist with sync progress""" - print(f"Looking for modals to update progress for playlist_id: {playlist_id}") - print(f"Progress data: total={progress.total_tracks}, matched={progress.matched_tracks}, failed={progress.failed_tracks}") - - # Find all open modal instances for this playlist - from PyQt6.QtWidgets import QApplication - youtube_modals_found = 0 - spotify_modals_found = 0 - - for widget in QApplication.topLevelWidgets(): - widget_name = type(widget).__name__ - print(f"Checking widget: {widget_name}") - - # Handle PlaylistDetailsModal - if isinstance(widget, PlaylistDetailsModal): - if hasattr(widget, 'playlist'): - widget_playlist_id = getattr(widget.playlist, 'id', 'NO_ID') - is_visible = widget.isVisible() - print(f"Spotify modal: playlist_id={widget_playlist_id}, visible={is_visible}, target={playlist_id}") - - if widget_playlist_id == playlist_id and is_visible: - print(f"Updating Spotify modal progress: {playlist_id}") - spotify_modals_found += 1 - widget.on_sync_progress(playlist_id, progress) - else: - print(f"Spotify modal without playlist attribute") - - # Handle YouTubeDownloadMissingTracksModal - elif isinstance(widget, YouTubeDownloadMissingTracksModal): - youtube_modals_found += 1 - if hasattr(widget, 'playlist'): - widget_playlist_id = getattr(widget.playlist, 'id', 'NO_ID') - is_visible = widget.isVisible() - print(f"YouTube modal #{youtube_modals_found}: playlist_id={widget_playlist_id}, visible={is_visible}, target={playlist_id}") - - if widget_playlist_id == playlist_id: - print(f"Found matching YouTube modal for playlist_id: {playlist_id}, calling on_sync_progress") - # Update the YouTube modal's progress display (even if hidden) - widget.on_sync_progress(playlist_id, progress) - else: - print(f"YouTube modal playlist_id mismatch: {widget_playlist_id} vs {playlist_id}") - else: - print(f"YouTube modal #{youtube_modals_found} without playlist attribute") - - print(f"Summary: Found {spotify_modals_found} Spotify modals, {youtube_modals_found} YouTube modals total") - - def update_open_modals_completion(self, playlist_id, result): - """Update any open modals for this playlist with sync completion""" - from PyQt6.QtWidgets import QApplication - for widget in QApplication.topLevelWidgets(): - # Handle PlaylistDetailsModal - if (isinstance(widget, PlaylistDetailsModal) and - hasattr(widget, 'playlist') and - widget.playlist.id == playlist_id and - widget.isVisible()): - # Update the modal's completion display - widget.on_sync_finished(playlist_id, result) - - # Handle YouTubeDownloadMissingTracksModal - elif (isinstance(widget, YouTubeDownloadMissingTracksModal) and - hasattr(widget, 'playlist') and - widget.playlist.id == playlist_id): - # Update the YouTube modal's completion display (even if hidden) - widget.on_sync_finished(playlist_id, result) - - def update_open_modals_error(self, playlist_id, error_msg): - """Update any open modals for this playlist with sync error""" - from PyQt6.QtWidgets import QApplication - for widget in QApplication.topLevelWidgets(): - # Handle PlaylistDetailsModal - if (isinstance(widget, PlaylistDetailsModal) and - hasattr(widget, 'playlist') and - widget.playlist.id == playlist_id and - widget.isVisible()): - # Update the modal's error display - widget.on_sync_error(playlist_id, error_msg) - - # Handle YouTubeDownloadMissingTracksModal - elif (isinstance(widget, YouTubeDownloadMissingTracksModal) and - hasattr(widget, 'playlist') and - widget.playlist.id == playlist_id): - # Update the YouTube modal's error display (even if hidden) - widget.on_sync_error(playlist_id, error_msg) - - # Add these three methods inside the SyncPage class - def find_playlist_item_widget(self, playlist_id): - """Finds the PlaylistItem widget in the UI that corresponds to a given playlist ID.""" - for i in range(self.playlist_layout.count()): - item = self.playlist_layout.itemAt(i) - widget = item.widget() - if isinstance(widget, PlaylistItem) and widget.playlist.id == playlist_id: - return widget - return None - - def on_download_process_started(self, playlist_id, playlist_item_widget): - """Disables refresh button and updates the playlist item UI.""" - print(f"Download process started for playlist: {playlist_id}. Disabling refresh.") - self.active_download_processes[playlist_id] = playlist_item_widget - playlist_item_widget.show_operation_status() - - # Use centralized refresh button management - self.update_refresh_button_state() - # --- FIX: Connect the finished signal from the modal --- - # This ensures that when the modal is finished (or cancelled), the cleanup function is called. - if playlist_item_widget.download_modal: - playlist_item_widget.download_modal.process_finished.connect( - lambda: self.on_download_process_finished(playlist_id) - ) - - def on_download_process_finished(self, playlist_id): - """Re-enables refresh button if no other downloads are active.""" - print(f"Download process finished or cancelled for playlist: {playlist_id}.") - - # Skip refresh button updates for YouTube workflows (they don't affect Spotify playlist refresh) - if playlist_id.startswith("youtube_"): - print(f"Ignoring YouTube workflow finish for refresh button: {playlist_id}") - return - - # Clear download modal reference even if not in active_download_processes - playlist_item_widget = None - if playlist_id in self.active_download_processes: - playlist_item_widget = self.active_download_processes.pop(playlist_id) - else: - # Find the playlist item widget even if not in active processes - playlist_item_widget = self.find_playlist_item_widget(playlist_id) - - # --- FIX: Reset the UI state of the playlist item --- - if playlist_item_widget: - playlist_item_widget.download_modal = None - playlist_item_widget.hide_operation_status() - - if not self.active_download_processes: - print("All download processes finished. Re-enabling refresh.") - # Use centralized refresh button management - self.update_refresh_button_state() - - - def showEvent(self, event): - """Auto-load playlists when page becomes visible (but not during app startup)""" - super().showEvent(event) - - # Only auto-load once and only if we have a spotify client - if (not self.playlists_loaded and - self.spotify_client and - self.spotify_client.is_authenticated()): - - # Small delay to ensure UI is fully rendered - QTimer.singleShot(100, self.auto_load_playlists) - - def auto_load_playlists(self): - """Auto-load playlists with proper UI transition""" - # Clear the welcome state first - self.clear_playlists() - - # Clear selection state when auto-loading - self.selected_playlists.clear() - self.update_selection_ui() - - # Start loading (this will set playlists_loaded = True) - self.load_playlists_async() - - def show_initial_state(self): - """Show initial state with option to load playlists""" - # Add welcome message to playlist area - welcome_message = QLabel("Ready to sync playlists!\nClick 'Load Playlists' to get started.") - welcome_message.setAlignment(Qt.AlignmentFlag.AlignCenter) - welcome_message.setStyleSheet(""" - QLabel { - color: #b3b3b3; - font-size: 16px; - padding: 60px; - background: #282828; - border-radius: 12px; - border: 1px solid #404040; - line-height: 1.5; - } - """) - - # Add load button - load_btn = QPushButton("Load Playlists") - load_btn.setFixedSize(200, 50) - load_btn.clicked.connect(self.load_playlists_async) - load_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 25px; - color: #000000; - font-size: 14px; - font-weight: bold; - margin-top: 20px; - } - QPushButton:hover { - background: #1ed760; - } - """) - - # Add them to the playlist layout - if hasattr(self, 'playlist_layout'): - self.playlist_layout.addWidget(welcome_message) - self.playlist_layout.addWidget(load_btn) - self.playlist_layout.addStretch() - - def setup_ui(self): - self.setStyleSheet(""" - SyncPage { - background: #191414; - } - """) - - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(30, 30, 30, 30) - main_layout.setSpacing(25) - - # Header - header = self.create_header() - main_layout.addWidget(header) - - # Content area - content_layout = QHBoxLayout() - content_layout.setSpacing(15) # Reduced from 25 to 15 for tighter spacing - - # Left side - Tabbed playlist section - playlist_section = self.create_tabbed_playlist_section() - content_layout.addWidget(playlist_section, 2) - - # Right side - Options and actions - right_sidebar = self.create_right_sidebar() - content_layout.addWidget(right_sidebar, 1) - - main_layout.addLayout(content_layout, 1) # Allow content to stretch - - def create_header(self): - header = QWidget() - layout = QVBoxLayout(header) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - - # Title - title_label = QLabel("Playlist Sync") - title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #ffffff;") - - # Subtitle - # Get active server name for subtitle - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = active_server.title() if active_server else "Plex" - except: - server_name = "Plex" - - subtitle_label = QLabel(f"Synchronize your Spotify playlists with {server_name}") - subtitle_label.setFont(QFont("Arial", 14)) - subtitle_label.setStyleSheet("color: #b3b3b3;") - - layout.addWidget(title_label) - layout.addWidget(subtitle_label) - - return header - - def create_playlist_section(self): - section = QWidget() - layout = QVBoxLayout(section) - layout.setSpacing(15) - - # Section header - header_layout = QHBoxLayout() - - section_title = QLabel("Spotify Playlists") - section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - section_title.setStyleSheet("color: #ffffff;") - - self.refresh_btn = QPushButton("Refresh") - self.refresh_btn.setFixedSize(100, 35) - self.refresh_btn.clicked.connect(self.load_playlists_async) - self.refresh_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 17px; - color: #000000; - font-size: 11px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:pressed { - background: #1aa34a; - } - """) - - header_layout.addWidget(section_title) - header_layout.addStretch() - header_layout.addWidget(self.refresh_btn) - - # Playlist container - playlist_container = QScrollArea() - playlist_container.setWidgetResizable(True) - playlist_container.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - } - QScrollBar:vertical { - background: #282828; - width: 8px; - border-radius: 4px; - } - QScrollBar::handle:vertical { - background: #1db954; - border-radius: 4px; - } - """) - - self.playlist_widget = QWidget() - self.playlist_layout = QVBoxLayout(self.playlist_widget) - self.playlist_layout.setSpacing(10) - - # Playlists will be loaded asynchronously after UI setup - - self.playlist_layout.addStretch() - playlist_container.setWidget(self.playlist_widget) - - layout.addLayout(header_layout) - layout.addWidget(playlist_container) - - return section - - def create_tabbed_playlist_section(self): - """Create tabbed section with Spotify and YouTube playlist tabs""" - section = QWidget() - layout = QVBoxLayout(section) - layout.setSpacing(0) - - # Create tab widget - self.playlist_tabs = QTabWidget() - self.playlist_tabs.setStyleSheet(""" - QTabWidget::pane { - border: 1px solid #404040; - border-radius: 8px; - background: #282828; - margin: 0px; - } - QTabWidget::tab-bar { - alignment: center; - } - QTabBar::tab { - background: #181818; - color: #b3b3b3; - border: 1px solid #404040; - border-bottom: none; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - padding: 12px 24px; - margin-right: 2px; - font-size: 13px; - font-weight: bold; - min-width: 120px; - } - QTabBar::tab:selected { - background: #1db954; - color: #000000; - border-color: #1db954; - } - QTabBar::tab:hover:!selected { - background: #404040; - color: #ffffff; - } - """) - - # Create Spotify tab (move existing functionality here) - spotify_tab = self.create_spotify_playlist_tab() - self.playlist_tabs.addTab(spotify_tab, "Spotify Playlists") - - # Create Tidal tab - tidal_tab = self.create_tidal_playlist_tab() - self.playlist_tabs.addTab(tidal_tab, "Tidal Playlists") - - # Create YouTube tab (placeholder for now) - youtube_tab = self.create_youtube_playlist_tab() - self.playlist_tabs.addTab(youtube_tab, "YouTube Playlists") - - # Set default to Spotify tab - self.playlist_tabs.setCurrentIndex(0) - - layout.addWidget(self.playlist_tabs) - - return section - - def create_spotify_playlist_tab(self): - """Create the Spotify playlist tab (existing functionality)""" - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(15) - layout.setContentsMargins(15, 15, 15, 15) - - # Section header (same as before) - header_layout = QHBoxLayout() - - section_title = QLabel("Your Spotify Playlists") - section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - section_title.setStyleSheet("color: #ffffff;") - - self.refresh_btn = QPushButton("Refresh") - self.refresh_btn.setFixedSize(100, 35) - self.refresh_btn.clicked.connect(self.load_playlists_async) - self.refresh_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 17px; - color: #000000; - font-size: 11px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:pressed { - background: #1aa34a; - } - """) - - header_layout.addWidget(section_title) - header_layout.addStretch() - header_layout.addWidget(self.refresh_btn) - - # Playlist container (same as before) - playlist_container = QScrollArea() - playlist_container.setWidgetResizable(True) - playlist_container.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - } - QScrollBar:vertical { - background: #282828; - width: 8px; - border-radius: 4px; - } - QScrollBar::handle:vertical { - background: #1db954; - border-radius: 4px; - } - """) - - self.playlist_widget = QWidget() - self.playlist_layout = QVBoxLayout(self.playlist_widget) - self.playlist_layout.setSpacing(10) - - # Playlists will be loaded asynchronously after UI setup - - self.playlist_layout.addStretch() - playlist_container.setWidget(self.playlist_widget) - - layout.addLayout(header_layout) - layout.addWidget(playlist_container) - - return tab - - def create_tidal_playlist_tab(self): - """Create the Tidal playlist tab (similar to Spotify but opens discovery modal)""" - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(15) - layout.setContentsMargins(15, 15, 15, 15) - - # Section header - header_layout = QHBoxLayout() - - section_title = QLabel("Your Tidal Playlists") - section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - section_title.setStyleSheet("color: #ffffff;") - - self.tidal_refresh_btn = QPushButton("Refresh") - self.tidal_refresh_btn.setFixedSize(100, 35) - self.tidal_refresh_btn.clicked.connect(self.load_tidal_playlists_async) - self.tidal_refresh_btn.setStyleSheet(""" - QPushButton { - background: #ff6600; - border: none; - border-radius: 17px; - color: #ffffff; - font-size: 11px; - font-weight: bold; - } - QPushButton:hover { - background: #ff7700; - } - QPushButton:pressed { - background: #e55500; - } - QPushButton:disabled { - background: #666666; - color: #999999; - } - """) - - header_layout.addWidget(section_title) - header_layout.addStretch() - header_layout.addWidget(self.tidal_refresh_btn) - - # Playlist area (scrollable) - playlist_container = QScrollArea() - playlist_container.setWidgetResizable(True) - playlist_container.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - playlist_container.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - playlist_container.setStyleSheet(""" - QScrollArea { - border: none; - background: transparent; - } - QScrollBar:vertical { - background: #2a2a2a; - width: 12px; - border-radius: 6px; - } - QScrollBar::handle:vertical { - background: #555555; - border-radius: 6px; - min-height: 20px; - } - QScrollBar::handle:vertical:hover { - background: #666666; - } - """) - - # This will hold all playlist items - self.tidal_playlist_widget = QWidget() - self.tidal_playlist_layout = QVBoxLayout(self.tidal_playlist_widget) - self.tidal_playlist_layout.setSpacing(8) - self.tidal_playlist_layout.setContentsMargins(0, 0, 0, 0) - self.tidal_playlist_layout.addStretch() # Push items to top - - playlist_container.setWidget(self.tidal_playlist_widget) - - layout.addLayout(header_layout) - layout.addWidget(playlist_container) - - return tab - - def create_youtube_playlist_tab(self): - """Create the YouTube playlist tab (placeholder for future implementation)""" - tab = QWidget() - layout = QVBoxLayout(tab) - layout.setSpacing(20) - layout.setContentsMargins(15, 15, 15, 15) - - # Header - header_label = QLabel("YouTube Music Playlists") - header_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - header_label.setStyleSheet("color: #ffffff;") - - # URL input section - url_section = QFrame() - url_section.setStyleSheet(""" - QFrame { - background: #181818; - border: 1px solid #404040; - border-radius: 8px; - padding: 20px; - } - """) - - url_layout = QVBoxLayout(url_section) - url_layout.setSpacing(15) - - url_label = QLabel("Paste YouTube Music Playlist URL:") - url_label.setFont(QFont("Arial", 12)) - url_label.setStyleSheet("color: #b3b3b3;") - - self.youtube_url_input = QLineEdit() - self.youtube_url_input.setPlaceholderText("https://music.youtube.com/playlist?list=...") - self.youtube_url_input.setStyleSheet(""" - QLineEdit { - background: #282828; - border: 1px solid #404040; - border-radius: 6px; - padding: 12px; - color: #ffffff; - font-size: 12px; - } - QLineEdit:focus { - border-color: #1db954; - } - """) - - self.parse_btn = QPushButton("Parse Playlist") - self.parse_btn.setFixedHeight(40) - self.parse_btn.clicked.connect(self.parse_youtube_playlist) - self.parse_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 20px; - color: #000000; - font-size: 12px; - font-weight: bold; - } - QPushButton:hover { - background: #1ed760; - } - QPushButton:pressed { - background: #1aa34a; - } - QPushButton:disabled { - background: #404040; - color: #666666; - } - """) - - url_layout.addWidget(url_label) - url_layout.addWidget(self.youtube_url_input) - url_layout.addWidget(self.parse_btn) - - # Content area that will show placeholder or status widget - self.youtube_content_area = QFrame() - self.youtube_content_area.setStyleSheet(""" - QFrame { - background: #181818; - border: 1px solid #404040; - border-radius: 8px; - } - """) - - self.youtube_content_layout = QVBoxLayout(self.youtube_content_area) - self.youtube_content_layout.setContentsMargins(20, 20, 20, 20) - - # Initial placeholder content - self.show_youtube_placeholder() - - - # Add everything to main layout - layout.addWidget(header_label) - layout.addWidget(url_section) - layout.addWidget(self.youtube_content_area, 1) # Stretch to fill remaining space - - return tab - - def show_youtube_placeholder(self): - """Show the placeholder content in YouTube tab""" - # Clear existing content - for i in reversed(range(self.youtube_content_layout.count())): - child = self.youtube_content_layout.itemAt(i).widget() - if child: - child.setParent(None) - - placeholder_label = QLabel("YouTube playlist tracks will appear here") - placeholder_label.setFont(QFont("Arial", 12)) - placeholder_label.setStyleSheet("color: #666666;") - placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.youtube_content_layout.addStretch() # Add stretch before to center - self.youtube_content_layout.addWidget(placeholder_label) - self.youtube_content_layout.addStretch() # Add stretch after to center - - def show_youtube_download_status(self, playlist_name, track_count, playlist_id=None): - """Show download status widget in YouTube tab - styled like PlaylistItem""" - print(f"show_youtube_download_status called with playlist_id: {playlist_id}") - - if playlist_id is None: - playlist_id = f"youtube_{hash(playlist_name)}" - - # If a status widget for this playlist already exists, do nothing. - if playlist_id in self.youtube_status_widgets: - print(f"Status widget for {playlist_id} already exists. No action taken.") - return - - # --- THE FIX --- - # The destructive loop that cleared the layout has been removed. - # By the time this function is called, the placeholder is already gone - # and the main card container is in place. There is no need to clear anything. - # This function now only ADDS the status widget, preserving the main card. - - # Create playlist-style status widget (the "green card") - status_widget = QFrame() - status_widget.setFixedHeight(80) - status_widget.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - QFrame:hover { - background: #333333; - border: 1px solid #1db954; - } - """) - status_widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - - layout = QHBoxLayout(status_widget) - layout.setContentsMargins(20, 15, 20, 15) - layout.setSpacing(15) - - # Status icon (instead of checkbox) - status_icon = QLabel("") - status_icon.setFont(QFont("Arial", 18)) - status_icon.setFixedSize(22, 22) - status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Content section (playlist name and info) - content_layout = QVBoxLayout() - content_layout.setSpacing(5) - - name_label = QLabel(playlist_name) - name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - name_label.setStyleSheet("color: #ffffff;") - - info_layout = QHBoxLayout() - info_layout.setSpacing(20) - - track_label = QLabel(f"{track_count} tracks") - track_label.setFont(QFont("Arial", 10)) - track_label.setStyleSheet("color: #b3b3b3;") - - status_label = QLabel("Downloading...") - status_label.setFont(QFont("Arial", 10)) - status_label.setStyleSheet("color: #1db954;") - - info_layout.addWidget(track_label) - info_layout.addWidget(status_label) - info_layout.addStretch() - - content_layout.addWidget(name_label) - content_layout.addLayout(info_layout) - - # View Progress button - view_progress_btn = QPushButton("View Progress") - view_progress_btn.setFixedSize(120, 30) - view_progress_btn.clicked.connect(lambda: self.open_youtube_download_modal(playlist_id)) - view_progress_btn.setStyleSheet(""" - QPushButton { - background: transparent; - border: 1px solid #1db954; - border-radius: 15px; - color: #1db954; - font-size: 10px; - font-weight: bold; - } - QPushButton:hover { - background: #1db954; - color: #000000; - } - """) - - layout.addWidget(status_icon) - layout.addLayout(content_layout) - layout.addStretch() - layout.addWidget(view_progress_btn) - - # Store widget reference and add it to the top of the layout - self.youtube_status_widgets[playlist_id] = status_widget - self.youtube_content_layout.insertWidget(0, status_widget) - - - def open_youtube_download_modal(self, playlist_id): - """Open the YouTube download modal when View Progress button is clicked""" - print(f"Attempting to open modal for playlist_id: {playlist_id}") - print(f"Available modals: {list(self.active_youtube_download_modals.keys())}") - - if playlist_id in self.active_youtube_download_modals: - modal = self.active_youtube_download_modals[playlist_id] - print(f"Found modal, opening...") - modal.show() - modal.raise_() - modal.activateWindow() - else: - print(f"No modal found for playlist_id: {playlist_id}") - - def create_right_sidebar(self): - section = QWidget() - layout = QVBoxLayout(section) - layout.setSpacing(20) - - # Action buttons - actions_frame = QFrame() - actions_frame.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - actions_layout = QVBoxLayout(actions_frame) - actions_layout.setContentsMargins(20, 20, 20, 20) - actions_layout.setSpacing(15) - - # Selection info label - self.selection_info = QLabel("Select playlists to sync") - self.selection_info.setFont(QFont("Arial", 12)) - self.selection_info.setStyleSheet("color: #b3b3b3;") - self.selection_info.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Sync button (initially disabled) - self.start_sync_btn = QPushButton("Start Sync") - self.start_sync_btn.setFixedHeight(45) - self.start_sync_btn.setEnabled(False) # Disabled by default - self.start_sync_btn.clicked.connect(self.start_selected_playlist_sync) - self.start_sync_btn.setStyleSheet(""" - QPushButton { - background: #1db954; - border: none; - border-radius: 22px; - color: #000000; - font-size: 14px; - font-weight: bold; - } - QPushButton:hover:enabled { - background: #1ed760; - } - QPushButton:pressed:enabled { - background: #1aa34a; - } - QPushButton:disabled { - background: #404040; - color: #666666; - } - """) - - actions_layout.addWidget(self.selection_info) - actions_layout.addWidget(self.start_sync_btn) - - layout.addWidget(actions_frame) - - # Progress section below buttons - progress_section = self.create_progress_section() - layout.addWidget(progress_section, 1) # Allow progress section to stretch - - return section - - def create_progress_section(self): - section = QFrame() - section.setMinimumHeight(200) # Set minimum height instead of fixed - section.setStyleSheet(""" - QFrame { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - - layout = QVBoxLayout(section) - layout.setContentsMargins(20, 15, 20, 15) - layout.setSpacing(10) - - # Progress header - progress_header = QLabel("Sync Progress") - progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold)) - progress_header.setStyleSheet("color: #ffffff;") - - # Progress bar - self.progress_bar = QProgressBar() - self.progress_bar.setFixedHeight(8) - self.progress_bar.setStyleSheet(""" - QProgressBar { - border: none; - border-radius: 4px; - background: #404040; - } - QProgressBar::chunk { - background: #1db954; - border-radius: 4px; - } - """) - - # Progress text - self.progress_text = QLabel("Ready to sync...") - self.progress_text.setFont(QFont("Arial", 11)) - self.progress_text.setStyleSheet("color: #b3b3b3;") - - # Log area - self.log_area = QTextEdit() - self.log_area.setMinimumHeight(80) # Set minimum height instead of maximum - - # Override append method to limit to 200 lines - original_append = self.log_area.append - def limited_append(text): - original_append(text) - # Keep only last 200 lines - text_content = self.log_area.toPlainText() - lines = text_content.split('\n') - if len(lines) > 200: - trimmed_lines = lines[-200:] - self.log_area.setPlainText('\n'.join(trimmed_lines)) - # Move cursor to end - cursor = self.log_area.textCursor() - cursor.movePosition(cursor.MoveOperation.End) - self.log_area.setTextCursor(cursor) - self.log_area.append = limited_append - - self.log_area.setStyleSheet(""" - QTextEdit { - background: #181818; - border: 1px solid #404040; - border-radius: 4px; - color: #ffffff; - font-size: 10px; - font-family: monospace; - } - """) - self.log_area.setPlainText("Waiting for sync to start...") - - layout.addWidget(progress_header) - layout.addWidget(self.progress_bar) - layout.addWidget(self.progress_text) - layout.addWidget(self.log_area, 1) # Allow log area to stretch - - return section - - def load_playlists_async(self): - """Start asynchronous playlist loading""" - if self.playlist_loader and self.playlist_loader.isRunning(): - return - - # Mark as loaded to prevent duplicate auto-loading - self.playlists_loaded = True - - # Clear existing playlists - self.clear_playlists() - - # Clear selection state when refreshing - self.selected_playlists.clear() - self.update_selection_ui() - - # Add loading placeholder - loading_label = QLabel("Loading playlists...") - loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - loading_label.setStyleSheet(""" - QLabel { - color: #b3b3b3; - font-size: 14px; - padding: 40px; - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - self.playlist_layout.insertWidget(0, loading_label) - - # Show loading state - self.refresh_btn.setText("Loading...") - self.refresh_btn.setEnabled(False) - self.log_area.append("Starting playlist loading...") - - # Create and start loader thread - self.playlist_loader = PlaylistLoaderThread(self.spotify_client) - self.playlist_loader.playlist_loaded.connect(self.add_playlist_to_ui) - self.playlist_loader.loading_finished.connect(self.on_loading_finished) - self.playlist_loader.loading_failed.connect(self.on_loading_failed) - self.playlist_loader.progress_updated.connect(self.update_progress) - self.playlist_loader.start() - - def add_playlist_to_ui(self, playlist): - """Add a single playlist to the UI as it's loaded""" - # Start with simple sync status to avoid datetime operations during loading - sync_status = "Checking..." - item = PlaylistItem(playlist.name, playlist.total_tracks, sync_status, playlist, self) - - # Queue sync status update for after UI creation - QTimer.singleShot(0, lambda: self.update_playlist_sync_status(item, playlist)) - item.view_details_clicked.connect(self.show_playlist_details) - - # Add subtle fade-in animation - item.setStyleSheet(item.styleSheet() + "background: rgba(40, 40, 40, 0);") - - # Insert before the stretch item - self.playlist_layout.insertWidget(self.playlist_layout.count() - 1, item) - self.current_playlists.append(playlist) - - # Animate the item appearing - self.animate_item_fade_in(item) - - # Update log - self.log_area.append(f"Added playlist: {playlist.name} ({playlist.total_tracks} tracks)") - - def update_playlist_sync_status(self, playlist_item, playlist): - """Update playlist sync status after UI creation to avoid blocking""" - try: - sync_info = self.sync_statuses.get(playlist.id) - sync_status = "Never Synced" - - if sync_info and 'last_synced' in sync_info: - current_snapshot_id = getattr(playlist, 'snapshot_id', None) - stored_snapshot_id = sync_info.get('snapshot_id') - - if current_snapshot_id and stored_snapshot_id and current_snapshot_id != stored_snapshot_id: - sync_status = "Needs Sync" - else: - try: - last_synced_dt = datetime.fromisoformat(sync_info['last_synced']) - sync_status = f"Synced: {last_synced_dt.strftime('%b %d, %H:%M')}" - except (ValueError, KeyError): - sync_status = "Synced (legacy)" - - # Update the playlist item's sync status - playlist_item.update_sync_status_text(sync_status) - except Exception as e: - # Fallback to simple status if anything goes wrong - playlist_item.update_sync_status_text("Unknown") - - def animate_item_fade_in(self, item): - """Add a subtle fade-in animation to playlist items""" - # Start with reduced opacity - item.setStyleSheet(""" - PlaylistItem { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - opacity: 0.3; - } - PlaylistItem:hover { - background: #333333; - border: 1px solid #1db954; - } - """) - - # Animate to full opacity after a short delay - QTimer.singleShot(50, lambda: item.setStyleSheet(""" - PlaylistItem { - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - PlaylistItem:hover { - background: #333333; - border: 1px solid #1db954; - } - """)) - - def on_loading_finished(self, count): - """Handle completion of playlist loading""" - # Remove loading placeholder if it exists - for i in range(self.playlist_layout.count()): - item = self.playlist_layout.itemAt(i) - if item and item.widget() and isinstance(item.widget(), QLabel): - if "Loading playlists" in item.widget().text(): - item.widget().deleteLater() - break - - self.refresh_btn.setText("Refresh") - self.refresh_btn.setEnabled(True) - self.log_area.append(f"Loaded {count} Spotify playlists successfully") - - # Start background preloading of tracks for smaller playlists - self.start_background_preloading() - - def start_background_preloading(self): - """Start background preloading of tracks for smaller playlists""" - if not self.spotify_client: - return - - # Preload tracks for playlists with < 100 tracks to improve responsiveness - for playlist in self.current_playlists: - if (playlist.total_tracks < 100 and - playlist.id not in self.track_cache and - not playlist.tracks): - - # Create background worker - worker = TrackLoadingWorker(self.spotify_client, playlist.id, playlist.name) - worker.signals.tracks_loaded.connect(self.on_background_tracks_loaded) - # Don't connect error signals for background loading to avoid spam - - # Submit with low priority - self.thread_pool.start(worker) - - # Add delay between requests to be nice to Spotify API - QTimer.singleShot(2000, lambda: None) # 2 second delay - - def on_background_tracks_loaded(self, playlist_id, tracks): - """Handle background track loading completion""" - # Cache the tracks for future use - self.track_cache[playlist_id] = tracks - - # Update the playlist object if we can find it - for playlist in self.current_playlists: - if playlist.id == playlist_id: - playlist.tracks = tracks - break - - def on_loading_failed(self, error_msg): - """Handle playlist loading failure""" - # Remove loading placeholder if it exists - for i in range(self.playlist_layout.count()): - item = self.playlist_layout.itemAt(i) - if item and item.widget() and isinstance(item.widget(), QLabel): - if "Loading playlists" in item.widget().text(): - item.widget().deleteLater() - break - - self.refresh_btn.setText("Refresh") - self.refresh_btn.setEnabled(True) - self.log_area.append(f"Failed to load playlists: {error_msg}") - QMessageBox.critical(self, "Error", f"Failed to load playlists: {error_msg}") - - def update_progress(self, message): - """Update progress text""" - self.log_area.append(message) - - def load_tidal_playlists_async(self): - """Start asynchronous Tidal playlist loading""" - if self.tidal_playlist_loader and self.tidal_playlist_loader.isRunning(): - return - - # Complete cleanup of all Tidal operations before refresh - self.cleanup_all_tidal_operations() - - # Clear existing Tidal playlists - self.clear_tidal_playlists() - - # Add loading placeholder - loading_label = QLabel("Loading Tidal playlists...") - loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - loading_label.setStyleSheet(""" - QLabel { - color: #b3b3b3; - font-size: 14px; - padding: 40px; - background: #282828; - border-radius: 8px; - border: 1px solid #404040; - } - """) - self.tidal_playlist_layout.insertWidget(0, loading_label) - - # Show loading state - self.tidal_refresh_btn.setText("Loading...") - self.tidal_refresh_btn.setEnabled(False) - self.log_area.append("Starting Tidal playlist loading...") - - # Create and start loader thread - self.tidal_playlist_loader = TidalPlaylistLoaderThread(self.tidal_client) - self.tidal_playlist_loader.playlist_loaded.connect(self.add_tidal_playlist_to_ui) - self.tidal_playlist_loader.loading_finished.connect(self.on_tidal_loading_finished) - self.tidal_playlist_loader.loading_failed.connect(self.on_tidal_loading_failed) - self.tidal_playlist_loader.progress_updated.connect(self.update_progress) - self.tidal_playlist_loader.start() - - def add_tidal_playlist_to_ui(self, playlist): - """Add a single Tidal playlist to the UI as it's loaded""" - # Create a TidalPlaylistCard that matches YouTube card workflow - card = TidalPlaylistCard(playlist.id, playlist.name, len(playlist.tracks) if hasattr(playlist, 'tracks') else 0, self) - - # Store card reference - self.tidal_cards[playlist.id] = card - - # Initialize state tracking - self.tidal_playlist_states[playlist.id] = { - 'phase': 'discovering', - 'playlist_data': None, - 'discovered_tracks': [], - 'card': card, - 'discovery_modal': None, - 'download_modal': None, - 'original_name': playlist.name, # Store original name for resets - 'original_track_count': len(playlist.tracks) if hasattr(playlist, 'tracks') else 0 - } - - # Add to layout and store reference - self.tidal_playlist_layout.insertWidget(self.tidal_playlist_layout.count() - 1, card) - self.current_tidal_playlists.append(playlist) - - # Connect to click handler (new card-based system) - card.card_clicked.connect(self.on_tidal_card_clicked) - - def on_tidal_loading_finished(self, count): - """Handle completion of Tidal playlist loading""" - # Remove loading placeholder if it exists - for i in range(self.tidal_playlist_layout.count()): - item = self.tidal_playlist_layout.itemAt(i) - if item and item.widget() and isinstance(item.widget(), QLabel): - if "Loading Tidal playlists" in item.widget().text(): - item.widget().deleteLater() - break - - self.tidal_refresh_btn.setText("Refresh") - self.tidal_refresh_btn.setEnabled(True) - self.log_area.append(f"Loaded {count} Tidal playlists successfully") - - def on_tidal_loading_failed(self, error_msg): - """Handle Tidal playlist loading failure""" - # Remove loading placeholder if it exists - for i in range(self.tidal_playlist_layout.count()): - item = self.tidal_playlist_layout.itemAt(i) - if item and item.widget() and isinstance(item.widget(), QLabel): - if "Loading Tidal playlists" in item.widget().text(): - item.widget().deleteLater() - break - - self.tidal_refresh_btn.setText("Refresh") - self.tidal_refresh_btn.setEnabled(True) - self.log_area.append(f"Failed to load Tidal playlists: {error_msg}") - QMessageBox.critical(self, "Error", f"Failed to load Tidal playlists: {error_msg}") - - def cleanup_all_tidal_operations(self): - """Complete cleanup of all Tidal operations - stop workers, close modals, cancel syncs""" - print("Starting complete Tidal cleanup for refresh...") - - # Close and cleanup all active Tidal modals - for playlist_id, state in list(self.tidal_playlist_states.items()): - # Close discovery modals - discovery_modal = state.get('discovery_modal') - if discovery_modal: - print(f"Closing Tidal discovery modal for playlist_id: {playlist_id}") - try: - # Cancel any active workers in the discovery modal - if hasattr(discovery_modal, 'spotify_worker') and discovery_modal.spotify_worker: - discovery_modal.spotify_worker.cancel() - discovery_modal.spotify_worker = None - - # Cancel any active sync operations - if hasattr(discovery_modal, 'sync_in_progress') and discovery_modal.sync_in_progress: - if hasattr(self, 'cancel_playlist_sync') and hasattr(discovery_modal, 'playlist'): - self.cancel_playlist_sync(discovery_modal.playlist.id) - - # Force close the modal - discovery_modal.close() - except Exception as e: - print(f"Error closing discovery modal: {e}") - - # Close download modals - download_modal = state.get('download_modal') - if download_modal: - print(f"Closing Tidal download modal for playlist_id: {playlist_id}") - try: - # Cancel all operations (downloads, searches, etc.) - download_modal.cancel_operations() - - # Cancel any additional search workers that might be running - if hasattr(download_modal, 'parallel_search_tracking'): - download_modal.parallel_search_tracking.clear() - - # Stop any active timers - if hasattr(download_modal, 'download_status_timer'): - download_modal.download_status_timer.stop() - - # Clear any queued operations - if hasattr(download_modal, 'active_downloads'): - download_modal.active_downloads.clear() - - # Force close the modal - download_modal.close() - except Exception as e: - print(f"Error closing download modal: {e}") - - # Cancel any active sync workers for Tidal playlists - tidal_playlist_ids = set() - for playlist_id, state in self.tidal_playlist_states.items(): - playlist_data = state.get('playlist_data') - if playlist_data and hasattr(playlist_data, 'id'): - tidal_playlist_ids.add(playlist_data.id) - - # Cancel sync workers - for playlist_id in tidal_playlist_ids: - if playlist_id in self.active_sync_workers: - print(f"Cancelling sync worker for Tidal playlist_id: {playlist_id}") - try: - worker = self.active_sync_workers[playlist_id] - if hasattr(worker, 'cancel'): - worker.cancel() - del self.active_sync_workers[playlist_id] - except Exception as e: - print(f"Error cancelling sync worker: {e}") - - # Remove from active download modals (shared with YouTube) - for playlist_id in list(self.active_youtube_download_modals.keys()): - modal = self.active_youtube_download_modals[playlist_id] - if hasattr(modal, 'is_tidal_playlist') and modal.is_tidal_playlist: - print(f"Removing Tidal download modal from active list: {playlist_id}") - try: - del self.active_youtube_download_modals[playlist_id] - except Exception as e: - print(f"Error removing download modal: {e}") - - # Force cleanup of any remaining thread pool operations - # This ensures any lingering search workers are properly terminated - try: - thread_pool = QThreadPool.globalInstance() - # Note: QThreadPool doesn't have a direct "cancel all" method, - # but setting cancel_requested=True in the modals should make workers exit gracefully - print(f"Thread pool active count: {thread_pool.activeThreadCount()}") - if thread_pool.activeThreadCount() > 0: - print("Waiting briefly for thread pool workers to finish gracefully...") - # Give workers a moment to see the cancel_requested flag and exit - from PyQt6.QtCore import QTimer, QEventLoop - loop = QEventLoop() - QTimer.singleShot(500, loop.quit) # 500ms timeout - loop.exec() - except Exception as e: - print(f"Error during thread pool cleanup: {e}") - - print("Tidal cleanup complete") - - def clear_tidal_playlists(self): - """Clear all Tidal playlist items from UI""" - for i in reversed(range(self.tidal_playlist_layout.count())): - layout_item = self.tidal_playlist_layout.itemAt(i) - if layout_item: - widget = layout_item.widget() - if widget: - # Remove TidalPlaylistCard widgets and skip static UI elements - # (like refresh buttons, labels, etc.) - if hasattr(widget, 'playlist_id') or isinstance(widget, TidalPlaylistCard): - widget.setParent(None) - self.current_tidal_playlists.clear() - - # Clear the state tracking as well - self.tidal_cards.clear() - self.tidal_playlist_states.clear() - - def on_tidal_card_clicked(self, playlist_id: str, phase: str): - """Handle Tidal playlist card clicks - route to appropriate modal (matches YouTube workflow)""" - print(f"Tidal card clicked: playlist_id={playlist_id}, Phase={phase}") - - state = self.get_tidal_playlist_state(playlist_id) - if not state: - print(f"No state found for playlist_id: {playlist_id}") - return - - # Route to appropriate modal based on current phase - if phase in ['discovering', 'discovery_complete']: - self.open_or_create_tidal_discovery_modal(playlist_id, state) - elif phase in ['sync_complete', 'downloading', 'download_complete']: - # For sync_complete phase, open discovery modal with "Download Missing" button - if phase == 'sync_complete': - self.open_or_create_tidal_discovery_modal(playlist_id, state) - else: - # For downloading/download_complete phases, check if download modal actually exists - # If not, route back to discovery modal (handles case where download modal was closed) - playlist_data = state.get('playlist_data') - download_modal = state.get('download_modal') - if download_modal and not download_modal.isVisible(): - # Modal exists but is hidden - show it - print(f"Reopening hidden Tidal download modal for playlist_id: {playlist_id}") - download_modal.show() - download_modal.activateWindow() - download_modal.raise_() - elif download_modal and download_modal.isVisible(): - # Modal is already visible - bring to front - print(f"Bringing visible Tidal download modal to front for playlist_id: {playlist_id}") - download_modal.activateWindow() - download_modal.raise_() - else: - print(f"No download modal found, routing to discovery modal instead") - self.open_or_create_tidal_discovery_modal(playlist_id, state) - elif phase == 'syncing': - # Show sync progress - route to discovery modal - self.open_or_create_tidal_discovery_modal(playlist_id, state) - - def open_or_create_tidal_discovery_modal(self, playlist_id: str, state: dict): - """Open or create the discovery modal for a Tidal playlist""" - # Check if modal already exists and is visible - if state.get('discovery_modal') and state['discovery_modal'].isVisible(): - state['discovery_modal'].activateWindow() - state['discovery_modal'].raise_() - return - - # Check if modal exists but is hidden - reopen it - if state.get('discovery_modal') and not state['discovery_modal'].isVisible(): - print(f"Reopening existing hidden discovery modal for playlist_id: {playlist_id}") - state['discovery_modal'].show() - state['discovery_modal'].activateWindow() - state['discovery_modal'].raise_() - return - - # Check if we have playlist data already (discovery_complete state) - if state.get('playlist_data') and state['phase'] == 'discovery_complete': - print(f"Opening existing discovery modal with data for playlist_id: {playlist_id}") - - # Create a new modal with the existing data - dummy_playlist_item = type('DummyPlaylistItem', (), { - 'playlist_name': state['playlist_data'].name, - 'track_count': len(state['playlist_data'].tracks), - 'download_modal': None, - 'show_operation_status': lambda self, status_text="View Progress": None, - 'hide_operation_status': lambda self: None - })() - - # Create the discovery modal using the existing data - modal = YouTubeDownloadMissingTracksModal( - state['playlist_data'], - dummy_playlist_item, - self, - self.downloads_page - ) - - # Mark this as a Tidal workflow - modal.is_tidal_playlist = True - modal.tidal_playlist = state['playlist_data'] - modal.playlist_id = playlist_id # For state tracking - - # Store modal reference in state - state['discovery_modal'] = modal - - # Show the modal - modal.show() - modal.activateWindow() - modal.raise_() - return - - # Need to discover playlist data first - print(f"Need to discover playlist data for playlist_id: {playlist_id}") - - # Get playlist data if not cached - playlist_data = state.get('playlist_data') - if not playlist_data: - # Try to get playlist from current loaded playlists - playlist_data = None - for playlist in self.current_tidal_playlists: - if hasattr(playlist, 'id') and playlist.id == playlist_id: - playlist_data = playlist - break - - if not playlist_data: - print(f"Could not find playlist data for playlist_id: {playlist_id}") - return - - # Get full playlist data with tracks if not already loaded - if not hasattr(playlist_data, 'tracks') or not playlist_data.tracks: - try: - full_playlist = self.tidal_client.get_playlist(playlist_id) - if full_playlist and full_playlist.tracks: - playlist_data = full_playlist - else: - print(f"Failed to load tracks for Tidal playlist {playlist_id}") - QMessageBox.warning(self, "Error", f"Failed to load tracks for playlist") - return - except Exception as e: - print(f"Error loading Tidal playlist tracks: {e}") - QMessageBox.warning(self, "Error", f"Error loading playlist tracks: {str(e)}") - return - - # Create a dummy playlist item for the modal - dummy_playlist_item = type('DummyPlaylistItem', (), { - 'playlist_name': playlist_data.name, - 'track_count': len(playlist_data.tracks), - 'download_modal': None, - 'show_operation_status': lambda self, status_text="View Progress": None, - 'hide_operation_status': lambda self: None - })() - - # Create the discovery modal - modal = YouTubeDownloadMissingTracksModal( - playlist_data, - dummy_playlist_item, - self, - self.downloads_page - ) - - # Mark this as a Tidal workflow - modal.is_tidal_playlist = True - modal.tidal_playlist = playlist_data - modal.playlist_id = playlist_id # For state tracking - - # Store playlist data and modal reference in state - state['playlist_data'] = playlist_data - state['discovery_modal'] = modal - - # Show the modal - modal.show() - modal.activateWindow() - modal.raise_() - - print(f"Opened discovery modal for Tidal playlist '{playlist_data.name}' with {len(playlist_data.tracks)} tracks") - - def open_or_create_tidal_download_modal(self, playlist_id: str, state: dict): - """Open or create the download modal for a Tidal playlist""" - playlist_data = state.get('playlist_data') - if not playlist_data: - print(f"No playlist data found for download modal") - return - - # Check if download modal already exists - if hasattr(playlist_data, 'id') and playlist_data.id in self.active_youtube_download_modals: - modal = self.active_youtube_download_modals[playlist_data.id] - if modal.isVisible(): - modal.activateWindow() - modal.raise_() - return - else: - # Modal exists but is hidden - show it - modal.show() - modal.activateWindow() - modal.raise_() - return - - # Need to create new download modal - route back to discovery modal for now - print(f"No download modal found, routing to discovery modal") - self.open_or_create_tidal_discovery_modal(playlist_id, state) - - def on_tidal_playlist_clicked(self, playlist): - """Legacy method for old TidalPlaylistItem - route to card system""" - print(f"Legacy Tidal playlist clicked: {playlist.name} - routing to card system") - - # For now, create a temporary discovery modal (this should be replaced when cards are fully integrated) - # Get full playlist data with tracks if not already loaded - if not hasattr(playlist, 'tracks') or not playlist.tracks: - try: - full_playlist = self.tidal_client.get_playlist(playlist.id) - if full_playlist and full_playlist.tracks: - playlist = full_playlist - else: - print(f"Failed to load tracks for Tidal playlist {playlist.name}") - QMessageBox.warning(self, "Error", f"Failed to load tracks for playlist '{playlist.name}'") - return - except Exception as e: - print(f"Error loading Tidal playlist tracks: {e}") - QMessageBox.warning(self, "Error", f"Error loading playlist tracks: {str(e)}") - return - - # Create a dummy playlist item for the modal (similar to YouTube workflow) - dummy_playlist_item = type('DummyPlaylistItem', (), { - 'playlist_name': playlist.name, - 'track_count': len(playlist.tracks), - 'download_modal': None, - 'show_operation_status': lambda self, status_text="View Progress": None, - 'hide_operation_status': lambda self: None - })() - - # Create the discovery modal using the YouTube modal class - # (it works for any track discovery workflow) - modal = YouTubeDownloadMissingTracksModal( - playlist, - dummy_playlist_item, - self, - self.downloads_page - ) - - # Mark this as a Tidal workflow so it uses the Tidal discovery worker - modal.is_tidal_playlist = True - modal.tidal_playlist = playlist - - # Show the modal - modal.show() - modal.activateWindow() - modal.raise_() - - print(f"Opened discovery modal for Tidal playlist '{playlist.name}' with {len(playlist.tracks)} tracks") - - def disable_refresh_button(self, operation_name="Operation"): - """Disable refresh button during sync/download operations""" - self.refresh_btn.setEnabled(False) - self.refresh_btn.setText(f"{operation_name}...") - - def enable_refresh_button(self): - """Re-enable refresh button after operations complete""" - self.refresh_btn.setEnabled(True) - self.refresh_btn.setText("Refresh") - - def has_active_operations(self): - """Check if any sync or download operations are currently active""" - has_downloads = bool(self.active_download_processes) - has_individual_syncs = bool(self.active_sync_workers) - has_sequential_sync = self.is_sequential_syncing or self.sequential_sync_worker is not None - - print(f"DEBUG: Active operations check - downloads: {has_downloads}, individual syncs: {has_individual_syncs}, sequential: {has_sequential_sync}") - return has_downloads or has_individual_syncs or has_sequential_sync - - def update_refresh_button_state(self): - """Update refresh button state based on active operations""" - if self.has_active_operations(): - if self.is_sequential_syncing: - self.disable_refresh_button("Sequential Sync") - elif self.active_sync_workers: - self.disable_refresh_button("Sync") - elif self.active_download_processes: - self.disable_refresh_button("Download") - else: - self.enable_refresh_button() - - def load_initial_playlists(self): - """Load initial playlist data (placeholder or real)""" - if self.spotify_client and self.spotify_client.is_authenticated(): - self.refresh_playlists() - else: - # Show placeholder playlists - playlists = [ - ("Liked Songs", 247, "Synced"), - ("Discover Weekly", 30, "Needs Sync"), - ("Chill Vibes", 89, "Synced"), - ("Workout Mix", 156, "Needs Sync"), - ("Road Trip", 67, "Never Synced"), - ("Focus Music", 45, "Synced") - ] - - for name, count, status in playlists: - item = PlaylistItem(name, count, status, None, self) # Set parent for placeholders too - self.playlist_layout.addWidget(item) - - def refresh_playlists(self): - """Refresh playlists from Spotify API using async loader""" - if not self.spotify_client: - QMessageBox.warning(self, "Error", "Spotify client not available") - return - - if not self.spotify_client.is_authenticated(): - QMessageBox.warning(self, "Error", "Spotify not authenticated. Please check your settings.") - return - - # Use the async loader - self.load_playlists_async() - - def show_playlist_details(self, playlist): - """Show playlist details modal""" - if playlist: - modal = PlaylistDetailsModal(playlist, self) - modal.show() - - def clear_playlists(self): - """Clear all playlist items from the layout""" - # Clear the current playlists list - self.current_playlists = [] - - # Remove all items including welcome state - for i in reversed(range(self.playlist_layout.count())): - item = self.playlist_layout.itemAt(i) - if item.widget(): - item.widget().deleteLater() - elif item.spacerItem(): - continue # Keep the stretch spacer - else: - self.playlist_layout.removeItem(item) - - def parse_youtube_playlist(self): - """Parse YouTube playlist URL and create card immediately, then open discovery modal""" - url = self.youtube_url_input.text().strip() - - if not url: - self.show_youtube_error("Please enter a YouTube Music playlist URL") - return - - # Basic URL validation - if not ('youtube.com' in url or 'youtu.be' in url): - self.show_youtube_error("Please enter a valid YouTube Music playlist URL") - return - - # Check if this URL already has a card/state - if url in self.youtube_playlist_states: - # Card already exists - check if we need to reopen existing modal or create new one - state = self.get_youtube_playlist_state(url) - if state and state.get('discovery_modal') and state['discovery_modal'].isVisible(): - # Modal is already open, just bring it to front - state['discovery_modal'].activateWindow() - state['discovery_modal'].raise_() - return - elif state and state.get('playlist_data'): - # We have data but no visible modal - recreate modal with existing data - self.open_or_create_discovery_modal(url, state) - return - else: - # Card exists but no data yet - this means parsing was cancelled/failed - # Reset the card state and continue with new parsing - print(f"Resetting existing card state for URL: {url}") - self.reset_youtube_playlist_state(url) - - # Check if this URL is already being processed (legacy check) - if url in self.active_youtube_processes: - existing_modal = self.active_youtube_processes[url] - if existing_modal and not existing_modal.isHidden(): - # Modal is still open - bring it to front - existing_modal.show() - existing_modal.raise_() - existing_modal.activateWindow() - return - elif existing_modal: - # Modal exists but is hidden - reopen it - existing_modal.show() - existing_modal.raise_() - existing_modal.activateWindow() - return - else: - # Stale reference - clean it up - del self.active_youtube_processes[url] - - # Create YouTube playlist card immediately - card = self.create_youtube_playlist_card(url) - card.set_phase('discovering') - - # Show loading state - self.parse_btn.setEnabled(False) - self.parse_btn.setText("Parsing...") - - # Show modal immediately with loading state - self.show_youtube_modal_loading(url) - - # Store URL for later use in completion handlers - self.current_youtube_url = url - - # Start parsing in a separate thread to avoid blocking UI - self.youtube_worker = YouTubeParsingWorker(url) - self.youtube_worker.finished.connect(self.on_youtube_parsing_finished) - self.youtube_worker.error.connect(self.on_youtube_parsing_error) - self.youtube_worker.start() - - def show_youtube_modal_loading(self, url): - """Show the YouTube modal immediately with loading state""" - # Create a dummy playlist item widget (required by modal) - dummy_playlist_item = type('DummyPlaylistItem', (), { - 'playlist_name': "Loading...", - 'track_count': 0, - 'download_modal': None, - 'show_operation_status': lambda self, status_text="View Progress": None, - 'hide_operation_status': lambda self: None - })() - - # Create empty playlist for loading state - empty_playlist = type('Playlist', (), { - 'name': f"Parsing YouTube Playlist...", - 'tracks': [], - 'total_tracks': 0 - })() - - # Open the modal in loading state - print("Opening YouTubeDownloadMissingTracksModal in loading state...") - self.current_youtube_modal = YouTubeDownloadMissingTracksModal( - empty_playlist, - dummy_playlist_item, - self, - self.downloads_page - ) - - # Store URL in modal for cleanup purposes - self.current_youtube_modal.youtube_url = url - - # Register this modal for the URL to prevent duplicates - self.active_youtube_processes[url] = self.current_youtube_modal - - # Link modal with card state system - if url in self.youtube_playlist_states: - self.youtube_playlist_states[url]['discovery_modal'] = self.current_youtube_modal - - # Show a loading message in the modal - self.current_youtube_modal.show_loading_state() - self.current_youtube_modal.show() - - def on_youtube_parsing_finished(self, playlist): - """Handle successful YouTube playlist parsing""" - try: - print(f"Successfully parsed YouTube playlist: {playlist.name}") - print(f"Playlist ID: {playlist.id}") - - # Reset button state - self.parse_btn.setEnabled(True) - self.parse_btn.setText("Parse Playlist") - - # Update the card with discovered playlist info - if hasattr(self, 'current_youtube_url'): - url = self.current_youtube_url - - # Update card state and playlist info - self.set_youtube_card_playlist_data(url, playlist) - self.update_youtube_card_playlist_info(url, playlist.name, len(playlist.tracks)) - self.update_youtube_card_phase(url, 'discovery_complete') - - # Store modal reference in state - if url in self.youtube_playlist_states and hasattr(self, 'current_youtube_modal'): - self.youtube_playlist_states[url]['discovery_modal'] = self.current_youtube_modal - - # Update the existing modal with the parsed playlist data - print(f"Has current_youtube_modal: {hasattr(self, 'current_youtube_modal') and self.current_youtube_modal is not None}") - if hasattr(self, 'current_youtube_modal') and self.current_youtube_modal: - print(f"Calling populate_with_playlist_data...") - self.current_youtube_modal.populate_with_playlist_data(playlist) - else: - # Fallback: create new modal if loading modal wasn't created - dummy_playlist_item = type('DummyPlaylistItem', (), { - 'playlist_name': playlist.name, - 'track_count': len(playlist.tracks), - 'download_modal': None, - 'show_operation_status': lambda self, status_text="View Progress": None, - 'hide_operation_status': lambda self: None - })() - - modal = YouTubeDownloadMissingTracksModal( - playlist, - dummy_playlist_item, - self, - self.downloads_page - ) - - # Store URL and register in tracking - if hasattr(self, 'current_youtube_url'): - modal.youtube_url = self.current_youtube_url - self.active_youtube_processes[self.current_youtube_url] = modal - - modal.exec() - - # Clear the URL input after successful parsing - self.youtube_url_input.clear() - - except Exception as e: - print(f"Error handling YouTube parsing result: {e}") - self.on_youtube_parsing_error(str(e)) - - def on_youtube_parsing_error(self, error_message): - """Handle YouTube playlist parsing error""" - print(f"YouTube parsing error: {error_message}") - - # Update card state on error (remove the card since parsing failed) - if hasattr(self, 'current_youtube_url'): - url = self.current_youtube_url - self.remove_youtube_playlist_card(url) - - # Clean up URL tracking on error - if hasattr(self, 'current_youtube_url') and self.current_youtube_url in self.active_youtube_processes: - print(f"Cleaning up URL tracking on error for: {self.current_youtube_url}") - del self.active_youtube_processes[self.current_youtube_url] - - # Reset button state - self.parse_btn.setEnabled(True) - self.parse_btn.setText("Parse Playlist") - - # Show error message - self.show_youtube_error(f"Failed to parse playlist: {error_message}") - - def show_youtube_error(self, message): - """Show error message for YouTube functionality""" - # You can enhance this with a proper toast notification if available - if hasattr(self, 'toast_manager') and self.toast_manager: - self.toast_manager.show_toast(message, ToastType.ERROR) - else: - print(f"YouTube Error: {message}") - # Fallback to a simple message box - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Icon.Warning) - msg_box.setWindowTitle("YouTube Playlist Error") - msg_box.setText(message) - msg_box.exec() - - # =============================== - # YouTube Playlist Card Hub System - # =============================== - - def create_youtube_playlist_card(self, url: str, playlist_name: str = "Loading...", track_count: int = 0): - """Create a new YouTube playlist card and add to the cards container""" - if url in self.youtube_cards: - return self.youtube_cards[url] # Return existing card - - # Create new card - card = YouTubePlaylistCard(url, playlist_name, track_count, self) - card.card_clicked.connect(self.on_youtube_card_clicked) - - # Store card reference - self.youtube_cards[url] = card - - # Initialize state tracking - self.youtube_playlist_states[url] = { - 'phase': 'discovering', - 'playlist_data': None, - 'discovered_tracks': [], - 'card': card, - 'discovery_modal': None, - 'download_modal': None - } - - # Ensure cards container exists - if self.youtube_cards_container is None: - self.setup_youtube_cards_container() - - # Add card to container at the top (most recent first) - card_layout = self.youtube_cards_container.layout() - if card_layout: - # Insert at position 0 so newest cards appear at the top - card_layout.insertWidget(0, card) - - return card - - def setup_youtube_cards_container(self): - """Setup the container for YouTube playlist cards""" - # Clear existing placeholder content - for i in reversed(range(self.youtube_content_layout.count())): - child = self.youtube_content_layout.itemAt(i).widget() - if child: - child.setParent(None) - - # Create cards container - self.youtube_cards_container = QFrame() - self.youtube_cards_container.setStyleSheet(""" - QFrame { - background: transparent; - border: none; - } - """) - - cards_layout = QVBoxLayout(self.youtube_cards_container) - cards_layout.setContentsMargins(0, 0, 0, 0) - cards_layout.setSpacing(10) - # Set alignment to ensure cards stick to the top - cards_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - cards_layout.addStretch() # Stretch at bottom to align cards to top - - # Add container to main layout at the top - self.youtube_content_layout.insertWidget(0, self.youtube_cards_container) - - def update_youtube_card_phase(self, url: str, phase: str): - """Update the YouTube card's phase - cards are the single source of truth for state""" - if url not in self.youtube_cards or url not in self.youtube_playlist_states: - return - - card = self.youtube_cards[url] - state = self.youtube_playlist_states[url] - - # Update the internal state - card handles its own visual appearance - card.set_phase(phase) - state['phase'] = phase - - # Clean up any existing status widgets for this playlist when changing phases - playlist_data = state.get('playlist_data') - if playlist_data and hasattr(playlist_data, 'id'): - if playlist_data.id in self.youtube_status_widgets: - status_widget = self.youtube_status_widgets.pop(playlist_data.id, None) - if status_widget: - status_widget.setParent(None) - status_widget.deleteLater() - print(f"Cleaned up status widget for phase change to: {phase}") - - # Ensure card is always visible - it manages its own appearance - card.show() - - def update_youtube_card_progress(self, url: str, total=None, matched=None, failed=None): - """Update progress display on a YouTube playlist card""" - if url in self.youtube_cards: - card = self.youtube_cards[url] - card.update_progress(total=total, matched=matched, failed=failed) - - def update_youtube_card_playlist_info(self, url: str, name: str, track_count: int): - """Update playlist info on a YouTube playlist card""" - if url in self.youtube_cards: - card = self.youtube_cards[url] - card.update_playlist_info(name, track_count) - - # Store original name and count for resets - if url in self.youtube_playlist_states: - state = self.youtube_playlist_states[url] - state['original_name'] = name - state['original_track_count'] = track_count - - def set_youtube_card_playlist_data(self, url: str, playlist_data): - """Store playlist data for a YouTube card""" - if url in self.youtube_playlist_states: - self.youtube_playlist_states[url]['playlist_data'] = playlist_data - if hasattr(playlist_data, 'tracks'): - self.youtube_playlist_states[url]['discovered_tracks'] = playlist_data.tracks - - # Update card with playlist info - if url in self.youtube_cards: - card = self.youtube_cards[url] - card.set_playlist_data(playlist_data) - - def get_youtube_playlist_state(self, url: str): - """Get the current state data for a YouTube playlist""" - return self.youtube_playlist_states.get(url, None) - - def reset_youtube_playlist_state(self, url: str): - """Reset YouTube playlist state (for cancel operations)""" - if url in self.youtube_playlist_states: - state = self.youtube_playlist_states[url] - state['phase'] = 'discovering' - state['playlist_data'] = None - state['discovered_tracks'] = [] - state['discovery_modal'] = None - state['download_modal'] = None - - # Reset card to initial state - if url in self.youtube_cards: - card = self.youtube_cards[url] - card.set_phase('discovering') - # Use original name instead of "Loading..." to keep playlist title visible - original_name = state.get('original_name', 'Loading...') - original_count = state.get('original_track_count', 0) - card.update_playlist_info(original_name, original_count) - card.update_progress(0, 0, 0) - - def remove_youtube_playlist_card(self, url: str): - """Remove a YouTube playlist card (for full cleanup)""" - if url in self.youtube_cards: - card = self.youtube_cards[url] - card.setParent(None) - del self.youtube_cards[url] - - if url in self.youtube_playlist_states: - del self.youtube_playlist_states[url] - - # Tidal state management methods (identical structure to YouTube) - def update_tidal_card_phase(self, playlist_id: str, phase: str): - """Update the Tidal card's phase - cards are the single source of truth for state""" - if playlist_id not in self.tidal_cards or playlist_id not in self.tidal_playlist_states: - return - - card = self.tidal_cards[playlist_id] - state = self.tidal_playlist_states[playlist_id] - - # Update the internal state - card handles its own visual appearance - card.set_phase(phase) - state['phase'] = phase - - # Clean up any existing status widgets for this playlist when changing phases - playlist_data = state.get('playlist_data') - if playlist_data and hasattr(playlist_data, 'id'): - if playlist_data.id in self.youtube_status_widgets: # Reuse existing status widget system - status_widget = self.youtube_status_widgets.pop(playlist_data.id, None) - if status_widget: - status_widget.setParent(None) - status_widget.deleteLater() - print(f"Cleaned up status widget for Tidal phase change to: {phase}") - - def update_tidal_card_playlist_info(self, playlist_id: str, name: str, track_count: int): - """Update Tidal card playlist information""" - if playlist_id in self.tidal_cards: - card = self.tidal_cards[playlist_id] - card.update_playlist_info(name, track_count) - - def set_tidal_card_playlist_data(self, playlist_id: str, playlist_data): - """Store playlist data for a Tidal card""" - if playlist_id in self.tidal_playlist_states: - self.tidal_playlist_states[playlist_id]['playlist_data'] = playlist_data - if hasattr(playlist_data, 'tracks'): - self.tidal_playlist_states[playlist_id]['discovered_tracks'] = playlist_data.tracks - - # Update card with playlist info - if playlist_id in self.tidal_cards: - card = self.tidal_cards[playlist_id] - card.playlist_data = playlist_data - card.discovered_tracks = playlist_data.tracks - - def get_tidal_playlist_state(self, playlist_id: str): - """Get the current state data for a Tidal playlist""" - return self.tidal_playlist_states.get(playlist_id, None) - - def reset_tidal_playlist_state(self, playlist_id: str): - """Reset Tidal playlist state (for cancel operations)""" - if playlist_id in self.tidal_playlist_states: - state = self.tidal_playlist_states[playlist_id] - state['phase'] = 'discovering' - state['playlist_data'] = None - state['discovered_tracks'] = [] - state['discovery_modal'] = None - state['download_modal'] = None - - # Reset card to initial state - if playlist_id in self.tidal_cards: - card = self.tidal_cards[playlist_id] - card.set_phase('discovering') - # Use original name instead of "Loading..." to keep playlist title visible - original_name = state.get('original_name', 'Unknown Playlist') - original_count = state.get('original_track_count', 0) - card.update_playlist_info(original_name, original_count) - card.update_progress(0, 0, 0) - - def remove_tidal_playlist_card(self, playlist_id: str): - """Remove a Tidal playlist card (for full cleanup)""" - if playlist_id in self.tidal_cards: - card = self.tidal_cards[playlist_id] - card.setParent(None) - del self.tidal_cards[playlist_id] - - if playlist_id in self.tidal_playlist_states: - del self.tidal_playlist_states[playlist_id] - - def on_youtube_card_clicked(self, url: str, phase: str): - """Handle YouTube playlist card clicks - route to appropriate modal""" - print(f"YouTube card clicked: URL={url}, Phase={phase}") - - state = self.get_youtube_playlist_state(url) - if not state: - print(f"No state found for URL: {url}") - return - - # Route to appropriate modal based on current phase - if phase in ['discovering', 'discovery_complete']: - self.open_or_create_discovery_modal(url, state) - elif phase in ['sync_complete', 'downloading', 'download_complete']: - # For downloading phase, check if download modal actually exists - # If not, route back to discovery modal (handles case where download modal was closed) - playlist_data = state.get('playlist_data') - if (playlist_data and hasattr(playlist_data, 'id') and - playlist_data.id in self.active_youtube_download_modals): - self.open_or_create_download_modal(url, state) - else: - print(f"Download modal not found, routing to discovery modal instead") - self.open_or_create_discovery_modal(url, state) - elif phase == 'syncing': - # Show sync progress - could be same as discovery modal or separate - self.open_or_create_discovery_modal(url, state) - - def open_or_create_discovery_modal(self, url: str, state: dict): - """Open or create the discovery modal for a YouTube playlist""" - # Check if modal already exists and is visible - if state.get('discovery_modal') and state['discovery_modal'].isVisible(): - state['discovery_modal'].activateWindow() - state['discovery_modal'].raise_() - return - - # Check if modal exists but is hidden - reopen it - if state.get('discovery_modal') and not state['discovery_modal'].isVisible(): - print(f"Reopening existing hidden discovery modal for URL: {url}") - state['discovery_modal'].show() - state['discovery_modal'].activateWindow() - state['discovery_modal'].raise_() - return - - # Check if we have playlist data already (discovery_complete state) - if state.get('playlist_data') and state['phase'] == 'discovery_complete': - print(f"Opening existing discovery modal with data for URL: {url}") - - # Create a new modal with the existing data - dummy_playlist_item = type('DummyPlaylistItem', (), { - 'playlist_name': state['playlist_data'].name, - 'track_count': len(state['playlist_data'].tracks), - 'download_modal': None, - 'show_operation_status': lambda self, status_text="View Progress": None, - 'hide_operation_status': lambda self: None - })() - - modal = YouTubeDownloadMissingTracksModal( - state['playlist_data'], - dummy_playlist_item, - self, - self.downloads_page - ) - - # Store URL and register modal - modal.youtube_url = url - state['discovery_modal'] = modal - self.active_youtube_processes[url] = modal - - modal.show() - modal.activateWindow() - modal.raise_() - - else: - # No existing data - start new discovery process - print(f"Starting new discovery for URL: {url}") - - # Store URL in input field - self.youtube_url_input.setText(url) - - # Directly start the parsing worker instead of calling parse_youtube_playlist - # to avoid recursion loop - self.start_youtube_parsing_worker(url) - - def start_youtube_parsing_worker(self, url: str): - """Start YouTube parsing worker directly (used to avoid recursion)""" - # Show loading state - self.parse_btn.setEnabled(False) - self.parse_btn.setText("Parsing...") - - # Show modal immediately with loading state - self.show_youtube_modal_loading(url) - - # Store URL for later use in completion handlers - self.current_youtube_url = url - - # Start parsing in a separate thread to avoid blocking UI - self.youtube_worker = YouTubeParsingWorker(url) - self.youtube_worker.finished.connect(self.on_youtube_parsing_finished) - self.youtube_worker.error.connect(self.on_youtube_parsing_error) - self.youtube_worker.start() - - def open_or_create_download_modal(self, url: str, state: dict): - """Open or create the download modal for a YouTube playlist""" - playlist_data = state.get('playlist_data') - if not playlist_data: - print(f"No playlist data available for URL: {url}") - return - - # Check if modal already exists - if state.get('download_modal') and state['download_modal'].isVisible(): - state['download_modal'].activateWindow() - state['download_modal'].raise_() - return - - # Create new download modal - print(f"Opening download modal for URL: {url}") - - # Check existing modal system first - if hasattr(playlist_data, 'id') and playlist_data.id in self.active_youtube_download_modals: - modal = self.active_youtube_download_modals[playlist_data.id] - modal.show() - modal.activateWindow() - modal.raise_() - state['download_modal'] = modal - else: - # Create new download modal using the existing modal creation pattern - # This would transition to the download missing tracks phase - # For now, route back to discovery modal (sync_complete means ready for download) - self.open_or_create_discovery_modal(url, state) - - -class OptimizedSpotifyDiscoveryWorkerSignals(QObject): - track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status - progress_updated = pyqtSignal(int) # current progress - finished = pyqtSignal(int) # total successful discoveries - -class OptimizedSpotifyDiscoveryWorker(QRunnable): - def __init__(self, youtube_tracks, spotify_client, matching_engine): - super().__init__() - self.youtube_tracks = youtube_tracks - self.spotify_client = spotify_client - self.matching_engine = matching_engine - self.signals = OptimizedSpotifyDiscoveryWorkerSignals() - self.is_cancelled = False - - def cancel(self): - self.is_cancelled = True - - def run(self): - """Discover Spotify tracks for YouTube tracks with optimized timing""" - successful_discoveries = 0 - - for i, youtube_track in enumerate(self.youtube_tracks): - if self.is_cancelled: - break - - try: - # Create search query from YouTube track data - if youtube_track.artists: - query = f"{youtube_track.artists[0]} {youtube_track.name}" - else: - query = youtube_track.name - - # Debug logging for search queries - print(f"Spotify search query: '{query}' (track: '{youtube_track.name}', artist: '{youtube_track.artists[0] if youtube_track.artists else 'None'}')") - - # Search Spotify - get more results for validation - spotify_results = self.spotify_client.search_tracks(query, limit=10) - - # Debug logging for search results - if spotify_results: - print(f"Found {len(spotify_results)} Spotify results:") - for idx, result in enumerate(spotify_results[:3]): # Show first 3 - album_name = result.album if isinstance(result.album, str) else getattr(result.album, 'name', 'Unknown') - print(f" {idx+1}. '{result.name}' by '{result.artists[0] if result.artists else 'Unknown'}' from '{album_name}'") - else: - print(f"No Spotify results for query: '{query}'") - - if spotify_results: - # Use matching engine to find the best validated match - best_track = self.find_best_validated_match(youtube_track, spotify_results) - if best_track: - self.signals.track_discovered.emit(i, best_track, "found") - successful_discoveries += 1 - else: - # Try swapping artist and track name (sometimes YouTube data is swapped) - best_track = self.retry_with_swapped_fields(youtube_track) - if best_track: - print(f"Found match after swapping artist/track for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'") - self.signals.track_discovered.emit(i, best_track, "found") - successful_discoveries += 1 - else: - # Third resort: try with uncleaned original data - best_track = self.retry_with_uncleaned_data(youtube_track) - if best_track: - print(f"Found match with uncleaned data for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'") - self.signals.track_discovered.emit(i, best_track, "found") - successful_discoveries += 1 - else: - # Final resort: try with raw title + raw artist combined - best_track = self.retry_with_raw_title_and_artist(youtube_track) - if best_track: - print(f"Found match with title+artist fallback for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'") - self.signals.track_discovered.emit(i, best_track, "found") - successful_discoveries += 1 - else: - # No result met confidence threshold even after all retries - self.signals.track_discovered.emit(i, None, "low_confidence") - else: - # No Spotify search results found - try swapping before giving up - best_track = self.retry_with_swapped_fields(youtube_track) - if best_track: - print(f"Found match after swapping artist/track for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'") - self.signals.track_discovered.emit(i, best_track, "found") - successful_discoveries += 1 - else: - # Third resort: try with uncleaned original data - best_track = self.retry_with_uncleaned_data(youtube_track) - if best_track: - print(f"Found match with uncleaned data for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'") - self.signals.track_discovered.emit(i, best_track, "found") - successful_discoveries += 1 - else: - # Final resort: try with raw title + raw artist combined - best_track = self.retry_with_raw_title_and_artist(youtube_track) - if best_track: - print(f"Found match with title+artist fallback for: '{youtube_track.name}' by '{youtube_track.artists[0] if youtube_track.artists else 'Unknown'}'") - self.signals.track_discovered.emit(i, best_track, "found") - successful_discoveries += 1 - else: - self.signals.track_discovered.emit(i, None, "not_found") - - except Exception as e: - print(f"Error searching Spotify for track {i}: {e}") - self.signals.track_discovered.emit(i, None, f"error: {str(e)}") - - # Update progress - self.signals.progress_updated.emit(i + 1) - - # Reduced delay for faster processing - Spotify client has built-in rate limiting - if not self.is_cancelled: - import time - time.sleep(0.15) # 150ms between requests - - self.signals.finished.emit(successful_discoveries) - - def find_best_validated_match(self, youtube_track, spotify_results): - """Find the best Spotify match using the matching engine for validation""" - if not spotify_results: - return None - - # Clean YouTube track name for better matching (already cleaned in parsing, but ensure consistency) - cleaned_youtube_name = self.clean_for_youtube_matching(youtube_track.name) - - # Create a mock Spotify track from YouTube data for comparison - youtube_as_spotify = type('Track', (), { - 'name': cleaned_youtube_name, - 'artists': youtube_track.artists if youtube_track.artists else ["Unknown"], - 'album': getattr(youtube_track, 'album', 'Unknown Album'), - 'duration_ms': getattr(youtube_track, 'duration_ms', 0) - })() - - best_match = None - best_confidence = 0.0 - best_match_type = "no_match" - - # Score each Spotify result using your matching engine - for spotify_track in spotify_results: - try: - # Clean the Spotify track name for better YouTube-to-Spotify matching - cleaned_spotify_track = self.create_cleaned_spotify_track_for_matching(spotify_track) - - # Debug logging for track cleaning - if cleaned_spotify_track.name != spotify_track.name: - print(f"Cleaned Spotify track: '{spotify_track.name}' -> '{cleaned_spotify_track.name}'") - - # Use your matching engine to calculate confidence - confidence, match_type = self.matching_engine.calculate_match_confidence( - youtube_as_spotify, - self.convert_spotify_to_plex_format(cleaned_spotify_track) - ) - - # Apply album preference bonus (your existing logic) - album_bonus = self.calculate_album_preference_bonus(spotify_track) - adjusted_confidence = confidence + album_bonus - - if adjusted_confidence > best_confidence: - best_confidence = adjusted_confidence - best_match = spotify_track - best_match_type = match_type - - except Exception as e: - print(f"Error calculating match confidence: {e}") - continue - - # Apply your matching engine's confidence threshold (0.8 for high confidence) - confidence_threshold = 0.75 # Slightly lower for YouTube discovery - - if best_confidence >= confidence_threshold: - print(f"Validated match: '{best_match.name}' by '{best_match.artists[0]}' (confidence: {best_confidence:.3f}, type: {best_match_type})") - return best_match - else: - print(f"No high-confidence match found. Best was {best_confidence:.3f} < {confidence_threshold}") - if best_match: - print(f" Best candidate was: '{best_match.name}' by '{best_match.artists[0] if best_match.artists else 'Unknown'}'") - return None - - def convert_spotify_to_plex_format(self, spotify_track): - """Convert Spotify track to Plex format for matching engine compatibility""" - return type('PlexTrackInfo', (), { - 'title': spotify_track.name, - 'artist': spotify_track.artists[0] if spotify_track.artists else "Unknown", - 'album': spotify_track.album if isinstance(spotify_track.album, str) else getattr(spotify_track.album, 'name', 'Unknown Album'), - 'duration': getattr(spotify_track, 'duration_ms', 0) - })() - - def calculate_album_preference_bonus(self, spotify_track): - """Calculate album preference bonus (simplified version of your existing logic)""" - try: - album_info = spotify_track.album if hasattr(spotify_track, 'album') else None - if album_info and not isinstance(album_info, str): - album_type = getattr(album_info, 'album_type', album_info.get('album_type', 'unknown') if hasattr(album_info, 'get') else 'unknown') - - if isinstance(album_type, str): - if album_type.lower() == 'album': - return 0.05 # Small bonus for albums - elif album_type.lower() == 'single': - return -0.02 # Small penalty for singles - elif album_type.lower() == 'compilation': - return 0.02 # Small bonus for compilations - - return 0.0 - except: - return 0.0 - - def choose_best_spotify_match(self, spotify_results): - """Choose the best Spotify track from search results, preferring album versions""" - if not spotify_results: - return None - - # If only one result, return it - if len(spotify_results) == 1: - return spotify_results[0] - - # Score each track based on preference criteria - scored_tracks = [] - - for track in spotify_results: - score = 0 - - # 1. Prefer album tracks over singles (highest priority) - try: - # Access album type through the album attribute - album_info = track.album if hasattr(track, 'album') else None - if album_info: - # Handle both string and dict album info - if isinstance(album_info, str): - # If album is just a string name, we can't determine type - score += 50 # Medium score for unknown type - else: - # Try to get album_type from album object or dict - album_type = getattr(album_info, 'album_type', album_info.get('album_type', 'unknown') if hasattr(album_info, 'get') else 'unknown') - - if isinstance(album_type, str): - if album_type.lower() == 'album': - score += 100 # Strong preference for albums - elif album_type.lower() == 'single': - score += 20 # Lower preference for singles - elif album_type.lower() == 'compilation': - score += 60 # Medium preference for compilations - else: - score += 50 # Unknown type gets medium score - else: - score += 30 # No album info gets low score - except Exception as e: - print(f"Error accessing album type: {e}") - score += 30 # Error case gets low score - - # 2. Prefer tracks with more total tracks in album (indicates full album) - try: - album_info = track.album if hasattr(track, 'album') else None - if album_info and not isinstance(album_info, str): - total_tracks = getattr(album_info, 'total_tracks', album_info.get('total_tracks', 0) if hasattr(album_info, 'get') else 0) - - if total_tracks > 10: - score += 50 # Full album - elif total_tracks > 5: - score += 30 # EP - elif total_tracks > 1: - score += 10 # Multi-track release - # Singles (1 track) get no bonus - except Exception as e: - print(f"Error accessing total_tracks: {e}") - pass - - # 3. Consider popularity as tiebreaker - try: - popularity = getattr(track, 'popularity', 0) - score += popularity * 0.1 # Small influence from popularity - except: - pass - - # 4. Prefer tracks with explicit marking if available (often more complete metadata) - try: - if hasattr(track, 'explicit') and track.explicit is not None: - score += 5 - except: - pass - - scored_tracks.append((score, track)) - - # Sort by score (highest first) and return the best match - scored_tracks.sort(key=lambda x: x[0], reverse=True) - best_track = scored_tracks[0][1] - - # Debug logging for first few tracks - if len(self.youtube_tracks) <= 5 or len(scored_tracks) > 1: - try: - album_name = best_track.album if isinstance(best_track.album, str) else getattr(best_track.album, 'name', 'Unknown Album') - print(f"Chose: '{best_track.name}' from '{album_name}' (score: {scored_tracks[0][0]:.1f})") - if len(scored_tracks) > 1: - alt_album = scored_tracks[1][1].album if isinstance(scored_tracks[1][1].album, str) else getattr(scored_tracks[1][1].album, 'name', 'Unknown Album') - print(f" vs. '{scored_tracks[1][1].name}' from '{alt_album}' (score: {scored_tracks[1][0]:.1f})") - except: - pass # Don't let debug logging crash the worker - - return best_track - - def clean_for_youtube_matching(self, track_name): - """Clean track name for YouTube-to-Spotify matching""" - if not track_name: - return "" - - cleaned = track_name - - # Remove all parentheses content for YouTube matching - # This handles cases like "MOUSTACHE (Feat. Netta)" -> "MOUSTACHE" - cleaned = re.sub(r'\s*\([^)]*\)', '', cleaned) - - # Remove brackets content - cleaned = re.sub(r'\s*\[[^\]]*\]', '', cleaned) - - # Remove extra whitespace and return - return cleaned.strip() - - def create_cleaned_spotify_track_for_matching(self, spotify_track): - """Create a cleaned version of Spotify track for better YouTube matching""" - # Clean the track name - cleaned_name = self.clean_for_youtube_matching(spotify_track.name) - - # Create a copy of the track with cleaned name - cleaned_track = type('Track', (), { - 'id': getattr(spotify_track, 'id', ''), - 'name': cleaned_name, # Use cleaned name - 'artists': spotify_track.artists, - 'album': spotify_track.album, - 'duration_ms': getattr(spotify_track, 'duration_ms', 0), - 'popularity': getattr(spotify_track, 'popularity', 0), - 'preview_url': getattr(spotify_track, 'preview_url', None), - 'external_urls': getattr(spotify_track, 'external_urls', None) - })() - - return cleaned_track - - def retry_with_swapped_fields(self, youtube_track): - """Retry search with artist and track names swapped (handles YouTube data inconsistencies)""" - if not youtube_track.artists or not youtube_track.artists[0]: - return None - - try: - # Create swapped query: use track name as artist and artist as track - swapped_artist = youtube_track.name - swapped_track = youtube_track.artists[0] - - # Clean the swapped values - swapped_artist_clean = clean_youtube_artist(swapped_artist) - swapped_track_clean = clean_youtube_track_title(swapped_track, swapped_artist_clean) - - swapped_query = f"{swapped_artist_clean} {swapped_track_clean}" - - print(f"Retrying with swapped fields: '{swapped_query}' (was '{youtube_track.artists[0]} {youtube_track.name}')") - - # Search Spotify with swapped query - spotify_results = self.spotify_client.search_tracks(swapped_query, limit=10) - - if spotify_results: - # Create a swapped YouTube track for matching - swapped_youtube_track = type('Track', (), { - 'name': swapped_track_clean, - 'artists': [swapped_artist_clean], - 'album': getattr(youtube_track, 'album', 'Unknown Album'), - 'duration_ms': getattr(youtube_track, 'duration_ms', 0) - })() - - # Use matching engine to validate the swapped results - best_track = self.find_best_validated_match(swapped_youtube_track, spotify_results) - return best_track - - return None - - except Exception as e: - print(f"Error in retry with swapped fields: {e}") - return None - - def retry_with_uncleaned_data(self, youtube_track): - """Last resort: retry search with original uncleaned YouTube data""" - # Check if we have raw uncleaned data - if not hasattr(youtube_track, 'raw_title') or not hasattr(youtube_track, 'raw_uploader'): - print("No raw data available for uncleaned fallback search") - return None - - try: - # Use completely uncleaned data - raw_title = youtube_track.raw_title - raw_uploader = youtube_track.raw_uploader - - # Create query with minimal cleaning - just basic text normalization - uncleaned_query = f"{raw_uploader} {raw_title}".strip() - - print(f"Last resort: Trying uncleaned data: '{uncleaned_query}' (was '{youtube_track.artists[0]} {youtube_track.name}')") - - # Search Spotify with uncleaned query - spotify_results = self.spotify_client.search_tracks(uncleaned_query, limit=10) - - if spotify_results: - print(f"Found {len(spotify_results)} results with uncleaned data") - - # Create an uncleaned YouTube track for comparison - uncleaned_youtube_track = type('Track', (), { - 'name': raw_title, # Use raw title - 'artists': [raw_uploader], # Use raw uploader - 'album': getattr(youtube_track, 'album', 'Unknown Album'), - 'duration_ms': getattr(youtube_track, 'duration_ms', 0) - })() - - # Use matching engine to validate results with lower confidence threshold - # Note: We don't clean Spotify tracks here since we're using raw data - best_match = None - best_confidence = 0.0 - - for spotify_track in spotify_results: - try: - # Use original Spotify track names (no cleaning) for raw data matching - confidence, match_type = self.matching_engine.calculate_match_confidence( - uncleaned_youtube_track, - self.convert_spotify_to_plex_format(spotify_track) - ) - - if confidence > best_confidence: - best_confidence = confidence - best_match = spotify_track - except Exception as e: - print(f"Error calculating confidence for uncleaned fallback: {e}") - continue - - # Use lower confidence threshold for uncleaned fallback (0.6 instead of 0.75) - confidence_threshold = 0.6 - - if best_confidence >= confidence_threshold: - print(f"Uncleaned fallback match: '{best_match.name}' by '{best_match.artists[0]}' (confidence: {best_confidence:.3f})") - return best_match - else: - print(f"Uncleaned fallback: Best confidence {best_confidence:.3f} < {confidence_threshold}") - - return None - - except Exception as e: - print(f"Error in retry with uncleaned data: {e}") - return None - - def retry_with_raw_title_and_artist(self, youtube_track): - """Final fallback: search with raw title + raw artist as combined query""" - # Check if we have raw uncleaned data - if not hasattr(youtube_track, 'raw_title') or not hasattr(youtube_track, 'raw_uploader'): - print("No raw data available for title+artist fallback search") - return None - - try: - raw_title = youtube_track.raw_title - raw_uploader = youtube_track.raw_uploader - - # Create a combined query with raw title and raw artist - # This is different from the previous fallback which used "uploader title" - # This uses "title artist" order which sometimes works better - combined_query = f"{raw_title} {raw_uploader}".strip() - - print(f"Final fallback: Trying raw title+artist: '{combined_query}'") - - # Search Spotify with the combined query - spotify_results = self.spotify_client.search_tracks(combined_query, limit=10) - - if spotify_results: - print(f"Found {len(spotify_results)} results with title+artist search") - - # Create a track object for matching with raw data in title+artist order - combined_youtube_track = type('Track', (), { - 'name': raw_title, - 'artists': [raw_uploader], - 'album': getattr(youtube_track, 'album', 'Unknown Album'), - 'duration_ms': getattr(youtube_track, 'duration_ms', 0) - })() - - # Use matching engine with even lower confidence threshold - best_match = None - best_confidence = 0.0 - - for spotify_track in spotify_results: - try: - confidence, match_type = self.matching_engine.calculate_match_confidence( - combined_youtube_track, - self.convert_spotify_to_plex_format(spotify_track) - ) - - if confidence > best_confidence: - best_confidence = confidence - best_match = spotify_track - except Exception as e: - print(f"Error calculating confidence for title+artist fallback: {e}") - continue - - # Use very low confidence threshold for this final attempt (0.5 instead of 0.6) - confidence_threshold = 0.5 - - if best_confidence >= confidence_threshold: - print(f"Title+artist fallback match: '{best_match.name}' by '{best_match.artists[0]}' (confidence: {best_confidence:.3f})") - return best_match - else: - print(f"Title+artist fallback: Best confidence {best_confidence:.3f} < {confidence_threshold}") - else: - print(f"No results found for title+artist query: '{combined_query}'") - - return None - - except Exception as e: - print(f"Error in retry with title+artist: {e}") - return None - -class TidalSpotifyDiscoveryWorkerSignals(QObject): - track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status - progress_updated = pyqtSignal(int) # current progress - finished = pyqtSignal(int) # total successful discoveries - -class TidalSpotifyDiscoveryWorker(QRunnable): - def __init__(self, tidal_tracks, spotify_client, matching_engine): - super().__init__() - self.tidal_tracks = tidal_tracks - self.spotify_client = spotify_client - self.matching_engine = matching_engine - self.signals = TidalSpotifyDiscoveryWorkerSignals() - self.is_cancelled = False - - def cancel(self): - self.is_cancelled = True - - def run(self): - """Discover Spotify tracks for Tidal tracks with optimized timing""" - successful_discoveries = 0 - - for i, tidal_track in enumerate(self.tidal_tracks): - if self.is_cancelled: - break - - try: - # Create search query from Tidal track data - if tidal_track.artists: - query = f"{tidal_track.artists[0]} {tidal_track.name}" - else: - query = tidal_track.name - - # Debug logging for search queries - print(f"Spotify search query for Tidal track: '{query}' (track: '{tidal_track.name}', artist: '{tidal_track.artists[0] if tidal_track.artists else 'None'}')") - - # Search Spotify - get more results for validation - spotify_results = self.spotify_client.search_tracks(query, limit=10) - - # Progress tracking - if spotify_results: - print(f"Found {len(spotify_results)} Spotify results for Tidal track:") - for idx, result in enumerate(spotify_results[:3]): # Show first 3 - print(f" {idx+1}. '{result.name}' by {', '.join(result.artists)}") - - if spotify_results: - # Use the matching engine to find the best match - best_track = self.find_best_validated_match(tidal_track, spotify_results) - - if not best_track: - # Try with swapped fields if no match found - print(f"No direct match found, trying swapped fields for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - best_track = self.retry_with_swapped_fields(tidal_track) - - if best_track: - print(f"Found match after swapping artist/track for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - else: - # Final fallback: try with cleaned data - best_track = self.retry_with_uncleaned_data(tidal_track) - - if best_track: - print(f"Found match with uncleaned data for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - else: - # Last resort: try title+artist combo - best_track = self.retry_with_raw_title_and_artist(tidal_track) - - if best_track: - print(f"Found match with title+artist fallback for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - - if best_track: - successful_discoveries += 1 - self.signals.track_discovered.emit(i, best_track, "found") - print(f"Matched Tidal track '{tidal_track.name}' to Spotify track '{best_track.name}' by {', '.join(best_track.artists)}") - else: - self.signals.track_discovered.emit(i, None, "not_found") - print(f"No Spotify match found for Tidal track '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - else: - # No search results - try fallback approaches - best_track = self.retry_with_swapped_fields(tidal_track) - - if best_track: - print(f"Found match after swapping artist/track for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - successful_discoveries += 1 - self.signals.track_discovered.emit(i, best_track, "found") - else: - # Try with uncleaned data - best_track = self.retry_with_uncleaned_data(tidal_track) - - if best_track: - print(f"Found match with uncleaned data for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - successful_discoveries += 1 - self.signals.track_discovered.emit(i, best_track, "found") - else: - # Final fallback - best_track = self.retry_with_raw_title_and_artist(tidal_track) - - if best_track: - print(f"Found match with title+artist fallback for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") - successful_discoveries += 1 - self.signals.track_discovered.emit(i, best_track, "found") - else: - self.signals.track_discovered.emit(i, None, "not_found") - print(f"No Spotify match found for Tidal track '{tidal_track.name}'") - - # Update progress - self.signals.progress_updated.emit(i + 1) - - # Brief pause to avoid overwhelming the API - time.sleep(0.25) # 250ms delay - - except Exception as e: - print(f"Error processing Tidal track '{tidal_track.name}': {str(e)}") - self.signals.track_discovered.emit(i, None, "error") - continue - - print(f"Tidal discovery completed: {successful_discoveries} successful discoveries") - self.signals.finished.emit(successful_discoveries) - - def find_best_validated_match(self, tidal_track, spotify_results): - """Find the best validated match using the matching engine""" - if not spotify_results: - return None - - # Clean the Tidal track name for matching (similar to YouTube logic) - cleaned_tidal_name = self.clean_for_tidal_matching(tidal_track.name) - - # Create a fake track object that looks like a YouTube track for the matching engine - tidal_as_youtube = type('Track', (), { - 'name': cleaned_tidal_name, - 'artists': tidal_track.artists if tidal_track.artists else ["Unknown"], - 'album': getattr(tidal_track, 'album', 'Unknown Album'), - 'duration_ms': getattr(tidal_track, 'duration_ms', 0) - })() - - confidence_threshold = 0.7 - best_track = None - best_confidence = 0 - - # Test each Spotify result against the Tidal track - for spotify_track in spotify_results: - try: - # Use the matching engine to calculate confidence - cleaned_spotify_track = self.matching_engine.normalize_track_for_matching(spotify_track) - confidence = self.matching_engine.calculate_similarity_confidence( - tidal_as_youtube, cleaned_spotify_track - ) - - if confidence > best_confidence: - best_confidence = confidence - best_track = spotify_track - - print(f"Tidal->Spotify match confidence: {confidence:.3f} for '{spotify_track.name}' by {', '.join(spotify_track.artists)}") - - except Exception as e: - print(f"Error validating match: {e}") - continue - - if best_confidence >= confidence_threshold: - print(f"Best validated Tidal->Spotify match: '{best_track.name}' (confidence: {best_confidence:.3f})") - return best_track - else: - print(f"Best Tidal->Spotify confidence {best_confidence:.3f} < {confidence_threshold}") - return None - - def clean_for_tidal_matching(self, title): - """Clean Tidal track title for better matching (similar to YouTube logic)""" - if not title: - return "" - - # Remove common Tidal-specific markers and clean the title - cleaned = title.lower() - cleaned = re.sub(r'\s*\(.*?\)\s*', ' ', cleaned) # Remove parenthetical content - cleaned = re.sub(r'\s*\[.*?\]\s*', ' ', cleaned) # Remove bracketed content - cleaned = re.sub(r'\s*-\s*remaster.*', ' ', cleaned, re.IGNORECASE) # Remove remaster info - cleaned = re.sub(r'\s*-\s*\d{4}.*', ' ', cleaned) # Remove year info - cleaned = re.sub(r'\s+', ' ', cleaned).strip() # Normalize whitespace - - return cleaned - - def retry_with_swapped_fields(self, tidal_track): - """Retry search with artist/track fields swapped""" - try: - if not tidal_track.artists or len(tidal_track.artists) == 0: - return None - - # Swap: use track name as artist and artist name as track - swapped_query = f"{tidal_track.name} {tidal_track.artists[0]}" - print(f"Trying swapped Tidal fields: '{swapped_query}'") - - spotify_results = self.spotify_client.search_tracks(swapped_query, limit=5) - if spotify_results: - return self.find_best_validated_match(tidal_track, spotify_results) - return None - except Exception as e: - print(f"Error in swapped fields retry for Tidal track: {e}") - return None - - def retry_with_uncleaned_data(self, tidal_track): - """Retry with original uncleaned Tidal track data""" - try: - # Use original, uncleaned title and artist - if tidal_track.artists: - raw_query = f"{tidal_track.artists[0]} {tidal_track.name}" - else: - raw_query = tidal_track.name - - print(f"Trying uncleaned Tidal data: '{raw_query}'") - - spotify_results = self.spotify_client.search_tracks(raw_query, limit=5) - if spotify_results: - return self.find_best_validated_match(tidal_track, spotify_results) - return None - except Exception as e: - print(f"Error in uncleaned data retry for Tidal track: {e}") - return None - - def retry_with_raw_title_and_artist(self, tidal_track): - """Final fallback: combine raw title and artist in one query""" - try: - if not tidal_track.artists: - return None - - # Combine everything into one search term - combined_query = f"{tidal_track.name} {tidal_track.artists[0]}" - print(f"Trying combined Tidal query: '{combined_query}'") - - spotify_results = self.spotify_client.search_tracks(combined_query, limit=5) - if spotify_results: - best_confidence = 0 - best_track = None - confidence_threshold = 0.6 # Lower threshold for final fallback - - for track in spotify_results: - try: - # Basic string similarity as last resort - track_similarity = self.basic_string_similarity( - f"{tidal_track.name} {tidal_track.artists[0]}".lower(), - f"{track.name} {' '.join(track.artists)}".lower() - ) - - if track_similarity > best_confidence: - best_confidence = track_similarity - best_track = track - except Exception as e: - continue - - if best_confidence >= confidence_threshold: - print(f"Title+artist fallback found match: confidence {best_confidence:.3f}") - return best_track - else: - print(f"Title+artist fallback: Best confidence {best_confidence:.3f} < {confidence_threshold}") - else: - print(f"No results found for title+artist query: '{combined_query}'") - - return None - - except Exception as e: - print(f"Error in retry with title+artist for Tidal track: {e}") - return None - - def basic_string_similarity(self, s1, s2): - """Calculate basic string similarity for fallback matching""" - try: - from difflib import SequenceMatcher - return SequenceMatcher(None, s1, s2).ratio() - except: - return 0.0 - -class SpotifyDiscoveryManagerSignals(QObject): - track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status - progress_updated = pyqtSignal(int) # current progress - all_finished = pyqtSignal(int) # total successful discoveries - -class SpotifyDiscoveryWorker(QRunnable): - def __init__(self, track_batch, spotify_client, worker_id, manager_signals): - super().__init__() - self.track_batch = track_batch # List of (index, youtube_track) tuples - self.spotify_client = spotify_client - self.worker_id = worker_id - self.signals = manager_signals - self.is_cancelled = False - - def cancel(self): - self.is_cancelled = True - - def run(self): - """Process a batch of tracks with staggered delays to avoid rate limits""" - import time - - # Stagger start times to spread out API calls - initial_delay = self.worker_id * 0.2 # 200ms stagger between workers - time.sleep(initial_delay) - - successful_discoveries = 0 - - for track_index, youtube_track in self.track_batch: - if self.is_cancelled: - break - - try: - # Create search query from YouTube track data - if youtube_track.artists: - query = f"{youtube_track.artists[0]} {youtube_track.name}" - else: - query = youtube_track.name - - # Search Spotify with rate limiting (built into spotify_client) - spotify_results = self.spotify_client.search_tracks(query, limit=10) - - if spotify_results: - # Choose the best match preferring album versions - best_track = choose_best_spotify_match(spotify_results) - self.signals.track_discovered.emit(track_index, best_track, "found") - successful_discoveries += 1 - else: - # No Spotify match found - self.signals.track_discovered.emit(track_index, None, "not_found") - - except Exception as e: - print(f"Worker {self.worker_id} error searching Spotify for track {track_index}: {e}") - self.signals.track_discovered.emit(track_index, None, f"error: {str(e)}") - - # Update progress - self.signals.progress_updated.emit(track_index) - - # Distributed rate limiting - longer delay since we have multiple workers - if not self.is_cancelled: - time.sleep(0.5) # 500ms delay with 3 workers = ~6 requests/second total - - print(f"Worker {self.worker_id} completed: {successful_discoveries} discoveries") - -class SpotifyDiscoveryManager: - def __init__(self, youtube_tracks, spotify_client, num_workers=3): - self.youtube_tracks = youtube_tracks - self.spotify_client = spotify_client - self.num_workers = num_workers - self.signals = SpotifyDiscoveryManagerSignals() - self.workers = [] - self.completed_workers = 0 - self.total_successful = 0 - self.processed_tracks = set() - - def start_discovery(self): - """Start concurrent Spotify discovery with multiple workers""" - print(f"Starting Spotify discovery with {self.num_workers} concurrent workers") - - # Divide tracks among workers - track_batches = self.distribute_tracks() - - # Create and start workers - for worker_id, batch in enumerate(track_batches): - worker = SpotifyDiscoveryWorker(batch, self.spotify_client, worker_id, self.signals) - - # Connect to progress tracking - worker.signals.track_discovered.connect(self.on_track_discovered) - worker.signals.progress_updated.connect(self.on_progress_updated) - - self.workers.append(worker) - QThreadPool.globalInstance().start(worker) - - def distribute_tracks(self): - """Distribute tracks evenly among workers""" - total_tracks = len(self.youtube_tracks) - tracks_per_worker = total_tracks // self.num_workers - remainder = total_tracks % self.num_workers - - batches = [] - start_idx = 0 - - for worker_id in range(self.num_workers): - # Add one extra track to first 'remainder' workers - batch_size = tracks_per_worker + (1 if worker_id < remainder else 0) - end_idx = start_idx + batch_size - - # Create batch with (index, track) tuples - batch = [(i, self.youtube_tracks[i]) for i in range(start_idx, end_idx)] - batches.append(batch) - - print(f"Worker {worker_id}: tracks {start_idx}-{end_idx-1} ({len(batch)} tracks)") - start_idx = end_idx - - return batches - - def on_track_discovered(self, track_index, spotify_track, status): - """Handle track discovery from any worker""" - self.processed_tracks.add(track_index) - if status == "found": - self.total_successful += 1 - - # Forward to UI - self.signals.track_discovered.emit(track_index, spotify_track, status) - - def on_progress_updated(self, track_index): - """Handle progress updates""" - # Update overall progress based on completed tracks - completed_count = len(self.processed_tracks) - self.signals.progress_updated.emit(completed_count) - - # Check if all tracks are processed - if completed_count >= len(self.youtube_tracks): - self.signals.all_finished.emit(self.total_successful) - - def cancel_all(self): - """Cancel all running workers""" - for worker in self.workers: - worker.cancel() - -def choose_best_spotify_match(spotify_results): - """Choose the best Spotify track from search results, preferring album versions""" - if not spotify_results: - return None - - # If only one result, return it - if len(spotify_results) == 1: - return spotify_results[0] - - # For now, just return the first result to avoid the complex scoring - # TODO: Re-implement the scoring logic once we identify the attribute access issue - return spotify_results[0] - -class YouTubeParsingWorker(QThread): - """Worker thread for parsing YouTube playlists without blocking the UI""" - finished = pyqtSignal(object) # Emits the playlist object - error = pyqtSignal(str) # Emits error message - - def __init__(self, url): - super().__init__() - self.url = url - - def run(self): - """Parse the YouTube playlist in a separate thread""" - try: - print(f"Starting YouTube playlist parsing for: {self.url}") - - # Parse tracks using yt-dlp - tracks_data, playlist_title = parse_youtube_playlist(self.url) - - if not tracks_data: - self.error.emit("No tracks found in the playlist") - return - - # Create playlist object with actual title - playlist = create_youtube_playlist_object(tracks_data, self.url, playlist_title) - - print(f"Successfully created playlist with {len(playlist.tracks)} tracks") - self.finished.emit(playlist) - - except Exception as e: - error_message = str(e) - print(f"YouTube parsing worker error: {error_message}") - self.error.emit(error_message) - - -class ManualMatchModal(QDialog): - """ - A completely redesigned modal for manually searching and resolving a failed track download. - Features controlled searching, cancellation, and a UI consistent with the main application. - This version dynamically updates its track list from the parent modal and has a live-updating count. - """ - track_resolved = pyqtSignal(object) - - def __init__(self, parent_modal): - """Initializes the modal with a direct reference to the parent.""" - super().__init__(parent_modal) - self.parent_modal = parent_modal - - # Handle different parent modal types with flexible attribute access - try: - # Try the standard structure first (DownloadMissingTracksModal, DownloadMissingAlbumTracksModal) - self.soulseek_client = parent_modal.parent_page.soulseek_client - self.downloads_page = parent_modal.downloads_page - except AttributeError: - # Fallback for dashboard wishlist modal or other structures - try: - # Dashboard wishlist modal might have soulseek_client directly - self.soulseek_client = getattr(parent_modal, 'soulseek_client', None) - self.downloads_page = getattr(parent_modal, 'downloads_page', None) - - # If still not found, try to get from parent widget hierarchy - if not self.soulseek_client: - current_widget = parent_modal.parent() - while current_widget and not self.soulseek_client: - self.soulseek_client = getattr(current_widget, 'soulseek_client', None) - self.downloads_page = getattr(current_widget, 'downloads_page', None) - current_widget = current_widget.parent() - - except AttributeError: - pass - - # Validate we have the required clients - if not self.soulseek_client: - raise RuntimeError("Could not find soulseek_client in parent modal or widget hierarchy") - - self.failed_tracks = [] - self.current_track_index = 0 - self.current_track_info = None - self.search_worker = None - self.thread_pool = QThreadPool.globalInstance() - - # Timer to delay automatic search - self.search_delay_timer = QTimer(self) - self.search_delay_timer.setSingleShot(True) - self.search_delay_timer.timeout.connect(self.perform_manual_search) - - # Timer to periodically check for updates to the total failed track count - self.live_update_timer = QTimer(self) - self.live_update_timer.timeout.connect(self._check_and_update_count) - self.live_update_timer.start(1000) # Check every second - - self.setup_ui() - self.load_current_track() - - def setup_ui(self): - """Set up the visually redesigned UI.""" - self.setWindowTitle("Manual Track Correction") - self.setMinimumSize(900, 700) - self.setStyleSheet(""" - QDialog { background-color: #1e1e1e; color: #ffffff; } - QLabel { color: #ffffff; font-size: 14px; } - QLineEdit { - background-color: #3a3a3a; - border: 1px solid #555555; - border-radius: 6px; - padding: 10px; - color: #ffffff; - font-size: 13px; - } - QScrollArea { border: none; background-color: #2d2d2d; } - QWidget#resultsWidget { background-color: #2d2d2d; } - """) - - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(20, 20, 20, 20) - main_layout.setSpacing(15) - - # --- Failed Track Info Card --- - info_frame = QFrame() - info_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; - border: 1px solid #444444; - border-radius: 8px; - padding: 15px; - } - """) - info_layout = QVBoxLayout(info_frame) - self.info_label = QLabel("Loading track...") - self.info_label.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - self.info_label.setStyleSheet("color: #ffc107;") # Amber color for warning - self.info_label.setWordWrap(True) - info_layout.addWidget(self.info_label) - main_layout.addWidget(info_frame) - - # --- Search Input and Controls --- - search_frame = QFrame() - search_layout = QHBoxLayout(search_frame) - search_layout.setContentsMargins(0,0,0,0) - search_layout.setSpacing(10) - - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Enter a new search query or use the suggestion...") - self.search_input.returnPressed.connect(self.perform_manual_search) - - self.search_btn = QPushButton("Search") - self.search_btn.clicked.connect(self.perform_manual_search) - self.search_btn.setStyleSheet(""" - QPushButton { - background-color: #1db954; color: #000000; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; - } - QPushButton:hover { background-color: #1ed760; } - """) - - self.cancel_search_btn = QPushButton("Cancel") - self.cancel_search_btn.clicked.connect(self.cancel_current_search) - self.cancel_search_btn.setStyleSheet(""" - QPushButton { - background-color: #d32f2f; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; - } - QPushButton:hover { background-color: #f44336; } - """) - self.cancel_search_btn.hide() # Initially hidden - - search_layout.addWidget(self.search_input, 1) - search_layout.addWidget(self.search_btn) - search_layout.addWidget(self.cancel_search_btn) - main_layout.addWidget(search_frame) - - # --- Search Results Area --- - self.results_scroll = QScrollArea() - self.results_scroll.setWidgetResizable(True) - self.results_widget = QWidget() - self.results_widget.setObjectName("resultsWidget") - self.results_layout = QVBoxLayout(self.results_widget) - self.results_layout.setSpacing(8) - self.results_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.results_scroll.setWidget(self.results_widget) - main_layout.addWidget(self.results_scroll, 1) - - # --- Navigation and Close Buttons --- - nav_layout = QHBoxLayout() - self.prev_btn = QPushButton("← Previous") - self.prev_btn.clicked.connect(self.load_previous_track) - - self.track_position_label = QLabel() - self.track_position_label.setStyleSheet("color: #ffffff; font-weight: bold;") - - self.next_btn = QPushButton("Next →") - self.next_btn.clicked.connect(self.load_next_track) - - self.close_btn = QPushButton("Close") - self.close_btn.setStyleSheet(""" - QPushButton { background-color: #616161; color: #ffffff; } - QPushButton:hover { background-color: #757575; } - """) - self.close_btn.clicked.connect(self.reject) - - for btn in [self.prev_btn, self.next_btn, self.close_btn]: - btn.setFixedSize(120, 40) - - nav_layout.addWidget(self.prev_btn) - nav_layout.addStretch() - nav_layout.addWidget(self.track_position_label) - nav_layout.addStretch() - nav_layout.addWidget(self.next_btn) - nav_layout.addWidget(self.close_btn) - - main_layout.addLayout(nav_layout) - - def _check_and_update_count(self): - """ - Periodically called by a timer to check if the total number of failed - tracks has changed and updates the navigation label if needed. - """ - try: - live_total = len(self.parent_modal.permanently_failed_tracks) - - # Extract the current total from the label text "Track X of Y" - parts = self.track_position_label.text().split(' of ') - if len(parts) == 2: - displayed_total = int(parts[1]) - if live_total != displayed_total: - # If the total has changed, refresh the navigation state - self.update_navigation_state() - else: - # If the label is not in the expected format, update it anyway - self.update_navigation_state() - except (ValueError, IndexError): - # Handle cases where the label text is not yet set or in an unexpected format - self.update_navigation_state() - - - def _update_track_list(self): - """ - Syncs the modal's internal track list with the parent's live list, - preserving the user's current position. - """ - live_failed_tracks = self.parent_modal.permanently_failed_tracks - old_count = len(self.failed_tracks) if hasattr(self, 'failed_tracks') else 0 - - current_track_id = None - if self.current_track_info: - current_track_id = self.current_track_info.get('download_index') - - self.failed_tracks = list(live_failed_tracks) - new_count = len(self.failed_tracks) - - print(f"Track list sync: {old_count} → {new_count} failed tracks, current_track_id={current_track_id}") - - if not self.failed_tracks: - print("No failed tracks remaining") - return - - new_index = -1 - if current_track_id is not None: - for i, track in enumerate(self.failed_tracks): - if track.get('download_index') == current_track_id: - new_index = i - break - - old_index = self.current_track_index - if new_index != -1: - self.current_track_index = new_index - else: - # If the current track was resolved, stay at the same index - # but check bounds against the new list length. - if self.current_track_index >= len(self.failed_tracks): - self.current_track_index = len(self.failed_tracks) - 1 - - if self.current_track_index < 0: - self.current_track_index = 0 - - if old_index != self.current_track_index: - print(f"Index changed: {old_index} → {self.current_track_index}") - - def load_current_track(self): - """Loads the current failed track's info and intelligently triggers a search.""" - self.cancel_current_search() - self.clear_results() - - # Only sync track list if we don't already have the current track loaded - # This prevents the index from being reset when navigating - if not hasattr(self, 'failed_tracks') or len(self.failed_tracks) == 0: - self._update_track_list() - - if not self.failed_tracks: - QMessageBox.information(self, "Complete", "All failed tracks have been addressed.") - self.accept() - return - - # Ensure current_track_index is still valid after any potential sync - if self.current_track_index >= len(self.failed_tracks): - self.current_track_index = len(self.failed_tracks) - 1 - if self.current_track_index < 0: - self.current_track_index = 0 - - self.update_navigation_state() - - self.current_track_info = self.failed_tracks[self.current_track_index] - spotify_track = self.current_track_info['spotify_track'] - artist = spotify_track.artists[0] if spotify_track.artists else "Unknown" - - print(f"Loading track at index {self.current_track_index}: {spotify_track.name} by {artist}") - - # Use the original track name for the info label - self.info_label.setText(f"Could not find: {spotify_track.name}
by {artist}") - - # Use the ORIGINAL, UNCLEANED track name for the initial search query - self.search_input.setText(f"{artist} {spotify_track.name}") - - self.search_delay_timer.start(1000) - - def load_next_track(self): - """Navigate to the next failed track.""" - # Sync the track list first to handle any resolved tracks - self._update_track_list() - - print(f"Next clicked: current_index={self.current_track_index}, failed_tracks_count={len(self.failed_tracks)}") - - if self.current_track_index < len(self.failed_tracks) - 1: - self.current_track_index += 1 - print(f"Moving to next track: new_index={self.current_track_index}") - self.load_current_track() - else: - print(f"Already at last track (index {self.current_track_index} of {len(self.failed_tracks)})") - - def load_previous_track(self): - """Navigate to the previous failed track.""" - # Sync the track list first to handle any resolved tracks - self._update_track_list() - - if self.current_track_index > 0: - self.current_track_index -= 1 - self.load_current_track() - - def update_navigation_state(self): - """Update the 'Track X of Y' label and enable/disable nav buttons.""" - # Use the internal synchronized list for consistency - total_tracks = len(self.failed_tracks) - - # Ensure current_track_index is valid even if list shrinks - if self.current_track_index >= total_tracks: - self.current_track_index = max(0, total_tracks - 1) - - current_pos = self.current_track_index + 1 if total_tracks > 0 else 0 - - self.track_position_label.setText(f"Track {current_pos} of {total_tracks}") - self.prev_btn.setEnabled(self.current_track_index > 0) - self.next_btn.setEnabled(self.current_track_index < total_tracks - 1) - - def perform_manual_search(self): - """Initiates a search for the current query, cancelling any existing search.""" - self.search_delay_timer.stop() - self.cancel_current_search() - - query = self.search_input.text().strip() - if not query: return - - self.clear_results() - self.results_layout.addWidget(QLabel(f"

Searching for '{query}'...

")) - self.search_btn.hide() - self.cancel_search_btn.show() - - self.search_worker = self.SearchWorker(self.soulseek_client, query) - self.search_worker.signals.completed.connect(self.on_manual_search_completed) - self.search_worker.signals.failed.connect(self.on_manual_search_failed) - self.thread_pool.start(self.search_worker) - - def cancel_current_search(self): - """Stops the currently running search worker.""" - if self.search_worker: - self.search_worker.cancel() - self.search_worker = None - self.search_btn.show() - self.cancel_search_btn.hide() - - def on_manual_search_completed(self, results): - """Handles successful search results.""" - if not self.search_worker or self.search_worker.is_cancelled: - return - - self.cancel_current_search() - self.clear_results() - - if not results: - self.results_layout.addWidget(QLabel("

No results found for this query.

")) - return - - for result in results: - self.results_layout.addWidget(self.create_result_widget(result)) - - def on_manual_search_failed(self, error): - """Handles a failed search attempt.""" - if not self.search_worker or self.search_worker.is_cancelled: - return - - self.cancel_current_search() - self.clear_results() - self.results_layout.addWidget(QLabel(f"

Search failed:

{error}

")) - - def create_result_widget(self, result: TrackResult): - """Creates a styled widget for a single search result.""" - widget = QFrame() - widget.setStyleSheet(""" - QFrame { - background-color: #3a3a3a; - border: 1px solid #555555; - border-radius: 6px; - padding: 10px; - } - QFrame:hover { - border: 1px solid #1db954; - } - """) - layout = QHBoxLayout(widget) - - path_parts = result.filename.replace('\\', '/').split('/') - filename = path_parts[-1] - path_structure = '/'.join(path_parts[:-1]) - - size_kb = result.size // 1024 - info_text = (f"{filename}
" - f"{path_structure}
" - f"Quality: {result.quality.upper()}, " - f"Size: {size_kb:,} KB, " - f"User: {result.username}") - info_label = QLabel(info_text) - info_label.setWordWrap(True) - - select_btn = QPushButton("Select") - select_btn.setFixedWidth(100) - select_btn.setStyleSheet(""" - QPushButton { - background-color: #1db954; color: #000000; - } - QPushButton:hover { - background-color: #1ed760; - } - """) - select_btn.clicked.connect(lambda: self.on_selection_made(result)) - - layout.addWidget(info_label, 1) - layout.addWidget(select_btn) - return widget - - def on_selection_made(self, slskd_result): - """ - Handles user selecting a track. The parent modal removes the track from the - live list, and this modal will sync with that change on the next load. - """ - print(f"Manual selection made: {slskd_result.filename}") - - self.parent_modal.start_validated_download_parallel( - slskd_result, - self.current_track_info['spotify_track'], - self.current_track_info['track_index'], - self.current_track_info['table_index'], - self.current_track_info['download_index'] - ) - - self.track_resolved.emit(self.current_track_info) - - # Auto-advance to the next failed track after successful selection - # Use a small delay to allow the parent modal to update the failed tracks list - QTimer.singleShot(100, self._advance_to_next_track_after_resolution) - - def _advance_to_next_track_after_resolution(self): - """ - Advances to the next failed track after a successful manual resolution. - If no more tracks remain, closes the modal with a success message. - """ - # Sync the track list to reflect the resolved track being removed - self._update_track_list() - - if not self.failed_tracks: - # No more failed tracks - show success and close - QMessageBox.information(self, "Complete", "All failed tracks have been resolved! ") - self.accept() - return - - # Check if we need to adjust the current index after removal - if self.current_track_index >= len(self.failed_tracks): - self.current_track_index = len(self.failed_tracks) - 1 - - # Load the next track (which might be at the same index if current was removed) - print(f"Auto-advancing after resolution: index {self.current_track_index} of {len(self.failed_tracks)} remaining") - self.load_current_track() - - def clear_results(self): - """Removes all widgets from the results layout.""" - while self.results_layout.count(): - child = self.results_layout.takeAt(0) - if child.widget(): - child.widget().deleteLater() - - def closeEvent(self, event): - """Ensures any running search is cancelled when the modal is closed.""" - self.cancel_current_search() - self.search_delay_timer.stop() - self.live_update_timer.stop() # Stop the live update timer - super().closeEvent(event) - - # --- Inner classes for self-contained search worker --- - class SearchWorkerSignals(QObject): - completed = pyqtSignal(list) - failed = pyqtSignal(str) - - class SearchWorker(QRunnable): - def __init__(self, soulseek_client, query): - super().__init__() - self.soulseek_client = soulseek_client - self.query = query - self.signals = ManualMatchModal.SearchWorkerSignals() - self.is_cancelled = False - - def cancel(self): - self.is_cancelled = True - - def run(self): - if self.is_cancelled: - return - - loop = None - try: - import asyncio - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - search_result = loop.run_until_complete(self.soulseek_client.search(self.query)) - - if self.is_cancelled: - return - - if isinstance(search_result, tuple) and len(search_result) >= 1: - results_list = search_result[0] if search_result[0] else [] - else: - results_list = [] - - self.signals.completed.emit(results_list) - - except Exception as e: - if not self.is_cancelled: - self.signals.failed.emit(str(e)) - finally: - if loop: - loop.close() - - -class DownloadMissingTracksModal(QDialog): - """Enhanced modal for downloading missing tracks with live progress tracking""" - process_finished = pyqtSignal() - def __init__(self, playlist, playlist_item, parent_page, downloads_page, is_youtube_workflow=False): - super().__init__(parent_page) - self.playlist = playlist - self.playlist_item = playlist_item - self.parent_page = parent_page - self.parent_sync_page = parent_page # Reference to sync page for scan manager - self.downloads_page = downloads_page - self.matching_engine = MusicMatchingEngine() - self.wishlist_service = get_wishlist_service() - self.is_youtube_workflow = is_youtube_workflow # Flag to track if this is from YouTube discovery - - # State tracking - self.total_tracks = len(playlist.tracks) - self.matched_tracks_count = 0 - self.tracks_to_download_count = 0 - self.downloaded_tracks_count = 0 - self.analysis_complete = False - - # --- FIX: Initialize attributes to prevent crash on close --- - self.download_in_progress = False - self.cancel_requested = False - - self.permanently_failed_tracks = [] - self.cancelled_tracks = set() # Track indices of cancelled tracks - - print(f"Total tracks: {self.total_tracks}") - - # Track analysis results - self.analysis_results = [] - self.missing_tracks = [] - - # Worker tracking - self.active_workers = [] - self.fallback_pools = [] - - # 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.active_downloads = [] - - print("Setting up UI...") - self.setup_ui() - print("Modal initialization complete") - - def generate_smart_search_queries(self, artist_name, track_name): - """ - Generate smart search query variations with album-in-title detection. - Enhanced version with fallback strategies. - """ - # Create a mock spotify track object for the matching engine - 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 - - # Try to get album information from the track context if available - # In sync context, we might not always have album info, but try to extract it - album_title = None - # If track_name contains potential album info, we'll let the detection handle it - - mock_track = MockSpotifyTrack(track_name, [artist_name] if artist_name else [], album_title) - - # Use the enhanced matching engine to generate queries - queries = self.matching_engine.generate_download_queries(mock_track) - - # Add some legacy fallback queries for compatibility - legacy_queries = [] - - # Add first word of artist approach (legacy compatibility) - 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()) - - # Add track-only query - legacy_queries.append(track_name.strip()) - - # Add traditional cleaned queries - import re - cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() - cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() - - if cleaned_name and cleaned_name.lower() != track_name.lower(): - legacy_queries.append(cleaned_name.strip()) - - # Combine enhanced queries with legacy fallbacks - all_queries = queries + legacy_queries - - # Remove duplicates while preserving order - unique_queries = [] - seen = set() - for query in all_queries: - if query and query.lower() not in seen: - unique_queries.append(query) - seen.add(query.lower()) - - print(f"Generated {len(unique_queries)} smart queries for '{track_name}' (enhanced with album detection)") - for i, query in enumerate(unique_queries): - print(f" {i+1}. '{query}'") - - return unique_queries - - def setup_ui(self): - """Set up the enhanced modal UI""" - self.setWindowTitle(f"Download Missing Tracks - {self.playlist.name}") - self.resize(1200, 900) - self.setWindowFlags(Qt.WindowType.Window) - # self.setWindowFlags(Qt.WindowType.Dialog) - - 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 is_downloading(self): - """Check if any downloads are currently in progress""" - return (self.download_in_progress or - not self.analysis_complete or - len(self.active_workers) > 0 or - (hasattr(self, 'tracks_to_download_count') and - hasattr(self, 'downloaded_tracks_count') and - self.downloaded_tracks_count < self.tracks_to_download_count)) - - def create_compact_top_section(self): - """Create compact top section with header and dashboard combined""" - top_frame = QFrame() - top_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 15px; - } - """) - - layout = QVBoxLayout(top_frame) - layout.setSpacing(15) - - header_layout = QHBoxLayout() - title_section = QVBoxLayout() - title_section.setSpacing(2) - - title = QLabel("Download Missing Tracks") - title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - title.setStyleSheet("color: #1db954;") - - subtitle = QLabel(f"Playlist: {self.playlist.name}") - subtitle.setFont(QFont("Arial", 11)) - subtitle.setStyleSheet("color: #aaaaaa;") - - title_section.addWidget(title) - title_section.addWidget(subtitle) - - dashboard_layout = QHBoxLayout() - dashboard_layout.setSpacing(20) - - self.total_card = self.create_compact_counter_card("Total", str(self.total_tracks), "#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) - dashboard_layout.addStretch() - - 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): - """Create a compact counter card widget""" - card = QFrame() - card.setStyleSheet(f""" - QFrame {{ - background-color: #3a3a3a; border: 2px solid {color}; - border-radius: 6px; padding: 8px 12px; min-width: 80px; - }} - """) - - layout = QVBoxLayout(card) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) - - 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): - """Create compact dual progress bar section""" - progress_frame = QFrame() - progress_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 12px; - } - """) - - layout = QVBoxLayout(progress_frame) - layout.setSpacing(8) - - analysis_container = QVBoxLayout() - analysis_container.setSpacing(4) - - analysis_label = QLabel("Plex Analysis") - analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - analysis_label.setStyleSheet("color: #cccccc;") - - self.analysis_progress = QProgressBar() - self.analysis_progress.setFixedHeight(20) - self.analysis_progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #555555; border-radius: 10px; text-align: center; - background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; - } - 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_container.setSpacing(4) - - download_label = QLabel("⬇️ Download Progress") - download_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - download_label.setStyleSheet("color: #cccccc;") - - self.download_progress = QProgressBar() - self.download_progress.setFixedHeight(20) - self.download_progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #555555; border-radius: 10px; text-align: center; - background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; - } - 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""" - table_frame = QFrame() - table_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 0px; - } - """) - - layout = QVBoxLayout(table_frame) - layout.setContentsMargins(15, 15, 15, 15) - layout.setSpacing(10) - - header_label = QLabel("Track Analysis") - header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold)) - header_label.setStyleSheet("color: #ffffff; padding: 5px;") - - self.track_table = QTableWidget() - self.track_table.setColumnCount(6) - self.track_table.setHorizontalHeaderLabels(["Track", "Artist", "Duration", "Matched", "Status", "Cancel"]) - self.track_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.track_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) - self.track_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) - self.track_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) - self.track_table.setColumnWidth(2, 90) - self.track_table.setColumnWidth(3, 140) - self.track_table.setColumnWidth(5, 70) - - self.track_table.setStyleSheet(""" - QTableWidget { - background-color: #3a3a3a; alternate-background-color: #424242; - selection-background-color: #1db954; selection-color: #000000; - gridline-color: #555555; color: #ffffff; border: 1px solid #555555; - font-size: 12px; - } - QHeaderView::section { - background-color: #1db954; color: #000000; 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().setDefaultSectionSize(50) - self.track_table.verticalHeader().setVisible(False) - - self.populate_track_table() - - layout.addWidget(header_label) - layout.addWidget(self.track_table) - - return table_frame - - def populate_track_table(self): - """Populate track table with playlist tracks""" - self.track_table.setRowCount(len(self.playlist.tracks)) - for i, track in enumerate(self.playlist.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 = self.format_duration(track.duration_ms) - duration_item = QTableWidgetItem(duration) - duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.track_table.setItem(i, 2, duration_item) - matched_item = QTableWidgetItem("Pending") - matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.track_table.setItem(i, 3, matched_item) - status_item = QTableWidgetItem("—") - status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.track_table.setItem(i, 4, status_item) - - # Create empty container for cancel button (will be populated later for missing tracks only) - container = QWidget() - container.setStyleSheet("background: transparent;") - layout = QVBoxLayout(container) - layout.setContentsMargins(5, 5, 5, 5) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.track_table.setCellWidget(i, 5, container) - - for col in range(5): - self.track_table.item(i, col).setFlags(self.track_table.item(i, col).flags() & ~Qt.ItemFlag.ItemIsEditable) - - def format_duration(self, duration_ms): - """Convert milliseconds to MM:SS format""" - seconds = duration_ms // 1000 - return f"{seconds // 60}:{seconds % 60:02d}" - - def add_cancel_button_to_row(self, row): - """Add cancel button to a specific row (only for missing tracks)""" - container = self.track_table.cellWidget(row, 5) - if container and container.layout().count() == 0: # Only add if container is empty - cancel_button = QPushButton("×") - cancel_button.setFixedSize(20, 20) - cancel_button.setMinimumSize(20, 20) - cancel_button.setMaximumSize(20, 20) - cancel_button.setStyleSheet(""" - QPushButton { - background-color: #dc3545; - color: white; - border: 1px solid #c82333; - border-radius: 3px; - font-size: 14px; - font-weight: bold; - padding: 0px; - margin: 0px; - text-align: center; - min-width: 20px; - max-width: 20px; - width: 20px; - } - QPushButton:hover { - background-color: #c82333; - border-color: #bd2130; - } - QPushButton:pressed { - background-color: #bd2130; - border-color: #b21f2d; - } - QPushButton:disabled { - background-color: #28a745; - color: white; - border-color: #1e7e34; - } - """) - cancel_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - cancel_button.clicked.connect(lambda checked, row_idx=row: self.cancel_track(row_idx)) - - layout = container.layout() - layout.addWidget(cancel_button) - - def hide_cancel_button_for_row(self, row): - """Hide cancel button for a specific row (when track is downloaded)""" - container = self.track_table.cellWidget(row, 5) - if container: - layout = container.layout() - if layout and layout.count() > 0: - cancel_button = layout.itemAt(0).widget() - if cancel_button: - cancel_button.setVisible(False) - print(f"🫥 Hidden cancel button for downloaded track at row {row}") - - def cancel_track(self, row): - """Cancel a specific track - works at any phase""" - # Get cancel button and disable it - container = self.track_table.cellWidget(row, 5) - if container: - layout = container.layout() - if layout and layout.count() > 0: - cancel_button = layout.itemAt(0).widget() - if cancel_button: - cancel_button.setEnabled(False) - cancel_button.setText("") - - # Update status to cancelled - self.track_table.setItem(row, 4, QTableWidgetItem("Cancelled")) - - # Add to cancelled tracks set - if not hasattr(self, 'cancelled_tracks'): - self.cancelled_tracks = set() - self.cancelled_tracks.add(row) - - track = self.playlist.tracks[row] - print(f"Track cancelled: {track.name} (row {row})") - - # If downloads are active, also handle active download cancellation - download_index = None - - # Check active_downloads list - if hasattr(self, 'active_downloads'): - for download in self.active_downloads: - if download.get('table_index') == row: - download_index = download.get('download_index', row) - print(f"Found active download {download_index} for cancelled track") - break - - # Check parallel_search_tracking for download index - if download_index is None and hasattr(self, 'parallel_search_tracking'): - for idx, track_info in self.parallel_search_tracking.items(): - if track_info.get('table_index') == row: - download_index = idx - print(f"Found parallel tracking {download_index} for cancelled track") - break - - # If we found an active download, trigger completion to free up the worker - if download_index is not None and hasattr(self, 'on_parallel_track_completed'): - print(f"Triggering completion for active download {download_index}") - self.on_parallel_track_completed(download_index, success=False) - - def create_buttons(self): - """Create improved button section""" - button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;") - layout = QHBoxLayout(button_frame) - layout.setSpacing(15) - layout.setContentsMargins(0, 10, 0, 0) - - self.correct_failed_btn = QPushButton("Correct Failed Matches") - self.correct_failed_btn.setFixedWidth(220) - self.correct_failed_btn.setStyleSheet(""" - QPushButton { background-color: #ffc107; color: #000000; border-radius: 20px; font-weight: bold; } - QPushButton:hover { background-color: #ffca28; } - """) - 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) - # THIS IS THE FIX: The specific stylesheet for this button is restored below - self.begin_search_btn.setStyleSheet(""" - QPushButton { - background-color: #1db954; color: #000000; border: none; - border-radius: 20px; font-size: 14px; font-weight: bold; - } - QPushButton:hover { background-color: #1ed760; } - """) - 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: #ffffff; border-radius: 20px;} - QPushButton:hover { background-color: #f44336; } - """) - 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: #ffffff; border-radius: 20px;} - QPushButton:hover { background-color: #757575; } - """) - 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 - - - def on_begin_search_clicked(self): - """Handle Begin Search button click - starts Plex analysis""" - # Only update refresh button state for Spotify workflows, not YouTube workflows - if not self.is_youtube_workflow: - # --- FIX: Trigger the UI change on the main page --- - # This is the correct point to signal that the process has started. - self.parent_page.on_download_process_started(self.playlist.id, self.playlist_item) - - 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 # Set flag - self.start_plex_analysis() - - - def start_plex_analysis(self): - """Start media server analysis using existing worker""" - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - if active_server == "plex": - media_client = getattr(self.parent_page, 'plex_client', None) - else: # jellyfin - media_client = getattr(self.parent_page, 'jellyfin_client', None) - - worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, media_client, active_server) - 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 on_analysis_started(self, total_tracks): - print(f"Analysis started for {total_tracks} tracks") - - def on_track_analyzed(self, track_index, result): - """Handle individual track analysis completion with live UI updates""" - self.analysis_progress.setValue(track_index) - row_index = track_index - 1 - 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)) - else: - matched_text = "Missing" - self.tracks_to_download_count += 1 - self.download_count_label.setText(str(self.tracks_to_download_count)) - # Add cancel button for missing tracks only - self.add_cancel_button_to_row(row_index) - self.track_table.setItem(row_index, 3, QTableWidgetItem(matched_text)) - - def on_analysis_completed(self, results): - """Handle analysis completion""" - self.analysis_complete = True - self.analysis_results = results - self.missing_tracks = [r for r in results if not r.exists_in_plex] - print(f"Analysis complete: {len(self.missing_tracks)} to download") - if self.missing_tracks: - # --- FIX: This line was missing, which prevented downloads from starting. --- - self.start_download_progress() - else: - # Handle case where no tracks are missing - self.download_in_progress = False # Mark process as finished - self.cancel_btn.hide() - - # If this is a YouTube workflow, clean up status widget (no downloads needed) - if self.is_youtube_workflow and hasattr(self.parent_page, 'show_youtube_placeholder'): - self.parent_page.show_youtube_placeholder() - if self.playlist.id in self.parent_page.active_youtube_download_modals: - del self.parent_page.active_youtube_download_modals[self.playlist.id] - - # The modal now stays open. - # The process_finished signal is still emitted to unlock the main UI. - self.process_finished.emit() - # Get server name for message - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name = active_server.title() if active_server else "Plex" - except: - server_name = "Plex" - - QMessageBox.information(self, "Analysis Complete", f"All tracks already exist in {server_name}! No downloads needed.") - - def on_analysis_failed(self, error_message): - print(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): - """Start actual download progress tracking""" - 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): - """Start multiple track downloads in parallel for better performance""" - 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): - """Start the next batch of downloads up to the concurrent limit""" - 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) - - # Skip if track was cancelled - if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks: - print(f"Skipping cancelled track at index {track_index}: {track.name}") - self.download_queue_index += 1 - self.completed_downloads += 1 - continue - - self.track_table.setItem(track_index, 4, 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 - - # Check if we're done: either all downloads completed OR all remaining work is done - downloads_complete = (self.download_queue_index >= len(self.missing_tracks) and self.active_parallel_downloads == 0) - all_work_complete = (self.completed_downloads >= len(self.missing_tracks)) - - if downloads_complete or all_work_complete: - self.on_all_downloads_complete() - - def search_and_download_track_parallel(self, spotify_track, download_index, track_index): - """Search for track and download via infrastructure path - PARALLEL VERSION""" - 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): - """Start track search with parallel completion handling""" - 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): - """Start search worker with parallel completion handling.""" - 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.parent_page.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): - """Handle completion of a parallel search query. If it fails, trigger the next query.""" - if hasattr(self, 'cancel_requested') and self.cancel_requested: return - - valid_candidates = self.get_valid_candidates(results, spotify_track, query) - - if valid_candidates: - # IMPORTANT: Cache the candidates for future retries - 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): - """ - Start download with validated metadata. This is used for both initial downloads - and for manual retries from the 'Correct Failed Matches' modal. - """ - track_info = self.parallel_search_tracking[download_index] - - # --- FIX --- - # If this track was previously marked as 'completed' (e.g., from a failure), - # we need to reset its state to allow the new download attempt to be tracked correctly. - if track_info.get('completed', False): - print(f"Resetting state for manually retried track (index: {download_index}).") - track_info['completed'] = False - - # Decrement the failed count since we are retrying it. - if self.failed_downloads > 0: - self.failed_downloads -= 1 - - # This download is now active again. The counter was decremented when it failed, - # so we increment it here to reflect its new active status. - self.active_parallel_downloads += 1 - - # The 'completed_downloads' counter was incremented when the track originally failed. - # We decrement it here so the overall progress calculation remains accurate when - # this new download attempt completes. - if self.completed_downloads > 0: - self.completed_downloads -= 1 - - # Add the new download source to the used sources to prevent retrying with the same user/file - source_key = f"{getattr(slskd_result, 'username', 'unknown')}_{slskd_result.filename}" - track_info['used_sources'].add(source_key) - - # Update UI to show the new download has been queued - spotify_based_result = self.create_spotify_based_search_result_from_validation(slskd_result, spotify_metadata) - print(f"Updating table at index {table_index} to '... Queued' for manual retry") - self.track_table.setItem(table_index, 4, QTableWidgetItem("... Queued")) - - # Start the actual download process - 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): - """Start infrastructure download with parallel completion tracking""" - 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): - """ - Starts the background worker to process download statuses. - This version is updated to use the new worker and pass the correct data. - """ - if self._is_status_update_running or not self.active_downloads: - return - self._is_status_update_running = True - - # Create a snapshot of data needed by the worker thread - items_to_check = [] - for d in self.active_downloads: - # Ensure slskd_result exists and has a filename - if d.get('slskd_result') and hasattr(d['slskd_result'], 'filename'): - # Pass the current missing count to the worker so it can be incremented - items_to_check.append({ - 'widget_id': d['download_index'], - 'download_id': d.get('download_id'), # Use .get for safety - '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 - - # The new worker doesn't need the transfers directory. - worker = SyncStatusProcessingWorker( - self.parent_page.soulseek_client, - items_to_check - ) - - worker.signals.completed.connect(self._handle_processed_status_updates) - worker.signals.error.connect(lambda e: print(f"Status Worker Error: {e}")) - self.download_status_pool.start(worker) - - - - - def _handle_processed_status_updates(self, results): - """ - Applies status updates from the background worker and triggers retry logic. - This version correctly handles the payload from the new worker and adds a timeout for stuck downloads. - """ - import time - - # Create a lookup for faster access to active download items - 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 - - # Update the main download_info object with the latest missing count from the worker - # This is important for the grace period logic to work across polls. - if 'api_missing_count' in result: - download_info['api_missing_count'] = result['api_missing_count'] - - # Update the download_id if the worker found a match by filename - if result.get('transfer_id') and download_info.get('download_id') != result['transfer_id']: - print(f"ℹ️ Corrected download ID for '{download_info['slskd_result'].filename}'") - download_info['download_id'] = result['transfer_id'] - - # Handle terminal states (completed, failed, cancelled) - 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) - - # Handle transient states (downloading, queued) - elif new_status == 'downloading': - progress = result.get('progress', 0) - self.track_table.setItem(download_info['table_index'], 4, QTableWidgetItem(f"⏬ Downloading ({progress}%)")) - - # Reset queue timer if it exists - if 'queued_start_time' in download_info: - del download_info['queued_start_time'] - - # --- FIX: Add timeout for downloads stuck at 0% --- - # This handles cases where the API reports "InProgress" but no data is moving. - if progress < 1: - if 'downloading_start_time' not in download_info: - download_info['downloading_start_time'] = time.time() - # 90-second timeout for being stuck at 0% - elif time.time() - download_info['downloading_start_time'] > 90: - print(f"Download for '{download_info['slskd_result'].filename}' is stuck at 0%. Cancelling and retrying.") - # Cancel the old download before retry - 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: - # Progress is being made, reset the timer - 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'], 4, QTableWidgetItem("... Queued")) - # Start a timer to detect if it's stuck in queue - if 'queued_start_time' not in download_info: - download_info['queued_start_time'] = time.time() - elif time.time() - download_info['queued_start_time'] > 90: # 90-second timeout - print(f"Download for '{download_info['slskd_result'].filename}' is stuck in queue. Cancelling and retrying.") - # Cancel the old download before retry - 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): - """Cancel the current download before retrying with alternative source""" - try: - slskd_result = download_info.get('slskd_result') - if not slskd_result: - print("No slskd_result found in download_info for cancellation") - return - - # Extract download details for cancellation - download_id = download_info.get('download_id') - username = getattr(slskd_result, 'username', None) - - if download_id and username: - print(f"Cancelling timed-out download: {download_id} from {username}") - - # Use asyncio to call the async cancel method - import asyncio - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - success = loop.run_until_complete( - self.soulseek_client.cancel_download(download_id, username, remove=False) - ) - if success: - print(f"Successfully cancelled download {download_id}") - else: - print(f"Failed to cancel download {download_id}") - finally: - loop.close() - else: - print(f"Missing download_id ({download_id}) or username ({username}) for cancellation") - - except Exception as e: - print(f"Error cancelling download: {e}") - - def retry_parallel_download_with_fallback(self, failed_download_info): - """Retries a failed download by selecting the next-best cached candidate.""" - 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: # Max 3 attempts total (1 initial + 2 retries) - 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 - - print(f"Retrying download {download_index + 1} with next candidate: {next_candidate.filename}") - self.track_table.setItem(failed_download_info['table_index'], 4, 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): - """Handle completion of a parallel track download""" - if not hasattr(self, 'parallel_search_tracking'): - print(f"parallel_search_tracking not initialized yet, skipping completion for download {download_index}") - return - 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: - print(f"Track {download_index} completed successfully - updating table index {track_info['table_index']} to 'Downloaded'") - self.track_table.setItem(track_info['table_index'], 4, QTableWidgetItem("Downloaded")) - # Hide cancel button since track is now downloaded - self.hide_cancel_button_for_row(track_info['table_index']) - self.downloaded_tracks_count += 1 - # --- FIX --- - # Corrected the label update to use the incremented counter variable. - self.downloaded_count_label.setText(str(self.downloaded_tracks_count)) - self.successful_downloads += 1 - - # Update YouTube card progress if this is a YouTube workflow - if self.is_youtube_workflow and hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'): - self.parent_page.update_youtube_card_progress( - self.youtube_url, - total=len(self.missing_tracks), - matched=self.successful_downloads, - failed=len(self.permanently_failed_tracks) - ) - else: - # Check if track was cancelled (don't overwrite cancelled status) - table_index = track_info['table_index'] - current_status = self.track_table.item(table_index, 4) - if current_status and "Cancelled" in current_status.text(): - print(f"Track {download_index} was cancelled - preserving cancelled status") - else: - print(f"Track {download_index} failed - updating table index {table_index} to 'Failed'") - self.track_table.setItem(table_index, 4, QTableWidgetItem("Failed")) - if track_info not in self.permanently_failed_tracks: - self.permanently_failed_tracks.append(track_info) - self.update_failed_matches_button() - self.failed_downloads += 1 - - # Update YouTube card progress if this is a YouTube workflow - if self.is_youtube_workflow and hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'): - self.parent_page.update_youtube_card_progress( - self.youtube_url, - total=len(self.missing_tracks), - matched=self.successful_downloads, - failed=len(self.permanently_failed_tracks) - ) - - 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): - """Handle failure of a parallel track download""" - print(f"Parallel download {download_index + 1} failed: {reason}") - self.on_parallel_track_completed(download_index, False) - - def update_failed_matches_button(self): - """Shows, hides, and updates the counter on the 'Correct Failed Matches' button.""" - 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): - """Opens the modal to manually correct failed downloads.""" - if not self.permanently_failed_tracks: return - 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): - """Handles a track being successfully resolved by the ManualMatchModal.""" - print(f"Manual match resolved - download_index: {resolved_track_info.get('download_index')}, table_index: {resolved_track_info.get('table_index')}") - 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) - print(f"Removed track from permanently_failed_tracks - remaining: {len(self.permanently_failed_tracks)}") - - # Update progress bar to account for manually resolved track - # The track was manually resolved, so we need to count it as "completed" - self.successful_downloads += 1 - self.completed_downloads += 1 - # Update the progress bar maximum to reflect the actual remaining work - total_remaining_work = len(self.missing_tracks) - (self.successful_downloads - len(self.permanently_failed_tracks)) - if total_remaining_work > 0: - # Recalculate progress: completed work / total original work - progress_value = self.completed_downloads - self.download_progress.setValue(progress_value) - print(f"Updated progress: {progress_value}/{self.download_progress.maximum()} (manual fix)") - else: - print("Could not find original failed track to remove") - self.update_failed_matches_button() - - def find_track_index_in_playlist(self, spotify_track): - """Find the table row index for a given Spotify track""" - for i, playlist_track in enumerate(self.playlist.tracks): - if playlist_track.id == spotify_track.id: - return i - return None - - def on_all_downloads_complete(self): - """Handle completion of all downloads""" - self.download_in_progress = False - print("All downloads completed!") - self.cancel_btn.hide() - - # If this is a YouTube workflow, update card and clean up - if self.is_youtube_workflow: - # Update card to download_complete phase - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'): - self.parent_page.update_youtube_card_phase(self.youtube_url, 'download_complete') - - # Clean up status widget - if hasattr(self.parent_page, 'show_youtube_placeholder'): - self.parent_page.show_youtube_placeholder() - if self.playlist.id in self.parent_page.active_youtube_download_modals: - del self.parent_page.active_youtube_download_modals[self.playlist.id] - - # If this is a Tidal workflow, update card and clean up - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): - # Update Tidal card to download_complete phase - if hasattr(self.parent_page, 'update_tidal_card_phase'): - self.parent_page.update_tidal_card_phase(self.playlist_id, 'download_complete') - - # Clean up download modal reference from Tidal state - if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states: - state = self.parent_page.tidal_playlist_states[self.playlist_id] - if state.get('download_modal') == self: - state['download_modal'] = None - - # Remove from active download modals - if self.playlist.id in self.parent_page.active_youtube_download_modals: - del self.parent_page.active_youtube_download_modals[self.playlist.id] - - # The process_finished signal is still emitted to unlock the main UI. - self.process_finished.emit() - - # Request Plex library scan if we have successful downloads - if self.successful_downloads > 0 and hasattr(self, 'parent_sync_page') and self.parent_sync_page.scan_manager: - self.parent_sync_page.scan_manager.request_scan(f"Playlist download completed ({self.successful_downloads} tracks)") - - # Add cancelled tracks that were missing from Plex to permanently_failed_tracks for wishlist inclusion - if hasattr(self, 'cancelled_tracks') and hasattr(self, 'missing_tracks'): - for cancelled_row in self.cancelled_tracks: - # Check if this cancelled track was actually missing from Plex - cancelled_track = self.playlist.tracks[cancelled_row] - missing_track_result = None - - # Find the corresponding missing track result - for missing_result in self.missing_tracks: - if missing_result.spotify_track.id == cancelled_track.id: - missing_track_result = missing_result - break - - # Only add to wishlist if track was actually missing from Plex AND not successfully downloaded - if missing_track_result: - # Check if track was successfully downloaded (don't add downloaded tracks to wishlist) - status_item = self.track_table.item(cancelled_row, 4) - current_status = status_item.text() if status_item else "" - - if "Downloaded" in current_status: - print(f"Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist addition") - else: - cancelled_track_info = { - 'download_index': cancelled_row, - 'table_index': cancelled_row, - 'track': cancelled_track, - 'track_name': cancelled_track.name, - 'artist_name': cancelled_track.artists[0] if cancelled_track.artists else "Unknown", - 'retry_count': 0, - 'spotify_track': missing_track_result.spotify_track # Include the spotify track for wishlist - } - # Check if not already in permanently_failed_tracks - if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks): - self.permanently_failed_tracks.append(cancelled_track_info) - print(f"Added cancelled missing track {cancelled_track.name} to failed list for wishlist") - else: - print(f"Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist addition") - - # Add permanently failed tracks to wishlist before showing completion message - failed_count = len(self.permanently_failed_tracks) - wishlist_added_count = 0 - - if self.permanently_failed_tracks: - try: - # Add failed tracks to wishlist - source_context = { - 'playlist_name': getattr(self.playlist, 'name', 'Unknown Playlist'), - 'playlist_id': getattr(self.playlist, 'id', None), - 'added_from': 'sync_page_modal', - 'timestamp': datetime.now().isoformat() - } - - for failed_track_info in self.permanently_failed_tracks: - try: - success = self.wishlist_service.add_failed_track_from_modal( - track_info=failed_track_info, - source_type='playlist', - source_context=source_context - ) - if success: - wishlist_added_count += 1 - except Exception as e: - logger.error(f"Failed to add track to wishlist: {e}") - - if wishlist_added_count > 0: - logger.info(f"Added {wishlist_added_count} failed tracks to wishlist from playlist '{self.playlist.name}'") - - except Exception as e: - logger.error(f"Error adding failed tracks to wishlist: {e}") - - # Determine the final message based on success or failure. - if self.permanently_failed_tracks: - final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n" - - if wishlist_added_count > 0: - final_message += f"Added {wishlist_added_count} failed track{'s' if wishlist_added_count != 1 else ''} to wishlist for automatic retry.\n\n" - - final_message += "You can also manually correct failed downloads or check the wishlist on the dashboard." - - # If there are failures, ensure the modal is visible and bring it to the front. - if self.isHidden(): - self.show() - self.activateWindow() - self.raise_() - else: - final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\nAll tracks were downloaded successfully!" - - QMessageBox.information(self, "Downloads Complete", final_message) - - def on_cancel_clicked(self): - """Handle Cancel button - cancels operations, resets state, and closes modal.""" - print("Cancel button clicked - cancelling all operations and cleaning up") - - self.cancel_operations() - self.download_in_progress = False # CRITICAL: Reset the state flag. - - if self.is_youtube_workflow: - # Revert the main card to the discovery phase. - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'): - print("Returning YouTube playlist to discovery_complete state") - self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete') - - # Handle Tidal playlist cancel - revert to discovery_complete phase - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): - if hasattr(self.parent_page, 'update_tidal_card_phase'): - print("Returning Tidal playlist to discovery_complete state") - self.parent_page.update_tidal_card_phase(self.playlist_id, 'discovery_complete') - - # Clean up download modal reference from Tidal state - if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states: - state = self.parent_page.tidal_playlist_states[self.playlist_id] - if state.get('download_modal') == self: - state['download_modal'] = None - - # Clean up this modal's reference. - if self.playlist.id in self.parent_page.active_youtube_download_modals: - del self.parent_page.active_youtube_download_modals[self.playlist.id] - - # --- THE FIX --- - # This block now correctly finds and removes the temporary "green card" (the status widget) - # without affecting the main playlist card. This prevents the card from disappearing - # on subsequent cancellations. - if (hasattr(self.parent_page, 'youtube_status_widgets') and - self.playlist.id in self.parent_page.youtube_status_widgets): - print(f"Cleaning up YouTube status widget on cancel for playlist: {self.playlist.id}") - status_widget = self.parent_page.youtube_status_widgets.pop(self.playlist.id, None) - if status_widget: - status_widget.setParent(None) - status_widget.deleteLater() - - self.process_finished.emit() - self.reject() # This properly closes and destroys the modal. - - def on_close_clicked(self): - """Handle the 'Close' button by triggering the modal's unified close event.""" - self.close() - - def cancel_operations(self): - """Cancel any ongoing operations, including active slskd downloads.""" - print("Cancelling all operations for this playlist...") - self.cancel_requested = True # Flag to stop any new workers from starting. - - # --- FIX: Actively cancel downloads on the slskd server --- - if self.active_downloads: - print(f"Requesting cancellation for {len(self.active_downloads)} active download(s)...") - - import asyncio - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - soulseek_client = self.parent_page.soulseek_client - - # Create tasks to cancel all active downloads concurrently - tasks = [] - for download_info in self.active_downloads: - download_id = download_info.get('download_id') - # Assumes the soulseek_client has a method to make raw API calls. - # A DELETE request is standard for cancellation in RESTful APIs like slskd's. - if download_id and hasattr(soulseek_client, '_make_request'): - tasks.append( - soulseek_client._make_request('DELETE', f'transfers/downloads/{download_id}') - ) - - if tasks: - try: - # Wait for all cancellation requests to be sent - loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) - print("All cancellation requests sent to slskd.") - except Exception as e: - print(f"An error occurred while sending cancellation requests: {e}") - - # Cancel background workers (like the initial Plex analysis) - for worker in self.active_workers: - if hasattr(worker, 'cancel'): - worker.cancel() - self.active_workers.clear() - - # Clean up any fallback thread pools - for pool in self.fallback_pools: - pool.waitForDone(1000) - self.fallback_pools.clear() - - # Stop the status polling timer to prevent further checks - self.download_status_timer.stop() - print("Modal operations cancelled successfully.") - - def closeEvent(self, event): - """Override the window's close event to provide custom logic.""" - if self.download_in_progress and not self.cancel_requested: - print("Download in progress. Hiding modal and updating card phase.") - self.hide() - event.ignore() # Prevent the modal from being destroyed. - - # --- THE FIX --- - # Instead of showing the status widget directly, this now tells the main - # page to transition the card to the 'downloading' phase. - if self.is_youtube_workflow and hasattr(self, 'youtube_url'): - if hasattr(self.parent_page, 'update_youtube_card_phase'): - self.parent_page.update_youtube_card_phase(self.youtube_url, 'downloading') - - # Handle Tidal playlist downloading phase update - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): - if hasattr(self.parent_page, 'update_tidal_card_phase'): - self.parent_page.update_tidal_card_phase(self.playlist_id, 'downloading') - return - - print("No download in progress or cancel requested. Performing full cleanup.") - self.on_cancel_clicked() - # on_cancel_clicked() calls self.reject(), which will properly accept the close event. - - # Inner class for the search worker - 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: - import asyncio - 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): - """ - Scores and filters search results, then performs a strict artist verification - by checking the file path. This prevents downloading tracks from the wrong artist. - """ - if not results: - return [] - - # Step 1: Get initial confident matches with version-aware scoring - # This gives us a sorted list of potential candidates, preferring originals. - initial_candidates = self.matching_engine.find_best_slskd_matches_enhanced(spotify_track, results) - - if not initial_candidates: - print(f"No initial candidates found for '{spotify_track.name}' from query '{query}'.") - return [] - - print(f"Found {len(initial_candidates)} initial candidates for '{spotify_track.name}'. Now verifying artist...") - - # Step 2: Perform strict artist verification on the initial candidates. - verified_candidates = [] - spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else "" - - # **IMPROVEMENT**: More robust normalization for both artist name and file path. - # This removes all non-alphanumeric characters and converts to lowercase. - # e.g., "Virtual Mage" -> "virtualmage", "virtual-mage" -> "virtualmage" - normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower() - - for candidate in initial_candidates: - # The 'filename' from Soulseek includes the full folder path. - slskd_full_path = candidate.filename - - # Apply the same robust normalization to the Soulseek path. - normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', slskd_full_path).lower() - - # **THE CRITICAL CHECK**: See if the cleaned artist's name is in the cleaned folder path. - if normalized_spotify_artist in normalized_slskd_path: - # Artist name was found in the path, this is a valid candidate. - print(f"Artist '{spotify_artist_name}' VERIFIED in path: '{slskd_full_path}'") - verified_candidates.append(candidate) - else: - # Artist name was NOT found. Discard this candidate. - print(f"Artist '{spotify_artist_name}' NOT found in path: '{slskd_full_path}'. Discarding candidate.") - - if verified_candidates: - # Apply quality profile filtering before returning - if hasattr(self.parent_page, 'soulseek_client'): - quality_filtered = self.parent_page.soulseek_client.filter_results_by_quality_preference( - verified_candidates - ) - - if quality_filtered: - verified_candidates = quality_filtered - print(f"Applied quality profile filtering: {len(verified_candidates)} candidates remain") - else: - print(f"Quality profile filtering removed all candidates, keeping originals") - - best_confidence = verified_candidates[0].confidence - best_version = getattr(verified_candidates[0], 'version_type', 'unknown') - best_quality = getattr(verified_candidates[0], 'quality', 'unknown') - print(f"Found {len(verified_candidates)} VERIFIED matches for '{spotify_track.name}'. Best: {best_confidence:.2f} ({best_version}, {best_quality.upper()})") - - # Log version breakdown for debugging - for candidate in verified_candidates[:3]: # Show top 3 - version = getattr(candidate, 'version_type', 'unknown') - penalty = getattr(candidate, 'version_penalty', 0.0) - quality = getattr(candidate, 'quality', 'unknown') - bitrate_info = f" {candidate.bitrate}kbps" if hasattr(candidate, 'bitrate') and candidate.bitrate else "" - print(f" {candidate.confidence:.2f} - {version} ({quality.upper()}{bitrate_info}) (penalty: {penalty:.2f}) - {candidate.filename[:80]}...") - - else: - print(f"No verified matches found for '{spotify_track.name}' after checking file paths.") - - return verified_candidates - def create_spotify_based_search_result_from_validation(self, slskd_result, spotify_metadata): - """Create SpotifyBasedSearchResult from validation results""" - 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() - - -class YouTubeDownloadMissingTracksModal(QDialog): - """Enhanced modal for downloading YouTube playlist tracks with Spotify discovery""" - process_finished = pyqtSignal() - - def __init__(self, playlist, playlist_item, parent_page, downloads_page): - super().__init__(parent_page) - self.playlist = playlist # YouTube playlist with cleaned tracks - self.playlist_item = playlist_item - self.parent_page = parent_page - self.downloads_page = downloads_page - self.total_tracks = len(playlist.tracks) if playlist else 0 - - # Progress tracking - self.spotify_discovered_tracks = [None] * self.total_tracks # List of discovered Spotify tracks - self.spotify_search_completed = False - self.spotify_worker = None - - # UI components - self.track_table = None - self.analysis_progress = None - self.spotify_progress = None - self.begin_search_btn = None - self.cancel_btn = None - self.sync_btn = None - - # Sync state tracking - self.sync_in_progress = False - self.is_youtube_workflow = True - - self.setup_ui() - if self.playlist and self.total_tracks > 0: - self.populate_initial_table() - self.start_spotify_discovery() - - def setup_ui(self): - """Set up the modal UI for YouTube or Tidal playlist discovery""" - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: - self.setWindowTitle(f"Tidal Playlist Discovery - {self.playlist.name}") - else: - self.setWindowTitle(f"YouTube Playlist Discovery - {self.playlist.name}") - self.resize(1400, 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) - - # Header section - header_section = self.create_header_section() - main_layout.addWidget(header_section) - - # Progress section - progress_section = self.create_progress_section() - main_layout.addWidget(progress_section) - - # Table section - table_section = self.create_track_table() - main_layout.addWidget(table_section, stretch=1) - - # Button section - button_section = self.create_buttons() - main_layout.addWidget(button_section) - - def create_header_section(self): - """Create header with title and summary""" - header_frame = QFrame() - header_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 15px; - } - """) - - layout = QVBoxLayout(header_frame) - - title = QLabel("YouTube Playlist Discovery") - title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) - title.setStyleSheet("color: #1db954;") - - subtitle = QLabel(f"Playlist: {self.playlist.name} ({self.total_tracks} tracks)") - subtitle.setFont(QFont("Arial", 11)) - subtitle.setStyleSheet("color: #aaaaaa;") - - description = QLabel("Discovering clean Spotify metadata for YouTube tracks...") - description.setFont(QFont("Arial", 10)) - description.setStyleSheet("color: #888888;") - - layout.addWidget(title) - layout.addWidget(subtitle) - layout.addWidget(description) - - return header_frame - - def create_progress_section(self): - """Create progress tracking section""" - progress_frame = QFrame() - progress_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 15px; - } - """) - - layout = QVBoxLayout(progress_frame) - - # Spotify discovery progress - spotify_label = QLabel("Spotify Discovery Progress") - spotify_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - - self.spotify_progress = QProgressBar() - self.spotify_progress.setFixedHeight(20) - self.spotify_progress.setMaximum(self.total_tracks) - self.spotify_progress.setValue(0) - self.spotify_progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #555555; border-radius: 10px; text-align: center; - background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; - } - QProgressBar::chunk { background-color: #1db954; border-radius: 9px; } - """) - - # Plex analysis progress (hidden initially) - analysis_label = QLabel("Plex Analysis Progress") - analysis_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) - - self.analysis_progress = QProgressBar() - self.analysis_progress.setFixedHeight(20) - self.analysis_progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #555555; border-radius: 10px; text-align: center; - background-color: #444444; color: #ffffff; font-size: 11px; font-weight: bold; - } - QProgressBar::chunk { background-color: #ff6b6b; border-radius: 9px; } - """) - self.analysis_progress.setVisible(False) - analysis_label.setVisible(False) - - layout.addWidget(spotify_label) - layout.addWidget(self.spotify_progress) - layout.addWidget(analysis_label) - layout.addWidget(self.analysis_progress) - - return progress_frame - - def create_track_table(self): - """Create track table with YouTube-specific columns""" - table_frame = QFrame() - table_frame.setStyleSheet(""" - QFrame { - background-color: #2d2d2d; border: 1px solid #444444; - border-radius: 8px; padding: 0px; - } - """) - - layout = QVBoxLayout(table_frame) - layout.setContentsMargins(15, 15, 15, 15) - layout.setSpacing(10) - - header_label = QLabel("Track Discovery & Analysis") - header_label.setFont(QFont("Arial", 13, QFont.Weight.Bold)) - header_label.setStyleSheet("color: #ffffff; padding: 5px;") - - self.track_table = QTableWidget() - self.track_table.setColumnCount(7) - self.track_table.setHorizontalHeaderLabels([ - "YT Track", "YT Artist", "Spotify Match Status", - "Spotify Track", "Spotify Artist", "Spotify Album", "Status" - ]) - - # Set columns to span full width evenly - header = self.track_table.horizontalHeader() - - for i in range(7): - header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) - - self.track_table.setAlternatingRowColors(True) - self.track_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.track_table.setStyleSheet(""" - QTableWidget { - background-color: #1e1e1e; color: #ffffff; - gridline-color: #404040; border: none; - } - QTableWidget::item { padding: 8px; border-bottom: 1px solid #333333; } - QTableWidget::item:selected { background-color: #404040; } - QHeaderView::section { - background-color: #333333; color: #ffffff; border: none; - padding: 10px; font-weight: bold; font-size: 11px; - } - """) - - layout.addWidget(header_label) - layout.addWidget(self.track_table) - - return table_frame - - def create_buttons(self): - """Create button section""" - button_frame = QFrame() - layout = QHBoxLayout(button_frame) - layout.setSpacing(10) - - layout.addStretch() - - # Create sync status display (hidden by default) - self.sync_status_widget = self.create_sync_status_display() - layout.addWidget(self.sync_status_widget) - - # Sync button - appears to the left of Begin Search - self.sync_btn = QPushButton("Sync This Playlist") - self.sync_btn.setEnabled(False) # Disabled until Spotify discovery completes - self.sync_btn.clicked.connect(self.on_sync_clicked) - self.sync_btn.setStyleSheet(""" - QPushButton { - background-color: #ff6b6b; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; min-width: 120px; - } - QPushButton:hover { background-color: #ff5252; } - QPushButton:disabled { background-color: #404040; color: #888888; } - """) - - self.begin_search_btn = QPushButton("Download Missing Tracks") - self.begin_search_btn.setEnabled(False) # Disabled until Spotify discovery completes - self.begin_search_btn.clicked.connect(self.on_begin_plex_analysis) - - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.clicked.connect(self.on_cancel_clicked) - - # Close button - hides modal without clearing data - self.close_btn = QPushButton("Close") - self.close_btn.clicked.connect(self.on_close_clicked) - self.close_btn.setStyleSheet(""" - QPushButton { - background-color: #6c757d; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; min-width: 100px; - } - QPushButton:hover { background-color: #5a6268; } - """) - - layout.addWidget(self.sync_btn) - layout.addWidget(self.begin_search_btn) - layout.addWidget(self.close_btn) - layout.addWidget(self.cancel_btn) - - return button_frame - - def create_sync_status_display(self): - """Create sync status display widget (hidden by default) - same as Spotify modal""" - sync_status = QFrame() - sync_status.setStyleSheet(""" - QFrame { - background: rgba(29, 185, 84, 0.1); - border: 1px solid rgba(29, 185, 84, 0.3); - border-radius: 12px; - } - """) - sync_status.setMinimumHeight(36) - sync_status.hide() # Hidden by default - - layout = QHBoxLayout(sync_status) - layout.setContentsMargins(12, 8, 12, 8) - layout.setSpacing(12) - - # Total tracks - self.total_tracks_label = QLabel("0") - self.total_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - self.total_tracks_label.setStyleSheet("color: #ffa500; background: transparent; border: none;") - - # Matched tracks - self.matched_tracks_label = QLabel("0") - self.matched_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent; border: none;") - - # Failed tracks - self.failed_tracks_label = QLabel("0") - self.failed_tracks_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent; border: none;") - - # Percentage - self.percentage_label = QLabel("0%") - self.percentage_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Bold)) - self.percentage_label.setStyleSheet("color: #1db954; background: transparent; border: none;") - - layout.addWidget(self.total_tracks_label) - - # Separator 1 - sep1 = QLabel("/") - sep1.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - sep1.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep1) - - layout.addWidget(self.matched_tracks_label) - - # Separator 2 - sep2 = QLabel("/") - sep2.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - sep2.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep2) - - layout.addWidget(self.failed_tracks_label) - - # Separator 3 - sep3 = QLabel("/") - sep3.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - sep3.setStyleSheet("color: #666666; background: transparent; border: none;") - layout.addWidget(sep3) - - layout.addWidget(self.percentage_label) - - return sync_status - - def update_sync_status(self, total_tracks=0, matched_tracks=0, failed_tracks=0): - """Update sync status display""" - if self.sync_status_widget: - self.total_tracks_label.setText(f"{total_tracks}") - self.matched_tracks_label.setText(f"{matched_tracks}") - self.failed_tracks_label.setText(f"{failed_tracks}") - - if total_tracks > 0: - processed_tracks = matched_tracks + failed_tracks - percentage = int((processed_tracks / total_tracks) * 100) - self.percentage_label.setText(f"{percentage}%") - else: - self.percentage_label.setText("0%") - - def populate_initial_table(self): - """Populate table with initial YouTube track data""" - self.track_table.setRowCount(self.total_tracks) - - for i, track in enumerate(self.playlist.tracks): - # YT Track - yt_track_item = QTableWidgetItem(track.name) - yt_track_item.setFlags(yt_track_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(i, 0, yt_track_item) - - # YT Artist - yt_artist = track.artists[0] if track.artists else "Unknown" - yt_artist_item = QTableWidgetItem(yt_artist) - yt_artist_item.setFlags(yt_artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(i, 1, yt_artist_item) - - # Spotify Match Status - status_item = QTableWidgetItem("Pending...") - status_item.setFlags(status_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(i, 2, status_item) - - # Empty cells for Spotify data (to be filled during discovery) - for col in [3, 4, 5, 6]: - empty_item = QTableWidgetItem("") - empty_item.setFlags(empty_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(i, col, empty_item) - - def start_spotify_discovery(self): - """Start the Spotify discovery process using background worker""" - print(f"Starting Spotify discovery for {self.total_tracks} tracks...") - - # Update all rows to show "Searching..." status - for row in range(self.total_tracks): - status_item = self.track_table.item(row, 2) - if status_item: - status_item.setText("Pending...") - - # Create and start a single optimized Spotify discovery worker - # Import matching engine for validation - from core.matching_engine import MusicMatchingEngine - matching_engine = MusicMatchingEngine() - - # Use TidalSpotifyDiscoveryWorker if this is a Tidal playlist - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: - print("Using Tidal discovery worker for Tidal playlist") - self.spotify_worker = TidalSpotifyDiscoveryWorker( - self.playlist.tracks, - self.parent_page.spotify_client, - matching_engine - ) - else: - print("Using YouTube discovery worker for YouTube playlist") - self.spotify_worker = OptimizedSpotifyDiscoveryWorker( - self.playlist.tracks, - self.parent_page.spotify_client, - matching_engine - ) - - # Connect signals - self.spotify_worker.signals.track_discovered.connect(self.on_track_discovered) - self.spotify_worker.signals.progress_updated.connect(self.on_discovery_progress) - self.spotify_worker.signals.finished.connect(self.on_spotify_discovery_finished) - - # Start the worker - QThreadPool.globalInstance().start(self.spotify_worker) - - def on_track_discovered(self, row, spotify_track, status): - """Handle a track being discovered (or not) on Spotify""" - try: - if status == "found" and spotify_track: - self.spotify_discovered_tracks[row] = spotify_track - self.update_table_with_spotify_match(row, spotify_track) - elif status == "not_found": - self.update_table_with_no_match(row) - elif status == "low_confidence": - self.update_table_with_low_confidence(row) - else: # error - self.update_table_with_error(row, status.replace("error: ", "")) - except Exception as e: - print(f"Error updating UI for track {row}: {e}") - - def on_discovery_progress(self, current): - """Update the discovery progress""" - self.spotify_progress.setValue(current) - - def on_spotify_discovery_finished(self, successful_discoveries): - """Handle Spotify discovery completion""" - self.spotify_discovery_completed() - print(f"Spotify discovery completed: {successful_discoveries}/{self.total_tracks} tracks found") - - def update_table_with_spotify_match(self, row, spotify_track): - """Update table row with successful Spotify match""" - # Spotify Match Status - status_item = self.track_table.item(row, 2) - status_item.setText("Found") - status_item.setForeground(QBrush(QColor("#4CAF50"))) - - # Spotify Track - spotify_track_item = QTableWidgetItem(spotify_track.name) - spotify_track_item.setFlags(spotify_track_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(row, 3, spotify_track_item) - - # Spotify Artist - spotify_artist = spotify_track.artists[0] if spotify_track.artists else "Unknown" - spotify_artist_item = QTableWidgetItem(spotify_artist) - spotify_artist_item.setFlags(spotify_artist_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(row, 4, spotify_artist_item) - - # Spotify Album - album_name = spotify_track.album if isinstance(spotify_track.album, str) else getattr(spotify_track.album, 'name', 'Unknown Album') - spotify_album_item = QTableWidgetItem(album_name) - spotify_album_item.setFlags(spotify_album_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.track_table.setItem(row, 5, spotify_album_item) - - # Status - status_col_item = self.track_table.item(row, 6) - status_col_item.setText("Ready for Plex analysis") - - def update_table_with_no_match(self, row): - """Update table row when no Spotify match found""" - # Spotify Match Status - status_item = self.track_table.item(row, 2) - status_item.setText("Not Found") - status_item.setForeground(QBrush(QColor("#ff6b6b"))) - - # Status - status_col_item = self.track_table.item(row, 6) - status_col_item.setText("Skipped - No Spotify match") - status_col_item.setForeground(QBrush(QColor("#888888"))) - - def update_table_with_low_confidence(self, row): - """Update table row when Spotify matches were found but confidence too low""" - # Spotify Match Status - status_item = self.track_table.item(row, 2) - status_item.setText("Low Confidence") - status_item.setForeground(QBrush(QColor("#FFA500"))) - - # Status - status_col_item = self.track_table.item(row, 6) - status_col_item.setText("Skipped - No reliable match") - status_col_item.setForeground(QBrush(QColor("#FFA500"))) - - def update_table_with_error(self, row, error_msg): - """Update table row when search error occurred""" - # Spotify Match Status - status_item = self.track_table.item(row, 2) - status_item.setText("Error") - status_item.setForeground(QBrush(QColor("#FFA500"))) - - # Status - status_col_item = self.track_table.item(row, 6) - status_col_item.setText(f"Error: {error_msg[:30]}...") - status_col_item.setForeground(QBrush(QColor("#FFA500"))) - - def spotify_discovery_completed(self): - """Called when Spotify discovery is complete""" - self.spotify_search_completed = True - - # Count successful discoveries - successful_discoveries = sum(1 for track in self.spotify_discovered_tracks if track is not None) - - print(f"Spotify discovery completed: {successful_discoveries}/{self.total_tracks} tracks found") - - # Update card state for Tidal playlists (matches YouTube workflow) - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): - print(f"Updating Tidal card state to discovery_complete for playlist_id: {self.playlist_id}") - if hasattr(self.parent_page, 'update_tidal_card_phase'): - self.parent_page.update_tidal_card_phase(self.playlist_id, 'discovery_complete') - - # Store playlist data in state for future modal reopening - if hasattr(self.parent_page, 'set_tidal_card_playlist_data'): - self.parent_page.set_tidal_card_playlist_data(self.playlist_id, self.playlist) - - # Update card state for YouTube playlists (existing logic) - if hasattr(self, 'youtube_url'): - print(f"Updating YouTube card state to discovery_complete for URL: {self.youtube_url}") - if hasattr(self.parent_page, 'update_youtube_card_phase'): - self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete') - - # Enable the Plex analysis and sync buttons - self.begin_search_btn.setEnabled(True) - self.begin_search_btn.setText(f"Download Missing Tracks ({successful_discoveries} tracks)") - - self.sync_btn.setEnabled(True) - - def on_begin_plex_analysis(self): - """Create discovered playlist and open regular download modal""" - # Filter out tracks that weren't found on Spotify - valid_spotify_tracks = [track for track in self.spotify_discovered_tracks if track is not None] - - if not valid_spotify_tracks: - QMessageBox.warning(self, "No Tracks", "No tracks were successfully discovered on Spotify.") - return - - print(f"Creating discovered playlist with {len(valid_spotify_tracks)} Spotify tracks...") - - # Create a Spotify-compatible playlist from discovered tracks - discovered_playlist = self.create_discovered_playlist(valid_spotify_tracks) - - # Mark that we're transitioning to download modal (don't clean up URL tracking) - self.transitioning_to_download = True - - # Close this discovery modal - self.accept() - - # Create a dummy playlist item for the regular modal - dummy_playlist_item = type('DummyPlaylistItem', (), { - 'playlist_name': discovered_playlist.name, - 'track_count': len(discovered_playlist.tracks), - 'download_modal': None, - 'show_operation_status': lambda self, status_text="View Progress": None, - 'hide_operation_status': lambda self: None - })() - - # Open the regular DownloadMissingTracksModal with the discovered playlist - print("Opening regular DownloadMissingTracksModal with discovered tracks...") - modal = DownloadMissingTracksModal( - discovered_playlist, - dummy_playlist_item, - self.parent_page, - self.downloads_page, - is_youtube_workflow=True # Flag to indicate this is from YouTube discovery - ) - - # Transfer URL tracking from discovery modal to download modal (YouTube) - if hasattr(self, 'youtube_url'): - modal.youtube_url = self.youtube_url - self.parent_page.active_youtube_processes[self.youtube_url] = modal - print(f"Transferred URL tracking to download modal: {self.youtube_url}") - - # Update card to downloading phase - if hasattr(self.parent_page, 'update_youtube_card_phase'): - self.parent_page.update_youtube_card_phase(self.youtube_url, 'downloading') - - # Transfer playlist tracking from discovery modal to download modal (Tidal) - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): - modal.playlist_id = self.playlist_id - modal.is_tidal_playlist = True - modal.tidal_playlist = discovered_playlist - print(f"Transferred Tidal playlist tracking to download modal: {self.playlist_id}") - - # Update Tidal card to downloading phase - if hasattr(self.parent_page, 'update_tidal_card_phase'): - self.parent_page.update_tidal_card_phase(self.playlist_id, 'downloading') - - # Update Tidal state to link download modal - if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states: - state = self.parent_page.tidal_playlist_states[self.playlist_id] - state['download_modal'] = modal - - # Store the modal reference using the ID of the NEWLY created playlist object. - print(f"Storing modal with CORRECT discovered_playlist.id: {discovered_playlist.id}") - self.parent_page.active_youtube_download_modals[discovered_playlist.id] = modal - - modal.exec() - - def on_sync_clicked(self): - """Handle Sync This Playlist button click""" - if self.sync_in_progress: - # Cancel ongoing sync - print(f"Cancelling sync for playlist: {self.playlist.name}") - - if hasattr(self.parent_page, 'cancel_playlist_sync'): - self.parent_page.cancel_playlist_sync(self.playlist.id) - - # Reset sync state immediately (don't wait for callback) - self.sync_in_progress = False - self.sync_btn.setText("Sync This Playlist") - self.sync_btn.setStyleSheet(""" - QPushButton { - background-color: #ff6b6b; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; min-width: 120px; - } - QPushButton:hover { background-color: #ff5252; } - QPushButton:disabled { background-color: #404040; color: #888888; } - """) - - # Status widgets are no longer used for sync - cards handle their own state - print("Sync cancelled - card will update its own state") - - else: - # Start sync using the parent page's sync infrastructure - print(f"Starting sync for playlist: {self.playlist.name}") - - if hasattr(self.parent_page, 'start_playlist_sync') and self.parent_page.start_playlist_sync(self.playlist): - print(f"Sync started successfully for: {self.playlist.name}") - - # Update UI to show sync is active - self.sync_in_progress = True - self.sync_btn.setText("Cancel Sync") - self.sync_btn.setStyleSheet(""" - QPushButton { - background-color: #e22134; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; min-width: 120px; - } - QPushButton:hover { background-color: #d32f2f; } - """) - - # Show sync status widget (same as Spotify modal) - if self.sync_status_widget: - self.sync_status_widget.show() - self.update_sync_status(len(self.playlist.tracks), 0, 0) - - # Update card to syncing phase - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'): - print(f"Discovery modal: Setting card to syncing phase for URL: {self.youtube_url}") - print(f"Discovery modal: Using playlist.id: {self.playlist.id}") - self.parent_page.update_youtube_card_phase(self.youtube_url, 'syncing') - - # The card itself will show sync progress - no need for separate status widget - - else: - print(f"Failed to start sync for: {self.playlist.name}") - QMessageBox.warning(self, "Sync Failed", "Failed to start playlist sync. Please try again.") - - - def on_cancel_clicked(self): - """Handle cancel button click - cancel sync or close modal""" - print("Cancel button clicked") - - # Cancel any running Spotify discovery worker - if self.spotify_worker: - print("Cancelling Spotify discovery worker") - self.spotify_worker.cancel() - self.spotify_worker = None - - if self.sync_in_progress: - # Cancel sync operation - print("Cancelling sync operation") - if hasattr(self.parent_page, 'cancel_playlist_sync'): - self.parent_page.cancel_playlist_sync(self.playlist.id) - - # Reset sync state - self.sync_in_progress = False - self.sync_btn.setText("Sync This Playlist") - self.sync_btn.setStyleSheet(""" - QPushButton { - background-color: #ff6b6b; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; min-width: 120px; - } - QPushButton:hover { background-color: #ff5252; } - QPushButton:disabled { background-color: #404040; color: #888888; } - """) - - # Status widgets are no longer used for sync - cards handle their own state - print("Sync cancelled - card will update its own state") - - # Clean up URL tracking before closing (but not during download transition) - if (hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'active_youtube_processes') and - not getattr(self, 'transitioning_to_download', False)): - if self.youtube_url in self.parent_page.active_youtube_processes: - print(f"Cleaning up URL tracking on cancel for: {self.youtube_url}") - del self.parent_page.active_youtube_processes[self.youtube_url] - - # Always close/hide the modal when cancel is clicked - print("Closing modal") - - # Update card state - reset to initial discovering state for Cancel - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'reset_youtube_playlist_state'): - self.parent_page.reset_youtube_playlist_state(self.youtube_url) - - # Update Tidal card state - reset to initial discovering state for Cancel - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): - if hasattr(self.parent_page, 'reset_tidal_playlist_state'): - print(f"Resetting Tidal playlist state to discovering on cancel for playlist_id: {self.playlist_id}") - self.parent_page.reset_tidal_playlist_state(self.playlist_id) - - self.reject() - - def on_close_clicked(self): - """Handle Close button click - hide modal but preserve discovery data""" - print("Close button clicked - preserving discovery data") - - # Check if sync is currently in progress - if so, preserve the syncing state - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'): - if self.sync_in_progress: - # Sync is running - keep the card in syncing state - print("Sync in progress - preserving syncing state") - # Don't change the card phase - it should stay as 'syncing' - elif self.spotify_search_completed: - # No sync running, discovery complete - safe to set to discovery_complete - self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete') - else: - # Discovery still running - keep as discovering but hide modal - print("Discovery still in progress - keeping discovering state") - - # Just hide the modal, don't reset any data - self.hide() - - def on_sync_progress(self, playlist_id, progress): - """Handle sync progress updates (called from parent page)""" - try: - print(f"YouTube modal sync progress called: playlist_id={playlist_id}, my_id={self.playlist.id}") - print(f"YouTube modal sync_in_progress={self.sync_in_progress}") - print(f"YouTube modal progress data: total={progress.total_tracks}, matched={progress.matched_tracks}, failed={progress.failed_tracks}") - - if playlist_id == self.playlist.id: - print(f"Playlist ID matches - processing sync progress for YouTube playlist") - if self.sync_in_progress: - print(f"Sync in progress - updating status widget") - - # Show and update the sync status widget (same as Spotify modal) - if self.sync_status_widget: - print(f"Status widget exists - showing and updating") - self.sync_status_widget.show() - self.update_sync_status( - progress.total_tracks, - progress.matched_tracks, - progress.failed_tracks - ) - print(f"Status widget updated successfully") - - # Update card progress as well - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'): - self.parent_page.update_youtube_card_progress( - self.youtube_url, - total=progress.total_tracks, - matched=progress.matched_tracks, - failed=progress.failed_tracks - ) - else: - print("sync_status_widget is None!") - else: - print(f"Sync not in progress (sync_in_progress={self.sync_in_progress})") - else: - print(f"Playlist ID mismatch: {playlist_id} != {self.playlist.id}") - - except Exception as e: - print(f"EXCEPTION in YouTube modal on_sync_progress: {e}") - import traceback - print(f"Traceback: {traceback.format_exc()}") - - # Update the card progress display instead of creating status widgets - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_progress'): - self.parent_page.update_youtube_card_progress( - self.youtube_url, - total=progress.total_tracks, - matched=progress.matched_tracks, - failed=progress.failed_tracks - ) - - def on_sync_finished(self, playlist_id, result): - """Handle sync completion (called from parent page)""" - if playlist_id == self.playlist.id: - print(f"Sync completed for YouTube playlist: {self.playlist.name}") - - # Reset sync state - self.sync_in_progress = False - - # Hide sync status widget (same as Spotify modal) - if self.sync_status_widget: - self.sync_status_widget.hide() - - # Reset sync button to original state - self.sync_btn.setText("Sync This Playlist") - self.sync_btn.setStyleSheet(""" - QPushButton { - background-color: #ff6b6b; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; min-width: 120px; - } - QPushButton:hover { background-color: #ff5252; } - QPushButton:disabled { background-color: #404040; color: #888888; } - """) - - # Update card to sync_complete phase - if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'): - self.parent_page.update_youtube_card_phase(self.youtube_url, 'sync_complete') - - def on_sync_error(self, playlist_id, error_msg): - """Handle sync error (called from parent page)""" - if playlist_id == self.playlist.id: - print(f"Sync error for YouTube playlist: {self.playlist.name} - {error_msg}") - - # Reset sync state - self.sync_in_progress = False - - # Hide sync status widget (same as Spotify modal) - if self.sync_status_widget: - self.sync_status_widget.hide() - - # Reset sync button to original state - self.sync_btn.setText("Sync This Playlist") - self.sync_btn.setStyleSheet(""" - QPushButton { - background-color: #ff6b6b; color: #ffffff; border: none; - border-radius: 6px; font-size: 13px; font-weight: bold; - padding: 10px 20px; min-width: 120px; - } - QPushButton:hover { background-color: #ff5252; } - QPushButton:disabled { background-color: #404040; color: #888888; } - """) - - def create_discovered_playlist(self, spotify_tracks): - """Create a playlist object from discovered Spotify tracks, reusing the original ID and name.""" - playlist_id = self.playlist.id - - print(f"Creating discovered playlist with consistent ID: {playlist_id}") - - discovered_playlist = type('Playlist', (), { - 'id': playlist_id, - # --- THE FIX --- - # This now uses the original, clean playlist name without adding any prefixes. - 'name': self.playlist.name, - 'description': f"Discovered from YouTube playlist with {len(spotify_tracks)} matched tracks", - 'owner': "YouTube Discovery", - 'public': False, - 'collaborative': False, - 'tracks': spotify_tracks, - 'total_tracks': len(spotify_tracks) - })() - - return discovered_playlist - - def show_loading_state(self): - """Show loading state in the modal""" - # Update window title - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: - self.setWindowTitle("Tidal Playlist Discovery - Loading...") - else: - self.setWindowTitle("YouTube Playlist Discovery - Loading...") - - # Clear the table - self.track_table.setRowCount(0) - - # Show loading message - loading_item = QTableWidgetItem("Parsing YouTube playlist...") - loading_item.setFlags(loading_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - self.track_table.setRowCount(1) - self.track_table.setSpan(0, 0, 1, 7) # Span all columns - self.track_table.setItem(0, 0, loading_item) - - # Disable buttons - self.begin_search_btn.setEnabled(False) - self.begin_search_btn.setText("Waiting for playlist data...") - - def populate_with_playlist_data(self, playlist): - """Populate the modal with actual playlist data""" - print(f"Populating modal with {len(playlist.tracks)} tracks") - - # Update modal properties - self.playlist = playlist - - # --- THE FIX --- - # The block of code that was here was incorrectly adding this discovery modal - # to the parent page's tracking dictionary for DOWNLOAD modals, often with - # multiple, inconsistent IDs. This was the root cause of the state corruption - # and the "No modal found" error. By removing it, we ensure that only the - # correct modal (the DownloadMissingTracksModal) is ever added to that list, - # which resolves the entire issue. - - self.total_tracks = len(playlist.tracks) - self.spotify_discovered_tracks = [None] * self.total_tracks - - # Update window title - if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: - self.setWindowTitle(f"Tidal Playlist Discovery - {playlist.name}") - else: - self.setWindowTitle(f"YouTube Playlist Discovery - {playlist.name}") - - # Update progress bars - self.spotify_progress.setMaximum(self.total_tracks) - self.spotify_progress.setValue(0) - - # Populate the table with actual track data - self.populate_initial_table() - - # Start Spotify discovery - self.start_spotify_discovery() - - def closeEvent(self, event): - """Handle modal closing - hide when sync is active, otherwise close""" - print(f"DEBUG: YouTube modal closeEvent - sync_in_progress: {self.sync_in_progress}") - - # If sync is in progress, just hide the modal (don't close) - if self.sync_in_progress: - print("DEBUG: Sync in progress - hiding modal instead of closing") - event.ignore() # Prevent actual closing - self.hide() - return - - # Normal close behavior - cancel any running workers - if self.spotify_worker: - print("closeEvent: Cancelling Spotify discovery worker") - self.spotify_worker.cancel() - self.spotify_worker = None - - # Clean up URL tracking when modal is actually closed (not just hidden) - # But don't clean up if we're transitioning to download modal - if (hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'active_youtube_processes') and - not getattr(self, 'transitioning_to_download', False)): - if self.youtube_url in self.parent_page.active_youtube_processes: - print(f"Cleaning up URL tracking for: {self.youtube_url}") - del self.parent_page.active_youtube_processes[self.youtube_url] - - # Clean up Tidal playlist state when modal is actually closed (not just hidden) - # But don't clean up if we're transitioning to download modal - if (hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and - hasattr(self, 'playlist_id') and hasattr(self.parent_page, 'tidal_playlist_states') and - not getattr(self, 'transitioning_to_download', False)): - - playlist_id = self.playlist_id - if playlist_id in self.parent_page.tidal_playlist_states: - state = self.parent_page.tidal_playlist_states[playlist_id] - - # Only clear the modal reference, don't reset the entire state - # This preserves discovery data for when user reopens the modal - if state.get('discovery_modal') == self: - print(f"Cleaning up Tidal discovery modal reference for playlist_id: {playlist_id}") - state['discovery_modal'] = None - - # If discovery was completed, keep the state, otherwise reset it - if state.get('phase') == 'discovering': - print(f"Discovery incomplete, resetting Tidal state for playlist_id: {playlist_id}") - self.parent_page.reset_tidal_playlist_state(playlist_id) - - super().closeEvent(event) - - - diff --git a/ui/sidebar.py b/ui/sidebar.py deleted file mode 100644 index 4b202488..00000000 --- a/ui/sidebar.py +++ /dev/null @@ -1,1367 +0,0 @@ -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, - QLabel, QFrame, QSizePolicy, QSpacerItem, QSlider, QProgressBar, QApplication) -from PyQt6.QtCore import Qt, pyqtSignal, QPropertyAnimation, QEasingCurve, QRect, QTimer, pyqtProperty -from PyQt6.QtGui import QFont, QPalette, QIcon, QPixmap, QPainter, QFontMetrics, QColor, QLinearGradient -from utils.logging_config import get_logger - -class ScrollingLabel(QLabel): - """A label that smoothly scrolls text horizontally when it's too long to fit""" - - def __init__(self, text="", parent=None): - super().__init__(parent) - self.full_text = text - self.scroll_offset = 0 - self.text_width = 0 - self.should_scroll = False - self.is_scrolling = False - self.scroll_speed = 30 # pixels per second - - # Animation timer - self.scroll_timer = QTimer() - self.scroll_timer.timeout.connect(self.update_scroll) - - # Pause timer for smooth start/stop - self.pause_timer = QTimer() - self.pause_timer.setSingleShot(True) - self.pause_timer.timeout.connect(self.start_scroll_animation) - - # Set initial properties - self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) - self.update_text_metrics() - - def setText(self, text): - """Override setText to handle scroll calculations""" - self.full_text = text - self.scroll_offset = 0 - self.update_text_metrics() - super().setText(text) - - def update_text_metrics(self): - """Calculate if text needs scrolling and start animation if needed""" - if not self.full_text: - self.should_scroll = False - self.stop_scrolling() - return - - font_metrics = QFontMetrics(self.font()) - self.text_width = font_metrics.horizontalAdvance(self.full_text) - available_width = self.width() - 20 # Account for padding - - self.should_scroll = self.text_width > available_width and available_width > 0 - - if self.should_scroll and not self.is_scrolling: - # Start scrolling after a pause - self.pause_timer.start(1500) # 1.5 second pause before scrolling - elif not self.should_scroll: - self.stop_scrolling() - - def start_scroll_animation(self): - """Start the continuous scrolling animation""" - if self.should_scroll and not self.is_scrolling: - self.is_scrolling = True - self.scroll_timer.start(50) # Update every 50ms for smooth animation - - def stop_scrolling(self): - """Stop scrolling and reset position""" - self.scroll_timer.stop() - self.pause_timer.stop() - self.is_scrolling = False - self.scroll_offset = 0 - self.update() - - def update_scroll(self): - """Update scroll position for animation""" - if not self.should_scroll: - self.stop_scrolling() - return - - available_width = self.width() - 20 - max_scroll = self.text_width - available_width + 30 # Extra padding at end - - # Move scroll position - self.scroll_offset += 2 # 2 pixels per frame - - # Reset when we've scrolled past the end - if self.scroll_offset > max_scroll: - self.scroll_offset = -50 # Start from off-screen left - - self.update() - - def paintEvent(self, event): - """Custom paint event to draw scrolling text""" - if not self.should_scroll or not self.is_scrolling: - # Use default painting for non-scrolling text - super().paintEvent(event) - return - - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Set font and color from stylesheet - painter.setFont(self.font()) - - # Get text color from current style - painter.setPen(self.palette().color(QPalette.ColorRole.WindowText)) - - # Draw text at scroll offset position - text_rect = self.rect() - text_rect.adjust(10, 0, -10, 0) # Account for padding - - painter.drawText(text_rect.x() - self.scroll_offset, text_rect.y(), - text_rect.width() + self.text_width, text_rect.height(), - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, - self.full_text) - - def resizeEvent(self, event): - """Handle resize to recalculate scrolling needs""" - super().resizeEvent(event) - self.update_text_metrics() - - def enterEvent(self, event): - """Start scrolling on hover""" - super().enterEvent(event) - if self.should_scroll and not self.is_scrolling: - self.start_scroll_animation() - - def leaveEvent(self, event): - """Optionally stop scrolling when mouse leaves (can be customized)""" - super().leaveEvent(event) - # Note: We continue scrolling even after mouse leaves for better UX - # You can uncomment the line below if you want it to stop on mouse leave - # self.stop_scrolling() - -class SidebarButton(QPushButton): - def __init__(self, text: str, icon_text: str = "", parent=None): - super().__init__(parent) - self.text = text - self.icon_text = icon_text - self.is_active = False - self.setup_ui() - - def setup_ui(self): - self.setFixedHeight(52) - self.setFixedWidth(216) # Adjusted for new sidebar width - self.setCursor(Qt.CursorShape.PointingHandCursor) - - layout = QHBoxLayout(self) - layout.setContentsMargins(18, 0, 18, 0) - layout.setSpacing(16) - - # Icon label with better styling - self.icon_label = QLabel(self.icon_text) - self.icon_label.setFixedSize(28, 28) - self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.icon_label.setStyleSheet(""" - QLabel { - color: rgba(255, 255, 255, 0.7); - font-size: 16px; - font-weight: 600; - border-radius: 14px; - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(255, 255, 255, 0.08), - stop: 1 rgba(255, 255, 255, 0.04)); - border: 1px solid rgba(255, 255, 255, 0.05); - } - """) - - # Text label with improved typography - self.text_label = QLabel(self.text) - self.text_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) - - layout.addWidget(self.icon_label) - layout.addWidget(self.text_label) - layout.addStretch() - - self.update_style() - - def set_active(self, active: bool): - self.is_active = active - self.update_style() - - def update_style(self): - if self.is_active: - self.setStyleSheet(""" - SidebarButton { - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(29, 185, 84, 0.18), - stop: 0.5 rgba(29, 185, 84, 0.12), - stop: 1 rgba(29, 185, 84, 0.08)); - border-left: 3px solid #1ed760; - border-radius: 16px; - text-align: left; - padding: 0px; - border: 1px solid rgba(29, 185, 84, 0.2); - } - SidebarButton:hover { - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(29, 185, 84, 0.25), - stop: 0.5 rgba(29, 185, 84, 0.18), - stop: 1 rgba(29, 185, 84, 0.12)); - border: 1px solid rgba(29, 185, 84, 0.3); - } - """) - self.text_label.setStyleSheet(""" - color: #1ed760; - font-weight: 600; - background: transparent; - letter-spacing: 0.1px; - """) - self.icon_label.setStyleSheet(""" - QLabel { - color: #1ed760; - font-size: 16px; - font-weight: 700; - border-radius: 14px; - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(29, 185, 84, 0.25), - stop: 1 rgba(30, 215, 96, 0.2)); - border: 1px solid rgba(29, 185, 84, 0.3); - } - """) - else: - self.setStyleSheet(""" - SidebarButton { - background: transparent; - border: none; - border-radius: 16px; - text-align: left; - padding: 0px; - } - SidebarButton:hover { - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(255, 255, 255, 0.06), - stop: 1 rgba(255, 255, 255, 0.03)); - border-left: 2px solid rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.08); - } - """) - self.text_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.8); - background: transparent; - letter-spacing: 0.1px; - """) - self.icon_label.setStyleSheet(""" - QLabel { - color: rgba(255, 255, 255, 0.7); - font-size: 16px; - font-weight: 600; - border-radius: 14px; - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(255, 255, 255, 0.08), - stop: 1 rgba(255, 255, 255, 0.04)); - border: 1px solid rgba(255, 255, 255, 0.05); - } - """) - -class CryptoDonationWidget(QWidget): - """Widget for displaying crypto donation addresses with collapsible section""" - - def __init__(self, parent=None): - super().__init__(parent) - self.addresses_visible = False - self.setup_ui() - - def setup_ui(self): - self.setStyleSheet(""" - CryptoDonationWidget { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 transparent, - stop: 0.3 rgba(255, 255, 255, 0.02), - stop: 1 rgba(255, 255, 255, 0.04)); - border-top: 1px solid rgba(255, 255, 255, 0.08); - border-bottom-right-radius: 12px; - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 15, 0, 15) - layout.setSpacing(8) - - # Header with title and toggle button - header_layout = QHBoxLayout() - header_layout.setContentsMargins(20, 0, 20, 0) - header_layout.setSpacing(8) - - # Donation title - donation_title = QLabel("Support Development") - donation_title.setFont(QFont("SF Pro Text", 10, QFont.Weight.Bold)) - donation_title.setMinimumHeight(16) - donation_title.setStyleSheet(""" - color: rgba(255, 255, 255, 0.9); - margin-bottom: 5px; - letter-spacing: 0.2px; - font-weight: 600; - """) - - # Toggle button - self.toggle_btn = QPushButton("Show") - self.toggle_btn.setFixedSize(40, 20) - self.toggle_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.toggle_btn.setStyleSheet(""" - QPushButton { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 10px; - color: rgba(255, 255, 255, 0.7); - font-size: 8px; - font-weight: 500; - } - QPushButton:hover { - background: rgba(255, 255, 255, 0.15); - border: 1px solid rgba(255, 255, 255, 0.3); - color: rgba(255, 255, 255, 0.9); - } - """) - self.toggle_btn.clicked.connect(self.toggle_addresses) - - header_layout.addWidget(donation_title) - header_layout.addStretch() - header_layout.addWidget(self.toggle_btn) - - layout.addLayout(header_layout) - - # Container for donation options (initially hidden) - self.addresses_container = QWidget() - self.addresses_layout = QVBoxLayout(self.addresses_container) - self.addresses_layout.setContentsMargins(0, 0, 0, 0) - self.addresses_layout.setSpacing(8) - - # Ko-fi option (first item) - kofi_item = self.create_kofi_item() - self.addresses_layout.addWidget(kofi_item) - - # Crypto addresses - crypto_addresses = [ - ("BTC", "Bitcoin", "3JVWrRSkozAQSmw5DXYVxYKsM9bndPTqdS"), - ("ETH", "Ethereum", "0x343fC48c2cd1C6332b0df9a58F86e6520a026AC5") - ] - - for symbol, name, address in crypto_addresses: - crypto_item = self.create_crypto_item(symbol, name, address) - self.addresses_layout.addWidget(crypto_item) - - # Initially hide the addresses - self.addresses_container.hide() - layout.addWidget(self.addresses_container) - - def toggle_addresses(self): - """Toggle the visibility of crypto addresses""" - self.addresses_visible = not self.addresses_visible - - if self.addresses_visible: - self.addresses_container.show() - self.toggle_btn.setText("Hide") - else: - self.addresses_container.hide() - self.toggle_btn.setText("Show") - - def create_crypto_item(self, symbol: str, name: str, address: str): - """Create a clickable crypto donation item""" - item = QFrame() - item.setFixedHeight(32) - item.setCursor(Qt.CursorShape.PointingHandCursor) - item.setStyleSheet(""" - QFrame { - background: transparent; - border-radius: 8px; - margin: 0 12px; - } - QFrame:hover { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - } - """) - - layout = QHBoxLayout(item) - layout.setContentsMargins(12, 4, 12, 4) - layout.setSpacing(6) - - # Crypto name - name_label = QLabel(name) - name_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - name_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.8); - font-weight: 500; - """) - - # Address (truncated) - address_short = f"{address[:6]}...{address[-4:]}" - address_label = QLabel(address_short) - address_label.setFont(QFont("SF Pro Text", 8)) - address_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.5); - font-family: 'Courier New', monospace; - """) - - layout.addWidget(name_label) - layout.addStretch() - layout.addWidget(address_label) - - # Store full address for copying - item.full_address = address - item.crypto_name = name - item.mousePressEvent = lambda event: self.copy_address(address, name) - - return item - - def copy_address(self, address: str, crypto_name: str): - """Copy crypto address to clipboard""" - clipboard = QApplication.clipboard() - clipboard.setText(address) - - # Brief visual feedback (could add a tooltip or status message here) - print(f"Copied {crypto_name} address to clipboard: {address}") - - def create_kofi_item(self): - """Create a clickable Ko-fi donation item styled like crypto items""" - item = QFrame() - item.setFixedHeight(32) - item.setCursor(Qt.CursorShape.PointingHandCursor) - item.setStyleSheet(""" - QFrame { - background: transparent; - border-radius: 8px; - margin: 0 12px; - } - QFrame:hover { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - } - """) - - layout = QHBoxLayout(item) - layout.setContentsMargins(12, 4, 12, 4) - layout.setSpacing(6) - - # Ko-fi name - name_label = QLabel("Ko-fi") - name_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) - name_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.8); - font-weight: 500; - """) - - # External link indicator (instead of address) - link_label = QLabel("Click to open") - link_label.setFont(QFont("SF Pro Text", 8)) - link_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.5); - font-style: italic; - """) - - layout.addWidget(name_label) - layout.addStretch() - layout.addWidget(link_label) - - # Connect click event to open Ko-fi link - item.mousePressEvent = lambda event: self.open_kofi_link() - - return item - - def open_kofi_link(self): - """Open Ko-fi link in the user's default web browser""" - import webbrowser - kofi_url = "https://ko-fi.com/boulderbadgedad" - webbrowser.open(kofi_url) - print(f"Opening Ko-fi link: {kofi_url}") - -class StatusIndicator(QWidget): - def __init__(self, service_name: str, parent=None): - super().__init__(parent) - self.service_name = service_name - self.is_connected = False - self.setup_ui() - - def setup_ui(self): - self.setFixedHeight(38) # Slightly taller for better proportions - layout = QHBoxLayout(self) - layout.setContentsMargins(20, 8, 20, 8) - layout.setSpacing(14) - - # Status dot with more elegant design - self.status_dot = QLabel("●") - self.status_dot.setFixedSize(18, 18) - self.status_dot.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.status_dot.setStyleSheet(""" - QLabel { - border-radius: 9px; - font-size: 10px; - font-weight: 700; - border: 1px solid rgba(255, 255, 255, 0.1); - } - """) - - # Service name with better typography - self.service_label = QLabel(self.service_name) - self.service_label.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) - self.service_label.setMinimumWidth(85) - - layout.addWidget(self.status_dot) - layout.addWidget(self.service_label) - layout.addStretch() - - self.update_status(False) - - def update_status(self, connected: bool): - self.is_connected = connected - if connected: - self.status_dot.setStyleSheet(""" - QLabel { - color: #1ed760; - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(29, 185, 84, 0.2), - stop: 1 rgba(30, 215, 96, 0.15)); - border-radius: 9px; - font-size: 10px; - font-weight: 700; - border: 1px solid rgba(29, 185, 84, 0.3); - } - """) - self.service_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.95); - font-weight: 500; - letter-spacing: 0.1px; - """) - else: - self.status_dot.setStyleSheet(""" - QLabel { - color: #ff6b6b; - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 rgba(255, 107, 107, 0.15), - stop: 1 rgba(255, 107, 107, 0.1)); - border-radius: 9px; - font-size: 10px; - font-weight: 700; - border: 1px solid rgba(255, 107, 107, 0.2); - } - """) - self.service_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.5); - font-weight: 400; - letter-spacing: 0.1px; - """) - - def update_name(self, new_name: str): - """Update the service name displayed in the status indicator""" - self.service_name = new_name - self.service_label.setText(new_name) - -class LoadingAnimation(QWidget): - """Thin horizontal loading animation for media player with dual-mode capability""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedHeight(12) # Increased height for text overlay - self._progress = 0.0 - self._is_active = False - self._mode = "indefinite" # "indefinite" or "determinate" - self._determinate_progress = 0.0 # 0-100% for determinate mode - - # Animation setup for indefinite mode - self.animation = QPropertyAnimation(self, b"progress") - self.animation.setDuration(1200) # 1.2 second cycle - self.animation.setStartValue(0.0) - self.animation.setEndValue(1.0) - self.animation.setLoopCount(-1) # Infinite loop - self.animation.setEasingCurve(QEasingCurve.Type.InOutSine) - - # Progress value animation for smooth transitions in determinate mode - self.progress_animation = QPropertyAnimation(self, b"determinate_progress") - self.progress_animation.setDuration(300) # Smooth 300ms transitions - self.progress_animation.setEasingCurve(QEasingCurve.Type.OutCubic) - - # Completion glow effect - self._glow_opacity = 0.0 - self.glow_animation = QPropertyAnimation(self, b"glow_opacity") - self.glow_animation.setDuration(800) # Slower glow pulse - self.glow_animation.setStartValue(0.0) - self.glow_animation.setEndValue(1.0) - self.glow_animation.setLoopCount(3) # Pulse 3 times - self.glow_animation.setEasingCurve(QEasingCurve.Type.InOutSine) - - self.hide() # Start hidden - - @pyqtProperty(float) - def progress(self): - return self._progress - - @progress.setter - def progress(self, value): - self._progress = value - self.update() - - @pyqtProperty(float) - def determinate_progress(self): - return self._determinate_progress - - @determinate_progress.setter - def determinate_progress(self, value): - self._determinate_progress = value - self.update() - - @pyqtProperty(float) - def glow_opacity(self): - return self._glow_opacity - - @glow_opacity.setter - def glow_opacity(self, value): - self._glow_opacity = value - self.update() - - def start_animation(self): - """Start the indefinite loading animation""" - self._is_active = True - self._mode = "indefinite" - self.show() - self.animation.start() - - def set_progress(self, percentage): - """Set determinate progress (0-100%) with smooth animation""" - if not self._is_active: - self._is_active = True - self.show() - - # Switch to determinate mode - if self._mode == "indefinite": - self._mode = "determinate" - self.animation.stop() # Stop indefinite animation - - # Animate to new progress value - self.progress_animation.setStartValue(self._determinate_progress) - self.progress_animation.setEndValue(percentage) - self.progress_animation.start() - - # Trigger completion glow effect when reaching 100% - if percentage >= 100 and self._determinate_progress < 100: - self.glow_animation.start() - - def stop_animation(self): - """Stop the loading animation""" - self._is_active = False - self._mode = "indefinite" - self.animation.stop() - self.progress_animation.stop() - self.glow_animation.stop() - self.hide() - self._progress = 0.0 - self._determinate_progress = 0.0 - self._glow_opacity = 0.0 - self.update() - - def paintEvent(self, event): - """Custom paint event for dual-mode animation with text overlay""" - if not self._is_active: - return - - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) - - width = self.width() - height = self.height() - progress_bar_height = 4 # Bottom 4px for progress bar - text_height = height - progress_bar_height # Top area for text - - # Background for progress bar area - progress_rect = self.rect() - progress_rect.setTop(text_height) - painter.fillRect(progress_rect, QColor(40, 40, 40)) - - if self._mode == "indefinite": - # Indefinite mode: animated gradient wave - gradient_width = width * 0.3 # 30% of total width - center_x = self._progress * width - - for i in range(int(gradient_width)): - alpha = max(0, 255 - (abs(i - gradient_width/2) * 8)) - color = QColor(29, 185, 84, int(alpha)) # Spotify green with fade - x = int(center_x - gradient_width/2 + i) - if 0 <= x < width: - painter.fillRect(x, text_height, 1, progress_bar_height, color) - - else: # determinate mode - # Determinate mode: progress bar with percentage - progress_width = (self._determinate_progress / 100.0) * width - - # Progress bar with gradient - if progress_width > 0: - progress_fill_rect = QRect(0, text_height, int(progress_width), progress_bar_height) - - # Create subtle gradient for progress bar - gradient = QLinearGradient(0, text_height, progress_width, text_height) - gradient.setColorAt(0, QColor(29, 185, 84)) # Spotify green - gradient.setColorAt(1, QColor(30, 215, 96)) # Lighter green - - painter.fillRect(progress_fill_rect, gradient) - - # Add animated glow effect during completion - if self._glow_opacity > 0: - glow_alpha = int(120 * self._glow_opacity) # Max alpha of 120 - glow_color = QColor(29, 185, 84, glow_alpha) - - # Expand glow slightly beyond progress bar for effect - glow_rect = QRect(0, text_height - 1, width, progress_bar_height + 2) - painter.fillRect(glow_rect, glow_color) - - # Percentage text overlay (elegant, small font) - if text_height > 0 and self._determinate_progress > 0: - font = QFont("Segoe UI", 7, QFont.Weight.Medium) # Small, elegant font - painter.setFont(font) - painter.setPen(QColor(180, 180, 180)) # Light gray text - - percentage_text = f"{int(self._determinate_progress)}%" - text_rect = QRect(0, 0, width, text_height) - painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, percentage_text) - -class MediaPlayer(QWidget): - # Signals for media control - play_pause_requested = pyqtSignal() - stop_requested = pyqtSignal() - volume_changed = pyqtSignal(float) # Volume as percentage (0.0 to 1.0) - - def __init__(self, parent=None): - super().__init__(parent) - self.is_playing = False - self.is_expanded = False - self.current_track = None - self.setup_ui() - - def setup_ui(self): - self.setFixedHeight(85) # More space for better proportions - self.setStyleSheet(""" - MediaPlayer { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 rgba(26, 26, 26, 0.95), - stop: 0.5 rgba(18, 18, 18, 0.98), - stop: 1 rgba(12, 12, 12, 1.0)); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 12px; - margin: 8px 10px; - } - MediaPlayer:hover { - border: 1px solid rgba(29, 185, 84, 0.2); - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 rgba(29, 185, 84, 0.08), - stop: 0.5 rgba(26, 26, 26, 0.95), - stop: 1 rgba(18, 18, 18, 1.0)); - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(18, 12, 18, 12) - layout.setSpacing(12) - - # Loading animation at the top - self.loading_animation = LoadingAnimation() - layout.addWidget(self.loading_animation) - - # Always visible header with basic controls - self.header = self.create_header() - layout.addWidget(self.header) - - # Expandable content (hidden when collapsed) - self.expanded_content = self.create_expanded_content() - self.expanded_content.setVisible(False) - layout.addWidget(self.expanded_content) - - # No track message (shown when no music) - self.no_track_label = QLabel("Start playing music to see controls") - self.no_track_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.no_track_label.setStyleSheet(""" - QLabel { - color: #6a6a6a; - font-size: 12px; - font-weight: 400; - padding: 20px 16px; - background: transparent; - letter-spacing: 0.2px; - font-family: 'Spotify Circular', -apple-system, sans-serif; - line-height: 1.4; - } - """) - layout.addWidget(self.no_track_label) - - def create_header(self): - header = QWidget() - main_layout = QVBoxLayout(header) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(8) - - # Top row: Track info and play button - top_row = QHBoxLayout() - top_row.setContentsMargins(0, 0, 0, 0) - top_row.setSpacing(14) - - # Track info (expandable on click) - now with scrolling for long titles - self.track_info = ScrollingLabel("No track") - self.track_info.setStyleSheet(""" - ScrollingLabel { - color: #ffffff; - font-size: 14px; - font-weight: 700; - background: transparent; - font-family: 'Spotify Circular', 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; - letter-spacing: -0.3px; - padding: 2px 0px; - line-height: 1.2; - } - ScrollingLabel:hover { - color: #1ed760; - text-decoration: underline; - } - """) - self.track_info.setCursor(Qt.CursorShape.PointingHandCursor) - self.track_info.mousePressEvent = self.toggle_expansion - - # Play/pause button - more Spotify-like - self.play_pause_btn = QPushButton("▷") - self.play_pause_btn.setFixedSize(40, 40) - self.play_pause_btn.setStyleSheet(""" - QPushButton { - background: #1ed760; - border: none; - border-radius: 20px; - color: #000000; - font-size: 16px; - font-weight: 900; - font-family: 'Arial', sans-serif; - } - QPushButton:hover { - background: #1fdf64; - } - QPushButton:pressed { - background: #1ca851; - } - QPushButton:disabled { - background: #535353; - color: #b3b3b3; - } - """) - self.play_pause_btn.clicked.connect(self.on_play_pause_clicked) - self.play_pause_btn.setEnabled(False) - - top_row.addWidget(self.track_info) - top_row.addStretch() - top_row.addWidget(self.play_pause_btn) - - # Bottom row: Artist info (always visible in collapsed mode) - self.artist_info = QLabel("Unknown Artist") - self.artist_info.setStyleSheet(""" - QLabel { - color: #b3b3b3; - font-size: 11px; - font-weight: 400; - background: transparent; - font-family: 'Spotify Circular', -apple-system, BlinkMacSystemFont, sans-serif; - letter-spacing: 0.1px; - margin-top: 1px; - } - """) - - main_layout.addLayout(top_row) - main_layout.addWidget(self.artist_info) - - return header - - def create_expanded_content(self): - content = QWidget() - layout = QVBoxLayout(content) - layout.setContentsMargins(0, 2, 0, 0) - layout.setSpacing(4) - - # Album info - self.album_label = QLabel("Unknown Album") - self.album_label.setStyleSheet(""" - QLabel { - color: #a7a7a7; - font-size: 11px; - font-weight: 400; - background: transparent; - font-family: 'Spotify Circular', -apple-system, BlinkMacSystemFont, sans-serif; - letter-spacing: 0.1px; - } - """) - layout.addWidget(self.album_label) - - # Control buttons - more Spotify-like - controls_layout = QHBoxLayout() - controls_layout.setContentsMargins(0, 1, 0, 0) - controls_layout.setSpacing(6) - - # Volume control (Spotify style - more prominent) - volume_layout = QHBoxLayout() - volume_layout.setSpacing(10) - - volume_icon = QLabel("") - volume_icon.setStyleSheet(""" - QLabel { - color: #b3b3b3; - font-size: 13px; - font-weight: 400; - padding: 0px; - } - """) - - self.volume_slider = QSlider(Qt.Orientation.Horizontal) - self.volume_slider.setRange(0, 100) - self.volume_slider.setValue(70) # Default 70% volume - self.volume_slider.setFixedWidth(80) - self.volume_slider.setFixedHeight(20) - self.volume_slider.setStyleSheet(""" - QSlider::groove:horizontal { - border: none; - height: 3px; - background: #4f4f4f; - border-radius: 1px; - } - QSlider::handle:horizontal { - background: #ffffff; - border: none; - width: 12px; - height: 12px; - border-radius: 6px; - margin: -4px 0; - } - QSlider::handle:horizontal:hover { - background: #1ed760; - } - QSlider::sub-page:horizontal { - background: #1ed760; - border-radius: 1px; - } - """) - self.volume_slider.valueChanged.connect(self.on_volume_changed) - - # Stop button - more visible Spotify style - self.stop_btn = QPushButton("") - self.stop_btn.setFixedSize(32, 32) - self.stop_btn.setStyleSheet(""" - QPushButton { - background: rgba(255, 255, 255, 0.08); - border: 1px solid #b3b3b3; - border-radius: 16px; - color: #ffffff; - font-size: 12px; - font-weight: 500; - } - QPushButton:hover { - background: rgba(255, 255, 255, 0.15); - border: 1px solid #ffffff; - color: #ffffff; - } - QPushButton:pressed { - background: rgba(255, 255, 255, 0.25); - } - QPushButton:disabled { - background: transparent; - border: 1px solid #2a2a2a; - color: #535353; - } - """) - self.stop_btn.clicked.connect(self.on_stop_clicked) - self.stop_btn.setEnabled(False) - - volume_layout.addWidget(volume_icon) - volume_layout.addWidget(self.volume_slider) - - controls_layout.addLayout(volume_layout) - controls_layout.addStretch() - controls_layout.addWidget(self.stop_btn) - - layout.addLayout(controls_layout) - - return content - - def toggle_expansion(self, event=None): - """Toggle between collapsed and expanded view""" - if not self.current_track: - return - - self.is_expanded = not self.is_expanded - - if self.is_expanded: - self.setFixedHeight(145) # More space for the new layout - self.expanded_content.setVisible(True) - self.no_track_label.setVisible(False) - else: - self.setFixedHeight(85) # Match the updated collapsed height - self.expanded_content.setVisible(False) - - def set_track_info(self, track_result): - """Update the media player with new track information""" - self.current_track = track_result - - # Update track name - track_name = getattr(track_result, 'title', None) or getattr(track_result, 'filename', 'Unknown Track') - if hasattr(track_result, 'filename'): - # Clean up filename for display - import os - track_name = os.path.splitext(os.path.basename(track_result.filename))[0] - - self.track_info.setText(track_name) - - # Update artist and album info - artist = getattr(track_result, 'artist', 'Unknown Artist') - album = getattr(track_result, 'album', 'Unknown Album') - - # Update the separate artist and album labels - self.artist_info.setText(artist) - self.album_label.setText(album) - - # Enable controls - self.play_pause_btn.setEnabled(True) - self.stop_btn.setEnabled(True) - - # Set to playing state (show pause button since track just started) - self.set_playing_state(True) - - # Hide loading animation now that track is ready - self.hide_loading() - - # Hide no track message and show player - self.no_track_label.setVisible(False) - - # Auto-expand when new track starts - if not self.is_expanded: - self.toggle_expansion() - - def set_playing_state(self, playing): - """Update play/pause button state""" - self.is_playing = playing - if playing: - self.play_pause_btn.setText("") - # Start scrolling animation when playing - if self.track_info.should_scroll and not self.track_info.is_scrolling: - self.track_info.start_scroll_animation() - else: - self.play_pause_btn.setText("▷") - # Optionally stop scrolling when paused (can be customized) - # self.track_info.stop_scrolling() - - def clear_track(self): - """Clear current track and reset to no track state""" - self.current_track = None - self.is_playing = False - - # Stop any animations - self.track_info.stop_scrolling() - self.hide_loading() - - # Update UI - self.track_info.setText("No track") - self.artist_info.setText("Unknown Artist") - self.album_label.setText("Unknown Album") - self.play_pause_btn.setText("▷") - self.play_pause_btn.setEnabled(False) - self.stop_btn.setEnabled(False) - - # Show no track message - self.no_track_label.setVisible(True) - - # Collapse view - if self.is_expanded: - self.toggle_expansion() - - def on_play_pause_clicked(self): - """Handle play/pause button click""" - self.play_pause_requested.emit() - - def on_stop_clicked(self): - """Handle stop button click""" - self.stop_requested.emit() - - def on_volume_changed(self, value): - """Handle volume slider change""" - volume = value / 100.0 # Convert to 0.0-1.0 - self.volume_changed.emit(volume) - - def show_loading(self): - """Show and start the loading animation""" - self.loading_animation.start_animation() - - def hide_loading(self): - """Hide and stop the loading animation""" - self.loading_animation.stop_animation() - - def set_loading_progress(self, percentage): - """Set loading progress percentage (0-100)""" - self.loading_animation.set_progress(percentage) - - -class ModernSidebar(QWidget): - page_changed = pyqtSignal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.current_page = "dashboard" - self.buttons = {} - self.setup_ui() - - def setup_ui(self): - self.setFixedWidth(240) # Slightly wider for better proportions - self.setStyleSheet(""" - ModernSidebar { - background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, - stop: 0 #0d1117, - stop: 0.3 #121212, - stop: 1 #0a0a0a); - border-right: 1px solid rgba(29, 185, 84, 0.1); - border-top-right-radius: 12px; - border-bottom-right-radius: 12px; - } - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Header - header = self.create_header() - layout.addWidget(header) - - # Navigation buttons - nav_section = self.create_navigation() - layout.addWidget(nav_section) - - # Spacer - layout.addItem(QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) - - # Media Player section - self.media_player = MediaPlayer() - layout.addWidget(self.media_player) - - # Small spacer between media player and crypto - layout.addItem(QSpacerItem(20, 8, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) - - # Crypto Donation section - crypto_section = CryptoDonationWidget() - layout.addWidget(crypto_section) - - # Version info section - version_section = self.create_version_section() - layout.addWidget(version_section) - - # Small spacer between version and status - layout.addItem(QSpacerItem(20, 8, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) - - # Status section - status_section = self.create_status_section() - layout.addWidget(status_section) - - def create_header(self): - header = QWidget() - header.setFixedHeight(95) - header.setStyleSheet(""" - QWidget { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 rgba(29, 185, 84, 0.08), - stop: 0.4 rgba(29, 185, 84, 0.03), - stop: 1 transparent); - border-bottom: 1px solid rgba(29, 185, 84, 0.15); - border-top-right-radius: 12px; - } - """) - - layout = QVBoxLayout(header) - layout.setContentsMargins(24, 24, 24, 20) - layout.setSpacing(4) - - # App name with gradient text effect - app_name = QLabel("SoulSync") - app_name.setFont(QFont("SF Pro Display", 20, QFont.Weight.Bold)) - app_name.setStyleSheet(""" - color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, - stop: 0 #ffffff, - stop: 0.6 #1ed760, - stop: 1 #1db954); - letter-spacing: -0.8px; - font-weight: 700; - """) - - # Subtitle with better typography - subtitle = QLabel("Music Sync & Manager") - subtitle.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) - subtitle.setStyleSheet(""" - color: rgba(255, 255, 255, 0.65); - letter-spacing: 0.2px; - font-weight: 500; - margin-top: 2px; - """) - - layout.addWidget(app_name) - layout.addWidget(subtitle) - - return header - - def create_navigation(self): - nav_widget = QWidget() - nav_widget.setStyleSheet(""" - QWidget { - background: transparent; - border-radius: 12px; - } - """) - layout = QVBoxLayout(nav_widget) - layout.setContentsMargins(12, 25, 12, 25) - layout.setSpacing(8) - - # Navigation buttons - nav_items = [ - ("dashboard", "Dashboard", ""), - ("sync", "Sync", ""), - ("downloads", "Search", ""), - ("artists", "Artists", ""), - ("settings", "Settings", "") - ] - - for page_id, title, icon in nav_items: - button = SidebarButton(title, icon) - button.clicked.connect(lambda checked, pid=page_id: self.change_page(pid)) - self.buttons[page_id] = button - layout.addWidget(button) - - # Set dashboard as active by default - self.buttons["dashboard"].set_active(True) - - return nav_widget - - def create_version_section(self): - version_widget = QWidget() - version_widget.setFixedHeight(45) - version_widget.setStyleSheet(""" - QWidget { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 transparent, - stop: 0.3 rgba(255, 255, 255, 0.02), - stop: 1 rgba(255, 255, 255, 0.04)); - border-top: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - margin: 0 10px; - } - """) - - layout = QVBoxLayout(version_widget) - layout.setContentsMargins(20, 12, 20, 12) - layout.setSpacing(0) - - # Version button (clickable) - self.version_button = QPushButton("v1.0") - self.version_button.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) - self.version_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.version_button.setStyleSheet(""" - QPushButton { - color: rgba(255, 255, 255, 0.6); - letter-spacing: 0.1px; - font-weight: 500; - background: transparent; - border: none; - padding: 2px 8px; - border-radius: 4px; - } - QPushButton:hover { - color: #1ed760; - background: rgba(29, 185, 84, 0.1); - border: 1px solid rgba(29, 185, 84, 0.2); - } - QPushButton:pressed { - background: rgba(29, 185, 84, 0.15); - } - """) - self.version_button.clicked.connect(self.show_version_info) - layout.addWidget(self.version_button) - - return version_widget - - def create_status_section(self): - status_widget = QWidget() - status_widget.setFixedHeight(150) # Slightly taller for better proportions - status_widget.setStyleSheet(""" - QWidget { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 transparent, - stop: 0.3 rgba(255, 255, 255, 0.02), - stop: 1 rgba(255, 255, 255, 0.04)); - border-top: 1px solid rgba(255, 255, 255, 0.08); - border-bottom-right-radius: 12px; - } - """) - - layout = QVBoxLayout(status_widget) - layout.setContentsMargins(0, 20, 0, 20) - layout.setSpacing(8) - - # Status title with better typography - status_title = QLabel("Service Status") - status_title.setFont(QFont("SF Pro Text", 11, QFont.Weight.Bold)) - status_title.setStyleSheet(""" - color: rgba(255, 255, 255, 0.9); - padding: 0 20px; - margin-bottom: 8px; - letter-spacing: 0.2px; - font-weight: 600; - """) - layout.addWidget(status_title) - - # Status indicators - self.spotify_status = StatusIndicator("Spotify") - - # Dynamic media server status - determine which server is active - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - server_name_map = { - 'plex': 'Plex', - 'jellyfin': 'Jellyfin', - 'navidrome': 'Navidrome' - } - server_name = server_name_map.get(active_server, 'Jellyfin') - self.media_server_status = StatusIndicator(server_name) - - self.soulseek_status = StatusIndicator("Soulseek") - - layout.addWidget(self.spotify_status) - layout.addWidget(self.media_server_status) - layout.addWidget(self.soulseek_status) - - return status_widget - - def change_page(self, page_id: str): - if page_id != self.current_page: - # Update button states - for btn_id, button in self.buttons.items(): - button.set_active(btn_id == page_id) - - self.current_page = page_id - self.page_changed.emit(page_id) - - def update_service_status(self, service: str, connected: bool): - status_map = { - "spotify": self.spotify_status, - "plex": self.media_server_status, - "jellyfin": self.media_server_status, - "navidrome": self.media_server_status, - "soulseek": self.soulseek_status - } - - if service in status_map: - status_map[service].update_status(connected) - - def update_media_server_name(self, server_type: str): - """Update the media server status indicator name""" - server_name_map = { - 'plex': 'Plex', - 'jellyfin': 'Jellyfin', - 'navidrome': 'Navidrome' - } - server_name = server_name_map.get(server_type, 'Jellyfin') - if hasattr(self, 'media_server_status'): - self.media_server_status.update_name(server_name) - - def show_version_info(self): - """Show the version information modal""" - try: - from ui.components.version_info_modal import VersionInfoModal - modal = VersionInfoModal(self) - modal.exec() - except Exception as e: - logger = get_logger("sidebar") - logger.error(f"Error showing version info modal: {e}") \ No newline at end of file