diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py new file mode 100644 index 0000000..9039be0 --- /dev/null +++ b/core/watchlist_scanner.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 + +""" +Watchlist Scanner Service - Monitors watched artists for new releases +""" + +from typing import List, Dict, Any, Optional +from datetime import datetime, timezone +from dataclasses import dataclass +import re +from database.music_database import get_database, WatchlistArtist +from core.spotify_client import SpotifyClient +from core.wishlist_service import get_wishlist_service +from core.matching_engine import MusicMatchingEngine +from utils.logging_config import get_logger + +logger = get_logger("watchlist_scanner") + +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: + logger.debug(f"🧹 Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'") + + return cleaned_name + +@dataclass +class ScanResult: + """Result of scanning a single artist""" + artist_name: str + spotify_artist_id: str + albums_checked: int + new_tracks_found: int + tracks_added_to_wishlist: int + success: bool + error_message: Optional[str] = None + +class WatchlistScanner: + """Service for scanning watched artists for new releases""" + + def __init__(self, spotify_client: SpotifyClient, database_path: str = "database/music_library.db"): + self.spotify_client = spotify_client + self.database_path = database_path + self._database = None + self._wishlist_service = None + self._matching_engine = None + + @property + def database(self): + """Get database instance (lazy loading)""" + if self._database is None: + self._database = get_database(self.database_path) + return self._database + + @property + def wishlist_service(self): + """Get wishlist service instance (lazy loading)""" + if self._wishlist_service is None: + self._wishlist_service = get_wishlist_service() + return self._wishlist_service + + @property + def matching_engine(self): + """Get matching engine instance (lazy loading)""" + if self._matching_engine is None: + self._matching_engine = MusicMatchingEngine() + return self._matching_engine + + def scan_all_watchlist_artists(self) -> List[ScanResult]: + """ + Scan all artists in the watchlist for new releases. + Only checks releases after their last scan timestamp. + """ + logger.info("Starting watchlist scan for all artists") + + try: + # Get all watchlist artists + watchlist_artists = self.database.get_watchlist_artists() + if not watchlist_artists: + logger.info("No artists in watchlist to scan") + return [] + + logger.info(f"Found {len(watchlist_artists)} artists in watchlist") + + scan_results = [] + for i, artist in enumerate(watchlist_artists): + try: + result = self.scan_artist(artist) + scan_results.append(result) + + if result.success: + logger.info(f"✅ Scanned {artist.artist_name}: {result.new_tracks_found} new tracks found") + else: + logger.warning(f"❌ Failed to scan {artist.artist_name}: {result.error_message}") + + # Rate limiting: Add a small delay between artists to avoid hitting Spotify limits + # Spotify allows ~100 requests per minute, so 1-2 second delay is safe + if i < len(watchlist_artists) - 1: # Don't delay after the last artist + import time + time.sleep(1.5) # 1.5 second delay between artists + logger.debug(f"Rate limiting: waited 1.5s before scanning next artist") + + except Exception as e: + logger.error(f"Error scanning artist {artist.artist_name}: {e}") + scan_results.append(ScanResult( + artist_name=artist.artist_name, + spotify_artist_id=artist.spotify_artist_id, + albums_checked=0, + new_tracks_found=0, + tracks_added_to_wishlist=0, + success=False, + error_message=str(e) + )) + + # Log 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_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans) + + logger.info(f"Watchlist scan complete: {len(successful_scans)}/{len(scan_results)} artists scanned successfully") + logger.info(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist") + + return scan_results + + except Exception as e: + logger.error(f"Error during watchlist scan: {e}") + return [] + + def scan_artist(self, watchlist_artist: WatchlistArtist) -> ScanResult: + """ + Scan a single artist for new releases. + Only checks releases after the last scan timestamp. + """ + try: + logger.info(f"Scanning artist: {watchlist_artist.artist_name}") + + # Get artist discography from Spotify + albums = self.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" + ) + + logger.info(f"Found {len(albums)} albums/singles to check for {watchlist_artist.artist_name}") + + # Check each album/single for missing tracks + new_tracks_found = 0 + tracks_added_to_wishlist = 0 + + for album in albums: + try: + # Get full album data with tracks + 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'] + logger.debug(f"Checking album: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)") + + # Check each track + for track in tracks: + if self.is_track_missing_from_library(track): + new_tracks_found += 1 + + # Add to wishlist + if self.add_track_to_wishlist(track, album_data, watchlist_artist): + tracks_added_to_wishlist += 1 + + except Exception as e: + logger.warning(f"Error checking album {album.name}: {e}") + continue + + # Update last scan timestamp for this artist + self.update_artist_scan_timestamp(watchlist_artist.spotify_artist_id) + + 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) + ) + + def get_artist_discography(self, spotify_artist_id: str, last_scan_timestamp: Optional[datetime] = None) -> Optional[List]: + """ + Get artist's discography from Spotify, optionally filtered by release date. + + Args: + spotify_artist_id: Spotify artist ID + last_scan_timestamp: Only return releases after this date (for incremental scans) + """ + try: + # Get all artist albums (albums + singles) + albums = self.spotify_client.get_artist_albums(spotify_artist_id, album_type='album,single', limit=50) + + if not albums: + logger.warning(f"No albums found for artist {spotify_artist_id}") + return [] + + # Filter by release date if we have a last scan timestamp + if last_scan_timestamp: + filtered_albums = [] + for album in albums: + if self.is_album_after_timestamp(album, last_scan_timestamp): + filtered_albums.append(album) + + logger.info(f"Filtered {len(albums)} albums to {len(filtered_albums)} released after {last_scan_timestamp}") + return filtered_albums + + return albums + + except Exception as e: + logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}") + return None + + def is_album_after_timestamp(self, album, timestamp: datetime) -> bool: + """Check if album was released after the given timestamp""" + try: + if not album.release_date: + return True # Include albums with unknown release dates to be safe + + # Parse release date - Spotify provides different precisions + release_date_str = album.release_date + + # Handle different date formats + if len(release_date_str) == 4: # Year only (e.g., "2023") + album_date = datetime(int(release_date_str), 1, 1, tzinfo=timezone.utc) + elif len(release_date_str) == 7: # Year-month (e.g., "2023-10") + year, month = release_date_str.split('-') + album_date = datetime(int(year), int(month), 1, tzinfo=timezone.utc) + elif len(release_date_str) == 10: # Full date (e.g., "2023-10-15") + album_date = datetime.strptime(release_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + else: + logger.warning(f"Unknown release date format: {release_date_str}") + return True # Include if we can't parse + + # Ensure timestamp has timezone info + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + + return album_date > timestamp + + except Exception as e: + logger.warning(f"Error comparing album date {album.release_date} with timestamp {timestamp}: {e}") + return True # Include if we can't determine + + def is_track_missing_from_library(self, track) -> bool: + """ + Check if a track is missing from the local Plex library. + Uses the same matching logic as the download missing tracks modals. + """ + try: + # Handle both dict and object track formats + if isinstance(track, dict): + original_title = track.get('name', 'Unknown') + track_artists = track.get('artists', []) + artists_to_search = [artist.get('name', 'Unknown') for artist in track_artists] if track_artists else ["Unknown"] + else: + original_title = track.name + artists_to_search = [artist.name for artist in track.artists] if track.artists else ["Unknown"] + + # Generate title variations (same logic as sync page) + title_variations = [original_title] + + # Only add cleaned version if it removes clear noise + 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 + 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) + + unique_title_variations = list(dict.fromkeys(title_variations)) + + # Search for each artist with each title variation + + for artist_name in artists_to_search: + for query_title in unique_title_variations: + # Use same database check as modals + db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7) + + if db_track and confidence >= 0.7: + logger.debug(f"✔️ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})") + return False # Track exists in library + + # No match found with any variation or artist + logger.info(f"❌ Track missing from library: '{original_title}' by '{artists_to_search[0] if artists_to_search else 'Unknown'}' - adding to wishlist") + return True # Track is missing + + except Exception as e: + # Handle both dict and object track formats for error logging + track_name = track.get('name', 'Unknown') if isinstance(track, dict) else getattr(track, 'name', 'Unknown') + logger.warning(f"Error checking if track exists: {track_name}: {e}") + return True # Assume missing if we can't check + + def add_track_to_wishlist(self, track, album, watchlist_artist: WatchlistArtist) -> bool: + """Add a missing track to the wishlist""" + try: + # Handle both dict and object track/album formats + if isinstance(track, dict): + track_id = track.get('id', '') + track_name = track.get('name', 'Unknown') + track_artists = track.get('artists', []) + track_duration = track.get('duration_ms', 0) + track_explicit = track.get('explicit', False) + track_external_urls = track.get('external_urls', {}) + track_popularity = track.get('popularity', 0) + track_preview_url = track.get('preview_url', None) + track_number = track.get('track_number', 1) + track_uri = track.get('uri', '') + else: + track_id = track.id + track_name = track.name + track_artists = [{'name': artist.name, 'id': artist.id} for artist in track.artists] + track_duration = getattr(track, 'duration_ms', 0) + track_explicit = getattr(track, 'explicit', False) + track_external_urls = getattr(track, 'external_urls', {}) + track_popularity = getattr(track, 'popularity', 0) + track_preview_url = getattr(track, 'preview_url', None) + track_number = getattr(track, 'track_number', 1) + track_uri = getattr(track, 'uri', '') + + if isinstance(album, dict): + album_name = album.get('name', 'Unknown') + album_id = album.get('id', '') + album_release_date = album.get('release_date', '') + album_images = album.get('images', []) + else: + album_name = album.name + album_id = album.id + album_release_date = album.release_date + album_images = album.images if hasattr(album, 'images') else [] + + # Create Spotify track data structure + spotify_track_data = { + 'id': track_id, + 'name': track_name, + 'artists': track_artists, + 'album': { + 'name': album_name, + 'id': album_id, + 'release_date': album_release_date, + 'images': album_images + }, + 'duration_ms': track_duration, + 'explicit': track_explicit, + 'external_urls': track_external_urls, + 'popularity': track_popularity, + 'preview_url': track_preview_url, + 'track_number': track_number, + 'uri': track_uri, + 'is_local': False + } + + # Add to wishlist with watchlist context + success = self.database.add_to_wishlist( + spotify_track_data=spotify_track_data, + failure_reason="Missing from library (found by watchlist scan)", + source_type="watchlist", + source_info={ + 'watchlist_artist_name': watchlist_artist.artist_name, + 'watchlist_artist_id': watchlist_artist.spotify_artist_id, + 'album_name': album_name, + 'scan_timestamp': datetime.now().isoformat() + } + ) + + if success: + first_artist = track_artists[0].get('name', 'Unknown') if track_artists else 'Unknown' + logger.debug(f"Added track to wishlist: {track_name} by {first_artist}") + else: + logger.warning(f"Failed to add track to wishlist: {track_name}") + + return success + + except Exception as e: + logger.error(f"Error adding track to wishlist: {track_name}: {e}") + return False + + def update_artist_scan_timestamp(self, spotify_artist_id: str) -> bool: + """Update the last scan timestamp for an artist""" + try: + with self.database._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + UPDATE watchlist_artists + SET last_scan_timestamp = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE spotify_artist_id = ? + """, (spotify_artist_id,)) + + conn.commit() + + if cursor.rowcount > 0: + logger.debug(f"Updated scan timestamp for artist {spotify_artist_id}") + return True + else: + logger.warning(f"No artist found with Spotify ID {spotify_artist_id}") + return False + + except Exception as e: + logger.error(f"Error updating scan timestamp for artist {spotify_artist_id}: {e}") + return False + +# Singleton instance +_watchlist_scanner_instance = None + +def get_watchlist_scanner(spotify_client: SpotifyClient) -> WatchlistScanner: + """Get the global watchlist scanner instance""" + global _watchlist_scanner_instance + if _watchlist_scanner_instance is None: + _watchlist_scanner_instance = WatchlistScanner(spotify_client) + return _watchlist_scanner_instance \ No newline at end of file diff --git a/database/music_database.py b/database/music_database.py index f799b87..0f80eee 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -74,6 +74,17 @@ class DatabaseTrackWithMetadata: created_at: Optional[datetime] = None updated_at: Optional[datetime] = None +@dataclass +class WatchlistArtist: + """Artist being monitored for new releases""" + id: int + spotify_artist_id: str + artist_name: str + date_added: datetime + last_scan_timestamp: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + class MusicDatabase: """SQLite database manager for SoulSync music library data""" @@ -172,11 +183,25 @@ class MusicDatabase: ) """) + # Watchlist table for storing artists to monitor for new releases + cursor.execute(""" + CREATE TABLE IF NOT EXISTS watchlist_artists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spotify_artist_id TEXT UNIQUE NOT NULL, + artist_name TEXT NOT NULL, + date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_scan_timestamp TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + # Create indexes for performance cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_spotify_id ON wishlist_tracks (spotify_track_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_spotify_id ON watchlist_artists (spotify_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_date_added ON wishlist_tracks (date_added)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)") @@ -1674,6 +1699,115 @@ class MusicDatabase: logger.error(f"Error clearing wishlist: {e}") return False + # Watchlist operations + def add_artist_to_watchlist(self, spotify_artist_id: str, artist_name: str) -> bool: + """Add an artist to the watchlist for monitoring new releases""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + INSERT OR REPLACE INTO watchlist_artists + (spotify_artist_id, artist_name, date_added, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, (spotify_artist_id, artist_name)) + + conn.commit() + logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {spotify_artist_id})") + return True + + except Exception as e: + logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}") + return False + + def remove_artist_from_watchlist(self, spotify_artist_id: str) -> bool: + """Remove an artist from the watchlist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Get artist name for logging + cursor.execute("SELECT artist_name FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,)) + result = cursor.fetchone() + artist_name = result['artist_name'] if result else "Unknown" + + cursor.execute("DELETE FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,)) + + if cursor.rowcount > 0: + conn.commit() + logger.info(f"Removed artist '{artist_name}' from watchlist (Spotify ID: {spotify_artist_id})") + return True + else: + logger.warning(f"Artist with Spotify ID {spotify_artist_id} not found in watchlist") + return False + + except Exception as e: + logger.error(f"Error removing artist from watchlist (Spotify ID: {spotify_artist_id}): {e}") + return False + + def is_artist_in_watchlist(self, spotify_artist_id: str) -> bool: + """Check if an artist is currently in the watchlist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute("SELECT 1 FROM watchlist_artists WHERE spotify_artist_id = ? LIMIT 1", (spotify_artist_id,)) + result = cursor.fetchone() + + return result is not None + + except Exception as e: + logger.error(f"Error checking if artist is in watchlist (Spotify ID: {spotify_artist_id}): {e}") + return False + + def get_watchlist_artists(self) -> List[WatchlistArtist]: + """Get all artists in the watchlist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, spotify_artist_id, artist_name, date_added, + last_scan_timestamp, created_at, updated_at + FROM watchlist_artists + ORDER BY date_added DESC + """) + + rows = cursor.fetchall() + + watchlist_artists = [] + for row in rows: + watchlist_artists.append(WatchlistArtist( + id=row['id'], + spotify_artist_id=row['spotify_artist_id'], + artist_name=row['artist_name'], + date_added=datetime.fromisoformat(row['date_added']), + last_scan_timestamp=datetime.fromisoformat(row['last_scan_timestamp']) if row['last_scan_timestamp'] else None, + created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None, + updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None + )) + + return watchlist_artists + + except Exception as e: + logger.error(f"Error getting watchlist artists: {e}") + return [] + + def get_watchlist_count(self) -> int: + """Get the number of artists in the watchlist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists") + result = cursor.fetchone() + + return result['count'] if result else 0 + + except Exception as e: + logger.error(f"Error getting watchlist count: {e}") + return 0 + def get_database_info(self) -> Dict[str, Any]: """Get comprehensive database information""" try: diff --git a/ui/components/version_info_modal.py b/ui/components/version_info_modal.py new file mode 100644 index 0000000..9d0ed2b --- /dev/null +++ b/ui/components/version_info_modal.py @@ -0,0 +1,286 @@ +#!/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 v0.5") + 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 0.5 - Latest Features & Improvements") + 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) + + # New Watchlist Feature + watchlist_section = self.create_feature_section( + "🔍 New Watchlist Feature", + "Track your favorite artists and get notified when they release new music", + [ + "• Automatically monitors your favorite artists for new releases", + "• Smart scanning that checks only for releases since last scan", + "• Real-time progress tracking with detailed status indicators", + "• Seamless integration with your existing music library", + "• Configurable scan intervals (now every 10 minutes for faster updates)", + "• Search functionality for managing large artist lists (200+ artists)", + "• Visual status icons showing scan recency and completion status" + ], + "How to use: Go to the Artists page, click 'Add to Watchlist' on any artist card, then monitor progress in the new Watchlist Status modal accessible from the Dashboard." + ) + content_layout.addWidget(watchlist_section) + + # Enhanced Progress Tracking + progress_section = self.create_feature_section( + "📊 Enhanced Progress Tracking", + "Better visibility into your music scanning and download progress", + [ + "• Three-progress-bar system for Singles/EPs, Albums, and Overall progress", + "• Per-artist progress tracking that resets for each new artist", + "• Real-time updates during scanning with detailed completion metrics", + "• Smart release categorization (≤3 tracks = Single/EP, ≥4 tracks = Album)", + "• Improved mathematical accuracy for progress calculations" + ] + ) + content_layout.addWidget(progress_section) + + # Performance Improvements + performance_section = self.create_feature_section( + "⚡ Performance Improvements", + "Faster scanning and better resource management", + [ + "• Reduced scan intervals from 60 minutes to 10 minutes", + "• Removed artificial 25-track processing limits", + "• Optimized database queries for better responsiveness", + "• Improved memory management during large scans" + ] + ) + content_layout.addWidget(performance_section) + + # UI/UX Enhancements + ui_section = self.create_feature_section( + "🎨 UI/UX Enhancements", + "Cleaner interface and better user experience", + [ + "• Replaced confusing colored status circles with intuitive icons", + "• Added search functionality for large artist lists", + "• Smart display logic showing last 5 artists when no search active", + "• Removed unnecessary white borders for cleaner appearance", + "• Improved status indicators with meaningful visual feedback" + ] + ) + content_layout.addWidget(ui_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 new file mode 100644 index 0000000..3f795da --- /dev/null +++ b/ui/components/watchlist_status_modal.py @@ -0,0 +1,1116 @@ +#!/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 + + def stop(self): + """Stop the scanning process""" + self.should_stop = True + + def run(self): + """Run the watchlist scan with detailed progress updates""" + try: + self.scan_started.emit() + + # Get all watchlist artists + database = get_database() + watchlist_artists = database.get_watchlist_artists() + + scan_results = [] + + for artist in watchlist_artists: + if self.should_stop: + break + + 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) + + self.artist_scan_completed.emit( + artist.artist_name, + result.albums_checked, + result.new_tracks_found, + result.success + ) + + self.scan_completed.emit(scan_results) + + except Exception as e: + logger.error(f"Error in watchlist scan worker: {e}") + 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', [])) + + # 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 + + except Exception as e: + logger.warning(f"Error analyzing album {album.name} for totals: {e}") + continue + + # 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'] + + # 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)) + + 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.spotify_artist_id) + + 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 + + # 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 clean artist card widget""" + card = QFrame() + card.setFixedHeight(36) # Reduced from 60 to 36 + card.setStyleSheet(""" + QFrame { + background: rgba(40, 40, 40, 0.6); + border-radius: 8px; + border: 1px solid rgba(80, 80, 80, 0.3); + } + """) + + layout = QHBoxLayout(card) + layout.setContentsMargins(12, 6, 12, 6) # Reduced vertical padding + layout.setSpacing(15) + + # Status indicator with icon (set based on scan history) + status_icon, status_color = self.get_artist_status_icon(artist) + status_label = QLabel(status_icon) + status_label.setFont(QFont("Arial", 12)) + status_label.setStyleSheet(f"color: {status_color}; border: none; background: transparent;") + status_label.setFixedWidth(20) + status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Artist name + name_label = QLabel(artist.artist_name) + name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + name_label.setStyleSheet("color: #ffffff; border: none; background: transparent;") + + # Last scan info (compact format) + 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_info = f"{time_diff.days}d ago" + elif time_diff.seconds > 3600: + hours = time_diff.seconds // 3600 + scan_info = f"{hours}h ago" + else: + minutes = max(1, time_diff.seconds // 60) + scan_info = f"{minutes}m ago" + except Exception as e: + # Fallback to simple format if timezone calculation fails + scan_info = artist.last_scan_timestamp.strftime("%m-%d %H:%M") + else: + scan_info = "Never" + + scan_label = QLabel(scan_info) + scan_label.setFont(QFont("Arial", 10)) + scan_label.setStyleSheet("color: #b3b3b3; border: none; background: transparent;") + scan_label.setFixedWidth(60) + scan_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + # Delete button + delete_button = QPushButton("×") + delete_button.setFixedSize(24, 24) + delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + delete_button.setStyleSheet(""" + QPushButton { + background: rgba(244, 67, 54, 0.8); + color: white; + border: none; + border-radius: 12px; + font-weight: bold; + } + QPushButton:hover { + background: rgba(244, 67, 54, 1.0); + } + QPushButton:pressed { + background: rgba(200, 50, 40, 1.0); + } + """) + 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.addWidget(name_label) + layout.addStretch() + layout.addWidget(scan_label) + 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""" + 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...") + + 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) + + # Clean up shared worker if this was a manual scan + if self.scan_worker == WatchlistStatusModal._shared_scan_worker: + WatchlistStatusModal._shared_scan_worker = None + 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 an ongoing manual scan from this modal class + if (WatchlistStatusModal._shared_scan_worker + and WatchlistStatusModal._shared_scan_worker.isRunning()): + + logger.info("Found ongoing manual watchlist scan - reconnecting to it") + + # Reconnect to the shared manual scan worker + self.scan_worker = WatchlistStatusModal._shared_scan_worker + self.scan_in_progress = True + + try: + 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.album_scan_started.connect(self.on_album_scan_started) + self.scan_worker.track_check_started.connect(self.on_track_check_started) + 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 scan in progress + self.current_action_label.setText("Manual scan in progress...") + self.scan_button.setText("Scanning...") + self.scan_button.setEnabled(False) + + 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 hasattr(dashboard, 'auto_processing_watchlist') + and dashboard.auto_processing_watchlist): + + logger.info("Found active background watchlist scan - connecting modal to live updates") + + # Connect to the background worker's signals for live updates + try: + dashboard.background_watchlist_worker.signals.scan_started.connect(self.on_scan_started) + dashboard.background_watchlist_worker.signals.artist_scan_started.connect(self.on_artist_scan_started) + dashboard.background_watchlist_worker.signals.album_scan_started.connect(self.on_album_scan_started) + dashboard.background_watchlist_worker.signals.track_check_started.connect(self.on_track_check_started) + dashboard.background_watchlist_worker.signals.artist_scan_completed.connect(self.on_artist_scan_completed) + + # Update UI to show scan in progress + self.current_action_label.setText("Background scan in progress...") + self.scan_button.setText("Background Scanning...") + self.scan_button.setEnabled(False) + self.artists_progress_bar.setValue(0) # Will be updated by signals + + 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 + + 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.signals.scan_started.disconnect(self.on_scan_started) + parent_widget.background_watchlist_worker.signals.artist_scan_started.disconnect(self.on_artist_scan_started) + parent_widget.background_watchlist_worker.signals.album_scan_started.disconnect(self.on_album_scan_started) + parent_widget.background_watchlist_worker.signals.track_check_started.disconnect(self.on_track_check_started) + parent_widget.background_watchlist_worker.signals.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/artists.py b/ui/pages/artists.py index 2d76e35..3de64cb 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -3546,6 +3546,41 @@ class ArtistsPage(QWidget): 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...") @@ -3605,6 +3640,7 @@ class ArtistsPage(QWidget): 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) @@ -3863,6 +3899,15 @@ class ArtistsPage(QWidget): 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() @@ -5060,6 +5105,106 @@ class ArtistsPage(QWidget): 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() + 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() + 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 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...") diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index c047fb8..3efddb1 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -25,6 +25,7 @@ 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 @@ -62,6 +63,7 @@ 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 @@ -1098,8 +1100,8 @@ class DownloadMissingWishlistTracksModal(QDialog): final_message += "You can also manually correct failed downloads." else: final_message += "All tracks were downloaded successfully!" - logger.info("Wishlist processing complete. Scheduling next run in 60 minutes.") - self.parent_dashboard.wishlist_retry_timer.start(3600000) # 60 minutes + 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): @@ -1135,6 +1137,8 @@ class DownloadMissingWishlistTracksModal(QDialog): # 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( @@ -2601,6 +2605,13 @@ class ActivityItem(QWidget): 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) @@ -2627,6 +2638,7 @@ class DashboardPage(QWidget): 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 = [] @@ -2637,6 +2649,7 @@ class DashboardPage(QWidget): # 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 @@ -2648,10 +2661,21 @@ class DashboardPage(QWidget): # 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): @@ -2901,6 +2925,10 @@ class DashboardPage(QWidget): # 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) @@ -2928,7 +2956,37 @@ class DashboardPage(QWidget): } """) - right_layout.addWidget(self.wishlist_button) + # 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 @@ -3682,19 +3740,117 @@ class DashboardPage(QWidget): 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(3600000) # 60 minutes + 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(3600000) # 60 minutes + self.wishlist_retry_timer.start(600000) # 10 minutes return logger.info("Starting automatic wishlist processing...") @@ -3707,7 +3863,7 @@ class DashboardPage(QWidget): logger.error(f"Error starting automatic wishlist processing: {e}") self.auto_processing_wishlist = False # Reschedule on error - self.wishlist_retry_timer.start(3600000) # 60 minutes + self.wishlist_retry_timer.start(600000) # 10 minutes def on_auto_wishlist_processing_complete(self, successful, failed, total): """Handle completion of automatic wishlist processing""" @@ -3729,10 +3885,10 @@ class DashboardPage(QWidget): message = f"Found {successful} wishlist track{'s' if successful != 1 else ''} automatically!" self.toast_manager.success(message) - # Schedule next wishlist processing in 60 minutes + # 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 60 minutes") - self.wishlist_retry_timer.start(3600000) # 60 minutes (3600000 ms) + 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}") @@ -3746,10 +3902,314 @@ class DashboardPage(QWidget): # Schedule next wishlist processing in 60 minutes even after error if hasattr(self, 'wishlist_retry_timer') and self.wishlist_retry_timer: logger.info("Scheduling next automatic wishlist processing in 60 minutes (after error)") - self.wishlist_retry_timer.start(3600000) # 60 minutes (3600000 ms) + 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 + self.background_watchlist_worker = AutoWatchlistScanWorker(spotify_client) + self.background_watchlist_worker.signals.scan_complete.connect(self.on_auto_watchlist_scan_complete) + self.background_watchlist_worker.signals.scan_error.connect(self.on_auto_watchlist_scan_error) + + # 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.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) + + QThreadPool.globalInstance().start(self.background_watchlist_worker) + + 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(self, total_artists: int, total_new_tracks: int, total_added_to_wishlist: int): + """Handle completion of automatic watchlist scanning""" + try: + self.auto_processing_watchlist = False + + # 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}") + + def on_auto_watchlist_scan_error(self, error_message: str): + """Handle error in automatic watchlist scanning""" + try: + self.auto_processing_watchlist = False + + # Clear background worker reference + if hasattr(self, 'background_watchlist_worker'): + self.background_watchlist_worker = None + logger.error(f"Automatic watchlist scanning failed: {error_message}") + + # Schedule next watchlist scan in 60 minutes even after error + if hasattr(self, 'watchlist_scan_timer') and self.watchlist_scan_timer: + logger.info("Scheduling next automatic watchlist scan in 60 minutes (after error)") + self.watchlist_scan_timer.start(600000) # 10 minutes + + except Exception as e: + logger.error(f"Error handling automatic watchlist scan error: {e}") + + +class AutoWatchlistScanWorker(QRunnable): + """Background worker for automatic watchlist scanning with detailed progress updates""" + + class Signals(QObject): + # Summary signals for dashboard + scan_complete = pyqtSignal(int, int, int) # total_artists, total_new_tracks, total_added_to_wishlist + scan_error = pyqtSignal(str) # error_message + + # Detailed progress signals for modal (same as WatchlistScanWorker) + 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 + + def __init__(self, spotify_client): + super().__init__() + self.spotify_client = spotify_client + self.signals = self.Signals() + self.should_stop = False + + def stop(self): + """Stop the scanning process""" + self.should_stop = True + + def run(self): + """Run the watchlist scan with detailed progress updates""" + try: + logger.info("Starting background watchlist scan...") + self.signals.scan_started.emit() + + # Get all watchlist artists + from database.music_database import get_database + database = get_database() + watchlist_artists = database.get_watchlist_artists() + + scan_results = [] + + for artist in watchlist_artists: + if self.should_stop: + break + + self.signals.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) + + self.signals.artist_scan_completed.emit( + artist.artist_name, + result.albums_checked, + result.new_tracks_found, + result.success + ) + + # Calculate totals + successful_scans = [r for r in scan_results if r.success] + total_artists = len(successful_scans) + 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) + + self.signals.scan_complete.emit(total_artists, total_new_tracks, total_added_to_wishlist) + + except Exception as e: + logger.error(f"Error in background watchlist scan: {e}") + self.signals.scan_error.emit(str(e)) + + def _scan_artist_with_progress(self, watchlist_artist, database): + """Scan artist with detailed progress emissions (same as WatchlistScanWorker)""" + try: + # Get watchlist scanner + from core.watchlist_scanner import get_watchlist_scanner, ScanResult + 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', [])) + + # 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 + + except Exception as e: + logger.warning(f"Error analyzing album {album.name} for totals: {e}") + continue + + # Emit the discovered totals + self.signals.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'] + + # Emit album progress with track count + self.signals.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.signals.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.signals.release_completed.emit(watchlist_artist.artist_name, album.name, len(tracks)) + + 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.spotify_artist_id) + + 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 AutoWishlistProcessorWorker(QRunnable): @@ -3775,8 +4235,8 @@ class AutoWishlistProcessorWorker(QRunnable): from config.settings import config_manager quality_preference = config_manager.get_quality_preference() - # Get wishlist tracks (limit to prevent overwhelming the system) - wishlist_tracks = self.wishlist_service.get_wishlist_tracks_for_download(limit=25) + # 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) diff --git a/ui/sidebar.py b/ui/sidebar.py index 81be070..b36e0fa 100644 --- a/ui/sidebar.py +++ b/ui/sidebar.py @@ -2,6 +2,7 @@ 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""" @@ -1238,16 +1239,31 @@ class ModernSidebar(QWidget): layout.setContentsMargins(20, 12, 20, 12) layout.setSpacing(0) - # Version label - version_label = QLabel("v.0.5") - version_label.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) - version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - version_label.setStyleSheet(""" - color: rgba(255, 255, 255, 0.6); - letter-spacing: 0.1px; - font-weight: 500; + # Version button (clickable) + self.version_button = QPushButton("v.0.5") + 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); + } """) - layout.addWidget(version_label) + self.version_button.clicked.connect(self.show_version_info) + layout.addWidget(self.version_button) return version_widget @@ -1309,4 +1325,14 @@ class ModernSidebar(QWidget): } if service in status_map: - status_map[service].update_status(connected) \ No newline at end of file + status_map[service].update_status(connected) + + 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