From a17e1030d31ba9d6b211ec7e4d5ff0ba7201996d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Fri, 17 Apr 2026 19:51:14 +0300 Subject: [PATCH] Remove desktop app Development has shifted fully towards the web application, so removing the desktop app so it doesn't cause any confusion in the codebase --- core/media_scan_manager.py | 389 - main.py | 461 - requirements.txt | 16 - ui/assets/jellyfin_icon.png | Bin 235069 -> 0 bytes ui/assets/navidrome_icon.png | Bin 13071 -> 0 bytes ui/assets/plex_icon.png | Bin 52870 -> 0 bytes ui/components/database_updater_widget.py | 396 - ui/components/toast_manager.py | 260 - ui/components/version_info_modal.py | 293 - ui/components/watchlist_status_modal.py | 1314 --- ui/pages/__init__.py | 0 ui/pages/artists.py | 5507 ---------- ui/pages/dashboard.py | 4216 -------- ui/pages/downloads.py | 11364 --------------------- ui/pages/settings.py | 3487 ------- ui/pages/sync.py | 9920 ------------------ ui/sidebar.py | 1367 --- 17 files changed, 38990 deletions(-) delete mode 100644 core/media_scan_manager.py delete mode 100644 main.py delete mode 100644 requirements.txt delete mode 100644 ui/assets/jellyfin_icon.png delete mode 100644 ui/assets/navidrome_icon.png delete mode 100644 ui/assets/plex_icon.png delete mode 100644 ui/components/database_updater_widget.py delete mode 100644 ui/components/toast_manager.py delete mode 100644 ui/components/version_info_modal.py delete mode 100644 ui/components/watchlist_status_modal.py delete mode 100644 ui/pages/__init__.py delete mode 100644 ui/pages/artists.py delete mode 100644 ui/pages/dashboard.py delete mode 100644 ui/pages/downloads.py delete mode 100644 ui/pages/settings.py delete mode 100644 ui/pages/sync.py delete mode 100644 ui/sidebar.py 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 71ee43a37fc99d4f4b42c411b9333906a69f892d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 235069 zcmeFZc~n!`_BBeCxAfXl6e=oBN~i*n&K3lOXbGYsL_k2KiGqss(o2_MSqg%H3WCxF z=^`K?(n=RQKtMp+&`0TeBoH7W$$9G>%6segz4wjr-oI~rRf7R4d!K#QUVE*%=9)YA z>uH-mq(ffPlc})2Fly1qA*LKmJ?b-)rI9D{AXL`1UW`<9f#h1o9#_axeb`fBx0x zl%bx0fUmTGK;T~j0v!0Oz%c;aYptuJC4iv+Csj}* z;cwP?oYvD`H@Gm6EO1)u`1zaNQ++-qS9Q$x%;YYr_KlzY_4a`#$sa@h z_;KgI&)g6YyHQdka?1PPMkjtbAahKB_3s0rJAReAb?n#H^?%xIHRwrIeSxcTc+loj7i ze_T$;)wXUfEqI|lnA=pZ$_v<~fbh7Dhsnqzxd@I*AsQ?^UtuQOdtKSe?GK@j2FslM(0j{@{^rL(zbU@bT6HNVQTx7Tar}IPLrlwT z$-+lJx4{vgm zmhD5zRV6LSpDj~}eK*>hi7ln*D2uSwV^c$exp;?n5G4ACAifXg3Ly z9A;gZWF_yk;3j#UQvQsmcL1|3G#m*rSqJa!d;uLTpkQu6JF?V%0>?ZdH2&AtxfCJS zkNnDH!Y&?TaR}Kuy6mRK^=%xtK^C6dXy(6s5FM}!eW=EEbnr3VoFjJgw{`X-kHp5? zh33XwX^Ey{JQ|IO{49j)el4Y_#?+e_E6E`b&!;Vi4#zXR>AQ)X&hc5~S($$bJn)hlw#YYIHA(M^R^+q0=30Umx`S_y z<&f3CU0J@b&!2T`^drtIsaE~&;N8!wM>hufYJ@H`%LesZffw)Eb2}H4#MQmy#U3^W z2fK97&qkg^2(cV8*Ig{h-=4j^qB^=^m=^D5u|I^{7(-qzAv0yyz^ZFkS1nvJkSdD9 z*QLx$r3p(~PgmC6<1>)7Q69nz|L9t^XTUOd`SU>}h9C})yU%+p3g3=Ah@e^bypM^A zCP^!e#%PDId5@jcIkf3o^a_f{Ow<<=>GK)w?a`-Aj483BRv)?q z551(Ukb`i$)wl2lcFGR0R0sHx@M@y%|Mpev2DGoz9D?Zilia8e^1MJZaMb}GSa#U%p;$V zjtzY3$*y;KjdUmI;zb_nhXud3r2QVscNyaqXt0sZ>e23{HxJQ9Y>A#-50UZK&FDZZ z+5|q$XLrSoB1=;A#6&}WS8&N}BIAKW1fSx;q*XYCH*Oc0GNdAp+{?eVu8BcTK8(|`V;je5-s~L> zm;jSL*@ijqKH=>uPPEws@4mT_T&znn@uji_3Qk#44s1C3P{SVIk#vyN)MrJi^^Lu2^!qI-j_%uH^5vioQh-0Cr z`tCngOA*dcd1I#MpTr25@XeM(wY&!Xv8|)zfm>@Kfgf( zPFUcxJak;(arQ@DXHxH;svybH*@PkUyfNf%KS3uFDXX~V8M;?f1D9f2&Sa(_$8%9f z+jBA#9ku)8!`2$BS65pthVYYQChY-pvmQUDtAB-7y+4QjTHS`Nq~u$r5Vs%!zkT-) z6i*6ddbigX9rg0@S@`;q?N<}qJF}04(1}tU^0pe6Lo9^9f$R0+o@t_gZ65Mm1s-

