1124 lines
No EOL
48 KiB
Python
1124 lines
No EOL
48 KiB
Python
#!/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
|
||
|
||
# Rate limiting: small delay between album fetches to avoid hitting Spotify limits
|
||
import time
|
||
time.sleep(0.1) # 100ms delay between albums
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Error analyzing album {album.name} for totals: {e}")
|
||
continue
|
||
|
||
# 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))
|
||
|
||
# Rate limiting: small delay between album processing to avoid hitting Spotify limits
|
||
import time
|
||
time.sleep(0.1) # 100ms delay between albums
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Error checking album {album.name}: {e}")
|
||
continue
|
||
|
||
# Update last scan timestamp
|
||
scanner.update_artist_scan_timestamp(watchlist_artist.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() |