Included Watchlist functionality.

Watchlist will allow the user to select artist to 'watch' and the app will automatically add new releases by those artists to the wishlist for automatic download.

included version information and modal
pull/8/head
Broque Thomas 6 months ago
parent d81e6ca72e
commit 3bd6a29bfd

@ -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

@ -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:

@ -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

File diff suppressed because it is too large Load Diff

@ -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...")

@ -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)

@ -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)
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}")
Loading…
Cancel
Save