^mzgzIJ zkZUiUWO{J{Nwu304Im^VrYusll;&al#jm)K)*@>et)o&Wl{AOJ@)k<}f{5>d`Qy2QhNk|bh+srKUdGT@ zSL=mY#{DsbN6Ah2NwAEPpZ}A9C4F3DwEUn>IX_BJV!m>#?BruuX;dBNCLx~a9w;lN zMT`d9EE=a5)FQdGlMBe+a0X!lah*J*&F$Wk9Z;2}sLn*j6VQRH{uQ#&EbPM1GpIAw z`oLQ>%|U(=IH<;^)vK=eqZj?Evo!AZMk)a4U_Dc zt%Hc1BfZgG@pHO;$b^m4SNG!A-pMT=zM!!K9rO{NDsU`U`ZOCk?4*+!v9zlhX%M4P z+K|ok@y4xeJsI-1PVcl+X1UY3SRXuR;Vt%hzOJn5xJqxILl-{@F%HyDawuE| z?_ZJn=OHv`Au8kwqYk_9Wk$+TPA%A!6S3e1V@s6{(Y)v(tD}!ocdg%9pVB<5r^Y7U zJ=BrA7{H6{pf9_mG!LqruN=>u89k`pY)%?-yV&phRnA$}zy$zDk^}1P0-v8&vEl^F zRvr}M!aq(*U5xctJi?*nwQ)0Ey9YyNWT)HbZ;i>mSD5S5epP4Y^9mfQu|F~7;4xq$ z>dfvqRLso?UJegll*#N-)#)xPZ$q+7@DKI~@1dMi9L z$*Bl^bdslOLIEqNrI+dCIy*#mxER4#O5z5_Fa@2NNa7%=$^T+MzcPlw+$4pbWT({- z+A;KTJX5&1Tyb`0+WoMc=PIFgP81Ck_k$qmo|LbM% zgtC`6xcVt@-~{wmpI;ro{mV%(f}KVT{YdDUcVX%AXGwpamdON9wp0=rlj7h#FHi#7 z#x>IL`8G9u_<#N*)Pl~ybk7afD&DJ0lE?~--8$Lh)gB7c_8@N2x* z<5-k}EIn^Ub_yvSnSN!1bU%mJKW4NVY193=AF`G7>+5MaxwHJ-lRKM{zQ?%HUFwms z&iUu9L{qJLmgj_A=hsUG%`Qm465U2c!*)1ub4V)yW! zVSJ@SEb<_|m$-oBc%3|BOYs`tKdbTxhU`iMK8fZF>BHDTIXf{Z)6X@k z7ov#@2_8AurIv!2?!j-=9a{pr}y}+;7Xyr4%G3*Eo z|MbSx3BHvUm5^L#a&F@|{H?Cc5{CTbeLlf2M(H;eni_ao2zAS(2X53M;O&Xn2{Bb2 z-}#*};`x{U1<~QspSepg!%LUYNfn&e(p!g|uwb0TlKEA;zcU1Bdor>yf$PGJEwd7M z5PO6aa%%1^nfx(wl% z&#IO6W1!g+ZbWc=x>LU8mIp1=+0Hz7-lV2~Z%o)3SisrEFfsThIWQ>G$4s#EVHdl7 zkdkJfG+P<^p0jhMSmZ2E-&s{r$aQ^`8aK|@RwXRHb#chy5q$Cr6NhxPlgOK<^KRLFHXH8=Yp zhgQZMw6L+|1oLN0HyegJb$GphF=gPMQo_cz%@AJor3|)hoejn^&Z<&6{OgbxU6TtY z+mLKsVavgS5VHrLn}^7ztnT!g6h!a>pCrUSNaNNyAyvU}PT%BU3F}u$Nw*;nmTpn` zK}nvQ0YhZnRvzAN0=ev;BVAVAl8lrQ+YT%SUA8>KF5PHV=oHxuPYt|BGlqrlvk0BU z)4Xcl63Znet+<7ZZNfZS-6__bRpg=~|9CwkbC{`5S{s%SVb z&wW8%h&@-vuY`gqkJ#$@-%xgTflPopfMvq}mQ+4cgQ0l4D0GI^g7s69gMRsNMtvSv zU1(0hkFU2t7Oqixsqd{i`a&BiE;@BJ#@%ZdkNjnT?B;!u)bA5>be(3VV(COHp950C}U*|sjkO+>giJgW@{;aFN6ScT`IyL z*S-@u-6gD`lPSvy!6hzvF0WW)$d7e`M{ta@Y5WN3?S-k)A=H}mVR3V%o2&-Lx+>*F2V@X6cu+#f8Sj^C;u*i;R$%%cX4yfZNf;tWru7 zIds_QvH2yjMKlXR(kNn_lINN&4}EEkgYNlpW6MnLx$*DA~wMu3ar6uxLM8R z%ebs-Oqypa?735iG?Ha%3(082EnJtelG&8#RDdpEh;9Ny?pnL@M(Hz0mP6@U9|++_ znuKKq0|2b`Dp4GAGToy>AZb#quSV6J;z(#MGRtj7%3_-GZggC&;g&xX1|uH1fG z(CV^pjHgcriFwfzXuDKNm*Sa`>3qhhL%ITE;>W-OZ+6?a6&%%n;-%)&5$YInZPJ4? zNUg1-I!_!*yVazniDN_w--~Wbm!Gv&DrYB&q?Ni6l+zV4NSr zV#|vz_EWLBgvj!oV5}(C_qv6`3@FU!9Jccq-9v~Xt%Uzzm@RAtWxe|SB6zmv&J0*W zV5!A?izrUWA-d=+loQFC(RT>0jmCO>6<_ZDdVm_l0yIr6${?X*k$#pZtdRERK00l- zKy=H20T|y_g}q0}>MXkMVo6UIjWv0l zjR{LD?RO%Ta}P4n8Qxb?X-eluByaN`4jJg36Axs{|ISN~E-Lifi_Xp~jEr7{!y|A`6U&GjSgcxP zYXq-*);@yoB4d86xEV1xBksAC$8ZoeeUM+B8nkqeed%9dq*>_C{iR@`Yp2!8U`{e3<5$Lh9fb4voXcx5D;9H1g$ij;1m@p& zUQbl1;O%7x1l>Q_q^s%+38w|5@(BmO?cR9`%5gAECS6(Zfy_~&ES%TJgz6{rJ-Yt3MtP{<*3Jv4CWN?qrJD;|B#I+0Yi2N&lurs0e`3#;)lZJoaTNSgJiQ^s-1S~_oa?- zpgU$Nk-`|~?#k*@Fv({;Hi@s-aCIE*s;zv0w4{W}lh9q~7RWsrh;EeNjj-&Z<9m^V z`xk?rJsZd_a+Pw5CUdjg)pIN_bRKolf@1A&Vp`*(|F|CGD20jBN|ni3Sn5tedY*2Y zRp!u~fZSQ?u72#8an5gnEXq4Dx?KAnVSmX|K+X4kIBbLYOtFp6jJM=KtsJic@^!~q z*T$n?-=aRMW`0bUl8`LjBzl?$P=xr^`tjW4o?QGCg>G5-@&V5e!sju9|87WxV{f;~ zIPudDQ8XjnNEeB-&mP}v*EB?$K1#3}I_&Vxh!d4Rn|*ngJW$EV-tI5I2k|DeaRr!L z;VSP#$nkJ=ZCzZ+vAj56Cgl2|vMwr`L)+Av#?RH@&|0T{e5{x`2EL!9ESDy3q&38S z^&au*`CXMMFW&@%_teGjMPIA&u_!*^x_kslPFt*%Q@bnPSMDFl$h0W!-EZqY2`A zT=atU;@~C0tRiTIj2;e{l9WE9eim4KVtew~7_wdSM5MCQq+uG*@ewm4NjW??eo&;} zy{^c;59m>WZd>$Cj)iWgD>aIfMlEq+Vt8Lj)Kq@1QTIx0Ym{d}EHh(xFvhe!OxB)R z{;&f zkx2HLKCzdJV~Btj!MxxUTi9 z!F9?5{L&#ioO6nYU*p#%xEq6Q3CbrwbVupXm^qOy0nzFZw2p0Lp#7Zd)yASlmC|7Y zyuoN5~}6rrBtAJ(aVZATLckZ_xc?p!0-B zt_aUIWVzB^Ns|-Ns{s?nq9;e-*ohoX*Gb)$J&M=o#k944NBN3@;Yn@a7I`~&eTd%h z(_($N3?k~m5F0Nh&l&e54sC%m{CN}R8@p~yS1U|P)dWei6WhW1YI-po8n}0|jP*wL zRKZ1^Fo>&>k14k-LN{w83BE1uW7*S;Y)cbhAU_oz*-8AQWv8w_N@E!$=s3)R{XwVO8GEp z2Av}d^VBzcEz9OcW`j?u3h>Na`6w_N0ZYL783{ zCU?&ejV`^@Tcw@vr_4`#``cdzELqwLYdPD9#>v3-Ko*a<>};rV(Mb(jIFNVo?{t?L z-W|@*byvURj91szOH_Hpp;e9{9X*c58xvY4`U&Br0GQ^u(Q_XK@Aocq!ClHAL?oGyhK|l)2IS>A!scr-; z1b@Y-KcFrfKRScXv6}a*QN-#{>9!(H2@W2t)XX#XiSo^BMil--9jQU2qa2;Zv&gQ^ zNJfxyk5g~$##y!Dj{M#`V3}&H|MunwdHJE{fSD4W&BFmUam5ng!d0!`(M%Xna9ntJ z1x}Wf`QEn5Q@`gX*Kh6F5!PWqYAVxbPo17Q3A3!bhQ_8hKOFt)Qf#r~A%9W>iJ5u& zGWMCqgw)!jdEMtGkc-`U`O2q;$)OapoC3D)Nt4zOd@x;t2m||ts zbx0QO1JQSa7bf~hU2bQlAX@jxGMpezgoN{5o`uOcb$6s~?|Bi0hRGSKIDJTra~~7P+yy9dRkEB;@$5iBTMp#ixAr^w`>gBT8gmcFIBJ}g~^SEyV*(~g+xe$Dt5)V zwCB+HEoB;up2A{S#o@EJtFkCurLvt8KR+ZbX;6C?|LqMKEwI82UQXa;P?k#nLd8R- zr_g@d6Jh@J~%kE257c8xS3E;icd8+PYKj3Kpkcz;`YWU+_P?}qn= zvlZC4I#H;DNJ={uHZs=H!5yG^Ouu_K;^Pfkpg=!trk{mOY1il1*o=;OOIb$*q#5BO zM;~iv&|x7#3zzjZ3NReRT2~oZy9_&Tlsk*p;Wyu=-S~EA#u_ZMgmoOB_|Wp~R(9Kw zX6RA_J*M)Xndrev%ck;`petLbJShcLc+=-py`IwZcKEnd|Gm${Hrm$&SWKmRCo;Q! z@;(Stp=T>_r}ux)#i^Pp9=)yUw=Z8>im4U3s<6o`sy8MpZNnj-2 z&ni_=rE+%S87}qD?vixPr!9UA(nL1?>kOoxPY1ANJ@uTvXU>q2b?KxtbVK>M2Y*_I zw3(&bQ(1GkD1k#RWLvws@s{>89FAEFN*IL?ToCA!9 zZuZrY^@OPzxh8KhV21ni=-0#1P>Au8qucD~OnOSPnHfP#t} z%G-Z81|xln%~Ek@H*cNG2!7H-H%)7it~wKX9yfa!_}>8FF4ZPHGTRV^)C{Fe{aKCC zg&N2|1o42AP_TM&h9!o}!uhi~`Uq)jiVe}P`5?c`5=fXQzoYb)z`@~6VHe+%Atlm_ zDs24M{i3G}iT???nF-v8((b%SC0nJEuvDt72BKT!Lutnw@sg9bT60nIdpmET~ z^}PZ^t!`XJYTz?(yQ9xQZt%Sp6q0_9-jHw-&8?Y^d37c0#wv*E&I|&Dm-0Su!ESQk zOVyn_4f8XB;Os9}oojulQpI>&OiLppJlLh*I|E$e5*lzH0Jmf^;ej8OCsR1%nWcim zR6gY;caywH1{B3pcl2cwX){E+<$xl<+)o>@jeNUO7~`bjZ)eL%2wuouQG5(8k!78N znD50m9`=$x+;`1W3yGnfN@HULOw%W286w`>m)%2n%^L09{DVNC*(Ee&?z@hb6C>f` zY)TYtn%y(vUwSp`*)?|Gn)s02U zN-FyGIB2f?=&b{u@ey;dwUe=7=8Q*fcoG6C1s+NQsC}V-}idCr(^?e#OG>*m?QVvBSpfp(~H5=d27S} zCO9!J#nu)GP5^BfVx^~sPkP*OkKW0{U!6d5MAXvq`dbmpI^CM`@pra-C2w6==JnNO zj+P1+OVHxyXSF_byH;)=HUr9EgGC0B!bFMwl6&KEZNyu{pszJKWIP-Kbrlo(ID7n_ zwGykC;e(B|Wiz4&9TLXf52&lXNwVNQ$|f|bk`GiTa4FxG)1cFh5@^PN;QFrJksk!o zoAymP=aTN7lfV%x;=BH!%2f4s6BOkANoYG@6Us3|Yv14Ty`JFddiGthIlXm?38-rv zfB|yfT_dAh`P5H^uI8H~eJ5fpqX`-XW}9ss8X09X8;O-4`k)%y@e8)8$1kAt5&IA) zJNV{MN$%5oz;nkba`4GP%GZSpLlli1ZC)(K*6Z_Vqj^M5kr94H+S>r^ zvuMQ`%_}fmWSL6lH zMgTAM2}cQ4{$6UkCJnXx$EI^%vs7oE14N!)U5{5^b(~e1nCrxH?kV2cAGat5_(26@ z1Z_o%`xdFault(x4I)RsBVL7VCL%n_1ZUG>dbopIBb_!p)THq?VHYX3E9qN7l~g zz#qdW3lj2XX6OCI=Bqb`^F_QN!6soGy?}A);$Lx;L^sa$#BY9jKn{4$7~xmZnRx+cAu=`s?$Uix~AGv40# z;UVjUm5aEgAI~mqNA1kvc-^6cU09lXj4(LR;A;LWx-<4Q5 z8)-Bl&7sA&A-@;egddrm&h09y?L+6fw>sC1-)Mq}R`{%Wd#HKhzRUpplrf>jSS-y7 z$W}V#*>PYkF44~RRZ^@{Zxwb)M7#Tc$)>uf_lxgnbCMpXX?JL!(Q3#YG{R31NaIQ2<#nGpKPnFBb4;CMOi_~25A2#bt?TmsVqlVGM?tYr8>lfo9s~51} zv$S4SJ&cc~H22EtZb$rR{LHSFE5l_ut=k&AYKPdF+lSBQ3H%CxA>qE~%8OQ7|9e`U}2TLy| zgF7|r1WUww8lQg#c(x>*_q}e~Snqb=3F-CHVg8#~vigv1q@YH%#lWQVVWFR=75#Dsw`gS7zBESkYcA z0u2m(wCig;j63@=dqrks{_c{1^<0%1o(H>vY=im$@EW66kn1TQ7Q zJ)OC7bz+gX(1l-nIC{p6|Au}HCjfOA!HYE!5qR-4yd3H}+4?`Gr}s<&9$eKcM4M0{360i<6t^m=1N*KEF4_~H z8r|cV#r*@H8KqEZsh1kalmbRR$W~v$LA~B zr>)7={;<1Mj2d&gppCI&r?bipf3U@6Aaa&3S^^fxUx%~IUObwxzilm|HoaIKzGBE< z((S7P*kFi-;)NL%h%&!r*V48N2}0u#C4o8uSL^FGuc;TC4cU}?8Td^<`Fqu!g+%HT z;70abcvPI|HR&@m0&lk-3&g&KFCXX-D+U|0qy|1_2%2=JUPy?X?>mX;$a=OxcVZ*G z=L#}584pPzCXOXlxY%MA$|q*{H_n9}Sk$}z2*Y2PNy zB}TRYd|bd921z1x$4<_Rt>cd#^G&ojd|UfJdtz(RQ`uj=O$#ejvZiF%&(PXqHG+ep zZ4B$`9UI-Q(!cy9ikS<7Uq8MR5b|aG=f-_2my7XiMkdhBn-6mEG5z~8sHCo|?6wne z#>z#DAmN*Ijku71Z9K(izy#jzNk7JImM+W*7xl@caaTabJ01~zk+Ri6or8}cXDFNz zr?|Xwa4geT*8@hae?*w#!co&hLHEMS-X7PGF$DTG5Sj}cPG8h($B(Dma&7wkdxlj? z=V5-!M<}2i`!r(fl1RZ#FFy{x$vi`*9OBSo_KYOT@UJOw@PZj@QYE^7eT+PPQX0AK zXB*3F3*@~eVUrV)Ms3Ya73{;sm7RS!nB@@`n{D~S4;}hNgcyv@AIuY%vmm~gcb|E2 z#LlxBCCOy!m!_d%A-u+S3MJ`|1H19C!9_6uJ|6`v$9)Hip258Tl(CaEJ~(6_GkdY5OBh* zdcA$1S`;T-UvH`{6^Up>yWC*@Pg1*L_B*L<3*h6$3tUFmiL~XJ*SEb$@`Q4K;BPM- zaL-WjDNDxJFmD<=e|z)=P*}j*U+i`zuY;fQ-d4F8qh9j+IOKLS zeZ%9tlo#%c+j$7fJzb{XV*%e=$nK~w_Wc9q?3M(ZfB;HNwa>?!e?!Y(C#5FgP8(tr zg=>OJVw&uAaWC=u1TwMtSL_k`qBe!}qZ>02^XE191Ru95l@GU>lRp>%Q}%f~7WPem zJDH4g5217upPrVv;`{VyuDql&;pVq~={`_qn1tjU+&eWGKTIg1GFOJ8O7HQNWLU`0 z)g9X>uWwB$@%>uhL|~@EqBadr!tU?8L60vPmN5(`s($tkzb7K_>5m0+PzFNd$7)Jf z83sWnW}g9ZFwht6Y86ZI(0m@#FsvACLxeQs1)cyJurfk&mt&F2qs5D3cZj43@=be~ zJ@z;3#h+&p%Ex78Yig3mYmxN~9bl6^W1DLc<7k(cnSKWyB9Rkl^926-@s?t__GaOe zy57Vxz{HJ_F#hvz*eh4dqb<@1H_jC;vF=`jR%iT~{pKr|RgRoCZI0dm{F`^*T4)^0 zrCl}@q)eXo!e@EKMdsA>YbGN#a!4_0_W@rHuER_pkIr3%lN1bte1o zMRWY{)2A?*irwJ<=mWOPn>>Y4`sUqL~(zdvkugC;Z!Qu(Cs# zEb)>(-MQ1vT1zwg@of=hS(8ys*547|V1bFcRwQaJO)ggt)AtD0M0mPV~8HQm0Y;LGThd-~St65VWso`)3!4 zLMbnQx65DW9W>CHTCw{4LSckc$@uR5b--lazNmVoC1p z2a7YoI_Q^-w^+p@$F#%_>vtJXS6Kkd3h^Ix4ue&55sFkO92y63D;EAugn5ih?D)ZC zG`r;=wS$RP44BcA+g5{{^GK^IA}4EhdG?C&?^g`Q_#>UQ-4 zShSnf`F_VsX&rg~JG0`Ws5eMoLBssMhYtEB<12=#-eKk>aEY?S;7Qdp0WXEKHmC8G z3=+TP8-d5qlt!ij7nRKfY8uz8EF))e0vI!@9P%YC!JTrGVTa8BN`O4~<~PjGs=XRc z$KeuAj&}z?ahd*2f%+jaj0wI`oDGc#tCS~lU53xVbZYq|kCmVLMYHpD&)$@6Gp+Hvcy`#m$-xNTV%Pv#U2YJ`E^1~FGVl2j#J%b6mX;BDqVODb{$Z<3zgzcWjgKU3!4|B~ ze~Ff(F_(_3S0!D0L_Z6+Rp_|FOh5e8b0x{5cK0p+cU({;$|KHo#trc6?ZzXqZU^fM zyy_IzPk*BcEt6`Q^!Oadm9i5(k=9NCy!H#JTSD-<7{AqFdD-SHzv*N2hTE2@hL-C3 zLOcpI9swh!X~w475!e&q>N%f;8HZ7N=>QewU>;*pt;UdlTWoxHWVv(8`lm%HDod{2 zGkR!ycs=v4QF)L&(43y`QYFH%ZzU+DPjIe0ZAOgJ`Ln)pk;ye3=Vz5=$`m}#K#+Y- zE}0Z`sqoK|f!ug$mFn{|gNscf-qYpMk^v5rG7diPo-guV&fCl74J>~KRfhTUCRP_d zR?QZTDlXK|h%NP_va>6b_FVHdy^u=0a3r8d(B~qU)WjNf?Uym-GjCmLWqFr+#-I2- zJ-5qZUpWDr!v2j16M{Wv=DDg(xvGOXK5Bb&sz6LTC$>Bi|~!Y z5DNC)Cgy`NPRl!Ya{i2G=8hQq6L*k*97;Tq>ul&)hqg;+NBzOd#R(kR6ScnKcpx&U z;hHHcsze*7B8t1d_hx9Kg4BM)sJxtE0DX|~pL!7R!ucU(na%Cvl$r$h8hHz^q3J{` zuz4=la$YValrLA;j2c+%%+}{GU8tPoy)>sgO&~{YhEY`n+CV!O_qZiIqfexvT3P0& zLpLWfh%+>Y#MgM))nCAWE^Xuw{#N*5U@5}I^Ou4p;phO?fYOW+1N z*`B`+^!UoX&Ng@T?VTM?>LK({}5z3;#e`6UlxCF{McgY%p zP62**YyFVVRf7qicel}=tjUIN&fTsZ$;f;GZh1pWTiJgA0+2&8NBFVBTSGVIc5%)T zpwtXRvF5(PoUR*#c|voGjryI;znTH`CtUmT{^E5kZx=4rN^<^L8fu0ScN)96zfu!y zOgT(<=`IKelYz@2Pn^)u|7MfAP^gO~^GjHOj)^0#h? z60DcK-K8+BXD`L~CVmah+^=UOb&}aoGix>D057isOzIHdD(yu4t7;Oc5Oy1Tx z1?VjTk=70+U6tzmU+ZQS3^hP)q~Cpj#q5$*!c`rqNGI6_&s2vVhedM-E~+O z{{rnKT1lvno{hA0pLj-0|FP!YyP6KkgGbNqW4pvf23XiOy70OVItS!Xe?`}d`Op2E z0}MyA;3(2{I^%J4`0D~xBc{kAw?m>THrp*sh9kFQ=*rDoeG6|qqeAR%10A*bnRr$x zAGvE#=gNw+a-Xz0y8Z`&H;!1ox%Q?|#*zInc%j;oOo!W^IY0p}kva|F>pP23yEtNm znBvfYo?lnpVUH5b3`_NX@}Ur->f{UaEsI*fRKLgCqnmmU%Q%rd&akB(mIh$Ln*0Bz zk0&Ep*YkN6UQ_^xS9I@mi)0KLdpxxkO-WbYAoadf?_U46x&Q)n@a?;8RNDUWW49 z0Kpy3D7#IFHF$<8BuvchYm%kUtg4&PKHdT-SUlOc!m{7@ja-qOjE)&J-QL*6KSnqM zf#xi>#R3Y6H|O`O4p{o}EBo$s!f`hI)IsWejCFD|5I=_}YeUiQrQG$qd#hVC>Zx;b zDbsiKPdr!(2#c`jf9)~NVTHOUJ^@4CM8Q+wt=Amw9*L-!nB0iq!d899^ACUYzp-+e zunITzWvt~AmM8y5k(hVL)9pE!ux1YuK=A))L+UpI>VFf41or)k_JyuFJC9mx9en}% zc9d~|lV+^*A>Db)1;BQS6_*VOyjS(m;Zx26-bn@2CV9or^A2mLa_`PZl8NQevLk>t=Q=#OlfSAAlq4Cbn6w<#zBe=~ny|enlG$YUq1a2t z`ahLoXSK%3j3y|?*bhe=fRKac;k6>w@zQX((ct1IuW;Q_<1>!g@A<9r&cR}QLnJ_` zwgw200?#`OL6EdpQlHCFN{_$(-1?X7w_^@g~*vaw&%&d+N&{ln%& zXdSVlh{aJcIevYUMGU$yZ1k$y_jy~pO#edER(1su(MCB%f{2DgGon2mlu)YrW$)5^ z=k!&6P*j@Lrc@@6r36A1qeofBGDhhLhm2ph+)?PQNK|FZ02j1MUY>hKNC5fqNOY@= z)WDbieyz)q)}HA>Gs+zNnT(V_Aa*HjYpZs8OeidHwVzJQ2aXCrWYuE+$ab}gFM3q+ zj0XYkX`RC^a3md2b?eTv;hzwiE6GJ(fzY8NoYvIprKH!YA1PiAhx(Oapm%F{{&pUP z%6ntF1J?@|2!L0H@)7NjPFd%#sgSHHBaeJ)(r7h;hC~~tCVl>CV7+-$qb%taU8Q*y zL}+YWAG2{){jsQNriC>lcHhs|)=&J~bJX0vj3LHK%I3nV#QYnLi|c$rEgV>idH1&0 zi+^S7=!6tcFV^1zNd$nzO>$>HE(i(r7(sQFo9WkTFP^ptVx4EKZu>Z6^j7E})H6Xn z&fUL)HClZ>G$!ccEH8ZI^L_^IYTyT`wf5$f@S|6B>gb?O3wS(!MQNl?hCk6SSoU^3 z<-4ZXy70ekW2cBTzvx*Yny2#$#_|g9WnXI%oaRO}RWusy_^xVx{^s2u62rHnbIu4w zbWaP46Arb3l~z^9S&nE@006Wyg{vqq%OoE-8qK2eX6?3P? zivuXs{os+Sr{|UouSvlrBzP3wA8WZwu%_vD5yQ5=_^ItZ7>&qz{{SvT6xC5B`kUU= zbo8SR?{A+;RQ5j@*Wc?FlGT4>WHIO_FjRp|46WQLo2fF7Xp-eQ&bp+&Je{Z3dcs?j zhf8ZS&}l|0id=s;m_Q>$S;8OSM(|UveV$o%z^lo;;+{MDg1d%?v*@|R`1vc)ez}U} znhixaTSnH^>>+LwDnoAj;XqIzPNq<&`?rpM5|%)nNv+suY%$-cP0J2$UreGwufe47 za|Te*fGd*$SB~%&E`F6w!-BjpDW*$xsDU5cce2z%#7ryZ;_;*?nZs}- zBT>kFF3Ew=$&`|1zV_oi1FlRiAz#^ZHb@-465!4tCj4rfM1@~GdDiD^kHfOt(Nd}M zd0t_D_O%&Gw3kKk%Wv$$#7-b-kD__v*yZhnUe3oTrcQF;_%nENeejJ0AnQYvdz!9# zLv?$y==_c=9TBT!G{pC?hNO_i~<|n-rK2& zZLMdUO17+7Y^nRHv2|uIL zPSI)_Qx$ao#<<4vX&H?QFe3IjAc24SkB2V#xP%{h6|B+<^02Nw|Imj02U#44BM$OA zkj6lv(6V@;)RukT0Qbls#kj+M~Pkp5JRZX^6mukES=#8_*nwQPg5H$}+7{gn*(!m4;z%ZGL0v@Fjv9 z><%U|PZFRfmJIi})y0Gv75#s~8Di#w3qgsT25<3KN0HD(=-DJ~!nUYsJDuTn<6?MK za7k0hPwDZU&n17lX+N`!0aVD03Hl#Q)aK~WL^|$daz?%J!UEZB<8OvCL9y@=5z*c9 zdKUaBm3Y^lwFFP#K35Z(EjuYeMCXHd_Hs=)5Un1K6BF)#>N?T%{p9h%1&sIi`Aun{ zY|0>dnQ4M2t%)$4bprCUUgU65I-H*~>;aVoc9)FrD>&sO?38Pi&ioZ;wez8sVcm+| z|1;n?y`s^ny?5n$jfER3UN91LIydzPwM{3o?I862P<4ulW$yS(G564oAd?(e4^15o zR=_R{O@OxOOsdCQ&5a&n-$BP}4RH{2!-6?q{J!!m*QbA<$Gha&($F0CRcOgCVA`* zhv!95xI_eZwlU}GvnKirE>gc!7^}kbZLEmO>OkKj)u-wH6XjMinr&_t3wI9|X;cYA z68309fx;L71!rGvt^R$WOi zXzTc&yzQ}W$`pMacN zI2hp_mhfaC{%_u5)N4Z@$?I;6Q1X9Nz;&m2_`mWBp;WOJiQDGU$s@X}$tzH^wr|&@ zbqMbk5GcU5=6YA5(rdP>5c_sjMP9Vb{cBG6sa}JM=!Gs2k>Qs*@!hJndugadtqIyB zk}^%uwY(e`DLsSB;5L1@CGN8%LgOeC?!X!`Wp9$UO1#(m<0)yw=s_oK+yZSS;D^7T z^4Fu8Gbge zK;qKow zh?{#(nL{t9Ok;;~R}uQjGBGXOLZR^=sw!T;ldhWRHGlm-q~xmy3H%^q(&IO2sOz&;HS=8Onkq3g9f^E=}Wq8-z`=V_z+lOomh22Jle>Bp9kQmL_3)C z#J>yfD!s&GE^s1;)LgVK&7e|Ea%9Ven|0)g9P0x~*cCXSH^6@2S2@Rl{k~J|`V;09 zbk%5mdN`Wx{+KM(X;Mc%H6;wn{sO%>EEXTSba{8g$f%dUyrhH$fEn~AUg^^N@KPyj zqgE$nU<>|pp92ECNmu7Z0P^(V&|T)X(m<1`VRmRyzH0_gv5O) z(0w~4L52JEp?5X+YU17>1TOiac6_rH)pV^4f5e3bvn3gqpjA}}pv%Sov}xO-vQ~ zRUyGQ+cXfez?x7#dXXT(q%-t2Bo57Fm6hy?wJUbVhyTlTfGbe^zH=#!+vB#ih4-}^ z_|A#~WGd6R`lG;`3Cu^cJa~7rZMEz0+YP$49J{A=WUqmP3hxI;mOdeZ=YQ$`R8 z1@pUEwKmt2|&z z1fn6R z-1J5NFUH;0MJ*nw2O&w~o z*FXZ3$(4Z`Wc|US(I`b-Bw>a&`w4!w41d`heJKmIwiVa72#_o45|8;bUzgGbcqaOP zf^}NOoYA_SteE^C61rnMtkIjwGvD&z{TvwOm{`{2{o7?X_ z@0FH7ns7jDhdV!caPq#VT;-{>vm49;O5z5Z^S#_!$E5 zgeZ&1HRJ4z{(z_}MT_s9Uxw}v=9_Ui5846H1OyGM3~xtgaZ!*B^(y>RM2uHg6CW8@9SA%3+Estmd9>}Nq^BjS zk2<6U=T$|W3-`27bsg+k8yoQvw){xRP5y0?0f!!m|40Kkb^nT&OfoHV1T=RW{8F5m zZO)qGNUuQo(Eqwh3X1_xQn0e7q!f^-r&{M zx*{pG8~=regry*M3Q}sTh=|QXeG(P}FdYiRz9={Gv>|6U1=v;y3(5t|a+FW@`Q}`~}^~m7g<5zZNl34fNcb>W-uo z#LK>oL(S7-7i~@g9`KS2|FtD`@JYw*|}n;Pvrq_gUpH)E=U5(!HkERw?!6O`z6=V(xM+-zP!BCJ39P^xxBy@ zui_LW0R{R+jQ?1JN<65Gs!#xCNezAjJ_21}xUGa$tOTku`3o+QP+Otrx2M@2GEjKrK1(Mx?@UrG?ST&~L^vJ2rTy zz$uHK3cEolw@@kfQ3F8rY(A)7)G!^;r5v?b4y6{G9J=@?4>KG8jF+zt_e_z7 z{U9$Qcvc-axXEV%SbzJO@k1tsfC-~qner@T4+LBq_8{mr7;^^YWYu28Zo2WOZwe}3 zvQU0Bj?)fm7=7-mze6X_m{v0uqNsh&YC*PH%Z|NYyZ;}UgHz}uMr}Rz0k20H5LG!q z(h6MdeOZ%!9JKeXH{^o2@9#Ixf8i(uP{6V;?2d5bjm$T9^JbFV)?i|E)LX&%SV|kxN1TNAJ5{vq|5vkbL5o~MblFwOK^$5 zX8d(D;13ulN)*9c$lM-3(@U*lrE3jL+KYftfZqlxj|D?eg7w9^^ZFm&LU>l~gDz-d zZqfv_@``@iAC`7N6Lnsl26s&+f5Ej&Oz03jC!2nVh;t|iuI>Q+Pq~8ail?d^Lt`wF zTPn$D+xR`vG4P3dZPUt<-Ul;t<{+S`XqE|X|MbT`eW0*g;<@Go{JmmH&$w*Prd5;s zY(DBDxKb2$YEP(8W|`-K44P3gir>VFw#=KUh*oeA>T*a=;=a_Hj9FEV+74eSc(>)89e8b> z%s+KmSwN>$KPKFarBBb0y?@&VKf2P1!$HE z!TR=)0Qpaos|eNyc*g%gpYe{0k0LG_QpYQNZ25sWQ0n~H8erntcz60^f|X-|DH;?p zaMcgs7SI0c(PW+^+#9efcjE2$v0gHP4JfzO_0i4oA(O8FVS5`b$Ts!x{rw?>FM=$} zTWJpyf}DKb-RM|+-z47dAn4Qq(qnKBs8swHV4n@hDt>#hhCPoL0e%%>l_r8~)7lI3 z4j26v$1Z-E*zrA#Gw`U^VM+hXdfJ!UH|zlP3+4V%Pc4Yj9wr6JUmeWja9=_vwxLR& zsK;F;{(VZEDmMcrq5U$ykg7Q&!$gvpag4nR*!hMTfw|u|eUI&g)J}?PJ!{v*9yZG_ z!cXbE?%a9ca5v+Y&kCQigde>kl=W}s$Nta$8E&WJ;W_Vvd6IygM#t^1QO|o)&!+{9 zg)xC|882b0?6#SICng!;enGSSsAkfQ=g@z^d4Zm{*qDAO&vn`QicXIwlQ%W*DdMw0 zMUAIi)QiC48Nw(E%MAEai|Dnq)|SVROdPS_3C{!VA_L!cbEX^T-RDysqz41o`^?NC zjpU$j!IiZT`~rgN2L|!N`?0|qg;o{qYR*~y=w0U=AGs~}=hgCMPod@H!b`L|$enTx zp{CWkXwfTXOy=9*schw+M_(XN&)p>`2%qIkjW!967W~=+#-LtMbpD4q`_s3x4R+*N zFs1bOCcM@`A-?u*IuJUZXUPupZdErgUuOTDl&}V~gTJ+RMDdrMsV|jRC%hA~W#(j) zIWCQ>m_8h1DXD*0v!*w>V5fn&`%xszLx^kZ0^kMv>KX1K!hC>l&Nr2pYSx+lK!7dT z=FxZ~iep|rzOWbh-myyubNiMjj^TwNmottSEw|cV!+Gb|I^Ou=KaUUeQoM4;uo-_u zmJnB+7lKm#Z*S8jGsgoW_fJX^L)ZAwrN{|>vx}08!kb~C@VEMtI4SuyHL{r|kCfQflg-AcTG&&_!!cGwZ12s3%Lsr*v6;3Ysspo)9B z9`n=_eF&!uKW{rh zUCyarhtlZ3DF7FM*;mfCvM1D7q|;7)0C-MJF=O?$2lHLNMg8@M4bY5K-76DAg05Y> zx@c9wc(4TqclQXCuZ)6{8m)_gU0fD8w(1}DJ_(eUrjrt=(wuO?!KNn1m*QdNog+WX z!JF1${boyh!x^vPujlZ;wt=np2hOj0Mo4&p8BXP_(#}V5S^=o9Jo$cCb&ILkdC~q0 z%>iBF%z8MxVF|9@SgKUKeLCKO07nf-16FO`w#u!_^!?C zt7~Tt0~zkqBjA5mh|~`z-AJ^)b@&TAd>_iA-^jPjA|4`+hXDLuyY?^!qlyBZfj18% zy&RF18j0L;Hn$ciAwYROvC}()(+)WOn!yU{+R}vH0lCjfn{J}WDRfRR21e7QE5W5~ z6R;cakW7ek_n+r*`nE+Xx&X<{LyrnDz@e_76Lpe~!Ys;dL3o8PAEkxfxO;SXWh{wZdfr=f1Lo;={$8or zrP0_pRxjPUcFP6NR(!GC%wp;QOA;|Hzj0@vrzGWM9m?`0TR1@?2s{Gig2q$5XrTl& z7)l(pb;aGIy0iw0WU4H8?<@{;10y(l+M3d6rU4HH1?&O`Z77$gCIsg=C$^}KV7H%# zwa3@FhZf_vIrW_Z3op3e_-EapUDc|VEuWhKz;1L%4)hpa*1XRuqBfQG(C{QRtikL} zM5_d0r@7zblY|9bB*~7Jg8BA?D$I5WSE4}W<@`Nu2bDAbP1AO~5MK;8J`L62y>1xQ ze4{Gq+C^lD=_IbjoaTF__jYV1_uo35=5bDM*M*UF!l(xR>VF4jeK$DEy)ko81SpVn z?qTE2nMj*X`xgbYPSv6$kQKHvFp(k?T8iRy5d3^qxF5$jlv zqQ7HeczD9q6?b`k4eF*O72krJYe8fQmrNUP z4XAq2l(OLC@;c#b65H(m>fyKbySN?arbY&I)O0A>=dIQD9AlI%d0YMU=M&Ii6PGYf zeo(B#aNu3I%>NzVgk8GOPK*U*-3CEHEf7W%!qTaQcvv;1pM7&Y4}^jaV#HE{ZO;lpriX=B=xrmAgSN7FMDzvP6|KcTqSxAqrM#-??SKjZ8vD-8*UYKH|@h>5t0{^aoJ9?e_{FS&JI4&bUUhh{s zRf%`uck(E_i&dv#B}$@(q$3p{z+Uu3E14ro0VBUR3WT32^XOv$v|zHmvinhzwA=U{|il>hy5t<|`>&$>- z+`~USq)@oX)M~z^))l#_nDUn@u4o(J#lMQFcVk(6iS!+#HA%jZ>!pvJul&9lzjyP@ z<^w`)4hAbab`^{6Cf`&9z@*4ONq;cm^~#T(7#<6;uBt_~G*}-!M+Z}{4aq90zk=HG zxJh-Ep8VI`TKAj_94GoUzp`n zgbCE}F{r^9$+%14)SHWYmoTXY^?#i*g!Yv~m;SDt%d*2xhq11$auOn?UD+eYAcGz24%i|$go^eH&4=yd%8bX7 zeP(lNyvtzm@lmADS$Y0*x-JiDX&TNe;1LCGy9d?A5cC`;M+4EKw)HO+)Qy7ME}5TK z$;g|I#%RB_{?%04J(i`{$e5WZkrQR6ONy2C2OgU5!Kf?+jyq{*M04(nXwUy@@mD2v zD5&vT$=t8;I|2wo%*magbL7je#8FHb^_}SASY3n-G~O1I6)-P;LKnEND>`;1&eZ=G zsnKd8>OZes4IT4aK%Op|D_k|r<{aOb$ka@PxAUt#d^pN^oI%QI_vO#(!?ee z{mL<=w^;h)|0tv0s?93>;RYmqeYm#Ezi+cnzLB+asXis8je3h^EZWtfBHxeo4dc<; zs|%WYs|Ki8@J{lNZ`_PUT=_3j5(DF~3_*qm%-E_7j{*fI>QGkvUbD;0cvZr-;!_!` z*fVNrow7?PDt511NttgdpLtsa9s1`zXa_ysKoBdc*z3m1i>%1VhO68)j`dR`T^vD& z-UgsN&>nJ{?>9DS+OZA&K|;T3*Zf?D77mJ}gKmAKSDoTV0B;0URfWT}UR*|wtd=*b z;XZD-_>G?t$v+SIlB&N!(K)g#EP;A`TSyMI%^tV%8{7;-`YYye(2DR6F2X}Gmw0AKHa#35-}-MaZED z?F-H#SG^VT#m&kU;|iT{zUBGpkf|3{OfX{pn~hP%jm^X^YLES#dh)yz>Y#9eNB5fV zo3;W)l(C6{;KsIGZ2 zSVI*&TPCq_$6C!iYV>S`M%dwD-Ic;Bd04l6f9Z|B^!TE@2WQF-Np+kfDWS3sjiYUV z8AO@ti$mU}=*K@@h}XF%{|65b3W`aQA2M=#y|J=J_FuC*d2XI~ z^i8a~Dj>%3>1F=m_WYxei>^$%imMe>YUzqIdUAr+<5a48mKxX;{{p$eYLfZJHHCfs zIcfU8hC+qCrLfPKL9&;|uTNdFcKilx!pYAe0qZ^xu`(hG-jddUKP)L#rmOp#&^Xf+qE+ zHC1JFv6t@crRU$9mbd?JD8huA7+AvprY#4yk}}0O9-31i&b93y_5mM-r#Lsc^_LNS zB`aR7gKatU7VQ`K^TBA@t}+OzJpQEM!#+QBwi4sI#$|mA&Y>A410WBkOEZxW$IOGE z{Aw>866Pn;eRn7P?NV(1xD$XW!u0xTznj33miHM`28oH?pM7Ek<(=E?FId zUwE+4|MO9i>U}cE!#2iG-91b1o3e5~1jk7Hjry0o`r|hM=nlN{znR;(Pt-aK(hAFx z(^>4R5!_evP&&S7i_)K9pnxl{3hK|*u?(?w1TkmUobbf&?G;6Tm2-(dc7qCFyfGlF zObIGEzjrqpcGgCAq9GO|aIgIj)N8}xqM}DF6VD9i9cW{{@MhMLU>?w=@$I(0RP~y$ zg%}8CkE|-1Im!HV(Y^VBriqYWpe(}@SdVeP7r*RTJPSGna;D2}!u*H4C%$U}p>}^v zhqS!i#Tu4Qi85d>Hmw3vV~rf1Ga)F&_b#q7nvC4d1+I{au41C4Ggx7u7DV z!__Wqt?c%B-z89J01erT@w~Ck?(5|8=YO~L@-s05 zOX{qVASqg!Cnbg07^G5c>sILQIOMQ8JttKNYEA7*9ma4?LK+0T ziH{TyTxGkFHQvTR(T`RhR`O0Aw0$ysQb))V_y$>SV+S@Uh;Gw3^)Mb{tujj@KFz;1 z4xYg)j^@g~*Z^XHH8NMPUuIzAg3g*mR?0xy4KQh2 ze1PQ$7vHLB>?DC08rG+hFDxgKxr($Sz~OwGf)x@%FAG&qjCNzCx5GfQ2b1}>1(OVA z=u>?4vLR_bjApbz3F=yV{wx$8J{5T0$X*zBknJcxA3h=Gxjqf~ zFmupJ?}AqFh(mM9@Dq~dJK~pqSr_7NW$Mi57X7Cs?cfQ_GPFD+f@rxR-g zNkd|w%n}MXz8(`uEDsnm&TG`DbT5PiT**q@*<$xCnMk|_Iub|^3HRZY!QPmQL!OxV zk3Vf0XLMfhZlkn&ki2U$W|qVoXE=F+uD*KOKHeJ>uSDZ*L_cs-DJPlH$mj9l(s2Y= zUB(azH@4CxkdWGm#|WFkTlCAN@W?U#Faq+@HiQG`NO`&EP^ihSkrv6CT)`xv=S0Z# zT!J^TvZTBbge?bGBqQ}*W{F{%Aev8$m#Wcfu3C6b!$p`Yo0x~}(x{vocEV~Jq;`(f z)@-rM?oH)vO6SqDaZA>DaJ6BX==T_K0&=w(#DzoORTQ7p~I*p#!-%Gp)}3EXu?D+~43$JT@T?V4ZE_tbHf%x?m?s z%lANNv9AhXk2gBoO}bdDlPfb>L~eFE&vs7r4%`HiEwOh|qp1&dT0;-fR}rkV1dUx- zLV#1$bS6cR{Gq z#~qhC-(0o)T)w~?>ofMLHw>5R?a(xr;9Fg$Qzrr;|D6_Ifed93?ou`Tr$k|F9n3DE zb>pS<^$c8_z!E#GxRSyDxdy<|*P{s<91#g!q&w)aTVYLXA(U6LNKJt8>bEA*HR7F= zTaZ0)pMQ;Wu}(6~!;Yn-s+Sk&RZK-WVPmB$rKPZid9KdwPJOF$aAPM-{Dl$0p{yy zoY-+@CK4&!{4w#5&e?3K*6yRSeC zVEYJ85M1fz%IWeRagN!BcS=YLz@#r>)kIH0!{J*N zr~2pa1FRa35wj0u&nCrW!tC8kRMyF*nev8=zwHI^7*S7k$^JzS&g?@Ce9XL zOy?!P@bBD+*;hhpO9BI0Ua4~PM>FiVMQIoy2|`T^zbW~ShUa9B%$8-b1F%$QY)%IdIQa$ICe37M`pVE@j8Jja&o~`SRqZDs3NIz=1 z2gA#8pXfE_nb4Uc;`3tp?mR8G4{WI`nML`9J;X_^xaYHkt&Za_j~Bbbq%ETA`7=wD zvx|08BZnuCh7%h!W_LpZ;NtTw&^1Hx8{o8SfnIzZ%o>Y3HfN|L&h9Et*b7D58)hz` z9)Ir1Ye2xmq?r%N2huqVcLj|_lfI*IOGRcbI@g6)7%-=6mcJ#zev_L&iy0}0 zgk1b58I19cAPXjT*W<@5EZF*$JIf(?rFzj^;zOE+I6xI9liL=ISdekLtk??bWIoQ6CAD%{p7>Q zTDmqvd=@70*1|78IP@K>9OK8q#oUmQLCAZtNdP;izI4ci%gC3YkI+wQZ0{IR)sN-S z2gDS#M>`4~Vv@H<1jy+hh?(DM5V{yXFdJhNjcS!|hvig4xG1-w6-wLc=I&F8qZWsm zgkqg;8@O(E!be{3BgpnG6h*bJP~wadrpSZ%VE=OftRX5P(`%lqb)|5%ZeX{QvBACf?jh0-ke9OV(75C(YRt@T+3(e z0EM(Pz}9P!92@%*Y4!}tE|-e&@kaX^j+b7r1B<&UeUx?WyZ9MfAgqb{anK2xf}5X+ z&cvALj8{!)du@bx1T<0cO-R{S82rMj6A_?E2-1`qT~S#o@L~>~eK3dz3LucKa1#n< z#v89UgRACAP}jdwmBSuG+ znh;l@K!ZkQa8~kX7Ad-hqGh^k<+hi^iU>Mnpx_k$7c<)xC3y2K;MI>Bk&Q&DS|};X zZx+1Uger?Z#q);H zjmjeXA-DH_VZ=}{Ka?2#92DXW+_~@|)H|(ssAh9wuLVnkaRrx>~;E>xv z>temT3szOYAIw8Vy3{*ng)l;L2LA6iB+=Yg}oTU?1|?4+defrmS5NtLu9FGxeD}}gmV335P5r&85R=~rZk_87 zQH(-)7-hEO-4ZlrTv3_IHAY4Fse9<14!#%@7~bMoGnuMRhJkK$Ne#xj4P%8hNuDcb zNc1gAn_No1JxB7LXBaJlE2&QYo-YzDY({=14?17Z`ct=%#q=RhY33YYBEMHT%&4cU zmuSJ0-wys~OhXW@>oxb0mNR7tdW&9|ne(%pv6t49HpKV>9kQiavBFbwdlsR*bAdqX zMG=gV#%*^yoGZgJX4kx+*Zr@)Y=ZyB3k!O@XH?$bE}yNH%)oP?8eB=&fuvepW% zW_K60OY?ik8uFr!94k4(d3N`1a~{DC-sxr(G;0X-1Lq_)LkDLmlx1HmS-20(SAlA| zHw^CLtAJP9h;%dOi!yes>7dxax7&E)PWjY07EpzYl*vJZRUcqOSQl{MSxRdy$grup$ zWfz!zxUWUYFZ4i-Zy;KNL}D6K*$@C!n9N5yKbE}6YSwADfx>C|k9{DTD*}y5C045P zde2Z)VX}SPVPhhmo(V^F4mg@$inv4WHTyXg8i&Py1P)BXj7TUB-RbTSP8=@6C7s93 z-IPNf1bL(H+N#O*a$0QicmQ?KqI2<`1mf7oQ&02SBN25dGt8oF346A`*7H6HCC6*m zJ=Xg?KD+St)G+2`kd?T3?5%}s5sZyegIKMZlR9BJToYdLa=?1g4%BKU^04N`)T3)g zJbgXwiO*VFia}uh=FKPgl!K?wqjPq^8$jAF7#_c9fii3|xuxMM`8g+CW~D_7P>3{D zoj=wyNG3%+K8$BV67?AiPju~eMS8_=>r~D_c3_LNb1rr%?nxEN$coGbWksvv66t|fjF~2D&}-M6J{q?F9*qTjb0nw!euZioT*Jre z>n$Dzh3wx)m=8qH?-Fof^47@2bDAm99o0*596Q^AZ@g6GW=&kWonBZYn`Pt)Aqz9U z#;6v2UroomDHUI!&z~QBXKk z3{gP*sYk#&OsVa^I_PpsFrO_SKR{%q*Ko!s=vbEF?E2~EcYfjAC2^Yn^1Rz{_@dH! z)Y>)0%_H+=h%kKt^bLNj>9dr?cO_iu8ndcfHR5?*vhX_j$M~iORLSu9e3kdIr2dD< z3>|a(OhlR$B>Zqz_7+Utg;}KkBk8z9mYD(hx1drr9rmb(iKN75T5BSGryPnB1qJDq zV?yRIo!V_yQt2%>^&_B6HDz!xhN^ZDUc%%6Q-nsX>?KuC%vfs3^vg`m3cC((fX2B<&$LaVs^(yaFp1&YKnyy(Yzw zlU_v{edbt@1y+6hh_`8T$ZtLX`AJ@@uwQkvr4!ng=GlZTkMm1H{->cw`C5KScr)y9 z`2#$Np|KO#n`$Xz+<*-c_dI{|6+ipp>z_F!Uaz)297DuX7b@*8x=p*3Z9EvNe$l(( zN^r~4m@tx8m#?ncuE?g`-d;#B$^(hp=En9W_v=)2_=#=Jm1uGkO)+@{;F*I5ir{m1 zKq^V`7!p`F*sjR&_v77G<;GTXW%1ph|wyL z%wOv^ff*I(TDNMNxEJId#jXjcBhSsML#wv047XHP4IYwmoLMq}(=cvwRs<83$Rafl z(IQS5(uiMs!&#C531HsuMwj~OZ1$ko1~`i+K_yvXk2=?tl~*L++t;L|tklPHCt{&d?%t^?6EYb!Dtj(s$Vl^r48!eNW$D zu(yKoL@cRvC>*ECTKhf6CAt@tu-4a4923khiWI`FgvWuTQYE&mbd&uHPc%7|y6&fF z+z!+Ppr?1CP$7@|fR9+tVqQ;hU}W&e^zt#%K`F#j2><4iDI($~w%^$?f z)k!;$efkkvZuQpdz+>13pUhKRF`?LKkEmB7&)rSKIwAKhd1K}?1p%$+pAcOMJzwvE zi3;5Qc#h(!rnw}u0q`r`eHQtp~OG$K~_kfn2F%rZlXOe%an0uExuu~)PC&Bu{u$&n^M8uOMztD{@ z9Lo;yd)h`2BTMz>&#B!|Or;X9)QtZmt&2HQ=?Gl>>1Q89)pcbU|w>A5)Rk+JH!@^pY>zT)+o`{xl-WqeJXXbq-nY!xF*sovvw|C(La9-bM6` zH-<@Fi3fwIoIiFNadGxp(Bb#PVsmoc&Hi7WV{7Lp*iwTCp+^x-zVJJc6~E72k}&hzvZhOOR*ujoo`Lj;L>!RTP`Iw(f` zV(6O2#Ln*t`*dIWs6>i7*%tFiW^Xc&-Fjn%-BdN)MN%0wdsv*rE2UdB)l)BhI`KXhkYX5oUdNe;VR-)SOc{!-FC&u zCuBRV`N*+V>m!3w7eBmP7CFC63^T-OA>own8v>Zq0;rAyl6zdI>ZL!r$7^0(72_T7JjAQW!1*Ywq1g=;R@e{E>i^?j|v zx)bDWw1V$nQ%C+6U0t3}*ClpLTa!%1)7|G8pmPhbB zN(-ZD2xdyF_PDTOpU?Vkowj#OEc}H047LXB`8KR4X=r6oS%fznAF==;EU5VA>8;Mh zThx0&%X1lF=R2@BQAcS?6By^}!rem?PZ<}x-u6=>!%5V9@6?>Wq2UVK*8S@looLfu0JD%rUsUONEJ ze!+o4{fHbm;zu_$H8&#X^!(k;Y7XpAx1ZdbKCx`_)(P0pv0ae&1h;zd5P2;HrN-EY z?N(Y4cHZ-m+TPA4dF~fE5$qJauzQmtxY8NHdiRwj>A|^cp#*A(dzJ~;wvC&bpYQw| zL-`SlGOsxUCEXNca!)Att6x;^_(xz+P(?)rr_YeaTxdqWRNWr*fxjIc8&%vAi7>@Z z!(gvS^v5LLoXaPCFoACT%MA_Hv)w9)RULmgt!?bMfKD=J zWaWPNjptehSi;5Ll0W3P$;5lqThCfp`SSQh>j|IhZ{HnnULGqw!>baWi#Z+!vYb4R zXJ$6dTI-xusd_7I4(W?-sH~X07a332n)QDuD1mwUN8WA_ZhnoF9bg3})_ak3r6k(g zlJpIkXJk|FLfxFpE1Fol8cIte8{*7s+K@wv-(PNwiR%C zNZIu<8(O?{QOx4NDz3BV$+;IVs5eL{WkL6hU~-FjeV#822)?3XuuAA(lGer?@j*}L zy$wt>{Bf7hF|zE;|7;EpRSgDp(KI-@S^=EtB?-U#f=QO1ypPSS!MG9ci^I!Q*P6tm zbTb;YtH(ZZF#>}0AZe}8lCOQXt@-thY+t@}f3s*mEFU}^RDr+o4Y@;438bBZ0xpxW z*N(rSxu-NyKS+G}R1A9xCRsmjZ4)v3bEJ2R9YpXu5KUbs*J0S%X*pi~uC?<`>@F{2 z`ws$37KiQFYpMN)q#c4Lvr9>R?*DN#^9lb@#mNH>@!2)ec^?f7K^aSN}V| zFb>J5rfE!Xn4)lH8ja}-v(~fB(pW%IG0o)>zRq_iS{Td7N%;>a_H^zI)7^ght?+7g3p96JUtqYgrwPP_?ug2`)x`s=p5x&2`l;*jKq z0Nzj;A&z;v3vCJZzCmqm<7NJ}a4X{DG)Hgp&J-}$VL*#_t8#xD9w**85zr*ZBpoOC z(lx|R)l`&YdrPB|-52kNalKNtrfJr=US7#ag$F*(+d_+fWUqDvlS@3M6KDYk9RZD@ z+&_?as$UVaH-r91dXG2CmR(-1)v3H8oX%!ZF|DO>r^{2QM*n~KtO zEO;mL0#YQZg|{`*nuZZ1Ha^)e=@V&3b8`h!OKH?d5)wrE+Vu!|cP<^fFRy8h1E>HP z35ZVwKfq3Mq^FTnqPtU*yQCEb;`3{_d{2<_^_=;aI#y@I!z!}ofP<78xD12|F!Aej zz=_4@E=9PFeGfGV{fc6au`bK;uiYn>%@lpBv0~kq{Wqd8rs+l2+o0uQ9GzreQDwvY zFnMG);-ix|)#n6RLi7-<_Rwe-Z6-21;vP`Vm1K8y?r2|wVWC;5N5c6e;ocEL>6G3Z zPvnp@1*l&W{^zg8;J7?A2Fwb{MZ{9{qd>yq*CF1g+AWdnZh>8F5ud0ktiFGKSAPDO zUHHBvOLT;6kX^zA*x|8vh9Tf<-YI&(lQD%|vPjj$9HF2adc1|A>)*H{xRPlsQIO3w zst@6Xs{zIVt#bWROW1cOmY-v+@LafHeN8gqlM<4AM%>)xYfSPSIrR(&CN!i|^7%r) zWNnu(5|;i8B!hm^y&p-=@`uo12+OdRyC*y_fs+)tqF#zVgy@o=r4W0)YzIb3x;$oF z*Vyqjv_KoOJNr(==cxRQlHZVIVFK$5=GF4jV7lOfh*Y{=a==6j>Dt-**AFD*&y>_o zz;x)(=GD*s@1`K$pFv_RMU|mr=5Shm8B>mH-gE|(&b4|;%(+U#567arc>FEI3?3h@ z$B)CfUJ1S$^d<~Heo$?Ma$kH22~6}v1XDQ|B9YJEl;5q>cL_}(9g>=TF?ziy`27~B zfkQSnMXQ`AH>YpdXAAlnx8D4rPl#XBTzwu)ey#c#1S^VPZ23YCOuw(2-mcDJrB#8l z>ci#xv@OuFo%^#vSSlzhKD*pJQn7G{Z0k0iyrbor&qio)z3{?{AwlAyVK&4A9+Rvm z*S7Yy3zzITK4N0Jf}#8&a?16~i^ zY5<65!E8DeCQ>=(=&5yg?Ewr3lN*M}K|y&Ttqg&x(N?>Fj-V|wtQD#=@eFPbd=u6) zhm}ycst5?U-qHcxS2Z4$>1GCN;i+cf{DNRmynj8`f1)=z;12rv)~KS)!_nD!mfOPo zH{H;KJS!?e3< zIK!m7F!uc_Wdeo}NlBd$p-dNA3#iAf#dx4XslY8Z#h2?JN{vWPaXu70YE@w_1!y|F z-j2AI#j4HjWs=S_dom=XuEdxHhuU56ou=<-e5Qqdn1Zq3#ytb!{{?%pK8?2#Rv>Gt znBDC&+*5ii;O$9xcQvWN8s)Hu9~A3e8e{Y)!w3LXpS~U_Ru_{rP3(I-6Ei@gmzYr% zeteras3$Vp5G9|5(Sfm(5pJ6EuLUHCw_6gB1xVAE|YtPxvC3s7K`qy5y|M%vt(aa>;@Yv21FAdaj+R9eP+)xl^9d)Od4BGc1Ce zJnVpT;~9^y!$k3x2FM%TaPEuIk&QgWI$0s>)MCffc5x5ch+PnNun9IN2J_F>&c3p< zKL#wjm~NBcdA%g|%C>xN?QhlQgtt^bs4#u&gwPa+zhbwZAF8{96Ov7CCW?z$j-Oqh zX@}gm(0<%!{3=QSX$;+P^GTbzaWb#=mXrpZYy31^%nwh;x_ZNeqfZ?t<$jtbxg^ti zl-56!=xwMgk8PhHv;HzsJY-N*>F(R#unjM!=eFDTvl~*L9`GtictLcF$CbT-Jp9*(eVgw1G^HP$l@s+FnPa4D!PL&Q zzcP4U+CO<;MM>5#-N@*nD_XR%&L!XJPHNC_;O712A+zm`%?1oK4_ZKW{UemALR8}){x-+EnmHBF+W zBTnPl3#7?zM1heR_SWM>cHPp|WxT$zh0e26zigPkk-dW(<=KgSDus2%;(l-BnP@%^ zzTJxKx$r}*?;?fXihL6Fe>QzEM>S6pTSdnG0!e(|_*4%@g1O6`{dUsBw&1vOUqV4oILAoDfm(s^-m zSNx0#Z*A?CYTF7Bo2Ib9jUE>xr(&FoD?ha&&0e+6dA{wLdam%xD5y^X^W{_A`%aHj z#xW01E+2?_6B!3@yVu+M>CxVnhP4St(h>Yc$um9K1=C2fo#hPCjyU>K79Och=rw#m zYEz}59d;Od-#h4@AiZOJj- zjQf>SW5Kn&<0R*SL-|Jy5VO|TZ`?&)*}oYe*1}9;*G)uyE+LQAd)=6(>(CyHNC&T7 zRDB$}5>eJut~dOtPrX2jy<6#`c^8*oghdE(ojrlTD=7@imt*LW;WpHhk5&yeL0Q~?#qa8O&;)1OAobz6$ zGv}?fs&}{H<1Kwe+(S0B|Em!HNNw*G_M&8vPP!^lQ2@EE$Cjwy!pb$%IHULK%h8ul z!;NLxl-T=-3|s42vwg@k@|l>hd*=DT;>7%}(m3|QgRws3S1EL}%<*6F>6hvpPLrIJ zn*}VZskbkj=ff3cWZdvkyu8o*w`J=*noAUPIGd-*9bcHL9ys;S4Xvv5x?|2!aq%q% zM+{hRZ(iBB=3yDhqMk?Ruy4rqDDepRrwf!K;%|%NZrwknsKX~QY;57wc4ITn6jx>s zoa@~!l^V=*jD+P--zIW6vztNlw!xfkfdx2S(4{7JWli9EUGmy#>ZL`Bdx~t+1*JuI zy<1OrjW$(`{UoS?uVowGos~SdpG)iT`mqvoM7585_+&CpbCOz!Buc?#)WL)o9BSR& z4FUJZL0o%!7yo<{em;B;%%b7*eZY)tw)X{?Q3cXAKq_vr;MQr^^Drcod(J)rg?`gwE^< z&1oq>jxD+VR-C%M$|Tb1OMXgZdX@fBr$w*Icy+2I)P{Sv@{gJyzqJp|`rHn_@O7vl z%bZ1t#&1e_*Nx;FiP&v3K#KD#gL7BrQ_B+!7S`8gwsd(pRCerAOu_zZr?ufZ7WRbx zFTavxmo?gdppozBCLV_$F7WcR*QnfBANDc-G4W$rF~U#tN(q@k)0PkJFts(DuKdx; zeoyM{Mh1EgoLHpXr=4z?N_|S?y|gaFwpmot&EW@K3{4;6C8+k7A0I{<`fLriTSwaN ze*Uc2!ng?k)#>R3Pew}ZnmlOf{qgS)@}Jt8#Qiw5=(Y0=tDHG`=E?9-f$??3kImtv zBhsl)a82z%h~JC%HZeaJHzy0Sd#%oQmYY~Au@v=Oo^nQQ3AKt2s!JCIyKl%B z#RYiJK3gDV`$eWpm`oxZJ zK3m^sxl~c3!7U5&unZ#j3?(cPgOGKY(Tn0j6(;1bBA6Rr+(pw&>|h_zsu+*s#@Ez~ zh<}roLiYmuIl9R|-!?XJvf3*l!7Kjd#3}0MUf2fq$L!J(vu2)vmCf#!^d<>O-qMQ> z-MaUrr*R5pgO5mpWHbDd&L86izXvQ!riKeuh!-w&BUO?->*+dQP4fHrL335nFbxN^ zK>GXye(epjW~E}u15#9f|DwWHggc|Gs`d)m?3i4wHQCF0^W16c&wk3y`dZPEWO+%L zJ?#9nY?R1)x-I3^FPZEokEh#Jl4c`D;Utyd#bl3WKAA=^y;-$gNVgkJPR|KGW)U@n zVxDex*kViFqiUld#oV{cWsd;@#e;)pJyadlRYmGnEUZtdMDWBq<;US{?Fg|q75len zQZ-ggt@A6>@>QAqOS5u;^bh*0U2uCY=)ALuDO(;^mh_c7BL;sR2B*P)ZoHRV%uU`e z#bz%%I&>pvWaNbEO}#X*eO8N<{nU?{a6V($D)Vpj2_p;J@FH6a*Juj~U+5eXGck8{ zco>ueZ+6Va@p>j*(BO}5Bz=15tTY?xo=L22%FUwRyrKqgW_1AkBdWYUjj4G?r?bW` zJ|S%Z-2<&kmnM+YW9@qJA8UC6T(MV`79+i+9*2416t%#yoBoSIjg>^kQD~$}+4*x_ zR)y0fk-;?4sp6gnUT2`w^%rt9;NHi4q&^}C(OH2EG&L`Mxdc#O|b;r{s z@{V-GaM|E4+~>WM1;3aV?t7xsYkKW5i&K^GXL40DUz)8oV z-HDek#hgZNI3;`@_J9ld#0zgu^|e9)qsq}B*#|wC^@orb#{!f! zP`i?7StzxhSKQ%6S7&2SOdx9eHMe>EVcU0p%0}-4{J?|#s_@?@J45whetT~2faHV7 zexiTCfKmQ#IPsO+rExb|-TKE1khRe~+Ng`lHx2Pi$?GM8b#fKN)SX5yiv{&ytXz57 ze!6>c6e;eW2OpkGq_)@dT*ltMm~h`GD$B+$q~OAdWfZRQ@H&jr_Bv-$$P@Q&>pWYJ z&xv}DurNDL!|8%~dc2=Gkgm;9eDc#ZWQ@3MyFS`?nbxytOgI#AAHgtUNgsc<6>%Ml zD@o3rJKreWxAd*UC8Ux#d>`2Q)ETS@{pj-Pi-i)v+j+{DjgDkm064R2~Srv`KHp5L2L+;cf<)Jt6wbz+a5<(rY);;xl{pSbU_Mcd~PVDxUogI6N zcY!Lvc*!-XymBlUCnIWV*m0dJem3MjqQ=g7uiQR$r43ThFef*Q%fQu@)VIO-QgFbk1f$y6gpsqXkYbUX|q7hO)+^0GO0?CkLXt1R^z zTB4IvcPn%|uq5InjgQ zB1AAdF7BPu`|v;ld2)Ayf6@)O5e^>Y*9DuNw%jT_KppvM>MQB<3?ZImdd<_6N?pv^ zg+FNJ5uXkfTxoDka=ewFqyVx;*uLdjDoHAiXr#yb?ZZX+|3lZC2U4|u|Kq38ZImRH zPzsfh3?)+oDPuB)P{vHT=J7ZhQ6VWZp2$3A&Um_!AtdwcBr?y#!8wloU3=@k@BO@g z@8AE9XYc1(&swkbTCcTsXR3u{Z)m;DD(fz?StTvK#tiW(`zA6yG&RTEP>4K{McwfO z%un9I(WLE%cQPGhp4_oHy5+@LzXNtRg>SvLsOhqPS-uN5NojWfxX9;6ecyNBUi8YWMmB$pM;=8sN8*2(?t_CAoYxFE}nx#U6o2!bJhv)L4v5~uhP=f59gRog)<+ry1FMB-X|X$ z&+0uq8s;xQB`aGpNaHdb+gCb&W|AJ{J25!`%$W$@T|S@**>c#H7LV+ptHiM4DyqS?4+~d{G9x0`aJ~RmZQqp8$ zm2Mz&=T`~+95n3U)}C!gx3WR#cD})B*vf+Am2qYxlRF|?a$|Cu5b!Mw=>m65R_x<^ z5K`@N8V>BS;tb}ali@Qk*4Md06u@`Ij}ACG8w$wD zlHqp_n<1xsp%+j(uqW|CXZx@oAZfC+y74iZOb;b}&H3T?Gn@zVcC{$6**fb`V#4@# z(`>m!y>IR3mm1v7J69869HsT6-)+NE^>IL31!1#ZR@qo;_oZOqD5=jL;q`5T_aI5s7-QZNQpLveB$em| z6xwjAiIdxh|2Is)M;+8j`x)su(BR=;`troCPp__C$?1ck4!+e-_Zg?wNGtMor~Vae zX&nj&TfA2^p6oEl6Y1E8pFXAlwKHspv_>RzVpv-SQxH>QnQToqG9R+0sxH%s2=P1v z)}&jab&CP*Y5ug#F$(Y5Sm)dCV-5x#@)ixfZQsGHQ;+ZPlQa{fz{?OX5_oI=$pq1z z731X#ES4&Kd;4P+YiOJU?m0v0+WQx==Bt+B6E9E#TW9ru|GSB5r z*S^HYGw4rL;N3oS=u*cnZ0R{xzjVn6a6~6xTpfh(se#9~RYjIIE6cM|PFy0OTR4Rh z7v2iGQlkOoa;7)m1c-Zv-MVghGe;cuh(-`!)bLNWqZY zsYLTteTnmSd|w9kz5Tbb-{+^1G^YP-{WR?8jo*5IfaY_3>7?vE+(})S1e1`Eim&in zU0cZ#?JJ-EFD|XM1}we7%8GL=Qt!{tos1<9|9}{o0s$CSb-yYy{A?r@k1wfV$cp9_ zRaUyN^)#JxtR(zi&H=dkge$YGsjJ@rJ4R+3EvRq4hf6M6{tD6;hddzFw2o!vN#Y}i z?@t%T2KzUGVCJk zsNTf%Exi*F=V;{N4xlkmos{n%LwEydo!~L-rnm)ak9&f#|e@Eu|5lc;-(XU?bfMFUC2S#Y(kB=9JMGL(~ zox2nDs4|OIH)Z1*Mm2dwI6B^p?1W9kJk-0heKYeCiN4hzlMG{Db1Un~REqGP%$spm ziuDI1%;;o*x0Ilf3r&T!IEr44?0jdv_9075Q}aHfv&?e;)vCuCJkllnc+Ul{G z@Jl)}F`Qa$kgYky90p&Nd9&^`K5O&zmWBF83_EbT)mCgh36^)S2Zg}=bi=J)ywB+7 zNI`Kaa{)y)aPoo=tn@-F?LaSIz4b%i+y(dGL5{Tje;SIB8T`Ooo6w_BqOfB8{Rlp# zNL^KO`hgvU$Qw);NPY8tU#YIFb9&1q1|ZD1Pg@)kqZy_}zP|_=TKD;>!3Dx~+xc?; zR@!~;3!6@)KG6*8dt-NEp))jx+0S1p%JT5(Mk z)daUgBL#>yu$Ovm&=A*6KE}m4SIGDuIQ-e_y85mTv!>BFv$tYm9RZi%Q*V>!Om!xY z3Ey`e=k)!C`+C2h<^42kV&Y@J(l3&$=Q_#T@awx7$Q;L>8cd%qhVRStbu4}E8kIGJ z_uBIHBpAFY#Xm5UJd;NMx)n)10k;57i~Ov@lB+#Rqsu;FiqD9e>k}d>Qb&m6=45Pm{Ld&d=Mdn;9nLS zE@4m?UVUk&+|Sv5f}%nQeVW!!+7+*|zAhQ8c4RgjJG&_<_UeGIbZ59f zYmgy}7wLs-;Av9|!B<*kVl80FA^iR*a&|$l_az}=`uuDOP5LVK6{|}0@oy;jKTS1N zE`$q};@AVBt=sj$cO430uZ>vsT|gQ{Nfx`GUX%G0cG0_wk9lCXH2I6DFJ-Q4NhrwX zJ9?5{ptk-S=7OygLdU)&m|6%g-r`owJRdXrDFZtvEzJJn7pR75Qkoi7RCq6l`P|CK z%xSMBMil+r4~S~Q5n%x=UV z$jV0(IhGJ8tH;kSh||ZV7nz&2Ug*o2Z1pCI({epBSQ9yI`6!i;QFbNP8iIgkIu2MO zsAK#^bxh~SoyZLo{Txj zIM+Ph_e+AKQTpk6eF#zs9RS&%wV6ekI(&}&g;D&j}@Ef>)cllEDgokt+&@MQ_sK)t2rG`bl6evAyvN3$> zWhGNp;-H*e>kEB#@02iZPoY1=_A~;cMgO4fQzDq?rBc2)TD>M{ZOxoT zoQN!2a@av$6|8_V_KmGr<`w_4)v{fom1E6=zS^0qz(HDhw(Y4*sy=7>aQyZq{5QB9X$%bhNh z7nL9#GW~?!=$9qY#?$AuWY-#prIl1}x<~U03yqFQy}$m5sLgD%AF_r8+iQ;u0|Fh# zX9hHKHO9qoM;=$Q*m~7nr<|n9!yi2V;y_3Q~My?wM4jUb!&`L$p??A*MV(33t1z4lYRvEg+M`qxjNrk z@G*8@)Y$4xiIN)cdmmNAMhpC9r-0C=+pjNcUq}u~Q)L~GJOqi_MUq-3{(_B_T4JJc zUzbJ!!aBscY|I4?^4dYn9-4}*vTqs5tWN()^K^_Lq@AdzDW$%H!-~$V4R&2-#6{_O zez|10ztuErI_T20c?{`Ug?nx+lAq!eW-nO$~15a_QfTjnR^Oc17RCah>c zKZ^dz=3`TZj5_2o60?mQ1(^=wo;i)iGaxD&0sDjcG%ygSI?2;X765+xoI3xJJL*(} z9_s{!HVUoB_?9hxL*oG)F7DaVLFmpO^jt~mHqe?*pEuULUlkCyHGXB?c=q_DdorkNx@!` zpo6UM7YLsfjg+Cxy|&BZJ2OE5^6pQhFN*SlOpTduVgu)UCZ%MO0z86Gn}v$;Np`pa z)0mKr(8y&W$;J2Q1{Zr}jo}&{!a{LekK`zY@mUe0EW!nzxfJiU)`VD$r7e(vVQn_G z@?Uk9jCxOqF0!213&*^RFQs;?Y$GC8Mj)?+=J%BT~wzk%FD6#CNeg z&mZG19@Mw{62Zw_BYm1H;&RV1MQy6gUlW7~{E{Q&=wF5|cRp9SCpohnv%77W-JOl@ z${poWTY5HP8236jJe~9XXx+S|e*(8v&ZXVmE~CiXcKJ_Y-9Ifmm%qsdl`OB|RF@!NHiD&v~)_OT)EhZN1QfNZcSy5Yd^f;erV6nCTxC6m3p-d`~7 zdY09`*MGy2XknG&r5&fvo=m1yITSGVCaUjpkqKzhrjT$)0A7$8k!}w1;0Pbh0Qb_E z#^`x}Z3FrEUWi!xFcSG~JCtMZOL(5l&E|4n)CQ;NfY zP2NKeh>(vy#U+#fone+fBNGPl>*CW@2>g%JYiI+%+UE7J970u{e zs!8KvmK{tUq)Nn0n{x}&IWyQ>yQS;%A0z^j87V09^eN*X#&&SKE1JHYnXG)@KGCR| ziO9+t{&0|?D1=)spUt|mup2ESen;cyZzx+H)vTD=?^0#gJ(*|SE zl>5Ih>^|ho71dw;GPn+h8dC4ZN2;oCjZvjXmQ2eMO-}A!4L-%ziS*~&{yOleF}7Q2GVcKukydg1 zmCIr*BM*BWaW%l*{+X}j9%@;n>^~sZ@(7J=9&vEGpo@d;V|joS@*t4;3Ai6q%zN`J zz964biZtNcpRjKe;gQ1YHT2-w>WwAB#pSQUr5Ne&i|SG062f(~-aDMt*&4yA9#mCN z(RoNdmN$b3PJw9sgn?f%qLYtqcw_#y{ZH{F(n~l&TpH$5&s_IGRt__{l}^(;+=Elx;*7^yxAPf&30@6 zce9O2Uy5XM@;>oRRv-7Nzq^Bp)myAJ?!Pb z=sM{9nl7%MVUl4AVx{$kro$agR6-dme#F0h3yy`Kj?_s%r>`E}k5+##e3E`n^IVdj z9GAW`wu=D?hZ7tHU6==XTDGjLUjKc$wEn7x{f_R`r^TEMqs`al@kJDpI+U|CPk_A; z1=`6;OAgf6!Bk}F$qJ4B3ft6o*rt~IcifHEnCT}FXi6EA_J>mt2g}R_lJ=pYO<)e3 z6ZCWy%c@z(2AW)Ielg(HTl5VErJB4r<2oQCpmH!#1H18z7$pxav6!YFO~^LExU%h=E{d=I^6L_houi+? zN1`r$wanTo#=7E}3RC#m6|YfY2BUNZt(OjC3sO~4OP?5gKf#V}#{%KVp&y6OJDhwX-H`zuV^J z-)M7WOD(zWG9nTB2N?V58>pDUHyJp&;@X)ZRXGzzGVLrRHbrq3FO-Bbp&7nrEl`a| z%;#%M0B&I_0t@{JIVZ+P(+lD z%Fj#65gA^vrS<4$n$z(4LR4PaJ5Z!x#(?YX@`zAhsJO7-CkBFahb38cpS;z8Z>B~P zS{R=w$Y@9N!mqYfQPGjyT#O-8{RChd+o)5?GOMZz*MZtWs?Mi%rtu%6!f3({j9rW^ zwD=jt;8*R(Q>MhC5WfF>@<}bW`hmgRFtOdQ8~2TsP(?loA@5vx&-uXX|7r*Sr(;5*%>|O(>S9#rrIhFGt$fhL&LO~ffr_7a z!C)Y1$OE&o@34W;d0iuI*2g zgx5Z$;1hLZFEf9;@uVwn zQ@MbR^v-iLGbCBMmemf^M*@}yi`a`b7E=!7U4|3DPWz#*l*Oc8MNK1|_B!3l`rKcv zLdablWi|GNx!6Q%6Stq2kC5wm94zh7!3@@ylpn7sk6hIFrqo{yp$;X{vUdu;UX;o| zd4(kE!gk)JBg=vFt?UB8j~J}@xjAQOMpR#A&~n=un4a>#)r%P~$?4ysi{`#F_<@^X z3gTAWU8E4{mHwUcag|1kv!fIzLGHbd~^(n}?rqjg2fqC94ZVg)wszr|OiI7pgDg$|VNJ(u&E#xCJ zT~lw}i+K{!nv4d)RX0RTT~9N6VoA@irQLDfw*<0;B_2Lp;>)%0}3#u{W@8q)?RJrEhzL)EB!#ZuA zR@8#0GAkoVK5~PiWlL`g;*#g{0*5E6HILHB9;1X_FWP}HqyH3G9lYsI?)oX!1&L{g zX&fxd6qdhm-hU3&x!qG8&X+#H<*~~>b6a0x^9=aaFuLu#1;UGV6E|Jl9m|-sE!(!zz<>*S8zon5ulp!+23KdF`tzoGnwTzVpR4ZW z$cAo}nLbQc4c#pf0N~zRi(e<3JRRjw_3WBi>5kX8NsdXAsV_s&TKBeq@r{d$v=zi@ zsZT)v8ia6M8D{dX4$ZSJWncXw*^La_6cAK)*ipT9?gRdErpCw#s9&+&>n9RTW2+!% zUf@J`RZ@Vo`UBY$#J#D{7IY%4%PZ0}j)dRwdL68WSX$q(ev=l+>GBud*bmI{6EA9x zI0CzW0$9majoA)9H7dFI8>E&6S(%gmA!`Myf>Bxa6E5)A-p(%$Ee^VI*{iOW`Rp%4 z0ao)wl{2L~tJRF6fbe!{)5p(rigr4 z+>6Lbb#T`=TSMFbu({z$LGT1m>)}FK1Kh=8RHo`R%Mept>2JG&BmddkhPL%ECQ}PUZh1ErfJiTkE=ukeq<%Ju5AGftuh)UF4 zSh<%1oaer~HLo7o?gVq(c4i6w15RZ$A`Ys*!W3`B;SdYHO0NKT`x7^{L!-xJ2`m(^FaruHudH}{ z`km41HQOB3Q26H>GC3oYqMx25+;0+c;FNrr1pD=Doolxq=4!T*Gx_Kz$oxLe0+R~t zOZC@^nLys{IKDUi@N9w7xutPkZHKdJthgJw&jmT?pycE6yC!@KNgRX&`5mM;9Rpr? z#BEoTpv~;_rJT|fhgu$012QVF!pULz%89zM>sGwaJ|08eT}{ZR`2Rti%9zuno!nbq zTao8Zfbsk&X9?wSO!Tc|v?rZj?k7Z+`8@GTdoY1q><*0~heMI~LHn;VwxzWCBV*vV zxv)#K@8@&o>Ap_C;<%KmX?vB&I-oQQ>PH0wOTY2ws4BG~qR75SAB@dgRbpC%%EeSz zKW*@gbh2Cb#JJ3QC-k%u5ecqi=i>@=FE-@SqLS~E@-D!> zjk}3ba`lhpKDcTzpjI#7A`W|v+lS$hcg9l8wjS@*!}fwfW3ZEwjsMhy{0n$$SW8Ir zgeir8aK8-2hChDH)iSQ-T?m^YU{?P=Yo~Tb4AuzdZ zz#Y|h?z~ix^s#}(3sF2xBc)3#ex6P`*Spo=)P>~qFKrLJB=Yxv9eXn!4xJpa0-*5O zho;{brkJJ;j`4w{xeKw(_okXBS_@;gv zwg(BLzYBC4PiE~n;#XD%{SF%ha`DdHi#5%DAAv$w$8r#x@q>i+DHX?O!3m;^|WtbrsIEBHN_Ki>Qcr5a2Pg%v}h)Zk- zP|d>KOJ-q8lB2>tQkT%h96L=J`gngHVkZk4gDOCWP^AQ%?&SOY_xn4|t`=xtu{oW6 zB`1WR`{zHa#1FsiW^`6jNtxJIEzT4I%D){>dv~o@nDhL1Sj%g=e0woom3|DK>~x1) z;v<3mc4T3@U~30d9ukqff6s@Lk51qJX7|E?6qumPbXlTn!L4fbfuBDES+N4FdJ?;) z)#;6YiYAXHURmT;!UWZ5MIe(~89=XJ`t|z9T|(E`c4n6{uN0i*tJGJ4x9J#Vu;QsN z8;~7Twdq9^45%`1{|B0C zfqP>eqAugpi^pAAoL~ozDuxEo(jDBh3svGC=sz8ObO5TAv6wwyV*P3Pct`r8vSDF- zVQrY~ZD4sm0gA`PW&D#&1@55~!1wKLN(>I5I!+Q2!QTQ><*zUIruJ??&86sjsKhW%{TDsLnUN|`5fdW!hBE5ZK$7rAZRwj6(-e;8~iIy2+ zYO?M=B_~-u4aHXOv>MALo_!&c#(zIwi#v6v%pZa~Of_XB?WX9{T&VF$Vy;2=XN5&b zYsS7H_qX(2f4Pn#CT~uF`Xw%*e%x`!c%f_S7ZYU3Z>%D@{qXE@*u0kyYXu}`r0wpn zy<3v;|IT|#hxA*f-90tKb_nKT(>cvf_seE_wlBy(Wwrw!l)Ao9Hnl?{fHkNC7rcuW79VzwqVYi|QebDE45kB+LCOiX>q(cH?M>k~ zs3vxkEulR+knYR``}Q&%tz=t#Ju9fF5aWQrBRg_sv`J!6s&n{sdxr*V3umBz$4wWsP)TlFP5gT15 zHeQjDsye)ot2Nbq6v``0iUr+$Kq~EB)I&)+C@+pNe)fAY@pbIEA6URUqh4+3t}5rR z%IrO7Rm310CsDBmSJp=WvWPgdTEc+ShKs1u>uW{j+m@DC8Nz?0uq8yI_|oiX>h979 zV5rx6C&ZU1=YwWd$;bY?5#~(Ejr{Ll&0=U@A)MvA8th27PWVDpWs#bzd`>aEk_+$o z2qWqaXtX(MUzdZ91GF~=)GwuYqfCwJ`3Kz9Ysdv4>2RiM4j1HP*IbeUyhBfEtqWag z*1`kywiHuDBCEkqPt0wOdLMX@1J<`cqZ*1$qhTK@2bjc5Ado*a5s^|IdP8m>o7xx4k9&7opxAnWQ*-fg%&YYJ1)U3>29utgwz;Qm5{6Pt;!oZOA56jj zlv_-eh0%jIzD~jInHgu}G?cKc)mk!U>V&;Z0V_w#7p{@lagmZ79fWdSN5! zZ|JieQ8kXQZ^zs9k09Kr5w2l1fd#8tt-_1o76LEXuse(~B3GjRu7o-z$VIBUuwYx3 z*R7%&DZnM8grOahu)9I<0maw33nZd*%RAcPpKo2J#xn$uH^KjXfW;!~VYG5AUr~Q% z{t2Mjzx!cS-Rcf7e+l9dg{)O>)-%AX#EDnx`8@!RsbJgnT=pft*1I|xwl)36r6S$y zLEOvwosepaSlb!0eAHn=%NEAcC-5U271L#Yh)UC<<@)Yq{5L)&Uu3JgSM>^(j&7M? zv8y8HJNEFP2UM5w(Q$$Pvq$c4)0XaSLav{*ppfS6@o#UZr>Atj8K_!nK~4L|3g!&* z8--^nfzPUC!d5N|DTF`mEBka^Imdsy|jKA{@%;7>`sTS zpKeaCrZ3M+dDYX;6+x*&*=fSxpWe=70o?B&D4X~xC+Z<&k$)SuJ0mg>`L|))UXv&- zS>OX%1Wuzjr0av~v+7LaB|V!En}5MmzBd*CPp{ez=nnO?0>hBO35V4y{$n5PW%P9` zvf$4?o z7jbEF@(6FjZCZ#tYOKDeiyaq8e!*7iqtmiAYpO{LgqsTCD5FLC;(XY3OOT%9<$(wK z=wW%IVL_v(QeHlapNsZ(<#K#vi#7d#vU&8dg$Q&B@3QXz(1ZrM=U(?NA3xG=M z#cp{UdH(GB7xy!tcAYnqNQt0k?U*75UfY4Yn1=>Z?^CrsZ@A88XIBv&xK2YIly&U* zq3}o*DVmHWn@d|jm|5pG^-S@f*Ehm#=q@&@*+6CuQtU28+aYa-E?Ix_vU>TYUOb?2 ze5p}`@A5tQi13fQBU|z^w4Z-Gb=nD9Yd2vPPL@_wRwOt0D28HwmPYO#E^vNv$(7$C zWka`;wX!8o3yAja&?W_PwujHg4M zWB)$`S|d~2a{Rcm#mz{fDp=(Qf^<6n>s{2Dk`JhBUhoRg2z?=AMHi6O1^K2QcJMC) zTo)Xf+|r{2b0-PPKoGl(5`zCjlnSrxB#Jeps`7Kp?{~^GR#}~3ia?EKr_JaW2HQPv zLRi1)fcS07`^05ZqdEl*2D=s%>DHVeF3=lpE&Z5(g)SKj$xkcBjh`CgV{R{L5qG(t zMDN6u*ukfAK#hV`IZu|vSsVhj*{|jSX;IVGw?61Xx%ZcX7z|Exxx5{ncAb6%-AjT+ zhT%5=xn(^dHX%<1MJ9g~IiB@c-NHx;Iss;|h+UUw7MAV5xUz%81ouI;;u{_EZ5JT> zq0AZP_(F{;6aVFuN*Srjn52Ihw)+SXH0LaPZd<8DZ8*{^=%B;s#I>jr%hJUZ|36t)shp|}_@;NHT#E@G-W)y-BWJ!{(m{yH&UaS2Ipb2OX7x|45Ef#`2r-!s8-#?R&AQNm?*w&S1{fgU}S=e=x3$%{Yp<2aZGL7Kr#I6pZaKV=RdiR+jGhMKn2pwj-gw9T zsjHc6TF7QK2naG5s*D;lna0N+V2QfCqk}Y8`ms~vO_~X-4jX`Se3W?MoPIOsK^62_ zdN5bwSK80&5`98t_wGEIe~6)6NwW?ACWf@64O{U>UC@f@2gmI#Q!5}nqouZDos6}N zp!j3$i1A2fFr0az9RJ<$mDcwLGlP!kCjG*wJB6|JJSKwGlJLKop$k?y>``Bzo#fk1 zXGJZ2gJkPDb=kFAMVaOs6jDLf2K0eUq=0rib<-i}% z1{S~_^Nj zo^8C?TnI43$2k*Co2K^!rQRGZE?&F924cv9&O}AD5(&*M1ri_b(Nmw?-;`+Ivt0QI zr5a9fqe$$=Wu4MO4cQ_{ly?v7zIyu!*!pJc_imBG4lLc$EpGe^tTA(=7PX9}QRvIK z>RtUR&NY{FnR*)PkZOJ0{>Smd(52U<-3 zf@$krmpu@r&)jJi7=HFOaDt+TD&k9`wT-#!yH+o(lV^E%1XFA>i1L5J6}gRFk?RYG zUE2F%?VXe8VV)mS@&h_+DH{|Kp9`5GUJ?LaL40W2U$W|0dqRGc0H4IVm2XYt|G98H zbY2s8mwIGuavoR_WqgeFcc0-y{R}ZryE@t_n-FKS0OHbjD?f-!uQ8J8#J2kI&}Y>u z&Sl^8k>3miznE_B*(^UL)LB)Wtq?Nj$_rN2yKtYG3Cu(yMBn~L?@tqp3>qHDOxr@@ z{y$@^`@mTnM}S@(Lj2+y-kM+QU_!n1!A2IG_Z)^%xIefz*$hNhs{uwCzm)dBh@Rr3 zGEhY+GOE)>uP|j~NdFsa&@N{xbu?s6@CqqUV>($7Bz*lFVxJZX=#dkd-Dt}@I9(ah zMeMOf{J?X;C~k!;k#eTx2(;|?U-d{EYE3|C1RHva*1G792?Ph5_-b!h&OoyvpS&;i zFl;DffZ;Cai5Fbe^!gg#Oj8$*m81I=hxL>npIRh+bjbF7u^Y8uPO#w2?-FBlVzX~| z=tp*Xh#$Y_&791Tnps{i=vYS=nkaIw8S$tXHJzoMA2ma00(7#o5Baj84zSskwgWE* zitZUx9uyE-RRX2OAB}9sU8G_b@EKF-COgcp8#4Mfm!o8_Fb)9K5!h8}lMAA_!4{Ox z1W)nuqPFY&HwU>vpG}mLl4eMR+a4=drsU}-+3{{sm(w_9w?WA!vE3(lM*u|iy|H{a zoCVg7wrxD4@VTr>;0=`j`#ULjo!ZPx9Vx?M3ON`=8X`SK-rU zzaKzfVHD$IlW*#r;_7``rYip*pY`Pgv)+?e2`80QBy>cu^!GMA&Ij!Ri_jf%gA9bp z0gZc?%)fiRPn;l_FO*Ox-Jw_<@YM`FWug-x=Bbpc<+Brj;AQQY8E{7~r3RK7m-v<) zYgtQsgSEB^MbJ6jX^#z(ZwyTkc<0^^?GEHWQUvFaU|LJ*j1_H1QgI~+otSdevf`BG90T9X=%M~E^8Z-u=2u4HnfPqlx29e3?X zd!|mImNj^MV!;9-T;xtfXGF^jS9GXJx-FSkMHQ+(P{)Lg{yGq@$mn%LcK?>qANxAU zXm_HMJ%?hKt!Qf{6XZU3PmjtzElkk6Y65Ww)0ZD%0v)7toUf7>bgpPjlOEtVZruc8PsC+DU1Gh5&N0Q=Geja$=8!G;lOn9A;sMGY4?VsY^Z#-rF zs+$VkHD0N;D_=lBQ0&|8!y6iCYklCVBRu{t6gV8&KybBK;+5-RyE55;>DI#zL4p%g z5ZD+1LN?~FYtx$A60@&a-g^M}e%&S-%CY(`Meml9Oxs1Y*n&n7*ra%`SqlZ5wz&>7 zPF_88CiK}coO=eVA3QhlKSkUW>ag*PE`{W89rSlm9EKYel--i+{d_XDQc?+?>D?;j z!0ezW@0@qKmxYji=O8t{;jnM=DV-XDYynUTLz!wz^wK<vV2_)tS(sbe-iZtkt0DQ%xFM~=6{n8(d#5v+@4_((VM<4NFD(GAE|xkV1iFSNEs z9f#d|H@REIZyou_R3D&@gCn)QulD>eIhHLTp0Ls&j&S|6T)BHdp#y5K9^@q^p&vqj7r$R#U%xpK4*0QaU*~zXmXV69RF@C+-Wo%+QQ*y+LG&jbh$tXju#R>$C)zZNYGuvjmPfomzIvoZg z9gl5kkX5MClcR=Hssk@f{cX2P9XwyWQj^63ARF0fT(kew#+0Pno%ha1!y0!EoU;T# zForS1D-6P$=(`IVI6k;|OfD+jW&F1R zS1g5XwfcvwVRgg1hWNFpNYCcTTVuAYuPu1t2QAj#>8#<$M{An@;vfOoJSwnTm1|q~ zTR}jWMWDgZ`%;9M%>`{)0}e|9|Lc|702gf*5c+`Z_wzEZXsEKjkQqPv zvlkQN(%Q0K;*P*QV1+}9-i|Ibf>;RT@1}LnmVPYs7k?nt8C$J}p&H{D+LJiDoUgBYQTu_zO1OSDV_^(21Z-Qv)&C|+m zZy-m+_!^!SFE#i@adqN_t?dXxA(sog>_-n}!Cc3SEtcPcPZsh$0u|9_HZlTD_m_^-Hz!<5bFDmw?q3?rYqi`$V|7k%PBSOWVH_?aJQtwTXm@a$sz z#)iEA0Ulur#n_x3q5#i^yW(tN*?F32h162T*|z^&M9Ql-6I@m{^tY{4+gR_}*jc%@ zQ}+Fh{)--a&;!Agh6g@4S&4xSA4W+UX%OugX3hDw@CLke1UJZ{IjX%yw!6s^v7MN#X^uq){AhP?s5pMU z{Sb}*5?%bZ1{!WC^&dM+Zl^#K$N2CtVR_pB-%S=H#k(Sd+1#Mb3)j`C*{m)%$SAku z0(oqM1SV(%x(AS0<-q|4{4%0+!cKb+G&%TUm|M2RVZuiK*$8bwb?3euymV2$jFzjo z{8QoZSL>Z|Nxxq3ELFLOMks5*YfRqCAMutspO{#c_DXYW+y1Y*&>pbN2y=bH-b{*4 zbtLRUmiy7fK@DKhM+|Epmx4ykpJUo@sW;hG-W?Zup!p*%p%ePZMv((HX0G;K*KNj< z+u5le=v}$j?i6}>W()5WTUJqaJU^ZIT3#rCw@;jbS^5!_8u_ z>(;>7U5_ZPpM3``3BP4(zo+A6YM>3vU(=hl0w#~9K$S|x$u#fe&q1Madee9OHBMR@ zgjGfQ&wB#}Ky(loY$#)9Hskxa{`0T@D0BWCt{?UFiKaBeZ@T8~r?G$N8bYy-1|cLP zqXxC98;?^3#D5TSdCV)vzFwU+&(A9b$cUQm0E!D#?*%y#s@|y#mt~?g8d$>)d;CB0 z#m0$ygYG=Ki?THw`SQ^hli{)Wnzy7fb-nv?U71A+KPJk61b{8zp4r#OZqo9rUQ%ce zTs+HhW?i?FuoQ8vG>k!JNGu205zYK3SFw{7oeO=9^(8)G97l3<@*C-G(Y!a5l+L&j zmTjR|Uk{xJwlu?bYKZz>R9!1zL>Ji^bYP zuRT|*P)$woGHXo`=Y7cqFLamgV$|G4tPwH7{zxCiXshRoEu?g*64-vPBo?*Ikn`) zQ4kn_m-+(>8=L2RLYc&2XP+5qu$Z8E|UU31(N2({}kuFfl-V5 z|3xhlQlD!^7B53mD|HdwqL`iCA8Qk32YIE7vTGIvP%l_Tei%y1AgUU^n(fKg{;l>pk>pyR^dc7cn@OTmHG+V0AFs0&cjSA>A$@0 z{2y{gI^@J(x0>$2gjUN_Fgg_eat#Bef#`TvQc_8C-9?!Z3hH7Z1aeOHyZuhmiIa$n z)pUr1FZ5p@Nnl|OTzl?dZ#gBc7Qqtr>ShzxRxr8yv@T)^wi|KVx^z-oYPFnIdif}7 zzFycGs_vDx2Y7x8h+-iYsJU}dZqbR2xhCk`0)RA}^VEAQ8c9lF^v+`($53zB5n|~1 zBYkWz^(dw@^I%sz@Q)3Pser!<8m>-0Egmyl%Y>fFDh5iTKlTTH+Y{8jx!dsD1Fx4& zi0*@@0&3W<#eYc6zzB}kO9 zo`Z1;GS%AnUxs6Zu(eVM0jXt|6nm}4fepd*CiC6Lr!VVdlI7L$Q4gJSEk^h2Y2V2zX}+Xd z?Z#>F!AlU^FVyei7{g!Hv)UDJX1m3hcL@eztdDg5hh+@AAwjj?JntP^iajbQNN*@Lm{3qvu zg4q)Tj9VUG2Dh7-acD^geXKKG77iIY5nNmmMlTpHXM>7pqiIsnA?>j-;O%X6japuzybDWrxkm#UYsJ}3h&ATRxo!q{_dX$+ z)|Lt8Q{h7mAP>^`Jf}!r_4PG4e+xMU|5-gM>mpV?DkTuN^Jj~lk4`N#n^drSa}T`4 zLyQI5QiM{=Om7OF0L{dZ1qPgaHiABFLDLEldiz;1JRyiq!e4zEAz}O_hI9n3JZ_wF zXg0J~c2GK#-t?H79uJ?&y$b{}oe}>i_M>ea^93u9$Cd9sCjQk>LMgFpJA2&9^IZc0cx06( z&kXlvwQUO+whV90*rV;I1s@^hLr>%T1DJ8V#>BML zc7v7!Vk;6pq0$-zb(72<@t30+bD`F{)-hofjXENn>7R?SSJvWACDKM5bS4V4_-QgL zODzSn-N0hD&`;E0Av6l&Hk~i)B<6 z&`Pckw=35}E}{l(n&sL5z|b)kiB=*lgayQ|vo&H67~E+$PiF_%tFRKkFaP=j4I`7- zU7y_T=p%FCiY!G)_`~iH z-jpl{ILI`B=33n*MfC9{Ts{jLNhm0pNI9%BNgOumOXNVC3&2a=+DywxrrUUTFt*!W zH&o=QVuB5NP()LQq3%!<} znb`4=m#dD244%$atFAOT`nWx`p=I&jzH#sxfZr*3y3m?z(N9ks<68$WEP+j1A;OJs zCYR{01zbq~;$fc#j+l_o`wq@s^~wf#n(d;1MrA$zr0d}5Ulx4;6C>Q@H$9U|H}S}0 zU|3Giva!2kzNnU`d$$u=^N?K-cWnDSn#tkRa?}Cv+a8y)w*o7V;IQ;hm)CtMweTJ? z6LUklh3WkNY1HICWY@hbgw9&-qCxf~pa$Kp*ZTK}Rkt-NTcx0zs|A*4A*dMRH#$_T zbd2qn?czIqK+}JY{t8u&UoOuXSb8;4)XxmjOq=+wCBLuwH+yW*>_2yk)|XH^e-sW5 ze|K_xY8Qrp@&L8E@T!$G+06G8au-xXpYHtuz4FCbFhQiKb{_*?{@cgLH3Uqv(}*8i z!n57v(d=Nxh0+HJ7Cd2HPfr%JtefzEUi#v3^dyUrHNmOm)3YJ5!5?!SoRM5cyq*A9 zL`Pj+LtZ_V76UaLwW7q#pB!0GXxj1Y>3hlszO#DN*vE+pv(U=ZWj!=E&map*s> z_o!$w1m*yJha{tN_e5>T8tGKJkM^6X&X&alzkS3?v_ITXiVN^?fdU8w38Q;oe8w8? zEz&CQM?Z28u2o9duLo~Y)p4!Lmv zR@VhZ-a59~G4E{5E1A_7jI7mAn7YTK4JdE}PnChrv1DPy@o>vAKJ-?*o5mgR=aJX0 zN*jXk@xi0ZFC}swU1-6Q*4bs!A>jzI8uAEzYF5mao2GFGDcE!fy#dlwGLVH_#3Tq7 z@SZ^kL9;3;bbQrqRPn8M^vS}B&BXJI)^|#Dz9mZ0NwvTQp0LS1&bRpyxjlm*yVf=` znKmIiN0I^D42rqDtDAs+C4UX~rRaClxIFDkJ>~;wPK5(k1Uq!;qWeSra*dqj{nf7n z{C2ZP11%g=~S&~+=;z~(M012)0@wQ>dU6KhWE4N4KmBd-P?&I=kAxVC|73h)yF#24Q7 zCCYy!4_?XyJFfaR*L570a=xd*J(DGNPo zCKFV4B%#a2MECcU&p-c>J)<%9^}qKQEM0PXr(XdtRS-B=Uore!y7nXf8g%OFv}k~J zF}wSFDr+)dqnM_2QsTHAwJYWCmRH6Kl5tkt%glmu1Ns5V?hkMe8LDXm)!f-SB`C*C zr)kR#vo%fs!5%5@yb~oOTb%WkvM}fScsNIrhJfCJ+c={}IA=N4foO{6MGRdW~Ci@Ls=7Nzz zTU3kEe@ErM7=18p&SNIF+sAszH>qT( zM4%k>TY^LHGD3fsPW?hsJ6ELM(^}n$0FUu`7EqRV*rs{eL(2$Y_BfIds%+PI*ondf z90af|5iZ`3w={^NwfwndIZw`2H9s^`n}T+|n?hN{iV-=7UD{cctBOX3EElh&p8xk( zAW*P;)@P0@V8|CxybR<{g!RCDlW31R%;;L2g3% ziG~&tZd(Bn_%Qcps5M!;AsJ{t1?F#DVCLDys`>i>(IVpVwrflERBQd`g4e9Vk2lcH z>9jbGrAtD(9Hhfx^ETn!+hWe%l2nfa6E3odQ`JU=#Jf^@f6XoB=ale1cpA_BwRI ztnrYU7})C9gr7UwLMI0?6v9nthi==>fQ?e=G8TmbE!$VLyfzbe5L(=F6&PVl%23jx zu0j3mK{3!9Di>uUo)!FfK$PNC)-7{|=bh`FlIFRzrRYxA;S*+qPG{&BKw5;0Z^?n3 z6t+PQv$ZQK?fzCLPT+C^NavO!4dI3RfYQE$!0ejUv%Z};SA~9l9yrS$$XRAp7bbpu zYM7}gPoG#jJr;FH8Un9B!V??2vL0}+gr@l?m%hhC{~d1a_KeWxxA(}qxEeqhklij5 zjMsfI#f)u!G09bke*ykA49Xy1kJlC)1bBfQJ|JG*RSw3~XN*4lPXg(necK_gZ_O3D zj_S^i_n$2lYW_~$q1-OLdro9%U(TQJ^zYTXB-OHP{us$PJWIem>*+5*2B+@w@#;F8 zDNKbMhBWtr6gc|-pFr+yrw$xM)Hu!;Lhcmy&3oo!6O?H=uu8q8LU5V{k#?tNdYV7! zTW|@cd2fiV*|T{hkk!S5m&j6t?v`-=gSY44Qp$@B>QWXq#HM*mA0p620Qc68&b3-t z@?~UspUDptC-4yfK?eD-C@MuVGIcdK3k?s8i@%UNCaXR*2RTx{T0 z-G%71)h*Xg`H@eoIwX)jyYqF}WXH82ReymujxoBBCfz^#gi zi9In*=DyD=i;$ZWLdC(dQHl?!1cm@0Co)lSKa@XcfG{oC4|Pcgsm&QN3`AoRM^M1k=y%(nH=t{?e(Iy;DIY-JO8DGeL{X$G2 z1*U=1_Rlfr1d?Vh-i`Ruz*p8(18lWu1Jyoit7p`5cJk21!A;fi5}F%%0T80Uaz-SV zbUp%}50aqRu>&3R20FeGGROUdNx~?JKgZ>_|75}`lx5+hSZNH@tnImJR&k8*>BZ_p z&kZ!#T!6zncCh~LJE%{BIOm$0!kSXa?F-<+Q*5)qhsip{JFYCwyRJK*p;Jxz(NeiFjCUQ@4U)Vo1D zZ%XzE>=8;V_e;8@LaRuWlSWyt?`%;Vh+xg1v{{jcWJfdLV(aaf%zUt|w&PX0Hc~|AMWx}Xx?HPE{Qj0< zadT!#H#ZPMEBAz4K4Yp4u$T}>9tzfH8;#WI+>ceW8*{=oJEVG7_0HlSJR4%5eH&ta z)ZN_V5l8FI!rf-;9yl~y7+R!zkI_(A#D3>Jz2AwxAe?a53Gv9FCYN-DT>)}4Sw}&m z2_~Fo^~=HOSiXt?e4UM)3(`W6`1FIX*5X)v^49FLp$)2ZOzqaA%)f1eo zVlEwd%>P^%NB^s4_Y+&HLf%+R_1usuVjn5uwq`BSKn-7G`8zDkZUgnEbUO98wBb$dS(P-S1sqZ+$Nq#vk^{#eVqJ_MM^wM`M2dq=STA=K7<0&b>8Ll@KrP!*}mLWit;9!CE&%ny3Y z>z7NDQ3fEx?y9FbP#XgPIY%zEzC}6ZyyARfBFc|%kv}fW?_BQ8 zsAfzUl_ei0P{z@5pY-dY$3TSUlYuPC6A9Fx<7E<082o<*(SFe`yA=`M&C|@Mn5!*m zv;~|Jii|T}5fx+JDN>mjue&&hR< zV~S+_y%`$L)doMO^I*B3cJ2)$W;iAeh0jfR;2wZcy0+JI34b}y?yfXPMfRfW_>Ktc z_Jw>#feen$D)dXmisGnRsUezJxWmqvS~E35QD~_RADhInL|dZ8(bnI0qsY8n*4EdK z0gXPBFmU0|SE10g8=fp{|1a0vyL7PfTp8x}k*3u-^;6A1_GxFWT~I~IqK!<+6syLl z^HCx&@f#+Zf=_4to^{N-876s64eNbk^S__8Gt^mlJaYZ&`b?fm7Q)e=8{+HS;Jgum zY-3R-r*Bjh&b<$Yj);?fM7HG!Q?(z#Z3pE(^4UV%J$(E6ff5GWY*G8lyC%^IZ2Sd^ zgiUE$klbey$tU8$@5l*Uu2v^x;x)cY72!V z@15$=d_w%p!2}-VYM7twf<+ma$pf0NrK~hCxpH{g@U--HhTh4e>W9LHNHyEfACUZ- zELXJIy|A}s1xO)vV`zs$^LBe+^IgGVhgNHS)+SHCvbh8o~8E~@nVFi+o z*CYXE-p-48LKl~kEGz7v3PjpJ^9*+Df-P9~~>nt3B^ z*R-$AptXi*i16e}+ff2=QCDohS)|1w2SS?pYJqT;YI9GY1@)Ikr*ZXK3g;(r|BG0^ zot=ICn78Qt?1Y^Ng0Cp7tApI48ZofCSvrUW?5L99s`wyMIxB_~sDGZzQ)9Ugz|Nn6 z9P_OUbGD|!cTt;WQP&Edfg!Ms(q6kj1|iw8VSx1V3)p`E5YqxgkeV!V>~I_sh%ZI3 z70GtxTDa>uw{|KlB=PX*1Aay=ru|*-UE}*QL2n%6XBR(2d)ai9;YZJNamR2~q1UvZ z=;E;PA*^~#mXZ1nGwdBFlzgU_c>mxH3Vlm@IT!;-^6!GdFm=gHNwqLKwu8!wN<2Io zj%t4>r>!Yc@^Y`ba@deZjUvxbb!#w|O<2zRup{N$z0B)Qe6`M$3h6}(&Dt4OYl3@* z;}4nc%a%B_Ax-THmlGQ^pL-5Q6b4Zt{=8;Zi&u#cHoJ^4;GRlBbA}WHK&gfl)&fZ# zK;4n~rZaCcmBqdPLEBppk^0FEkj*MsY#H$Z_qsH080XdLQ<7JUz%E)TlpS}*=|v7y zdT&rlbc{%n5BdB(YLwbIBk>u%PKU<^%0avLkj2fn^y=>9`|i>dxz;DamzwO}05uZ` zHKd+h{gVsOgS=(ZKD359loxc8a+0SkBaVXa+_)Qi_<&#gE;10Kc^3rN`|UUUH=$9FFEPN0;5(wp%3a zJP=vsviYbs&7cXfT1hW5VXFbj^x3#woxxyC&h6M@^HE!1O=c%ch{H8;eTKQsk${Fr z_!$uX>UUh2B{#o07eq}m+XFL0C=;o)*SN+JMst(hrf}%y&M8tnSP318jSBZbj2Oe% z&XDoDxlKqi$EIc3YV!cvr%{!DSj8#elcmVG+q!o~hXA&AXHkcEdIi*EeH~CxUn>FX zE+D5BOWLam0XInPkJtucOs(;>%ywVDr(2~vtjABM_czLXslbsOL zfCkQ?@aE3TSdQuGRaz}|voTvk2c{lJ_v{B$gvO~?2*2}k_|UuMy_{)gN=3M&=kD$d zS{8{Ye=6Jq+@7yZ+kN?hdv<EF^55QweD6pAly2Am4nqhi)G!M(g@;M=lPgIq;oS zj;w+9#l^7sqV~Ke7CB9F(#WE}XK_P&?ahoxBjum{2kL? z^sT!%=Z$%v%w;WxiK~W&?va?C9LykKlfi|3>yK=DWOF|vaLwqUJF0$fx^|1Kip)RSZYzhI37%X>Sd zjCQ5kpRXk5%OlSpNSPRa-jP_ksQ>H9;)dRhz;BiAd=na#Xy0uv+*Y4WuAz?g$_Rt* zhlpekd=Pfpa3nnJr4IeMQT3woso zBqC;zWClUm#LrGZlVv_1P~NJjNN7*vRB;z9YDVTr_)B-u1YxQF|P2+6ycY7mCs*Fe6HpG+x8o)HE^HfxZLK(mH9euYiYfyQ! z|3J!lLdQd#VYCAUo$3-Bp$_ES(6tL=0VeH>#AN5jZNueOM%{P3wBl8`(28_Pk>Mmd z`SqkCnS=v4BiSnnaeP{~l1--`Bh)n;Yk?QzKMTD+y>NcBJIP%Y_F?>Y?wR_Y&kn+aFZfF5*^2L zO>^@VkQ*v#Tj>nvzaBowx)Xkh}1dR^F++E7Ze|6tYUgv?r#t3u)t=k@!qo>x?8d{R{Op!;u z$(If?co_B(lceX8igtd%jvc-CN#Ux7T;r=6Kl9w(T@BEDZDaW$FLbfX>#kd}_sFmH zDmLIGE9A!T9t^WqGe*9c+AC&@#IrusxSk*J>t){-2=dne7!&e>ix05B7mw8HMii+FdtKWW^s15A!1K|09`F|L7-AOFGld1pwFk`!YVRm@>Du2mC zhd@UDB%w9B)?;!~n=@CE_t@J|;H;{cP<-iZ8brn34C8_W2hnzYv6 z$(>0^JfPeLf7SB1UYsK`StFw6MHEQB)$J#9A!bBK-qvF09Vpw{eeu5zO9(+1ksj9{ z$`?E>yP@$iZc{3NK4bhpkb)+GrlmCT6I7d2A%BGI(>xwS4ocEns~k2jVM_89`*m-% z9zR-ZxOl6h3Ls^7nvB_>!ja2Ec638zq;neuRbJR#2e}QvGItza+ac6jpIuPFA8D_x z-GU;QEz~f)(I^H~sW4#Ep`rGr=^j28fr7xO4D=?j)P+t; z6IE|FP;xQz*tw@uX{<+Gd}P;A*A%0il2m*sN}HOD(+z~n5ZsykEk;W)DGIDy$fu+|EKJ3-e2npp{)h@krP^6YD;0U6rvUR z;}FgKBNKB9P2L02iK%4_?(Gyb$U_@O;Liz>YelroMJmd@|rM%7+lCeM=&&cDtk zH^yb=ldt>5!4&m+YI`)Eq>=91p1u)t5$t|E16YOg1gt+{^fSHU zRpk=*sRoM6@tjV@D_d+-q9?cxq?pKyUfLW$jHON#^{Ru#>7swW1TCk7%1lKlr5mj@ z-WiDu!Xdvf5y88_?tB3r!r!Y^sE~NP?}f9_UgaZDs%JagWM9^1_M%t=KpC7w&fK#c z|1`%+k$|7~cb9!SP7!Pw^+;@X%pX&GB`POk?n6GvaJ}CE=QTC&!#WiMo~nAS zj_Cb)G10P|80}p>X_88rq-VqvL|qS7-NaVwh*RDS9D0a4`a@JXjVj;O*tk$%rxYB- zuqS$p1AtwCkWkO2b_2wlv^ura6b%mTQ?BHC(!ixmyfa(^<3|fUW;9vDT2;DF3IQgk zTOC80_$DPUhEJAm7Wn*Mwmo8~%=Z>H;oqLJfE};;rDE4Y{bukf(yg2BbwqM_jU5@2 z*t`VXly0#RFW{y}E&L-}wXxBM7EhH}s*$iur)g>!bB?bPQ0X{F9BSG_zRAH-Pl5746>)3s&iQ+KNaJ4Mtbx;yb z9NzI}_%yk>N~FESRO9W`%0BuP{{$@2Er5@FXSiQ7A(^9#d5ZbNX9Wf!+(_?0X(!EY zo_rQO;Ul`TMaH^ZRmXwlC4A~FwQA3$X*JD1N1ckqU8C~2 zEh1nREh(W1X`L*}B{efI{2y*MeUNJOfFs8%9#KreKi;KrkNs0TrObX}2Bmh6Ps@ns zd=T&SznflH&8!qBXw{zEK;!o28uM#IC{@vlAxCFbSKUnXu^GBsPQ|8P#gA~S{fea=9a6-w*S z)61#XtX;L&6n%z;#-)k-3L#%kr<|`7?E4146QSBgk_4@tzlj}9*Wu|x<%R82-&&|n zc6Um$OX$oMdtffyYPFf-O2kb~cCR`Aq8mmBZ`@Qn)OBY}f_41Tuk1pdWIzk(_Y=0V zx$~R}Cn$^;7lrhVA}&(F)?{*sKW9A0`Hkv+6R`xH-^{T4P-@|?AkU8+G16Qcv*h*^ zTE3W42O?`}cMc$`dF*!;%Kuz+yQ+5bQ#nE!(a#+is@f zH*nNR%wm(Hwah6bAI!z3f9w$ZkhxIw)P4Kor-9CnXLsEkBAB@EGiO~xJuDY)AkCK*UN*~i0Hjda_4xp z?l~aTjUB+zKVpM2>WsW3PXelCxhS8lw!l44{%)#m3_q7aB3c~IP{d-Vq>s)fGv3W^6Pfb*=K90}?d zc6klJW=xN{^07H5A2*zzZ7U*l_c89ve+f3WUEF(eyHB!nUV7cW?KG>NB{dG&pV%zH zubz8P=9=2Mj>JCqyw%qfr-D17U-sC&z&in0n|yKeuw7bA5j^E>DC=LpExP*Iywh*fjhic^77`d^=+?8`M1HXElowOCu_*D<0kT835 z`}@7{Aq5k^#uIb+FPyNHyUAwHCfeaEHJ;9h|IWO|&S;SpYo3>}?r?aHVIEiU(=Xvy z=n}HvH#dj%W`7%pG7&*Ky&AoxrEir^0}Yb|KdzEUd^mE2zsHbon-fIPdx>hG3tgD) zI~IJ%NiF(iJC($zb=47CyHl&0iEbNn0gtgkxCQIVd8(fgdZRG={_s~yJ^6krGre1q zL0}H)q)Oc7C(O`(rjlg)BhEsn0X6CC{t>HL6lM3)n+>chyM}8N)V&4Th?QX+-#-a<|q;r zXE2`?q3jYFMKGbH9Zmrnq^#BW+;4XaVu27%1h9vP-Lchvzn?AC+%+(DSfR$;5VUjw z6xU9)H?^vy{0E>2ixOc{7pW{?T)M!%m3-fV2LK4Z5Mx+s3o?9>jKR^q7=897tcUS- zSCR$-kSRe0mqtvs>3O))_ILz9v~<#mj-LG`Itt7p)-O8U>1iQuzZ{t2P+<6EzO$KB z`O12HTH{EIPp7a{7vxSy887p$(T2-&$4RBvEb1>7u)(fwwVf;UxpKGTgj7E#7y_S< zzQ+9@ybFb%)%C@3aHrQ$nt**?fxr&}Ie?tXYz~XP-xAoV&;=DHFBF4c3hDw+QXb2m zE31H_OP+#k!W>^?>YUE8rLI-f+I%I`h2+szZ9BCj#E8uoq0*RCEz}zNxKSKY^Rr zUAJsOg=0tkW7IdQBmsL?16b_62a44Fa>wwv$kIh=2WuBomEMW>^Eg(v*?cHdeaUTcbAq z?^0*J`qzP z3>V*ZiuppBVXWGcg3|8Bq;WEPbjLxWySoBFgfwJX3aCZR5m))AA zDw0uQq??zzp{eG%FA_Y%LFgI2Q+&x5OtdW_Y}(Qw*LV3tORH`Sd9?VbMUd!g!jA^Z(xU>uIiwj6EVl`M+rBX z)4>7&j8j`62yj0!tN+BB2v|EJ)YGR%(;?!MgD`;uj_NS~3F^`hASv;Cy_Pij{{)vK}R-XPfHH~C^>=dTQQ}Y$c*twcfRfYCE z1bqu8Ccw<6t1>w-e>N2<;BL6#ek?bz5)3+R(z?v7jW+U*mg>^ zx;7&e0#?>M_NqKZArh=scNdF*9n#ea*xWD5RNIAPsxP&rJXz=#W0n(7oK((jCH4Q-gZHDm|Ot9=&4 zQGHFsDqYH^w5#aT4DuLci1bYdG!JMg{El}y4!-{*@mN1%!d=TKyXEkUYn1wC^AdQO zM2m`4$5qMll~>Y>J_ilC{R2iJoTO4_jQePdj^xJnVC@1Rg$r#zaB3hbqdk_xy5fCe z^A^$GelQV4g#8f1ng640tR1E~t9Re{P31%90g|HcvTKq%v~V(=di)H>d4y&6ZdoVf zAAnU_UF7OJM}CQUwZ_MDhNSXly2KNtf1k5Tlv<0EhCmNJLF90qdYS#GYcOU<7%lp} zXiyZQWD_eEZ}lu#4e@^%;{Hla=;8#|uy$pF)lpeMNlo{O4cbj(%O}}Aiiwo_;N({B zD-K9D-JfxKl0uAnxup)GN?bGAqvc~O1`FF;?7OnP_!!~jE-WD;UKT9^)Q<}@mR%zN zxu%(MuG^RtN|Cs*rwa$VTn=}Htj_Xk9mGIDqJ0%+ci1mxD=9(#8t&Pz)O$}cVXmirY&|EPiat3d)W74 zN4VHWwBm0<^4fMIbFn|``HjAC?t3JVoQ$p$LyY1|mC7Vz{=(Dc2KQF{CsivO*n8FQ zJ^Omzkbo`YuIfWwRg#6H+I<7nW2i^Gm&j`0>sK4-;mE_XQa4QbSgS^@yu+W*g&>q1 z`XGrMJ4X0@Oj^D~PE`)rI@h-HwRz$tp{7JXKu}}s1>5_I!wqm2qQHK6xN@}Urk{n~ zE_#l+_p92=5dT0$>cT;}d7WNn*gy2@8trB>?=xt(aygF>P#YXjObP(^2ko>g_UP76 zGn_>Vm+X&+?RDw?_OlR(T`q2t2Q?H5NaNJzP!~y26WC__Ln~@>L zhSsk)Xz_11N4Si)@n?k@qcd^R(~{*oFKqAFbqGN(PS1;S`;i&?`^D&IB(&a?gHr+GD9SXI{Kva0IvE$XRR>2{>}d9dr)~Z`z`^v zV$%%K_2!ggoCYO&<+U1k5{pVUH{82%tp%Brl zDK#kkEO-uIC?%*%NCM*C1HF5?_id~0h33v-+9?q6aZdZI3t*hZ^EMubFLrBv0b&HD z!gs?&=|(h48#_OAPxMDWWHdGVc#E-w>)##T{IazYXMQ$jGnWU zydg~b7(@*We*Xoy36z)a2(D0My4FvCb){#jIEI;zTxes14QFuZH1i zvROTyq~t&-R}P__I?o;fZowL1InO;z-@`wh?0lf8Z%!cuc$~_J5>(kVH7&_?010|t zow-%x)yA5OJi@VHzm1j8=WumF#yetmr>xNH=@Kg>K)m$>IqAzzW;=lqkU^?K1~~y_ zdaB%Y`znB#U~XG77zQjmx4i^wm#k|&Z*Bmz6O)hX_8bqe#_gV1uTB|f?<-&4f1ISZ z!(9$ChgGwV1fOyn#;!|$mSV8?z}Z?y2X~wj9gPjGVQ5tD7D?FSx7mq>AsNLIRfR&K zW;6#N4W$BwMZJWs@|s>F#gS+7@!xE zYtuOo#8%s*4oqwn7i^zmAL3p!)eQ$ziVYlPIrTVI5=^5cio@>y(I?s}7fXW-W`YUI z9$w59xS0sRP4`I(?(2tkk`KMJIJCymaa1x9QnTu%8G08FwRH&Ef>*s}p>0^$lK?Oj z9{@VZDS7Zp-IHN;qwOxPzS*%{o_EAOOUh=p^CTW;7w60&T1(RpY!f%C=)pfePu_4c zFmay?A+PifiNWh^yj%A7UG#r1)uoD3asT0%3xvuBHv7s>{oM;9biwpp?>o zaFHS%L}3MC%blvca+G~*pIVU&i0q{MCtV=n(8DATZ1Z4K@~GzpE>VBN-0XI1I((%y z+S~*JYM=k+1pey|R^2;nQTU=z3Oo~ZA|p27;6&*97wSdw(Y4#Y>?amcY}QYGWEG+T z)yZFA^rvY#1Q@{v?^G-76s3be+v)N1m5=IKc*#z~|5i~r^rl1L=um>9#m3BB1mXLh zCF5iVpl6lXar0c6H!fMT1IpsS>R{wZ+qJ92=}%q^;Qe)13RqwQmh#$_7P$Ck$}g{g zxO~!%4fgKL`_>&V{&~`>dd7hv8W>eMt;$Q@GAEH>LOmoM`p66c>y&kxsws4+v`@s$ z8yjbM(`eU~I9fAunylB0X$Kuj%sXcU+kbbpN`QHFfjRi|ndkd(a}5-W0>5J7;9|vX z)Ew#eMw5Km6!X~t1qQJxC7YJcnb#=A^;MLC_uW~i%?<@J6`REZJAVLz`t2Dv7`^iI zCt3j-W)anKhuTzjxVGd0ZM|o0Xs5AK&P=frrny6NtR&>n8VHU;F=A>~ekEvoWjoUEwNT_3f zz5$4Z;Nhvng3$n~y&{^I0ouwwNwiGhdDem+HKw<)q zP~h-b$R6O|zM3lNPq1yPwu;9fR=BiO_Rw4906WG&Gr zI?u?yrN0Im<%17He$YUT4;5BmwbtLlxBXMuYYg<+Asgkb+9ecpb{5np`U#PKW$eOjP&11mbS={ICPL;JwL_iP#4#bBiH9xJCDQ*TgZG&)-{ zdG^mX)}rqTt%ig{Da`*?R?lm7uX8f=)vWS{@+rQ74xW+z1T!b_`i7aS`C(0<8X=iX zbZ{J~=^Fo^}ZJ+dQ2r2;@W#U1NYbR6%!3GqBCdXHzv+!Qb~Oh1bL*$#8R@o2$jCKznF$a?_#7gJwC2vT{iWXA!o_ zx>=CrTjg5R`&Fg59#Z@yx+Jlc9Axf$$ipm5!Hvl|{`?sxYXT+tLeQ769zoQY0B(^# zs-&M)x9yDpNq~;EMw0pk%bAAD9l0Vsnzn@ZgK9b^s*7g9@vtPf?lm`>Ku5dQ%HETc zeUc|vsVB3}9DR?Qq+k{0(Tz59)JPG&x{~6Dt1|2?<>%$p?GIOeR@lb+H^gyZx~Dml zI4*WQFXWZ8kC+UO{VR9U6`iz9s}0rnK>Rr}suMRD;=nIQ#>@8roZs;0u(>qD_@Pt4 z9IRwjy$DHh-^1WP`kd!Oin>}nL`$DV^saSecat6u*tt=D#D1oOt($9;-?da# z{m>hVlXXh+R9#iFhh)?FRj%D~ixeyE+?wG#^)dOV3a3r<1e0oYRHY^Ebsi)t*G6J{BY6R*7j&#}ha&K}#3OU$Or8Qr?fmb37x|fSF9*_7XCK^xd>!aCq2^M+CbYGVNPqGb-XNAUG|iJrFqGsfjtP*8T>utn4vDA@`* zOd*t9sDVDf=3!pYrFiWG`(=Aj^U3}6a0Sn}`xk(|VPk#Z|M0cRyO`b`n}?)4z(WWj z%3WEjE^AN=UJvFfluN+N>>i9fO|iH=8SsiidhAqQALA}Rm)wf4V%EK8<~R?gOCEN% zMKS=$p4)ceUSr(k`M6&4Z z>kptOdOG%u-~Q}81375Au&g~a!`j;J;6pGCjra$0MjB-wP8kL6cFAk(wGPs_g&E_kLR6PKGG=SWe;W>+N9fa0R0^{A^T+$g z=rb#=n^G6rRStD^IVdk3xog?drZC5P`U>o`7j@d~Qg`l~;B*BdzgLr-pdR~2WlG}Z zh@mHdaHBHeM>Pg|eP}7(KYG-+&B2K?%H!e5{|>EybS8a2Bt1@95>#Qo3O0YQNJg;9 zGRS1)4PD@<(MuxD3hd^0o(eB-&>6kHGHxpHhq#4)kIB%%`B=gC_&;=5)fC>Y3U4}k z4wDZMCfWcCl}wSaRL*24FduO3jXcPw=no#mx+0`Q%;;WIEf<(9(l+LityJY73E z?P%1N-5+is9idt73|P<+zey&FzNgv2DPUZ~jK$%F%lt-R)wUU!h0-U588#H^&+^01 zt(?j})f@JCOh3Gt0$WONjBS#Ryp~x6k;=YhH3u7BtEe`ydwNd%4<;1)9%^&3Nix9l zAu!0J8@UlCrnuCev5y{U-`&d`zPlVNXW~sBk&Rlv;$~YdYPa7Z1X`J zjkL(aN4&-4t9I(qbrGW{7P7?Djv*0(ZI@PSIfA)irbZ)9>6r1DwoQpI1AXtD=(DOR zK$I7U*EmR>p$Bp36VQQkfwLfpdvvs~Rt3F0bjIQ| zbG~k-BW9&4p0(=4EixADxFf zWrwC;f(?piImntLdB`JdC}3(Q&I{dB?fz?<-sQ#DD#gq3e(Ud|CLPb%GQL&|tJc*x za#L~taaS6kE+vvp+62i;?>lYC#Ckpz7sCpF7^Aw~x&pjX;SU{0NKpuQesXLM>mRwG zr6@GfAf{Mdlo1NfgPYD%z%*P`sA>Whf_6a1{BP;?ZcFQuRs(&6rAsrjLFN}&9hGE| zc{+}Y&BAg{QX|*u{XxGRtHI;hF)q6OK~adHt!6Q%#&*-+wyJ13gXa3i#71tT4A*rb z@!BIWu%X=Paj+Hva-c)hQj>bprSN}$p0Yj6V&~4;S0eT|soTc?C#j2@nme|_YhfMN z{6oKxhb)pRsOu0aREXEa`sr!RJ~pQ-F+HdE&LtjKO#k8wq1??EES11Q5Oj7uf7{%F zGVx>G_`@fHZm}8V0@hLwM9Lv~11lGVdJQW18KF=1h|`V>Gl-oRCtz>EzQS~8|M+&? zuO}pHCQ8cx=5_&YSK{75nOP*>>WpnD2sLuVuIhUlRPo#PvMV`p@Unq7HSrni zKr{>1O1Hj$7$M#1J8^uk&P`UZPHZ}fN`6GAJrMxdNe|Q^&qJU~a$j!B8TuSyaimj1 zh;TXH+`}6xsiTl)WRT+|Fwi|jeW7-236&duP}iX_cL7}!QiX4$NCEqV==N^QTp8SL zg}P$m-f68e$PGZd$S6l74h6;c9F8} z-9_!)!Brc}*)hEH2#qxMG8=mh1dBC@c);Ib)TPaVe5voPuv^!c)`nZXpa%%ad8k7? zvQMt%RZ9QIKKmg1?Ef7KFGjvE5^21W`G}+?LP>OaN>d8+z7slMKL2K27I)FlS10iX zlfgg?4;j^|utk_IDrXG+!;56RFea!J%drHElYRJh`Bp>;EAB}>biDoqsB89U z;T|#t+<9XP0lae$R((^?{Z${@%uNs+D@x|rvfd01z@!OUaGW>Y`(5(;&Rwt*0q!NB z%8-Dg;+|M>fK#cmo~jJg_&xfSh!%^c>TEw&zquF*{s_p!%hV~jb9I1w!3IB~Yhs4Q z;dK%#|8$!>jT}s56q6HqW^6c1wyaZNsG=kSeFl=#%Fj@+%n7dNu7XgKkN#(w*_!uH zL@213Eomi8|9*!Q7Mh^qr9R0gzX}q|Rz4oqxSdjYQon!xodW6Hi4=3)J5h%4Z*&z0 zE2oU%JuA-;RoL$_9m>ds^Ke#O6K!597_gS)cERnYm|q=cT;(gNP+*WnolcFBN{+=%Lj z8XGKJMy*j4vmC=7&&undtTB6*P-FY{)#q1r;6^|t=yB(RHK0kfJo?bGfi5~FyJ;=K}nqe0vpovElP1}g5%pAYXH!0c?&YSZ-bIeBC7*mnd2gZ zFK{M$fWiw6g$}o$ZB=S;;U-Q#A6deNX2Zstr+2OaVELF8%Xs+1P1PX9r*133C#=Tg z1NJi;7@9e3O6hr>9Rwj%vmbQHTa?bc*-4utL6pA6iHy5lElJz zS7h95p!Gk*H2AMkx6H({MN18MO8heeTp^h?VqG5JmcqGNFaskvafOM$bbFf>eS#h*Bf3AM! z0S90~iOOOz&`SB`W}mQkAN~6f%DlA$&hQm)9KDAL z#>`L~_mn4TRs1hqTlRQ=!opbrnMi-dv7C)2=0{&q@Q+iQV z7wh}Im7?<(g5)wPG4dJe+D!~4Ko#aj4OSHE*H0m{$NAe<+Kf|KvzobTzEwU4x#c)b zIS9#tdd-bw1T<@a8;Dk3n0+vdn;AB74F==PsHeJyV5+71mhT^BWjY|bYUR#mvMZk3 zc01*Br;eH5pf_AlS39P|%ev|I+BNnQPvLUc7JZmQs}t>^O;%$bp1gu%OV}e^F{qh; z8sphD&Zoq0Z?75a4c%`XeE5A}=1)AwjZfI{T@%H)Xe(~f9C?-l(=gF6T?gn>glz8= z;fK1jlji1D!RhLFi6+j`5lMX038y>>JL-W$w1f|ZKH}^gSp{K6<;J4$LKYsQ+(Zcc z@P%<;OKZwxuy+qTyieoR+8musG8@@&hK}*SQVbj)hRn!ov>tTOh~)1Q8JSsjE6Q0ZL-8eu~q%I}CkmA2v)xXZ=h7 ztn(Y(E|63Lc0=05*$nI)rT|t;7wFe_&gFVgS3hzDgO6cCzZdlE=5Ak4+TkCJAh+}K zOd4bk*#$(H#EI+D?bRuHSjvS3e2$F9wJMM3cwfOCUhP0Xd(hbF#(%@srG|JC>o;mq zV#pz@Oc}7y)xelT=m)e2#`nkuJNJjDD1u-ut%oEkwqucVkNx8zB%`LpySq_3Vf02t zEKFCM{w%X|bE;Rd^L*~+0u(TirPpBp(rr@@&-kNV7a>A~G+35kALv_qn40SWTYXV$5=h$*4o zSc4;h((Wv6vy%5Zz*WXeK13G5}$hTMB4s3->%4Dw8@tZ1cgo5Z1 zf3k<@7lgrbtNs_F1X$TvfC7sy;PbCdL&UlFqljl+7&xoVagm| zpq`zbJ|t_+rcfI2;B@gO|B&u{?^=xK%+AP3SDU5F&psgyQl-%E_kK~O z6GC#ZXFB%xaD>2eG9>2$Of=WMogxj5$Z8Pu!IJjg`X9OdNi{aAWLhCQy(2$f7W_@{ zBlY#sUe8q;N0iei-nj!ERQWA|Xaxo}ywFeZru=am%Aq3`vG$3YpP2n;p(&8T4Z0kf z0pg~ca`g5snv7w(-D~I43b`W&+iJaD?L)uK63ceFM!@aVtjGtgWG($lej#=vR8ktf zD=GSAL=O;J_GjSi8&EbeC_C#6u*CxH;kWjgDc#<{36gmZtRSDcY^#ay zEFattE~FU!5;(_!q4+bk$zWfmI02vz%oz4Cm3}05jNd9mbbD-|1&d1yN=t&H)n!dC zO)j!x-lkn0{ZA*IUV{~Hsor=ML`zH_c$<;rOsooi0=|JVdQGCCrSzw{O+sipV+<8^ z(|gUs;}jT5D7;q*u3Uyhl|bl1&_f3%A-rl*R@lYf^2jC79tj4}2S{&&-cC1a!`#hm zRju%CI|J^5_i+S{_5$HrQ9fw*)RC?TCgmLcw2qXDAb)f8X2*l4Z)_7t`LH0GAj7+r@_1{2N|?KIbR={E0yl7A!{J(l}x-f}Zq6wGR{>L(nt3e1s=fGOz6v$iqqNcuM5F~t5e20~QhHECKnVqr7`nT=RYF>%LqI_q0bz)Nnd{xxct7{^yx;f! z@t+5K)?RU*Yn^Lf$6+IMVwtOVuhV+!w~#1htcwhnx)RLw^6j-|TO?A|x`4DXF*v8o zQ0POWHx)>gDPE&Iv|D?9o39^3F*a-x9r(|-Oc%g|4>yY=!VjaoA2*tGQ#^%ZQ+$?Q zyVj*;>^us*)Q9gMOOUe$9V+mSlVfw@UH-eCdg`3`xyaLwmw*L`PpftDyKJYeOKuY1 zKw_dH6Sk6!zsL=LrF$E9p;bD&=z=%Z$Fn&}?Vtz3VzHB+=E⪻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 z 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 X|Lf-oD**H%$*r0uOOOMD(Xoj7Gr(r>8<* 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

&-uDgT| z#f_g`qa98|0FYjtik%UmSDX*$3}IGy6R|6l1|+F`#?aCgZ>yUzdb9GH*@l1zrwO0=X#<&a7iq+7 zZ;|*p)a3ak5s)G-nOGJAo)CtuqD_IjO zOc|o#`yxqP1>$Ab;Uh$HVDx?%vox6xctTMx^Z&T!J@*tQ)bs|sWJ!~8&JUL2{3}<^ zyZB!x2U0R`YhR3)j_2z0%Oe6<*M8`bg8}-Q-|WnC@zJocyTL*P>_5dg?VMG( z`Q9Z1gYe9DrUkS48ef5fN51$F@`wNyck*-6gbtQT&9<{rflTMuCj?xxA;CYdORzMv zKZKo3oQ(8L#<#^%_mGYIQn?uQ8e)iCO{Ktv_awM_!{f^}WY}ea{^&U~Xt^P|>W@`| zpXd(ScYQZN^k|99mC+ORfXlkkCmU4yN= z8Uy25c@AV59o(KV=Zxv{gKgW}7r1KesyBf7Df#WG$%~+wH4MM?8Jqv_D8bWUb4v=wKKCpZi4vd4Z8fNgoZ-D;^NB{WG?dRxK&; z*5|Y_-7z&Esm#qom`3C2?T{LjTc};Xu%vOlLif(2D3)9)IN<7KW|cySA{sPO5^67* z$QYO2GJ!AsXbsYA{&*G>o!q*}ykWdnbjw}`tOVw4W5`&>5#j>AF{>EDxj)Nr*A7CK zgi*H;UpFh8+_y1=3_6GEbU3tL3cos8|5DW-skLl8VWv>F(y0xpml%=#PSCid5 z{shkljRZLV<`fsNH&~J)1Q8j2cVW-eYsaA+1WFhz%G)U)RYk~G7Q421nA}SGnEACt z?(2E5Qke)}V_7lFV88hu8M8H&o@aJD#g@>mbNCxaG zofkls(W<$Nk}r+v@;zm*RsZYOu09cLPSZW79(WbdG6uMRJK^J(rcvuku(NX4PZhMl*JB;uus}%o>NO3_?W@yvT#sTI6ubBCRi2%= zg$CYb&Y5x4$`*d_8FQF!SGzM|YUDonp!pEO(L44`AFz+Hyf{O>8W*-6vjLG9yx`eZ zJ>A|iiK+BsZX-xy7&9${kAL%Ev~vaxh-bCi0D#jvm*T{+8BmhF23Q5?>?EOtd#o=j zMQX^782#N(8<;c%nI`DB-(vU3U2h!vk17v2t{|dA2Si7cZr_#zDpnT;{B#gez>J0g zu6oem2e&lPNXJ(pN0rb{7)Eo-s26atE6=FbExBiqhQ%3pVwz24Q8#ZAWNFI0@uo#3DvM!46>B){7o^xe}`*)aqW7N^1%2ZXikU zqBww^zZ%MwUHz*Pju=J`-mkzX|M&j5bHfx8(?6co?f_|mV_O%|S!20yNyc97*R4Hl z88QD!UX=SL`(D7v5U_r$!8fi=v469q#63`w9hLo4m7cjS%JgUqUFqpnw8fitdTimI zSDIg+4wguTrceP5fW;|r-c`*g z&+?5~mgcCuaRI{hSz-#KO33Synf(#bf@Y0FXcK=}9W4%uG{|NG{BV7OU)`G_OCAN+ z&;h)Fl}=mm9SOn5v4j?Trd0wkAEzX{(BB)Ly9Wm?wj+qcn?x~Lu2C#@!9Ma)CuVuC z?yHJwQZFvFK|OTT%fyQLldj9!ml>-NFx3RUdBqeiy07{&#I$&}cZ z=#@SQgaa1mc>8be!KRtvZ_I*}G+nKGcraieCU7?STar;<3f~Wq=+Y(6RMIYFm*#Q^ zWO(5K(5@2@c1f-r_XhnYFjg#LyyNq0@bGjKX27Unrvo%Hym98z^!RbwFkXgblC!vg z+U2EOYL@QlGyje2tHR8DTAHANc>~nzD_EQt)rrtYX&G3 z#Hq=-S`q@b%@Y3QnnxfvOEDpWa{ClG*;o}t%d)_^Q)CdYpilxeKNrxsi5&)>d-xpQ zcuWg>Is=T77iqAryvyBVh1;|s(v=orpX{)YQoE@nhywWOIkLcD*Q7iAC=1r|P5 zQ5~i0+!Mptx+}pK&tEgp?PLI407+hqHI_9tz`Azrfo~!3@!jl7vjc`9;M>=y*xbt% z^j@Phf9Edvvqk6dLZx?}T|uy~d_*-VyMoYQT?g|{Qr;eJGaGWh0=3HHl+DB(~w!gfdm8L|!9 zD!L2hcf^oQGsB*EHKi0Z(G{?sUd^xoANk-|KLMCbfOCJ*K*3c?WOyscSn$w+^x*yl z_si!)nc(XY!6%K{&lINSAh0TX1V>=V01nu%z=wZ(H>L*!;03E#TCD!7Dhl`jiA@rQ zUtcShaO<``c=t~&{8rag@A~@lxfK5#Z*7QNOtPTCO!&yZt+aKo&*d1>3DKG3vSloQ zb@xV+pD;qGJn|yMCPKaH80(C_ z5`69b^AMtXHJ!Pv>=#K5uzs5G{aYI2V_6!2G_w;IPO~B-0Hf0v6c}l+Q=k&Ll?Ktd za{uidIO!QPcYBlHt0wT05Tx#v}QEUG;59wej<}$QbR<9*q`w=VePhR~F)w;pdp<2;Wto@f@ zS(*V~zdpmg+f9$r*6L!xu{@Fo_*r`DducXC1>*zokWXsVxmXhO3fohun2`f7>F|tU z{L%5d&`A1hWu_V_J8DMm^ROA@Wj;*eip%|$OTKCuyPIRr(9$qe|&H&RQ{5w_n;jUixn3pnrU z=Dd|=;B|#Td9c!rG^8(>QhZ9{PBAm`I4NdYGXiXH%oP!zyXt6TWv=g(m4T#vhV zWg;%Vf4Ck@=a;yCAeuyt=&!o4Qy6!|u3DEu33ZhFd6SBM_u3|JoFY6FAUiRCONy^w ze9*uZO7j9}OwfwC3(b2+z5Ymmb?t`^IhfhJZ7v7AF9fJoXKySa8)(0O7kW(IrRbdm zJ;_agmMzge7>-1TO08Zs%2BXO8lWfo&qF;( z$?o>d%9VeW;`fX%fUTt0geP0I&vYH6C(2<*r3h$himZSVZF&-X)vRO*ENx7#xs?Et*KM~W5+K@des_QJ^hf66#v#omSn)^e$~MC8B|Jj z&@LE7>?)37kv~?MsOAWI7Nv9<-N7h|D4=DGcyHyohB1gH3FFUB+=Wij9h$N@V6PcK zML*+V3IV>}>ij5qj*>g&%V9{B09?>s#hM13Qx#{V3j4M~@Yfoz#C4Dyp@6&Q%pBos z7p}1EyN?tF(8zFLK0EuTqgYZKux?s=$o>W6O&z;NYUr8)=zb_mfD3L_+q%*l9b5&fxXO&0(is(Lu*mLc9!eTUDfV zynvFXSyr9{ z1=DGK1@^CNNo~NIfbnz} zwh|H~O=8rZNat_)-Z=v8;gw6@ErVn+ix|H4ElPoZzA?p>_oYbZaSbJdJLE;l4;o}t zMRmVPXXITu`H9qWKz@`M9`yYA9;jHcF;efLg7_gW(kcwzaQsd@W&hmHRYR~j+C0MB zSxv~hl3~*cdB&BskusqNBOFz{o2rQceh9=XiCP#@ys_rBP*sH-ZRa6Yi zJDxQz5@l}*%TRY!CC=f`2`yXIY~7R2FHf3eeC3)3w(J?O+m0&(Z!fvBP^>2Wz=~-F z2BmcgGh!%a=HgZ%X|x<{m){-GsqaM(nUK513@n=}@MkaEg>7?jnUJqMPv+CyeD7uankFeCgb0JwivhFGk7(JP7W~f zX?5+Y1z1;|x%%`pF$Y9I)}r(&JHUfHsZ?xq5bup<Bx1Y(T41%dQH~a5l6#n)(v+f-K#x} zUav#Pi!e9^6-QMpXe<)UB-$nD66#$7JkBDIFYfcCYGyEZ35WalI|T>QRKdRBlIWTd%5L})naY4~ z-kjp52h(U&Rx=xvz(xSYyJ&!a9{%_!uBCJ-;L|~ONZ{SOdZd02I(-&=v_ffMe;~M# zM+Q*fttV{9u1?6!lByUpp^7!gJvibL2-IQV9EwvweH19|eT~>C-w~@%(>upSEZXJJ&Z!*Eb_RL1c)8i6L4zQbz$^e#U}$A?%&0&TyDl)n`2H(1T1L~ zzWkpn2i|HY2O3Q}u5)Sf+NzdS1}x^}-R)dlAn~37z2AI7z{S5#F`Ew7nY;|OxT`u zAjA>)L~P45u%c91RZNm}yOPEGf*rvvjF}lJyFiRiL3q*A_TXhtZeyn7>d2_i)0KZ% zW{qQ)r>ee)Nvdm%AXIuU^Wy!WgI}C&)Jv8-d3i9w21ySpSNF*VKH>W-9GuLB<)FH(8Vty1(u?6ZrZdh5`V0`1o4EJtNkQnW@S#3Jj6xIeWftDpT z9%=N*VHBG(qD!XZ=)5lYAiRL0099}c-Tk}YnDkQ1>O5P*FbU(WFWrHJT=&dD zMe+CJK2Z2nRs5oQ-euF^GM-LY-*3nk(UovK;$6PbtHN`OK)fQ^Z<2A|1xvAcTiSQm zXl9_1VS)@^U&WqEfW^$O^*J!;9+M$3+8y9qH^}0AgC$^AptJhXj;GvXy`K7aTTxqV z`;#mcdAQZ}hWTYBo1sg&nlub>*EWM6-O@yALW5xzaEjeK?J~+r3Sx`6Y#MM>e-reS zGU8DJ9kC%KoN~;hbSi|b5P`tTgn6|K!tXq58ZSJwgH~Q|NI$9<3M8wI^p=F3%S66L zsH=?`O+q27nzNULN=q?w@49XX*f~e|;Z>{WU6quWE5)&f2CQq=9`qhEw3O8le)*9m z2DoNJg7w=m|8gfBdLlg_6p*}h@Q4y=@a&oU9Z^xYt%yF4O7$vLv5du3atEm|6^qV-#D&0Tjm!hmk^8!xcW+EB5n(7Bv{3 z|KSp}J0`?1OJdhcprvycnfDB9((nw6i<1LQ2SR`5Ya$9JeD}r#SsmB9R@F7i7cnh0Vkpi&vD-CAj^{8@I$~n31SDQoT6RY3E*LoGuo=Ah1$(e% z20)ePsDm?#8Ix!G)Z^Is#nuS&#mD-Gs>=FaLCg(PWX4Xfl;I5cUD*B3?4E9QUpO_u zts5J-`1<7oS8_;geK>holZFDUE6-SdXd|WL^TIwvDXw6zjX!Lh2EM=EpgF{f0fAcD z1^e_8My2t`v592Tdlz&J>g`CHgI53><9p;a3CB~B~0|35nRnul4!{7;@BqX4Aq7ft&YKe zf8h?i@JSua<&`+C`V)|euj|=FrLqrc#JZjIK(6ZII~Pcw1dmuXnf*ZFSeu7R8KIG_ z__|NR{)-qt_~mlUwL<4pmUa$g86D4;;m~2s849q{wD~^fFd`Uebhrsaz@>Ml*nhsP zb|<5f<(rfpHC2IRl}OHnA89P-Q$V#AB9bj4xl+ z#6vrc0}joC5CB0TN~mZm)xq6d_^W5~H(r@y+*Y5-I@>d?g^tTkbl;UzS82c^C z@s5+Gv16`EB&)r|S(C4GCCY zd{63XtIHS%##iXR!uyViL*5tZ>(qfRA^qelDmizc$_*%@(vxCW9wznDc{g1w^QJcem+2C zMEGC`*gb1-{*_Axra0s|K%kfK`B*uh9rH7+8Ty?@N=N378fs%@u2e#d+cp_owP~JI zhDc(Vh)Cy{9rAcSo~+d!m$SXSY8Bo0Iuv`DLldj$`tF}(568ZthsEJ^iG2)0h&BSi zq73+tTQgj-zUg8MIwuOz3Jf3$+jsfz!k=BsomI>!fLH`zrGR|Y+Vr&vp+*Ww8dmio z&J8n42N9tF;m=;V87n4>u%bg$BJ-3PhkyqGs2al;MLPL3FnGo0MyTFpHWN9W)I%*% z`I~xI^)~Ac8X!O7UcR#Zym*4~-Cyj7?K}G~6@Bj{001BWNklU4npY&1))~iaRtL}x18wsQGhH0U7-L@K(W8j+7GRv z4k`fIvoh9WZQ4s_)&mhktALw8;a&_?1mA#x^lg;LO)fqm@@oT_N@ObRfGn4#V;)(&9ZN)+OxO|MGQL-Xwh$qFz zX+V>D{JxE#S94ZrL8jRJIeZt&uxci`nupTq77t|2kPW~cCE+;f=P z;`?6-rnpYt`;Hhvzbjw&HgLneBhoB`weH$_69Wo0Seim&2>*835|{z_U{{l+7td$I zK)|~Cy$8L$0IdqkMc5{zoeX1u8y+ydH%6@@!X||IxEgF9hU%S0>_BwSB2vjY z{_AnU;nZm$_2e%gfvzk=DnvYzW6xXG0KRff1J~W(ux`Ar^%TwxtG>ds8TRa}1SE33 zaxy3aqw?!Ec^0MtPbG^+AgDkt0#mr1b^^;1ruY}H-ij5I1u6|13e$J=mi=YxS8P9s zA}c^di|ma8RMKfsXdmOAcOVKFE(!< zWXWKLMSNnGLIyAqJp4dCQv-bG<{{R+>UW9QY zM&9hNj%C#7@ceZ+#E1NYS68p5Pz(!^1#xTSn?4=NVDTfEu4k(Oe6gv$nQ3N#yS5X) zf88V!Q-rei2&tN}cKM>$z_h4*p8BrLsG`w96W)W|Dh{fq>0^CFPSM$Tq|t&}UJzdS zoNYMis5WK?ZN#d@k80Wi_pvAa`@A}Qp(~LNj9id9xZrgpbZ77#X7E13IR5y7i~WUt zd=P_%m?>5y0etP3#%(W4bR ztsA%7h6Lez0iiJR0)#V8-in6lvk|K*AK&=pPqD{JT^dI(>T97UHf_h&Z>f9LDxmHw z0~jKHFdoS23H5tV=pvr_azbqoy#f;{2eNJ!mCC5s;gy742dTEl z3d*b36X_=jGLjy*^7FUjcb?M5+yK|mV!DR_gn^Fm zl)VWRBs@FCJt*o&YDnnV?eny4$B#|zWf9hCA4eD%)77n)9gCa5CD$!QJ}A3^0U~+| zUxlpK?>zx)roHW$g!maTjgB8xYJe;6Gd+H(FX4t=#ff&3)~#T)t0>&}|JTtRs5!&e z7Pazf?aHIN$o`3W!y0H`Wja#7we0t6F#hpplXzfzs#b}$9Y$b|&~3#7k>4=YVYCj0 zm$T%3j7Wn~#JZ#bu$w5Yz)%CDGolOZ-1Crh2?j7Vnd9_VZNvH<0|KpLzaWVOvui#@ zDMJ73D4rJY$J`VM4==8)81eHQWl5Z2gaR1pQDGe{3f!yvuCj!2{XHq}*|ezdvNJ^v z5aAf?&W`Ezd(S6G30{$=CJLgt>h2#v!N4{587vwx_ex*DHuX5LFXBl{<@!UVPs-WMOJmv+r(0^aWF8@v~Yuv}Ggn*qb;8Q{@=O{TyP?@Dpu z&67x-YZwECv&0AC1JklY_2uNz?jXFj%j;5m6KYV>Yrc26e41JL-&IP6cW3QQ=4|k$ z6Sm+f2jnsu6^a`06)PGQ6m}a(^UxC5lj0pRb0glF0u>)D;vSKt;)U(AIRtdL)(PKeI6GSvWneD$J%Nu-Wty{^}9U4XT2?drn;KBOqr#X0oymJTu=oHn?7 zCm}J2m#3`@TwQdEJ;NBMU(S%JG<8@@Z?W}8^_Wo?jq3GB+BFH=RG?V3oA($II-l6^ zSPq(GeD>l+*tRF`VdIKYVM+Vl=3RYEdRfsSv|&H&X)mKtmq0ayL!Jq zk{ICrod)M!K4n>!Vm4Npu$Iu@`dlpe9i-ena?dmd?=bzy-dF%4br2A**x>cEojXVl z%p+zN^lQ5bjGPUA>zUi}rWed$dMvjnVN2fBR z?8fbd<;Wv07`XgygJv2|ieKvsv6Q_-@~AP4raKg-1XOF%=bq%uRUjH|x{oIlB<_h~ zS5=j9kB>trS<+y9>$)Z`zikR>D!qgPcY;e(P-|)V6+y$RW>$Xcjp!i-bUu|rxp5k7 zj6TO*SCv!5(xM%BK^Dwk2Equ^}-HSbpz^o$~>wqu;^Y{ zt;kxl06as{mAv1v0GcV|>sF^ymP)xQr@mJz5-4{!?iTDA`nwnFW_J zfRnaA+D1$R7qOcjWC7CVafOVw{g<}zp4V;1rrqUQAoB?Q$I&}UVS#`h^%nmW7=_%2 z0`+AQJA}h_PV-2dc|TSrhv?Ui$((b+2b2i%zDbeV^r)*2kWEsh0reS zZ;CZV@g=W|A{(Vm>-36FPI%RCY{6@eZee#@6x+I1E**BAcb(d7O$%WSCI^~zpP?S; z(CFYmmQVL20T(?_LEJ)84Xc81 zs8OZWxDoQk>dJt7jQEHYC`BK|7GFgMED1#fY}=FIw)-arFP&_VbuLX#ioa6AoW3G4 zWVqZyDEO-F99XxJ&`1~pVUNapHLIkLFs}%Xtj7s%aTxyWDSf#Kl)k9Vm z+X_}pFg|<9B!2y1!}f?O9u#_9n>^^Z@glu65C>bL5x{V6x4Y>;NJbEKd8l(eYjRIl zb_4Rqxnupx4*Ka3Fga1+j8iw^?j35zs+tiWE?ut-edA_DnfUIzPEI)dz}-0a52mqmHn1Q#aE%wHu2)&4+Xc9!X;hF2 zPMNGjzqiE#u+U&>pYCJnfkcnHB#8lTc_72KU6Y_baV)nv#bJE$(q;0U32!>jA>SQ9 zKV?X8W~M{9cbf@|^^FzOeg77H)awz&@tm3$m)>AdX;xCF%gH__R*x#``G&1gYUY6V zl1PI=6$26bgca^rvx;U4{P?aEKe}!)l0*f|RZ0WIeN~`9?tY>7s15TY4ue0BEUKq$SuTGKR_h0%jj$V_aoD?t~tW5H#7$3W5EEq+4UUt(Mo~*MMab?>;5F*PQcDt z!oAZ#I_7er(tBM=S-*}!yvI8RT^8SMnfVQPNd4+R$1H5HQrn`f`y{QN{OOhLj%=!W zYV4dJvWRikj~8R}_7sV%qpqlGFxTCKxNOFwnY%}ho^u2&s^f}+=T5&dVG?${d8-?l zboo}^t<(%HN@A7rwbxoS*}<8oZoy0&YI>-ybrox%pPLAO->jbH$cUxu85n-AxiQl$ zQvb*8kX79Zopjfx9Rup#WCpCet67fE$5H@!&M%UW0k8_ZxX=wuDgns5gu7e7J=0-> z%kHwNS6aG{5Qob5#ISy{-8F_v$5)qdh0#TKyb_~@w>6YuCB)E`&bx)$f31o>_3lAWe$KH)w)y7%Hxl5cdnN}i)XoJ!1izb(Ka_x#KFvvWeUkA`U_88wu(Pc@ z%5pz6+Cz|&WqOY6$LDGy^j1~1wU3%I-czaQmPc#rp)tJl}7u?psr8g}>mKL6^1aJwRO#mqk5~Y~{a+|%}j9y^lQyEyfj=>G}eSt=d7l>sZ+zKi9JQi@POg$jN6Bwu{~sLTjDM{tJYfayJi4G&NCA1Sm1b@}YYcrxG_R_kbDKG@&9JS5*(7={?PZXz^G^lfiox?jRq zDw1ylb)SejFY@!p@Ps{uP-MuvKoQ*-N|h#m%A2TXt)k?0!yDyJn;1xmv9o1x_W1|c ziOG%)0Z?(F*wL4B-523C0y}H1KaAv)b}7Se5f~7M<4Mh{BZLflmFOBa+VDLz#5FwW zd7JTv&!550Ic;=BDc4<6&ysru-G_g=x&!g|#e)}k8ASw!{9;FJ%g=hYVf42<$$1{V z1Z?!Kq1(1{`2rYZa(ll^p)jZ=?w4k+Dzs1e%ZvjJExGP zrBgL{tDLP6vr?ms{=b+lX&13)tSdSv#K+`&<2SAd_YYxei1n1W8Jq27c;_jbaoGMj zIzI>(BE0vfdJtl7&(bF2oF6U0j$OuW!m5vRr>Selci{52 zBm$zSW#rz;wqEjCF{uc&N*k4a%pHLKpLs#v_pTBwm)vJY7?c@Nbca$kW4?;`!7fr)W&4G62!0(n3N6B0IiUJAQ{aYsvcb07TMK|i zz!7nRT)3G1-|mpJyCalLFNaF%$kD`G;D+2n<8{A`j5qeq%e%&mBrz?8W63eT)-c$S zZ{;F}nE=CzZP@kiwG5!Fo;AbHC%xjH6kq%4GBmS1Fjvi<6zJ)-tfUXpa|45m=tiA1 zz&PAFv;J5$Dawg-J2gAAixqix8RiH<}lN9x1Hh3HRS4%w@aU2 zt3H#szw(i)m{d`mg{Am}cmRIgHpDLQFp?#}Z5z_TWx#+TJ^~2%^Ata)M=r!MZQnUd z*wYC;c9%V4Lie6(#Eq*%F?y+!YmIGGMbQx5Aj&@vt4H-@7PMT`uYiE$kLMo1Q$v(5 zp!?7jix^+LYy#KaHHFj&&^UKN*KYwFF^+Vl4$Lb29tuzg*)7UB@b<1?U{%7x{2E?R zGY?2c9@)EpAuU6*Gh1G1%2@k{8!Xvbg!-ern;2_RWNU`}Q$rs#SVU z`VK5+vhUP-+>owN2jdsyqY)?RyW?e}xc$Wl1WiXOVK12`tU`;Gs#MQX6HdZWs6q1H|1 z76eo5pc#cjNq*~tJ1{C-Zq9TwBO>vA4^( zG8dDP1cKskB@<3Meid)}$%9xKwyU*ZIQ#^R4fF_8TJbgh6jM*43ap^Fj?9y0G$mWv zJzkWRUZbYge>ssd0=|7~AHy6|1Mg*M9z$4JhDG*cLE4U4i@xz7p`XMf(%1QtZF;82 zs`6_y@|`j6%@k)j<`aK6zg~052yT0l26DpsiHTfM-nDp&O-k{&PrjjvFk_emzD$y#Q<-3AzUp&90Xk|VPnafOP zVrUFe$z)j_aTW=<;kKoz3M*QeMF7aY@Y<8^1C~wuSZi1kMr8lpqPzF^4 zx0ALo-tghA_|Em4te?OmCxT7lm{i`~5ENo53FMBbcZx^^NYIr%w=QxPL>ZNub|+O% z)w-;rtuTeZxB&dOpScAYxRq-dx|~PhA}5mm(wM36bI*Qkh|_nnJPo1lSu}plzPhOo zTzkvX%(Dss4AwunaGxwgaTdS}0N{F(lBkOx90+@0$w+5le$%tAw6y}ehjH6#&BpLm zOi*;3_P}nfp?aNTZWMRx#Z^*Hl@2KShzSYG(ojGO#WpNw&|}y&U&ft#{+ zMuWtCXn`yzmCMtVbsdSs?KLfsxE~ys?R;wi|M;bCSm=jYoB)QZC@4)y(Cc9DDPEVL z2UR5LeWD7;MAOfc6j4$ZY`dJXT_WN*2r4?mvmUh%XYWZ5Vy#nqlAgn3N6BmMcoT;1 zZm3fPB0FIYUA%<}B)f+wf45{#%ojWFJrQ8zXX|Ictp{f)tniEr)XPrHnE8ZW=0}Mu zEOICrxOtUA!^Qxj2${&%fyi~L(8ne1->QYw6>ZKxQBi6dygh2l)Rd|6j7Y&;II7cL z08o*Goe7KVz2&MaYTT>b?{wLQ{F5SS2S4(|cbf-{pZ?PwxZ_YCy`Cs48sp|^Y|nI2 zEY7768|tgo(3nSP!gKaBsWPM3w`2!hEzB#pFm=CqQ;s+N%&j=MMm1uSsp_zOwz{Yk zx4sb)ey|xwO0MisjK4Kj2t~9Y*xDE5XIh~yVg0hR3^=sfpL#t5==VTe;Dn5_a;pX* zCuFJ;0SDK{tHK++2C_{h(b#_KUe}p%>eA>Q{*5Wh9s~Ivw{VN6pyVA}|U|HXuNrwQS6)y*-g|>UkBz{2AsSFc*}=&hn4nY^-8Hy8ACwvGs)~% zdY!tO@KpSuvRz;@&rQ; zS=A?MXc#jB4y=X)Zl=x@0w{r{42v&Dj{pE507*naRG=+mmq`4I2t|V zR>x5lTo?+UOoFiTqCfSTh&8rk8Do3GD_LF+5Hk*~5eDgv5U{WJ%*E0ry>S9+Q4!F* zvpVO6U1tPI5A~PTlC=5-MFrp4>PN-G;tp2Dnki$d*hL}8A)eiYBUQ$%aBH$KgC6kh zTY7l=mB(RmkWa9_hOWj-5g2A@%RdVNWBXmrog|@72B|}Oqr8vH?tdG8`SO+N38ie; zEBqm(IapkIZ9x+3=BQB2gX^lyryxWh$BJDoO^h9HmW@npMvOaFGK})x)T=TW6`3ws!k$@gisN^Cfme{knedh zhb+;w4dqmj#^ybE#V}JwPNYi}UHf(lebYZ@>o6Ig|zzcuu791X8L+{_tY;d#P?)zrm;W(NNV9v&li(@-mxxEi-@&J2I5mp72zV!`e!#!?;nKyJqr$?M|9D z=nYYXnzqf%^g%L<#xAc8l*VKZjm)Az{rT=4MwWnSMaJQ9h@D2SA{WMKT<{B-zZfj~$18R#@9|1z9=e8h8fb?K3c;3m$cfqd-xNY+6SE z)`opN_eXBT#b*w&KAJ2Ufh}S}hL*d|$w$}E_`ZwYb&8$y(Ep(29r|xxo;wXn2U24)Y zHOp{+^_6|Ubo#(~L-Mi8X^bdrdcPzJy!|dm*K`Bg5 zc(N>jcIK&dpMefK?Z=iWAfaiqK6CaaRAS|{1EmZ85hnU;jV}~ zF+SZMNCNC)bmy@#U|K->)(6-9x5-tmq0+uK4TS>3LiW{~LO~BMW?(E%#1^h27Dd|S z#|my!N75-N@{KcW;tEkxA(P%WEsq>>iRzCuiJ}`R7?mdFYD?7-(DILTfL2F#Q*s@P zx;!T#>6q+-tTmr=dBFJiw+49MKOKjKevu9=uGq+`g6L*Xx>M|{qHqLYTI*!^`N!Ub z6Sfz~3+rE`=|M+NE{02+z?_Qmww&!t@vy|rt?qK##h?reY&a5`v6z8)(SKu#Bvdwo zP_Q35J{FR~_*N8`;MnvmO}E;ejE7}qTRdbw&aCp~h^$&bn9vEL!#xHjTez4cNN64u z+nSy7@bS)?v~v^V`S00|>uz2`zh{YulJ_L(a3N=@vZSYFz0QLqWgvVo!z4YbED4*J z7;kybt#%NrLy2?SC(=`}xL!aBkag!>l{dP50D0T*)IK~z;BnzLN`G_Wu znO#P&B*5uYbdU}y03eT&(*bJRjkGV$z@aU*Fs5zYH~(tRa{Ko4^&6?}qq~KkZQX>i zGUFg@yMH(l;gn5`w|;0hMxzXt5NzxT(5;QejN(xl`5TkvV7d2fvJ-{Vb{=x}ZFtIq zVs4h@GU%hsReJ5d&d8;Zs2f$fAu)lNG?XIXQLdc_ZMbz0VIc!Mgats{G*Koc$DKxz zpu@S4stPx?A&r}+9nH_O$|h=oI@UO+A7p)7b7v54<~QByr0b(kcCG7CQadu}B(~Uy z9jTGtya2r8(@XfrFNNH!P!8+gAaf`zK{o9kVQ%ENsn&ebNTEv+N*>_FkG~D~I6g-i zC#t-EHE;24=G-s6no}tiqp2Wn}I1n*5^?~o) zu;^r=iUM$G#+PyD0IX27i$&?u9oT27mz`UNIAvFff;%_gR5Am?F6i!!o(SE!6!&(5 z1_NpN$AXxF3`FbW7M=Bqj1YE^^seH3#i+cComI5?9L7FTjvq+>Fj08XI58DM+zDuENNN7Feg0dv=N^D=-W^f$uZ*gPGUsPBfL}L6=1G9 z38HfSHojfxkMNiOv>R7_a{+@scW%C!@~F;W*cwGnvElRIVN}J5|A)$#1dq8L$K|azOmWR4Ce8X9ILr1cO}eRd zjTk53N+ws!PR~HxiPFVXS~g&Uw%zA?g4qLHbAa&br`&-ZTZS65h`lT2&6Ur|s5$oJ zms%xhEC`=_8RP03w&3@#+=i_SRU@1*nu9D*W2E0W)HlDmiRvP_&_A^&i6Atev6O8r z_j*Rax|7GGQ&>*u5vPJzT*gl1W;*=x1^ZhmsqM%`>UBv{>Rj?xL_g^OY|d2%tpuX0 z?rN((C|i%Tjh~FU7~w&6H`pd_Apr;03BPpN5nO!EevArRy__N`xt?5oP7>zMAYYN` z9CJoF-uS)~ur`!KWv9#zc6kg1-iAKSU{0eh{X3cp)eC9bIE$ug7#^5LvlMMK=j&Ms zO4pk!$|LPbyOyjrAANo>2B_$EB{;Uk)y~hbP5A)y zvmAf@nG^62UtPrVf_n&(-C4&+Pdh>6#g)M_n5QAoIcO4(q+-*v-_U&yMJ)7cIF^KA zim(zP4g2)6F@iIR7U-?^?lr6 zh^nJDEwz!1olYB4R;&tM4%n!xJQ%>?b;7Sc@c?%37@;i3D|{T2IwR)#+ns^4#Hgj} zHT=sm#&x%D!dw4l2X_0N<)+3@=J*6UBdsdy=usRmlF~xz3FwcVSTFW?_9hD9LDw?^ z536oqxPa76(<8vAR(6XB4nfe!+eW^UP!;?>41Kmj!`&-9N6s zj{#AfwX6mx!smXL_xkD>&YdnI1|)LX*Nqe<7QwsmD+ zB^Sp^_3SamS#DUUAptPx=lJmF_u&1XTgKMKYVV0xR)J#Oikm=O`NT1qbE>>^BZuDD z`h_m!9&;0#5d&BnFo??8gOISEA)^C&mljB{r4fWp3mkWBfa^JP(k2{vN{jjp;pScO zW9ZGhhwY2=$ZZ;rnYHCQ-S{SX-nMPTsLIy{hb9&_e#j83NIT^Q@ZHg&b;4_Y;t+Oi zPhCOc@!Ha~3AWcUopy{N7RPlqR{yLs2 zJ$I+Dig*~1U3JxouOI!GO3c8H&7~$1PZT8>+yfag?ntUoIyKp1$E}M%$+8zv@-*E9 zb!^o7iv}43dmU;;MfF3UlDA#RHJ=qTO@xUQ28Cazec<;wgz^#W< zS5Vr@cdgd5v@y|AkT!%H_&V7@aR_y93ua*ZCPptSF>NX@>c~4XfcINi z$9fDo;kGU^Mhg9^9jGNqz%Vg9JkEQL3NmHG69z*&esTg&RRJ~|SJnaT@Knzv!CCd# zxpp1Dvq62zV2v&j>qAecl7Y<&CI0I(ZU-o{NgFRnL@VS6gR_cZR?x|=v7k7cdKC4R zk3-t~P-d@}<6Ga`j#s~b8+I*M9W_DR^1-2a8Q>GTd2<0PALqK*eS-p-b>fAlUDJR{ zChXi&V$h#{LM;KHjP_?GVI>*3(k^k9keRT32^g~L_N^>+g_x^-HyR0PP`@{NhjNlz z<>5$Y^=wsr+vkY?VkWAek!u>uQp@G|rnzZ^y1jj@=)_t!xt}I|>Es=M4*>gC2tWU* z72NZbRo!yMyH?iSNZZqb9gNHdA)gP+`xaXo6C%o>w;oVZ0VRg0t^^y-|-LQR7Ql=@69-vkbr{Al?=d| z?e<9HeVT!7OR=$za1- z4aKco>OUh-c&DEy$lyN?3*fRdb3Emt2QbPFnS|TyvqoJi{cTKf0~IA^*UZy9OlEhz z!Ej?@(97|mPw&RZ{(S+)0)z&IKQv#rRpkj^c2 z*TrM;=z^fIqUl~+dT0Pe*Go-(16eB1m@N7g1YAi~|K6<`Uiss9VDnM|My76Ecoc;& zmL?(W#Ynl&3aCWsv6F*gXxS^97Ni*zNh~PF731*hWhJh=c{5)9S380cpT$q^2dvFv zE}PB$bnk-+qMr-LDMU8T*_{*W^-=mhN>f9il7a1;84ELxB`iyT3qF())4q~XCJUfd zVTRH&0*+s1lw$oQaZv7Y*LEh?N&RB7H;{hMtI#5R&eeahcPM)NdcmO$U1WrqPp6E+2uYZ zx-bqBHrwMKG`*)Lc|M|;^gA{LNC<=GH{W{#vL4l0_XZs!%+P(SSQ2k1US5MJeTi)_ zIq|kVC6DKzp=|^!MpW81J>;ZGS1n6m>!y6_O$4AQQBsC|8D+&yU>=3C{NPI;_#QhL zCHq>MpWo~?v?wXlp8-|r>h#VF<2W9sK#r9?Yolg1b013xd~G>K12kAc7rItYV<%Nf zn~-t!bysuD>1)^wHu8uK;(3e;;M@}n{LJNdVBH^z$z$EgP!>#)>9tWhi5sa)>8h07 zs}2z+a2d@JQ}UZBCgWxCFu4~x1}uIWKKHW%AOGSmy!VSs*fhfx9IwAPRw65k#(ko> zoH1Y>!}OCZkr8<-6BlhMDMghLCBOS<&BT4zJd+t!mY2Zp?d#K>w4fwMjPjLg1$`@3w=HN7vCpEAt6_Rx8IeK)SnSx1~$YLX7dC3GeEXLlJ6CY5cb)Qc@hi zr2Sn7PZRkR5hC=1I+q#88?d2c!&zTj9MZOK+=FHWO)B5Ki1VLtz16!Vt7@b#~DeH+~BQ zJ5?<{bUN@DXY2q*B8c`AOHJrR-s%%#j>(kMMp}(HBemwT<3MNPXkjFg&Q7QCFSW__ z0fxPAHR0hz&_vC|8a5;y5U!I(I%@cSMJXY_G0EKTVsvXS3KVTgMXkxsls zlucsYMIkY}_*LaMMOjh?qjbDfnMx%y((B1k*`Qb8zrFu>e7CavW;U4@5}K6kKR=+` zv<0O%+L36|dFIith3pV9cRGq{bxU#Kv~Q(NHj|Fzz}@z&q2$Sx8(EDO_YS|60YF(| zJ;CpQl^h%Yq^)H@Sd)YxZTWUI*5ODihaiT|&|b5BvS{Mlu8=t)XXH9}7EuZj$x!SZ zZK_ulgh#*Dm7P7}83;I^0gT)c!h@FmbHak_)+ zgjYV{Aa-sYsVgYgr-5;ohZWr|QD)WPcj}|bccOIM^@5BN1!UhR?xMR-@Q+?w#@cfC zWJJhE8D9Mt$6?Te+d`;*eUX+FCqJZcudADq@6z5YgBdo{8S``x)2ztQbYy#OirSL) zszD_K_uf6iLLZox(vXE_xdw;|E6eEH0gRD<()dlVl-rho>6I#%p)ZpUAjaU0q0q9oksl+nzK zvJA+;-v*+>V(>M7w^hGIGDjq`bGgLkK~)PLrAjeiXH}ves&vMtO&vJ-ZW0=J(v)y* zF>RpfHX`iG;q=;CVw#l-{laC>H+*EfJhgXi0bl?r7W*qoVD~1*%N`eY5Y;6`QYQ*+ zqM=ZfC5kFa>4N8iPIXC6f{+l~-i#(!eoxvYA}7;_7IgQ@DGk<#YX`j&U;WNby!py4 z*tMzDXWppZCPf`2e&|<GZ~wCaw~}pU2q7U~=pq6yjI9n}(Bat}}&c(b}IPq)YPZHoiHb^&B|&w9)L_3E&d@ z0EuB`#ZBuYF7zIcvJfhJkTFi)&M4SfYrhrD458|fp9vJ?lV0HMNV76h@nUyTZ>0qa z8l$)#4n8Tc%B^k(a<(bP2O94fS}n@Yx{}%Y`W#KUBewr~p5gL)j_|mP4#nj+PWfOw zKpuaXSg2JQB}&J>8%Jshl9IPcj4a|8hsaHlxn)X;;tQXb1|$5{CwJo$-(J8{KPsEr zbrppnK~=sArGKQ#ua&`c6`-!!+}%KDaM_I&Laa2`F9!#Ms10sKEJI$_}->LPLgASZ+!QHU^`qcu)^_L|}VNc|5^x_CYD#OV+t65OhrRvE8)@_r2Z ztY}HfPP&dFarzyr2#==s!Qp$6D{fwfN2jj(zcuI4i z(I~@j|Med1TAG0J8;~tGQqvgz+3B{GwrElo+h5TNEZD}i_B_2ps=U=8Yy2(_Ci4i$ z$v2spfo+=@ryRFpt7xjl5|9CR&}H~85ET{xFmOwfUe9oq8m!TtEsU*$IA|;?-1O0B zlG+{HuEAMo|JsfuR!7wa=tzoOCZhAEosq30%C}_QzBXxFe9qjPPi5M%Lo2En5vFfiV4S|KjQhJ&99Q$G9QS}! zMQ+BwyNiAl8Zu$;^I$p(tFexd0X}uX>~vuwAGEGG*A>KwWB(fP$|oGemQ6*>xpITV z6=O=PQhZD-^?Au0k%+ixdTD7W%21lzC*`Hjsc98fymLz(AIaN_10^Hi+I?H`+aK70 zyKm))=3T+AsJi6)Fju}!?0nO1Sy2-9pbh!7AZc-FcdJ7G5Do#(pkV>r_p~Fx^qrAG z;new0$!AqqW!C%WOzR*(5a$ts8Q8uAoVpFVrcl?Cb*?D)fNX6+>Y&RPU5YZNP0E>P z1B+z|GlkNK%?prxnh0>k!*T9#U1F%4Fm18z#=;~h;lMiZ@<$%Qeeb@8QJy8}MU;fM z@TMzM$rJZZFEm6X|7^XQxtns011@PGy|m>beil(qzqaGp+%+H&7kKjrcH!n#LND`j z%fM{eSz^Mok2N`!+>U3w*Y1Ss5J>N8H;$r!0doNq;W`)YyEWUi${QNB8PuE<#iwYV>oboQoF zrpcbnu2VMGH|j2!Oig4lgk6-tjzz}PA9gSx>_pj^d|a|3BQ<`^iE?9oO%n}8AxU~f z)xGrJCE4!rK5_XBiJ1!;&LpS?pwk`uh#DY*-0Uhmux;ML?h`#{tb| zK@w`L44p6x@kovaMcl9Wgslx@PYBP&F)wUVJ*Bc0C2;o1BkbIA1UzNYb1wr%L;gPP zJ+NQCORi+_*VQc)v|tVjN(Rn3p~R{h&`AToZKavnmV*hjam6K!mvjY2tpeCp8guy~ zHysRA>66XtMP$?|^--S5K9Xp(j;Lkn1Y5eNrQAIb{hi6%6JEDpEsI-djF+BKEJxofsT)VDj5&!^yod zje#guv!iCY%B)#4(fG$U4PAr1#_|N6x;i2zNWP%#MqxBzd_)=*gmdnmW6$>WsWXIG z&`w`ZzDqt3e@ETIWIwD>I5T6@0JwNhIHxeq7gWUSQfZX%OMDu^bFJCSK=F#W10|Il zJD=O$V_epzwmFtDF*&!Uhz&~~zc;vociURxXC8hSd0}xY;mk}iXzCG{C^UA81p0$s zBe77&s~XP;vwJ+I)S$?UTs?_`jK8^uNp@t2in5=T`0~};@b1sZL8z|A*zKn2>!c2f z85^^#gW3*nkZ0bQ-DEOZ&34WaVO84!aQ!=$>$EwEu1Fqede2+WfwS&*M3mBG5f~_$ zzvI74E+@+Vp(qiKyJQ)FY2p1ISqDh#(vHxBvhk07*naRLGU>k(^gza7vG?9Q_T-hXAY`>Elfw+KGjpxV{xx z-MUWe-?KZR2i|gJXhKP(te~ov=vP@K`frX1%h6b`bKP9iRwMhs%r~gP4@d{4%TWTF*8%$;)_{)Wz)ze#!sX|# z*#7Ks?HIe~l3Zd_3Qqp`EXSrlqL6+)%%@HvXx8qpVl7(i-Q91 zxN;Bv_1Zob`!OO{0kg>ftl4}#;k{OePWPt!n>BCG^ph&mqhQZ;9|R!T@i*jR*(w3+ z@zHptxHOfRft{NQ-22q&oAvwxDpF->Nhk& zmq93=#D#fgKx z6su~3U-!s&D8$fHsw_N0^U5hnLjb`NQ4PI$*V1+w5=1sW1BeOba~YQ3{QEUq@RpBn z#;#?+(mt%3bQuy1X*H_+YB+#{&|=>vA+cr@N1m_SqoA=;#p>D%lz8IWU-jX{V2wZB zFehAo?i%{N;Z%`(89*6-t@{0#!Xo0&_Od9c%kC4UR5EbpaV3shvO_7X%sFTQ8C#m) z;mVjB;+-dSuAsEmO2u=LdY>RC8M1A_Fj9TN4i14AJ?sch-8Di{RI<^>X79`mt0I~t zUKL$5m4*$i`B_GD>7X&EG!+5nHhQEyAuz8Aj`4aT!uooKH+}Fp>{wEjWJcE;3}=v? z`KU*U24wv%nMCT;4P{NNhd^`e*W}++;>XelqtRh|urV3^i_lsQJmTDSEDW6d70NOI zOrIV5e(Xvv|2=GlYz=5@s={kVz{Afdv0nIZCrDPCDg&O(Ld#5H*QT(JH(%&%t_10H zwNDzYQW2g$xaMuJ{2M&GBo3| z)h8(};Zcg)AmYVFuEd){`^n%-CAH7S`CaX0hxkuINr$C01H8als*O zD@*C9a<+rK^AoEj1G~1AIR74pP?nRoam~UK)f;k@9~t|t`o)D;?E4;wt_vGw*(mXV zj~O^|TZz-RatwZYY6piB{)p4qqNdy)PVE~*U;rh7#HJjkl6wG6*lb!t)v7Va#oFx< z79;j!rrP7|z>x5B4_U|Qdq!a=hU(P5Q$S;`AIso(L3y@L?m>X^VsqED{seJ3zH+@G zDaoz!s5vIEmZJ2?njHW!y0<4dYb%>oGiu(fc5c4 zR*e!|s13G7VwWE9NRwm1t0dP_PV;pJo--;-K%c4o<}yKC`UJ<}|B zrC-mayacu_lz7G^hhltE2D~8~bMa_QStYJuHTVrvZeX%mK0uWj7$K*tbjYoOxB?%O z%a<*POL>V>1)<+#{Nq=4;G^GM!16+@ReeO8@sj()ec{t2H8hzj=t7JQGc;$BJi4kT zTGpr3O%ua%l+{)om!axqzd9NVU-=o=Dym#0FZ?PKR!6|w@F|22K4u% z!gct7_YCnM9Myf{sU?mSazR&e2Q8>oca0!cvL$zU4xTD%`)5$45-UtXy`5@>g;yIs zPHEP3A(?@Kf#3b{!^nC-S({-Oa!R*_@f$ogrYL6!ZBT`GhoxH% zF_TYLo)dUT$UlBESrFSct_Co#+Qw|P=86(H^Y|QRoOpQp{#O8s9OzT;A13`iQDL2b z!b#s?#y2#*fec+qZhKw=54>ABW`pVyRo+{*!woXAOcl%7l9HtZzE_S()QqRJp~T(* zL!@oV>cM@{%6kxnKYM^291)&;-y!aIw;}Re&tOSiJs=<;r)2y%gOmySvX4Y8`wPh z#>C$zeMvv}#Ssy%&d79!jTh1i-??1kp{JH^_lEk!mG_!_sym$;M7P|g=}@y)nX))@ z3e;+&3nlJhQxg(vv$WccX~IJVvuu(j8Sf3`V~!IJ6P%C8EH8n3Y%lTjOIA@7aD!h2 zajqUYB_=3}J@R7sZ1ZY-DoKfOndnKrm2R}hiLw}zOXXlE2Pb^{8RH9AZ^2)Dei2(1 zrq!N9^vsY%x=>6Uh>Y5O&+9j@XhoS)oJ6S!ey5i}rci_TX(i4VY=Bw!l5vB!2Wtr85i{kzKu`8@k`H z&{PvE&VZtAa=%;KZdodE>3N5d7t^I90=Q4_p7#4xg>~WUZ~Rc6^IFt+<|3%#3JYT| zIjz9{A-Yw3VrtmFLPS??hNX2eNd>2=4W?|`s6!GjCDaVLSX>KXz2ZzWtlK?#R3$27 zl~!}$x#x{=(dp}&a|J~|Pz7&F0oE7P&Gasu54KyvWf|8U1|($xbs($k{^?!ApwP5c zG$vME2rU<6$#)Aq#vgz5INWe33p+8)s3j`{NmaKskjZ!@Br}dQ`N~E_)z_%F!~YZg z-nkG7f6?K|F2!z(@Je4>%Lz}oXbsypud2bC#Ctg6wZ%siADUMF>}Jg9%AToDq^-$1 zTX^w_9Csn*cH=6kQ@{G$S=#?VkjC7SK|@YnUu(@c${&K$)h)VSoPMqh zK;ctz*rWeB4_QeDdH@%$*LmclzX$nDv3Zo^u1!#sxP56_MCxJ+FWc`baR*CP!TKhHq+Xp>>fGcN7GE-rpfn}^wt=2@%cv+Dxgb2x@lZ|9=0}!5;P@Nt0V2H^0ISt8_8%2)lSf9AY1HQ-u)ox z(*po*StC6E!D~2STiBo7Ts*VVKH#m(q+(3U6pUV=r6j2eo|l6j{{6V(aZ~jkwM7(V zF)RPi$~&^&!*73hC+@ywqrERp1g$NCL$@8ewKrmFd2D~O?)&Yrx?+F>JM*LjJw$^P z^KB^k-5IwDPU%fY2$x^54|y?JMLLq_97NNerl0FNns zC*!P?|^ zQ&FWu%dQnwCUu#*r9$zRl7Sup&%WdcGLlsVu~<CJntnx~4HXuZ z*#`mwsk#?Vcm^JGpB%SOoQSMLNiUD@tn$^$i+{x(dJsFb;k-^SMI&}mfm(l*g`sT~ zqDPrs58!b7JSlo$92gRw^T2i7ZD)?6B(2{BJ-L(uQ!1n_6t00&9&X^MF^JcfcE2eg zza0!!l#_E$t2{Zn$Ps-daWf|Lsl;3UZUBK2=59^?xbl4zj;^cFFXvE#Xv zi7qXxw~&E{P)#Z4xscc9hzggSx>B$x2HIdvQ37Y|De-`_Zb306_X1rzRbe2I&W?}D<_ISZCUD@i(y|D77Q3AuN6E5mE7_J+x$ zDYx#D+wgSL-_DI_oPlKqUHLM?L8$lHRpROQUrp8usynMnDk=z3Ho`>?c5o<6X9)c@ z4k3ZPJLr==MM%~c-L%i;Vukys%nUlE*UuR5`}9tH>iYvMyPX(n)fGbryUaJ(I!p%z z>+q^vs)L#heRsz9-k6BxRo9HriFcJM%GbxJm`xv~Gq&Nz!|Q}6KIi~;Yz3ydgS`yM zNBkcHDnB~$0!JGvtc!l-##=MspL%h=(kb?cctVHWzRY;|>2{*d#xx0DikXf5C)RuF ztHZu*K>qdsfTR;PRVkFdmA(-Nt!$HQYZsfd^}Psb;?BAD!HeUYLK{Zu5%lbGM+xmU6GZ$t{PYu7t_0j88L3SZ2@oo*cR+u zY@psbwjf zwC(1l5|4f0Ar$%avU)wBWd7f@7q@OC*M3L*|10vWP_S3Z87T0~Sn&@KD>d(;H4%#dEMkKtwtUEUQE1m>V3`|G@-1Im){ zXIE{Z6{?xk_QYH#xSnaqr#eUyRjRQh`xj=xoxAD2*Ua?NE4nJeLS-gVt~XwTUWu!| zu?7F*%Zu2&&@Rtg`k9zw4Lww~Oik9QUW;f_#*OH1O8a1Ac@tgE$~Rfu-I9`fD>CXt zle+1qT*fGbp(gvvp(=;K zw35PN=35U8@H-#fhFweMnKZ6@8HDm&PV2TclCFj$Z+2mhWFEFXk?YfuuP~h(%Hk@o zxL!0)BIuKl0gR#m&OT{~z31IAHTMb#>sXm5ZzVH{YAdY!zhU2-xWoZ7_AWgjm{Al= zxc{jI&e##QlBh$zBEr~8cW5wgu`48(0^^u_;Pq(Ub}p&JRYWDv zE!R2pm4WeRSM9*9tCTFiX*F1r_KfxJp0VlVwCH-GsMOugkzHdUfM_2%e=)=+}3_3?G%R$KuGT>?F z<+x*5o2wdJX;)c~r>+pWA=^qDcr+2ECzf{pe}HxO5+h>tQ}{Nts`LTdle^< zoAyTvyTL5?7(adCT0Bg``yqyM5{2VjGQfbE;_3tnk{<4m#+PI~O?C$jIhhhP|5A8y z+?T6@>>~euR^qGQU&il#W(k`I9W7a+q@9s)eXmi~lY7HJvWBIzy7FF5|ZDR+QZke zYq<=lm!YvHixv6J6XTOe&Xq$`W2lH(+sLOor0B{aCE)=$1r!jv&N2eFk38%?4fsU2 zk^mf7>ElgTZpS^hmrg@8t0+SXmV2e%eU(>}_GFVsW*WO$-Sl4Rb?Ra=C4FaZ#IA-s z;x@;(_?UI`Kl z|A>K%81MW1HhlAzUb4TE%y`x%c7N?Vwp|HYlAlQz*K$6+%{l~$`!1Tw^_BUNLxXWl z8v9T%xs-;IffKeDc>E>%XQndrdjJvtYkzMsac9wK)JS0g08#PgK@X52udmMh77Sc= z&m4V67cla0?w%=A8zSrwp?3!}VtniRWxV$b zOV~DP`Aw@j^JZqc(yQ0H@Ncg=x3(n<59=0}04?%zo=GnmRDf0=UvGp$(Ti^_R~cbs#hw5Y9m@NfCrw)c+|ay$@bOiQmHEjRRm)}cGqs|o|O!6 zmgGf=YpJK;IJ8tv^3?PMH+z`IvcxqEzv)4n4&`M8{MF~Tpq<YkYM`c;TZ~aOO$V&m06)0lu@1EUgsQ zMQ_;m*1RZh&D?@$+lxKCFBv#~Gvhy>zX8h95h}>UCpEb1+6Y=9js+@zs{Gc1p(z)) zPZ(E|8#a*M#UKI-;;0Op4?|{PsSiBwf$I|ko_^<0xnA5g`E1I|$E(6;zq3OMiG5C$ z#h*-v`*M9=6_PR}a9_$seKN~fh7^VyZXe(s|GbRtiw?CUfTn3n+Vc>gGu7_FT2QP@ zxIp+=Q*`6)`LGl)e~4ve2y9j)>jMp;+E<3ae}2@>VPEzsoU#lU6@2U9vhuBMBx$9v z;;rurneQG;O9v0)4h!J%_bsq{c}gtH1BWq~DUCj7igIdktz8L6al%0uwp#b+D-pPUPqD2@0PhhdxUVI?T6n#gHL2~NR?k`}a;qBI&ir8!kx;|MQx zBSQ@o1@MlmwqjI52Zj7B%Tn3}{UWj@Gnr;CV1hN^r|-NnO{i^;#cUR00p!Q{u7r8HQQG z8C0Ep^Qg!@(pV5(m6uCH5Cs=vSn+rLLvE?hcs^B#eXuIQu?c|4e<1@t@{O(d`>Pk? zP7DMwm$p#>xxyQeOBU5qY>XYST26Etz4}Y)_Je|!5x(0UHuGZ)%-4s@o7gT@aVG&+-+-^ zJrqi}?TH$j#<2+jb8X*HoGYc3-g1G)lyPHzW~B^b=}>Xs)uDasgcm+&9ZUTho>2w8 zR9!^d+se?E8W?w?jh`7bBudJ=iA2D@13moVrPi_!3 zsIR0t7!i4M+%Yp%R^x3wjcn3ET>C_cw-s=#6qpj>>JQy1VPn=O8L|Q{4i5=WykrIU zI%W3EV6P8=_+9j1+|gF94lW^(4f=mD%K66ejnb?JO9u9AVLbWVJe;nRTwyI4y^7cY zj~>KQcYmxCHK9u5L2^+ft)V_w9Sl6qwTWzpN5IdXJHoy96t$N{0n?P0&Dt5laz2<> zT+!#znMaSzM4?UCK~%#b?sr3EUa^Hv`X~Rq4R@>&G|o;g%{KO**0zU}H@EwNT$e6ISa4KgHPj@m%u4o8PB}`5W@{z!HMNND1Q+Mr}aS$Vft<5`YxpMZb)?lS)5s|(mX7_W@#1V!_QhYoigkx3P{s82-oBwiY! zai6G5W6ymwY<(OndIoX)%m8j%&G7Ok?Z=rX%|=)K0RZN=(}Ry(-$j&83JU=4f5lC2 zTp#gmnGZZW2r2J+1ibWNBdqIA4rbBFUl@rdyqgQLlq(&*QgBVOc*%>!<)gV|mscpmJJq#{HG>h^6{`{b3ETrWy0A~92lE+N$^wIuM) z7B9K=a3BBu)0=R{=JC!!Byq!bsI+>I^aa+x9s8bB$T}%7ul^kCNqS+}9d&X_P+Lm+ zF}cTyzZM1IwBts2#@_2^?#R%~fZ-b7ws?8@#;!7TR#*T~Qu#+3Y?@-BiV}FxX$3Ai zN%qSUixxVp+MkN{n!CY}e3gW8yH*ZQ5S0`=$S(?;oI-2BbMlADbR-8JcV>?BPMk6; z*re>JfD%-spxGfw=e|{!&x|Q4nDR>Ux12&um;2bieg?e%i<@!vZN2m$RHrcGaD}*~ zZO3a+((3eQ4rZTh*3i zWa;DxOa335V>8bXN-R`WcvMn2-%lTFFkY_FmD=^C5 zz=?_TtrFhnC2*hPOFZq|0tZLFg!x5XLK*}~j9qY3?aij^iu@kn|gUNe>kCnkAu6dGnh2T$= zQCq(1CvOS)aMSHc>>K=sd5JPNg>}IdH~vux`fPvf2`G7AGVtW{a_rv3Nt%OFL?{Wu zP*%h27k9W`1U6T`Y96|yr*N8@9ue9XV+{loJmF8Rpq{;ZeSGHo`YL>c!<07UX z*dg8q#PIxwwPC92FJ-phv$PeCzR6mhEUt29Ddr`1wnU>6`osx{455mtb@v zv2axB(%n#&Diua_b8RcW$i9($in0KJ7_wByJJ^hXa-RFZbu9Ge+Bc_=i$ejhAUn)$ z^omF8B3wIm2!$ZA330C;0oUI?z&k#_h#k{hK_Pa1E&3`_^UWt8CPbQ&umu`cwD9u~ zF}iL|?AR<4WjIN%9u1@gATJ5Kx8`{MV{e)PedD;ufTEZE@;u9*tHQefYi{^#Uf?~w zMCqDq2ZRR9OW-l*`!F;c7Ck zUgj}cT7%&r)TiGs#|0ks>CzoQ6c;JPo>edhGn4k*qhz+o5I;b; zeTDGb9%=Q$tF5)3Rv+EBwBc$UIR_;u==mf%<%^-}zlj1K*+q&e~bx$Ic#N zR30TWI)y|h=}P?sC5e(^l*|rQkH; zx>4j9b8aeot;H@zJ1-)jCh%OYhED z^BLOF5)q&?W{v^6bfUmc)}YO+VY#C3$RI@{sw}sy#7()$3dt9N{QGnnIPDs_mgzU?f_wy4F70&ASJ+s1fF*nte4>N7j{ zxh-=&M1_0@oHO!f;D&>QUwz8$xW_58cVigzfxN)i7B1tt#;$~pioybb68uYfSr$G3 zFI$><_&(?XFMY%i>!qKXcPPk~;=}*ym^LGq9j!Z6c7`f$hEN8w?4sI;we40xfF4ds z0CEOS-NJa*1BO`3kF6`X5$Oydkh*~4D)RWLJYf6WC&Fj0S;9xI>0_w@x>9ALIasVl ztxXM)f0ZQCwlm2R;$sVh1aB}JaZ!qGn^N_ijgRT7x?BG~v_^Qr<%jXOi}ztPnyxa4 zfKiTuvf`II6*z;An!q zMhz}X&;rKuu}G|g*8Z}wZrEQGSh0Ox83E5ce~9C^%s=O9F60oG#k9s#7A)>1ndyXm zhcf)x7nZPVqglbUxJZ7miaXl!0R#ypox?RkOHxE7O10t^whq82h7HjjNice(cKw?W zT)|C?C0_Bg>oMrbjPPU{41kRA&i{& zz+=zJ@yIhu93CMFh$ni*LFm?HXg4UH|J|sK0jeO_q5XlPM zYytwV9lN`(QBlVOB6Li(l-AQ7tArOl?htSLOFPlTLGwG-+tP5Xz z<6na4gZ-Xc?*t?@N))vD&P|LLKWu~pLqaJyNKlHRBUH^)EsB62NP?5ytPml_sv>F- z#pT_TCcM0~jDSj3OW?&19AfvTA5vGa4vo2sp)J<(48Q-`B`jtMsBTDxDyWUpl0A($ zI`MYMK>C$}D} z1#uiXzE9e3ErLxVxLD2T0yRxq#Ke*b!vZ+xxDt;(>xW=gaE`NfIzQx8X^%-d5^|KhOIC^`WA$7KxCu*+xR5Ea2 zjq#eN--KOThEq>(2EyXz(TJ}fESE27UQ%;9CJO6<-?;920zWf}^pM$@xT*r+kVN|2 zONMy(-5G~R)=iQIZbcqaxfwim$gLrN>o{EbzB?vo4l}4Pdhj~>*F*MQ#k1p9KrlQ-S-x$s7gseds3jdUg#`c?zV60fEK2@HFEj0& zV%$I_11D@{yzJp449iOLp2R9zMLP_sQSxnbq6zQyxA9b!>MQ(G*h>Mef9 z15-NPBC{>MzA-9G;I<>cpS<*XoV;so<{npskaI=MuO95h=en2PjE=d&0)X>hbK_fc z=JyXIvB<}J2nniK&k4_e&ub|Q_4fV^a!xr6Z& z=Z-c!2=%T;y$tx&H3M9AZH9%o{3cQ84J%B%K)qe#+4YPBRX)kSYbKKGlye9;$@Vo~ z;MOzK?k-J}=n!OIFMzi{|2CX|kApLV>_Et7FABWB?NRex=}u8t0C3@JZ+hCO;H&#R zvc>EZ`Uf+xGzdwhXYFFF7cGp6V)9^peZQH@`e)hmDe}{~rY5`@xOtuMtP4ljz4wW`Q(Qw7Sp?7PRdk1CZeKpx! zFe)mh^f0?NWOD*ye4ER9-GL0h^n?R=$|G*VaI}H4>H~SsR}c0UPirCWT&4I1?x`bX%d?_k^|2^H^(J+ z&rz0PkpJKEZ_s1>@xLzNmQ^5&iog~Ey8w9uEzkZh#gI0OJ6@jxFkw9+D{<4rv=gt4R#KIJ10iodl;IUm+K(4L?z#MDG7V?!YUEK(!> z2S>o~{pdP&EH^_}M8F`{LvouSR_}5(cvOEWPxua%->3bS5%Be!didSXE@6}3i9wE8 zPvm1mr%i}#YZ;l8sr9(zfPHMp^d5(to60nyplY2q3xsj zzrXnZM?a&PLH5{GCH44O3y&!OM?9MCJ)^VuVn2w3PdK5=y) zSA1dtn|clQPI8Z$WV%l|w6qCVR(>f`Akk(u`5el_;SzA@BayBGL>a(B2JBko@!m{! zW%d4gBnmmHtZKMXfa^@wE5ne@t|GZux9k-Df{CB;50)5#IZXYp`SUXy&4309aTAM#J*A7xwb6w-R%X^h2Sr0N~57 zJK^JlEW2zJ&#|jYCMbr8G3WuG{LTO``uhP+-&DVU21e7YP#2pg9HT`Rx2GYusbx5R zRVf*h!#0WjqUO^0{m5+f5vi+d+%~Sa$#HN&1+vgPFjl3}KvJD`qe=43xX-UJcXcF+ zkHxaZxaY(Ms3TW=&yLAFdIxD7kTkHr(O@_-B%FET2!HDBa7>)Q7 z3zwIdcNTaK^h2Yt0N~44obZJ~mOWrJEnCuc#9qeu_|<*<#>W@1d!dO@t<{^GQDUk6 z=SDqz0=o*HX(@NGz|Dt9Rq1&+MeH821z!k$b4sD9fQd>_cd~wcrz(ZDjucy2f?aK% z6~?Zv1XY=WK}6!M`iDpLHN{-d38(JL@$O&wK2F(PLs<=g;gG+$czJnIBLR;h-KjzM zG0OTso#*`BS!dBnyOd0L)V*`O{J}X^iw$Cp&L?7Ok#evRxu~q)ioO$RXGZO*`pJA! z_oHr9mO$yG0n4qdv0a_OsKe6n5?0|6xnADX1QH(Vx;AXsBrU=vky4VM)l#rXBxf8_ zx?K`e<-$5-BD$}nJ$UAp-@^jfwp`+mUwUmVWi<%M>bvNdKiy2oqf0+j3hToEa{YJE z%buDuRwl)!{GgNF9iDpO5YM|P$M;rdFlKw>x2a+@?lMzkA{jZA^6ZMn<**O4arLJL z!g0c@?kME+CVdLoZ-dm#*JL@yE4&MPIkn%3T|uuR1$8IDIfmv#4GEI*5*0MJ%^1ir{)ty(O9H_ix`TqS|crvwN*E%P6xXw z6Nv@M;$+E_Qo!8BpVn8Ws+$ACguJa_Ur>eVFbB3QG2Zhl*WrSD9NaM1D$9U^G0L*R zQx`5BeYt~(M}_L1{&r0153f4>AHQ(f=26LXnS@^aNnc4cCcZuA#1h;4gnzj%!yud9 z!E7z!yvSk*c5(?@EURN#p<^qKYkqUQvz;iZa+fzW`9pn{5OqwptueKqE%d1j@}PBD zXX6zemMPfU|~dJ{>UK&qIl{%^}CC6ljp zlc>T8AL>r;`)&{rl|56cOxw4mFUxDMdCegkKlndb8xeMFF7eKnUyu8pUPDIu!X89UCn7cN7N@a|U z8?Ypug;VYg;G{i|JKN70wV-?I*W&FDO0vvmqqnbZ@^wld)`~xEVqGf=43t9;$QwSP<@;iN8@sA7G z)DPR5z+FXhmK(|a&8xww5_>P}!`yUpYf|$sJlpdVMQz(R*B{Jj>?|rOcNSiw%#EbU zA{vc|t()PnB-aMIY62UI3)d@qr?;*83gTlRJTIg@8tK*v?cpaK0+S;{!o5x&;!j_8 zElxge(R6G!iU~tr;p^()S$=s*tZAx z-T%FSl7aptP&Nkj+YRQl0|uG=X1k5-vqwa+H!R^0A6vUM5zWNE>Czm3 z-RuWVMAEfUF^{To@~P+Q$|lRjY8yvXj_OWxoR|T+;^ z(82)7bG&P?xA>V3f*dot%P1@W_{!@~c-N*u_N>*RpQ%bmlgz#lfWupa8WI% z$>RZ<>ok@;+0?@#=z$LMz-> z3b+d@ECBfG8%}s<26}ceAqQw2Krs3};FiOL-@9rFpZ-A)TNYfn)Bd!OBsJx_yd&A* zs~HNZK8*5evqU7hFQUJ;UZHqhUapodrezK|4bRjH{IUBxmfX}93<;_oi}^L^Ck_QE zKS~#rcbNhqgdKVF5_rW=?8kF{$riQeyC>&r_nn;x$?mmK62BZ z<%R6xyp)U{LpJGH3Wu_8U1U7?i~_eF&hYu`d)P8yWaQ&09U}@-cszCq6lZQ4Go;F2 za_C6R>c)y^+|BZdl^Y916W-Y~ijL_zxtDHgJTuE$l5`(74ba0=i2oEC6`N zRV!D%;E`L(K_7cdwq&l+L(65*G>iJvsi@tFOgF?z?}(w1pr;$g|4V`85ldm3R6!F>UEC=@NSS z>KjgYBN(rwj521%n5QBZSKd-MPwk7>_VJdh7IEWY!g7C{Z!L1LKYXUjSKP?e(R-u+ zWz+rbW(9O$B)4{Ja&^FxWY#A0^Uj5H@^$dvkZupt-o5w7VTb~gT(fpuot1GXg0tHh zwFzJ5aBkp}E?&iJo^d^n+cvBfT+f0FSoTo?qhTv)f%<*%O=G1z8%y8RxhInw7VPUP;&L|#A zs}%a3thS}%)K@FUP!(0n+TOFxRrFok^hO$PQJIi48A_pu`f#l|1-tQeRYFOeSB$Ni z6doqcm*~(#B@03=PYBFF@JG2uoWQ{&!iP4D@h4Y4Ma}1!iT5Cju~I^V{%x=}xut3g zjyIeXgaxqiW2d}96TQbb%r9+^Wz9nSxARbVCf%v#Q}+$?uN!@q)u@(s*?%}l;A0Uj5?Oca^(}=^y1RMOzn*ZH% z6OJRCG=v4P^&_XgD3th~z>~9!B!HRvu^|F&ElxK|P=2tZ#wYHpvHysabB}ikH(=CW zS9+0K`AgXbj9O!*$Ekm-va&(bvopq>_{^)(doXKJb{|vxr7C9Ro|}e!l;s4X7H#jV zkw{)Q+RB-^+(cpjVc{(=Kf+)C-fqrZIYroozY5ZT&@j{;c;0cDb)i?BRD=ca;El_l zZ!lj7JoC~hQAY&0@kkD`hsZO^?s1P>zca+opEAt%yBynA;R|hKWq+&)t2FE&$=<}h zI%#v?PFti!Lr$5HOJs7_p2~Y|NFp( zG2VLF9_A0lUHPjZOKTkK&(tK>9B=uo`o&2{SQfvyapiaFfvjsqTKFMy-$5DnLGlcQ ziE_`+hq!Hh_jx;l)bGiM_#uZjd+d8>w#kkWA)z(Y&l=9|6ioi&IZ7c4j7*IC_g9f9ZLcIB`hJg`<$H|Dxt4Y|Xb^$_KII9515O3$50z;mWx*ESC_*uzON?|C=Yce*NRjAJVQ(z|6Ux2+3{7!|pcE6i#Zw z0(j^ntFARBzQgxqMHC-hVWmhPBu|v7(D22d)wp+4Kr@DV+q0AVt%{lJB9^D>n90o> zyX#KrDt#fZTc0-Jv|z^`ctPDiq}@vG-SmR$JT+5UcEv(oa)+>uWA$R;xVQJV-GN^i z`6x^_g>z4h_~2Xj@~R6DAef`>73d=H(@joA_>0=wqPS*KCu_&M7i8^yVaYNuWG) z+2`$cNUySqYRSp%E9t2ux5$D3XMx!BIJ!>V-oxZc{?Act=T-z`4dMFV8s{Bve45db zCSl~Y+W|9PB1oO<+g1EG)z`MRRjAy01^? zqTTe!{phr|+Vm&*iUsT)ii#6BI3-;CoRB-N+rSr$c;YTbj|-H!9?2_dk@$RYM=AmolHaO0;V~jvf6a+qv&bwTWAC`mlOv7*1Jv{rV84MX+f(xK=as zZ^fdRcU(EnHS2aWzn&a3g|UmkhbU%JR6iK3OYZ8eh9@TG9AN=${JYUtNTi?fJh>=N z$Qi59G_KF02qd(m6(zz0kNJG&M?>rw6XttPU}w(+Q6`0HW2G09#F8lFsNl$nu3UO%+x*w(on~J1)whp(1Z)~mc@gLOj z73S5zRPbL-M=_B5b)mRqTb$gxIaiQDIh|nTabR0gP?pDdRa4glw$MQ zu6~PbbLvPBB$%6CnYrC>OjuE^^j`H7=IPw+oLKv0RN@52j=;IAV&1o5KUchTA4cdp z02I=uWxFsxDUw_ptmS+C)apdXoF^=RNAEcOv}U7qm%w^47*%jLv~PDj6-NL74fRPx zK~x*7rN0GIf)Uuc&*MKHtn=+h17d}Ghpon@!LdNwW1C7AiB_?8tklp_p?xiZDLagM zAk{*x_LHB!T%;4Prdhc?l~*{dq%EW*MYW113S&nM=bsvJ?YeQUdc{FTM;e52cLAUl zd2E&HmxJNtdRfDhU7viCVnA39w%oGp8jw4EV@AWcAgyVHm3=P0Dq^=hqio(4@cEzA z`R0y*#ep((w6%%FtG%QaX7$!MBvZwQ<+#CebI{$sth%;xKFw0|9Pd?saai`n;Sy>^ zBngZ+1m9QQ_dAE!@S1&$E@@So{ew(ZrQr%squfwimwcgz>YS7q5LO3lx_QNCfwu>q z0Rs*E@_13)9@TB3`5V6Uw4-3Jc7^g|~7SBt#;mzY*ed%tNEJ}#NYD5J#2$UH3 zTzy^q$JHBha$`VPGhoZjE7v9Hoq@;2apD??DcmtqY$#ej%J))->-N1K|Fv<5@9ppy zZy4rf>6xb?A2hS8uJXP1dLPI6HtVVRT6Gzn||95VY$+@yxy{MsZ##K-KS`# z`A`L-7!EarmpnJ*w_kpk4X-`GvV{#4kLh{Yjt*ne5Sb`uqnP*x|5CnNt%SLO0bv~j zn{QchyDHZOJ`3X5rKC;@EB&Q3Xe4#aEAYfYk9!}g^Q}i}j5T0h(7ws5J-biy#wp3I zN1qa_EF`;is+D|S)=$*wz#hme?{-^{-U+ZsPvg>*y-2y}BcTu{%IXy{H>^L%n)4?Z zUD64u?>T zfiQvxepTZKkJk9^FMSS&!n`2erFwQ2^4d#wAgNwR!{MwnR*6dFP;k0ipFVs}`DBHw zaC_{yw)SY2y)=yAz?AUP)giCHq`~@E9^lN;BN$O)^@^EX+ax)*y7D~G1XT6@*fZ}L zzBIbOw@S}F3<#?SJbdfub&2ZV7~yn)B!6lh^N@DZv2W~h)&t*9owj=?4BvXV&iA+1 z*m1ysrt1}<|C^L6kl6?$nC8su<~;J~cB?jLAY=Ls| znIUgmcZiEuA7=TYBdB^L*nrP6If;RMLyQg;u<5sxgeO36tgTBv-Ags+G6saz2R7fl z;wB`yZn*9(YekwlTAlcHjmc2XG3Ul*=%|_scr9f-8^^upAFovnnaNvk=&WeaXyL^&YUO2%8XN-e* z#IeaEJ91TNt-xGiIrrD8fHB|)&}!&@ML#|CI{iSOO`o$E5Y{YU^Q}uiBzW(v`K*fK zl(;(PXiFtQuEyGD7A1S=J(HRZjSznJSdE|Vtntl90(MOr7S>W?^USeto}=|B)Ifi? z+|CfDi;uGr+~vlu#nKsf9NYuS7M*U8S6UANZ-_iW2sT@VQ_pzDd?_pvPtn z&;bL&dIquWwx!p{$R7>|a!Hh=#N}>?(LP9zrnX#1zDPRL=o#=m<*|J}+jiA>c$d$Q zcKSTD+anN|@24Ba`bIE@-WK#a%1WI|7Fid1e28p7t5sKdil1r%%PyBxg;<3!NmbN% z3l9QefMv7E>2y(J5T;7%7CPlQn(^QFk*NmVNg(wli z7$!qu>AZwuh995q$y@9BhPj8Y4P zW~dD#`T&NH*Vn~gt=Js{RK$R=euLQZ@fB|n;@=nKO(KjKBbm9_X=bs_5&{E3W^&Ht6LYVrC1-TEx~Pyu zsfp?YXaemH{6te#8YV(3PRVLPO<6RoES;~Mxjf{&(?ec(X2|H`kdb*I%NMn>EC(i? z{ICvwnSqql`l_afqPWBbiWmqyYvSu6fv?7@Uz~qMd{XSHd?H{#SkDr+-MZ|r)UbZ2 z#+q2ur5;v26sBRDQ@-7Qlpj;rZSBVksrpK1t%|Iv;|;^EF`vET9*3rUetp33*nY!f z`#m0h+GF35RE^cWl)yYmXT1#RG`-2nb`@H#TAX%TMPtRJHQ!W)gfwxYst{|M?I2W` ziqmy^7p#amdo<##6)|Ux#;jTzvt&WYDN9`S12e)#X2*yc|Tbq zbK|Gnc4YnKdujU{5%&x7<>AX)w^q901I!8rg!L?A)9p*n2;jpYm(~I~TNR?r=FxTn z!ZBfhf~16!Grxn`SdIp_PW;piO9yFDs)#btH0&Pp*f;4h(J)Ll42PQ@Q%zx_A&eg} z9B!uSCyI-mio-bdBgKgjc&QI9@RV9Ma~60|_mzc1FfuP;;jl7qC}zP>!ouO0#q(oM z83|dnB*ym?*QsrrpcyfxDy_)5Z`+&p+4PBv{QJ^zS^r3({)|aa_5GCWTcPd{G2d@U z>%&X0;PL+M_yGN2Kv>5Kw%@g21yTL=3A{zo*C31ro=BQ0A{(jTn1RLL?b#yQ%^G(% zpib)x%c?9rMkLKBA(EOx1u1CmJBkx26cgmjS**%2lA2p-Sw(qvvLNu&{9*26sLp?u znO!yH5)e+EX7WjucKnutpk30JOePuu-&ev26{Am!=zRvcH%h{9Eqo(;tK824o*4`X z>o~>3cP@LEfpv(!G4L3Iln9cJX5$PBtBu?``^u)DGtCt3SZ14YagVd10QQR0>S0kP zl!9D4n`Xs1&Tm#3O1aKOeT(n&U6-@#r75mc2fBz5#@Y~(uf=iv-I1%JPcp!9hXG-o z0C?oik@d!yx2ejx#^b^uO`VHPL9gu!L(2o+Qd|+0?I?Kb4V<6uG@v@z7J6Zal#fcJR++9vhb?b zmydln26#3xAgmJzTfRiC{`B&Tk|erPkT(OTi^!>g2eImmN#k6#G3`5O8~o6TfLx=k z(|Wn19Y2lKIw-%6zZo~I!!>J*x>|+h=3=)I>LPZMAzdO9#tO<)2v2}~MJ4|F$dPcf zTuqAsP6!ML>jcKme^~k4Fl=363>ToRQMgzj@H~tWI|FBro;ggyHS2E^6}G^m`G7yyCX6{lxQLlis(i`?g!mktMiTFH#8qR zM%NB-oMJ#&CmD8ra_Qw!!j&TWe1wZ@KF{$?=Ko4kHIh2WowUy-!b-oGnQUC*D@@CF zC%)!sbdILTIulUGbF$+sK}i-`VIaRY79Inq6dwzSCRWX6aP?U=;uq`a%hmX^CZTAu;w5h z|EERIi(2La&{Hsa3YgW1o&&5Fqz)R0P*bS+9%4-Hx6b0AQfM2gIk5tif;4lID$GC0 zf>1Fw&zIWBb`{YU&_XmsXrk;iMju7Jofvit@$Q6R>*&?vzkDY9X@KJo1Hu|$8g~BU zvhzHN&r#7+NMxm$WFaC$s=7dg5rie8j9{pv)POo@U4*)d)C8@Qy11G~XsOZ^LsLYX zsI)}1iEv0%530x^gh_-3FyVX2o 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