fix settings page, add rate limiting, live tracking to watchlist modal
This commit is contained in:
parent
1bf7995864
commit
8063197a54
5 changed files with 443 additions and 364 deletions
|
|
@ -13,7 +13,7 @@ logger = get_logger("spotify_client")
|
|||
# Global rate limiting variables
|
||||
_last_api_call_time = 0
|
||||
_api_call_lock = threading.Lock()
|
||||
MIN_API_INTERVAL = 0.1 # 100ms between API calls
|
||||
MIN_API_INTERVAL = 0.2 # 200ms between API calls (more conservative to avoid bans)
|
||||
|
||||
# Request queuing for burst handling
|
||||
import queue
|
||||
|
|
@ -42,8 +42,12 @@ def rate_limited(func):
|
|||
except Exception as e:
|
||||
# Implement exponential backoff for API errors
|
||||
if "rate limit" in str(e).lower() or "429" in str(e):
|
||||
logger.warning(f"Rate limit hit, backing off: {e}")
|
||||
time.sleep(1.0) # Wait 1 second before retrying
|
||||
logger.warning(f"Rate limit hit, implementing backoff: {e}")
|
||||
# Use longer backoff to avoid getting banned
|
||||
time.sleep(3.0) # Wait 3 seconds before retrying
|
||||
elif "503" in str(e) or "502" in str(e):
|
||||
logger.warning(f"Spotify service error, backing off: {e}")
|
||||
time.sleep(2.0) # Wait 2 seconds for service errors
|
||||
raise e
|
||||
return wrapper
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import List, Dict, Any, Optional
|
|||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import time
|
||||
from database.music_database import get_database, WatchlistArtist
|
||||
from core.spotify_client import SpotifyClient
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
|
|
@ -16,6 +17,11 @@ from utils.logging_config import get_logger
|
|||
|
||||
logger = get_logger("watchlist_scanner")
|
||||
|
||||
# Rate limiting constants for watchlist operations
|
||||
DELAY_BETWEEN_ARTISTS = 2.0 # 2 seconds between different artists
|
||||
DELAY_BETWEEN_ALBUMS = 0.5 # 500ms between albums for same artist
|
||||
DELAY_BETWEEN_API_BATCHES = 1.0 # 1 second between API batch operations
|
||||
|
||||
def clean_track_name_for_search(track_name):
|
||||
"""
|
||||
Intelligently cleans a track name for searching by removing noise while preserving important version information.
|
||||
|
|
@ -135,12 +141,11 @@ class WatchlistScanner:
|
|||
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
|
||||
# Rate limiting: Add delay between artists to avoid hitting Spotify API limits
|
||||
# This is critical to prevent getting banned for 6+ hours
|
||||
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")
|
||||
logger.debug(f"Rate limiting: waiting {DELAY_BETWEEN_ARTISTS}s before scanning next artist")
|
||||
time.sleep(DELAY_BETWEEN_ARTISTS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning artist {artist.artist_name}: {e}")
|
||||
|
|
@ -192,13 +197,20 @@ class WatchlistScanner:
|
|||
|
||||
logger.info(f"Found {len(albums)} albums/singles to check for {watchlist_artist.artist_name}")
|
||||
|
||||
# Safety check: Limit number of albums to scan to prevent extremely long sessions
|
||||
MAX_ALBUMS_PER_ARTIST = 50 # Reasonable limit to prevent API abuse
|
||||
if len(albums) > MAX_ALBUMS_PER_ARTIST:
|
||||
logger.warning(f"Artist {watchlist_artist.artist_name} has {len(albums)} albums, limiting to {MAX_ALBUMS_PER_ARTIST} most recent")
|
||||
albums = albums[:MAX_ALBUMS_PER_ARTIST] # Most recent albums are first
|
||||
|
||||
# Check each album/single for missing tracks
|
||||
new_tracks_found = 0
|
||||
tracks_added_to_wishlist = 0
|
||||
|
||||
for album in albums:
|
||||
for album_index, album in enumerate(albums):
|
||||
try:
|
||||
# Get full album data with tracks
|
||||
logger.info(f"Checking album {album_index + 1}/{len(albums)}: {album.name}")
|
||||
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
|
||||
|
|
@ -214,6 +226,12 @@ class WatchlistScanner:
|
|||
# Add to wishlist
|
||||
if self.add_track_to_wishlist(track, album_data, watchlist_artist):
|
||||
tracks_added_to_wishlist += 1
|
||||
|
||||
# Rate limiting: Add delay between albums to prevent API abuse
|
||||
# This is especially important for artists with many albums
|
||||
if album_index < len(albums) - 1: # Don't delay after the last album
|
||||
logger.debug(f"Rate limiting: waiting {DELAY_BETWEEN_ALBUMS}s before next album")
|
||||
time.sleep(DELAY_BETWEEN_ALBUMS)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking album {album.name}: {e}")
|
||||
|
|
@ -252,13 +270,17 @@ class WatchlistScanner:
|
|||
last_scan_timestamp: Only return releases after this date (for incremental scans)
|
||||
"""
|
||||
try:
|
||||
# Get all artist albums (albums + singles)
|
||||
# Get all artist albums (albums + singles) - this is rate limited in spotify_client
|
||||
logger.debug(f"Fetching discography for artist {spotify_artist_id}")
|
||||
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 []
|
||||
|
||||
# Add small delay after fetching artist discography to be extra safe
|
||||
time.sleep(0.3) # 300ms breathing room
|
||||
|
||||
# Filter by release date if we have a last scan timestamp
|
||||
if last_scan_timestamp:
|
||||
filtered_albums = []
|
||||
|
|
|
|||
|
|
@ -35,32 +35,69 @@ class WatchlistScanWorker(QThread):
|
|||
super().__init__()
|
||||
self.spotify_client = spotify_client
|
||||
self.should_stop = False
|
||||
|
||||
# Progress state for reconnection
|
||||
self.current_scan_state = {
|
||||
'total_artists': 0,
|
||||
'completed_artists': 0,
|
||||
'current_artist_name': '',
|
||||
'current_artist_total_singles_eps': 0,
|
||||
'current_artist_completed_singles_eps': 0,
|
||||
'current_artist_total_albums': 0,
|
||||
'current_artist_completed_albums': 0,
|
||||
'scan_active': False,
|
||||
'scan_completed': False
|
||||
}
|
||||
|
||||
def stop(self):
|
||||
"""Stop the scanning process"""
|
||||
self.should_stop = True
|
||||
self.current_scan_state['scan_active'] = False
|
||||
|
||||
def get_current_progress(self):
|
||||
"""Get current progress state for reconnection"""
|
||||
return self.current_scan_state.copy()
|
||||
|
||||
def run(self):
|
||||
"""Run the watchlist scan with detailed progress updates"""
|
||||
try:
|
||||
self.scan_started.emit()
|
||||
|
||||
# Get all watchlist artists
|
||||
# Initialize progress state
|
||||
database = get_database()
|
||||
watchlist_artists = database.get_watchlist_artists()
|
||||
|
||||
self.current_scan_state.update({
|
||||
'total_artists': len(watchlist_artists),
|
||||
'completed_artists': 0,
|
||||
'scan_active': True,
|
||||
'scan_completed': False
|
||||
})
|
||||
|
||||
self.scan_started.emit()
|
||||
|
||||
scan_results = []
|
||||
|
||||
for artist in watchlist_artists:
|
||||
for i, artist in enumerate(watchlist_artists):
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
# Update current artist progress state
|
||||
self.current_scan_state.update({
|
||||
'current_artist_name': artist.artist_name,
|
||||
'current_artist_total_singles_eps': 0,
|
||||
'current_artist_completed_singles_eps': 0,
|
||||
'current_artist_total_albums': 0,
|
||||
'current_artist_completed_albums': 0
|
||||
})
|
||||
|
||||
self.artist_scan_started.emit(artist.artist_name)
|
||||
|
||||
# Perform detailed scan with progress updates
|
||||
result = self._scan_artist_with_progress(artist, database)
|
||||
scan_results.append(result)
|
||||
|
||||
# Update completed artists count
|
||||
self.current_scan_state['completed_artists'] = i + 1
|
||||
|
||||
self.artist_scan_completed.emit(
|
||||
artist.artist_name,
|
||||
result.albums_checked,
|
||||
|
|
@ -68,10 +105,20 @@ class WatchlistScanWorker(QThread):
|
|||
result.success
|
||||
)
|
||||
|
||||
# Mark scan as completed
|
||||
self.current_scan_state.update({
|
||||
'scan_active': False,
|
||||
'scan_completed': True
|
||||
})
|
||||
|
||||
self.scan_completed.emit(scan_results)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in watchlist scan worker: {e}")
|
||||
self.current_scan_state.update({
|
||||
'scan_active': False,
|
||||
'scan_completed': True
|
||||
})
|
||||
self.scan_completed.emit([])
|
||||
|
||||
def _scan_artist_with_progress(self, watchlist_artist, database):
|
||||
|
|
@ -124,6 +171,12 @@ class WatchlistScanWorker(QThread):
|
|||
logger.warning(f"Error analyzing album {album.name} for totals: {e}")
|
||||
continue
|
||||
|
||||
# Update current artist totals in state
|
||||
self.current_scan_state.update({
|
||||
'current_artist_total_singles_eps': total_singles_eps_releases,
|
||||
'current_artist_total_albums': total_albums
|
||||
})
|
||||
|
||||
# Emit the discovered totals
|
||||
self.artist_totals_discovered.emit(
|
||||
watchlist_artist.artist_name,
|
||||
|
|
@ -171,6 +224,12 @@ class WatchlistScanWorker(QThread):
|
|||
# Emit release completion signal
|
||||
self.release_completed.emit(watchlist_artist.artist_name, album.name, len(tracks))
|
||||
|
||||
# Update progress state for this completed release
|
||||
if len(tracks) >= 4: # Album
|
||||
self.current_scan_state['current_artist_completed_albums'] += 1
|
||||
else: # Single/EP
|
||||
self.current_scan_state['current_artist_completed_singles_eps'] += 1
|
||||
|
||||
# Rate limiting: small delay between album processing to avoid hitting Spotify limits
|
||||
import time
|
||||
time.sleep(0.1) # 100ms delay between albums
|
||||
|
|
@ -220,6 +279,9 @@ class WatchlistStatusModal(QDialog):
|
|||
# Keep track of whether this modal started the scan (vs background scan)
|
||||
self.is_manual_scan_owner = False
|
||||
|
||||
# Track when we're reconnecting to ongoing scan vs starting fresh
|
||||
self.is_reconnecting_to_ongoing_scan = False
|
||||
|
||||
# Simple progress tracking
|
||||
self.total_artists = 0
|
||||
self.completed_artists = 0
|
||||
|
|
@ -632,35 +694,43 @@ class WatchlistStatusModal(QDialog):
|
|||
return "✓", "#4caf50" # Default to up to date
|
||||
|
||||
def create_artist_card(self, artist: WatchlistArtist) -> QFrame:
|
||||
"""Create a clean artist card widget"""
|
||||
"""Create a professional artist card widget"""
|
||||
card = QFrame()
|
||||
card.setFixedHeight(36) # Reduced from 60 to 36
|
||||
card.setFixedHeight(48) # Increased height for better visual hierarchy
|
||||
card.setStyleSheet("""
|
||||
QFrame {
|
||||
background: rgba(40, 40, 40, 0.6);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(80, 80, 80, 0.3);
|
||||
background: rgba(40, 40, 40, 0.8);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(80, 80, 80, 0.4);
|
||||
}
|
||||
QFrame:hover {
|
||||
background: rgba(45, 45, 45, 0.9);
|
||||
border: 1px solid rgba(100, 100, 100, 0.6);
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QHBoxLayout(card)
|
||||
layout.setContentsMargins(12, 6, 12, 6) # Reduced vertical padding
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(16, 8, 16, 8)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Status indicator with icon (set based on scan history)
|
||||
# Status indicator with icon
|
||||
status_icon, status_color = self.get_artist_status_icon(artist)
|
||||
status_label = QLabel(status_icon)
|
||||
status_label.setFont(QFont("Arial", 12))
|
||||
status_label.setFont(QFont("Arial", 14))
|
||||
status_label.setStyleSheet(f"color: {status_color}; border: none; background: transparent;")
|
||||
status_label.setFixedWidth(20)
|
||||
status_label.setFixedWidth(24)
|
||||
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;")
|
||||
# Left side: Artist info
|
||||
info_layout = QVBoxLayout()
|
||||
info_layout.setSpacing(2)
|
||||
|
||||
# Last scan info (compact format)
|
||||
# Artist name with label
|
||||
artist_label = QLabel(f"Artist: {artist.artist_name}")
|
||||
artist_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||||
artist_label.setStyleSheet("color: #ffffff; border: none; background: transparent;")
|
||||
|
||||
# Last scan info with professional formatting
|
||||
if artist.last_scan_timestamp:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -674,44 +744,48 @@ class WatchlistStatusModal(QDialog):
|
|||
|
||||
time_diff = now - last_scan
|
||||
if time_diff.days > 0:
|
||||
scan_info = f"{time_diff.days}d ago"
|
||||
scan_time = f"{time_diff.days} day{'s' if time_diff.days > 1 else ''} ago"
|
||||
elif time_diff.seconds > 3600:
|
||||
hours = time_diff.seconds // 3600
|
||||
scan_info = f"{hours}h ago"
|
||||
scan_time = f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||
else:
|
||||
minutes = max(1, time_diff.seconds // 60)
|
||||
scan_info = f"{minutes}m ago"
|
||||
scan_time = f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||
except Exception as e:
|
||||
# Fallback to simple format if timezone calculation fails
|
||||
scan_info = artist.last_scan_timestamp.strftime("%m-%d %H:%M")
|
||||
# Fallback to formatted date
|
||||
scan_time = last_scan.strftime("%m/%d/%Y at %I:%M %p")
|
||||
else:
|
||||
scan_info = "Never"
|
||||
scan_time = "never scanned"
|
||||
|
||||
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)
|
||||
sync_label = QLabel(f"Last Sync: {scan_time}")
|
||||
sync_label.setFont(QFont("Arial", 10))
|
||||
sync_label.setStyleSheet("color: #b3b3b3; border: none; background: transparent;")
|
||||
|
||||
# Delete button
|
||||
delete_button = QPushButton("×")
|
||||
delete_button.setFixedSize(24, 24)
|
||||
info_layout.addWidget(artist_label)
|
||||
info_layout.addWidget(sync_label)
|
||||
|
||||
# Delete button with modern styling
|
||||
delete_button = QPushButton("✕")
|
||||
delete_button.setFixedSize(28, 28)
|
||||
delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||||
delete_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: rgba(244, 67, 54, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
border-radius: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: rgba(244, 67, 54, 1.0);
|
||||
background: rgba(244, 67, 54, 0.8);
|
||||
color: white;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(200, 50, 40, 1.0);
|
||||
}
|
||||
""")
|
||||
delete_button.setToolTip(f"Remove {artist.artist_name} from watchlist")
|
||||
delete_button.clicked.connect(lambda: self.delete_artist(artist))
|
||||
|
||||
# Store references for updates
|
||||
|
|
@ -719,9 +793,8 @@ class WatchlistStatusModal(QDialog):
|
|||
setattr(card, 'artist_id', artist.spotify_artist_id)
|
||||
|
||||
layout.addWidget(status_label)
|
||||
layout.addWidget(name_label)
|
||||
layout.addLayout(info_layout)
|
||||
layout.addStretch()
|
||||
layout.addWidget(scan_label)
|
||||
layout.addWidget(delete_button)
|
||||
|
||||
return card
|
||||
|
|
@ -815,24 +888,29 @@ class WatchlistStatusModal(QDialog):
|
|||
|
||||
def on_scan_started(self):
|
||||
"""Handle scan start"""
|
||||
self.current_action_label.setText("Starting watchlist scan...")
|
||||
# Only reset progress if this is a fresh scan, not a reconnection
|
||||
if not self.is_reconnecting_to_ongoing_scan:
|
||||
self.current_action_label.setText("Starting watchlist scan...")
|
||||
|
||||
# Reset overall counters
|
||||
self.total_artists = len(self.current_artists)
|
||||
self.completed_artists = 0
|
||||
|
||||
# Reset current artist tracking
|
||||
self.current_artist_name = ""
|
||||
self.current_artist_total_singles_eps = 0
|
||||
self.current_artist_completed_singles_eps = 0
|
||||
self.current_artist_total_albums = 0
|
||||
self.current_artist_completed_albums = 0
|
||||
|
||||
# Reset progress bars
|
||||
self.singles_progress_bar.setValue(0)
|
||||
self.albums_progress_bar.setValue(0)
|
||||
self.artists_progress_bar.setValue(0)
|
||||
self.scan_summary_label.setText("Preparing to scan artists...")
|
||||
|
||||
# Reset overall counters
|
||||
self.total_artists = len(self.current_artists)
|
||||
self.completed_artists = 0
|
||||
|
||||
# Reset current artist tracking
|
||||
self.current_artist_name = ""
|
||||
self.current_artist_total_singles_eps = 0
|
||||
self.current_artist_completed_singles_eps = 0
|
||||
self.current_artist_total_albums = 0
|
||||
self.current_artist_completed_albums = 0
|
||||
|
||||
# Reset progress bars
|
||||
self.singles_progress_bar.setValue(0)
|
||||
self.albums_progress_bar.setValue(0)
|
||||
self.artists_progress_bar.setValue(0)
|
||||
self.scan_summary_label.setText("Preparing to scan artists...")
|
||||
# Clear the reconnection flag after handling
|
||||
self.is_reconnecting_to_ongoing_scan = False
|
||||
|
||||
def on_artist_scan_started(self, artist_name: str):
|
||||
"""Handle individual artist scan start"""
|
||||
|
|
@ -949,9 +1027,12 @@ class WatchlistStatusModal(QDialog):
|
|||
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
|
||||
# Keep shared worker around for a bit so other modals can see completed results
|
||||
# Only clear it if this modal was the owner (manual scan starter)
|
||||
if (self.scan_worker == WatchlistStatusModal._shared_scan_worker
|
||||
and WatchlistStatusModal._scan_owner_modal == self):
|
||||
# Keep the worker alive for 30 seconds to allow other modals to see results
|
||||
QTimer.singleShot(30000, self._cleanup_shared_worker_delayed)
|
||||
WatchlistStatusModal._scan_owner_modal = None
|
||||
|
||||
# Calculate summary
|
||||
|
|
@ -1021,28 +1102,42 @@ class WatchlistStatusModal(QDialog):
|
|||
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()):
|
||||
# First check if there's a manual scan worker (running or recently completed)
|
||||
if WatchlistStatusModal._shared_scan_worker:
|
||||
|
||||
logger.info("Found ongoing manual watchlist scan - reconnecting to it")
|
||||
logger.info("Found manual watchlist scan worker - reconnecting to it")
|
||||
|
||||
# Reconnect to the shared manual scan worker
|
||||
self.scan_worker = WatchlistStatusModal._shared_scan_worker
|
||||
self.scan_in_progress = True
|
||||
|
||||
# Check if scan is still active
|
||||
progress_state = self.scan_worker.get_current_progress()
|
||||
self.scan_in_progress = progress_state.get('scan_active', False) if progress_state else False
|
||||
self.is_reconnecting_to_ongoing_scan = True
|
||||
|
||||
try:
|
||||
# Restore progress state BEFORE connecting signals to prevent reset conflicts
|
||||
self._restore_progress_state(progress_state)
|
||||
|
||||
# Now connect to future signals
|
||||
self.scan_worker.scan_started.connect(self.on_scan_started)
|
||||
self.scan_worker.artist_scan_started.connect(self.on_artist_scan_started)
|
||||
self.scan_worker.artist_totals_discovered.connect(self.on_artist_totals_discovered)
|
||||
self.scan_worker.album_scan_started.connect(self.on_album_scan_started)
|
||||
self.scan_worker.track_check_started.connect(self.on_track_check_started)
|
||||
self.scan_worker.release_completed.connect(self.on_release_completed)
|
||||
self.scan_worker.artist_scan_completed.connect(self.on_artist_scan_completed)
|
||||
self.scan_worker.scan_completed.connect(self.on_scan_completed)
|
||||
|
||||
# Update UI to show scan in progress
|
||||
self.current_action_label.setText("Manual scan in progress...")
|
||||
self.scan_button.setText("Scanning...")
|
||||
self.scan_button.setEnabled(False)
|
||||
# Update UI to show reconnection status (will be overridden by _restore_progress_state if needed)
|
||||
if self.scan_in_progress:
|
||||
self.current_action_label.setText("Reconnected to manual scan...")
|
||||
self.scan_button.setText("Scanning...")
|
||||
self.scan_button.setEnabled(False)
|
||||
else:
|
||||
self.current_action_label.setText("Viewing completed manual scan results")
|
||||
self.scan_button.setText("Start Scan")
|
||||
self.scan_button.setEnabled(True)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not connect to manual scan signals (may already be connected): {e}")
|
||||
|
|
@ -1064,24 +1159,37 @@ class WatchlistStatusModal(QDialog):
|
|||
# If we found the dashboard and there's an active background worker
|
||||
if (dashboard and hasattr(dashboard, 'background_watchlist_worker')
|
||||
and dashboard.background_watchlist_worker
|
||||
and dashboard.background_watchlist_worker.isRunning()
|
||||
and hasattr(dashboard, 'auto_processing_watchlist')
|
||||
and dashboard.auto_processing_watchlist):
|
||||
|
||||
logger.info("Found active background watchlist scan - connecting modal to live updates")
|
||||
|
||||
# Connect to the background worker's signals for live updates
|
||||
# Set reconnection flag and restore progress before connecting signals
|
||||
self.is_reconnecting_to_ongoing_scan = True
|
||||
|
||||
# Restore progress state BEFORE connecting signals
|
||||
try:
|
||||
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)
|
||||
progress_state = dashboard.background_watchlist_worker.get_current_progress()
|
||||
self._restore_progress_state(progress_state)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not restore background scan progress: {e}")
|
||||
|
||||
# Connect to the background worker's signals for live updates
|
||||
# Now using the same WatchlistScanWorker signals (no .signals attribute needed)
|
||||
try:
|
||||
dashboard.background_watchlist_worker.scan_started.connect(self.on_scan_started)
|
||||
dashboard.background_watchlist_worker.artist_scan_started.connect(self.on_artist_scan_started)
|
||||
dashboard.background_watchlist_worker.artist_totals_discovered.connect(self.on_artist_totals_discovered)
|
||||
dashboard.background_watchlist_worker.album_scan_started.connect(self.on_album_scan_started)
|
||||
dashboard.background_watchlist_worker.track_check_started.connect(self.on_track_check_started)
|
||||
dashboard.background_watchlist_worker.release_completed.connect(self.on_release_completed)
|
||||
dashboard.background_watchlist_worker.artist_scan_completed.connect(self.on_artist_scan_completed)
|
||||
|
||||
# Update UI to show scan in progress
|
||||
self.current_action_label.setText("Background scan in progress...")
|
||||
# Update UI to show reconnection status
|
||||
self.current_action_label.setText("Reconnected to background scan...")
|
||||
self.scan_button.setText("Background Scanning...")
|
||||
self.scan_button.setEnabled(False)
|
||||
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}")
|
||||
|
|
@ -1090,6 +1198,78 @@ class WatchlistStatusModal(QDialog):
|
|||
logger.debug(f"Error checking for background scan: {e}")
|
||||
# Not critical - just means we can't detect ongoing scans
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_shared_worker_delayed():
|
||||
"""Clean up shared worker after delay to allow other modals to see results"""
|
||||
try:
|
||||
if WatchlistStatusModal._shared_scan_worker:
|
||||
if not WatchlistStatusModal._shared_scan_worker.isRunning():
|
||||
WatchlistStatusModal._shared_scan_worker = None
|
||||
logger.debug("Cleaned up completed shared scan worker")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error cleaning up shared worker: {e}")
|
||||
|
||||
def _restore_progress_state(self, progress_state):
|
||||
"""Restore progress bars and UI state from worker's current progress"""
|
||||
if not progress_state:
|
||||
return
|
||||
|
||||
# Handle both active and completed scans
|
||||
is_active = progress_state.get('scan_active', False)
|
||||
is_completed = progress_state.get('scan_completed', False)
|
||||
|
||||
if not is_active and not is_completed:
|
||||
return
|
||||
|
||||
try:
|
||||
# Fully sync modal state with worker state
|
||||
self.total_artists = progress_state.get('total_artists', 0)
|
||||
self.completed_artists = progress_state.get('completed_artists', 0)
|
||||
self.current_artist_name = progress_state.get('current_artist_name', '')
|
||||
self.current_artist_total_singles_eps = progress_state.get('current_artist_total_singles_eps', 0)
|
||||
self.current_artist_completed_singles_eps = progress_state.get('current_artist_completed_singles_eps', 0)
|
||||
self.current_artist_total_albums = progress_state.get('current_artist_total_albums', 0)
|
||||
self.current_artist_completed_albums = progress_state.get('current_artist_completed_albums', 0)
|
||||
|
||||
# Update UI elements
|
||||
if self.total_artists > 0:
|
||||
overall_progress = int((self.completed_artists / self.total_artists) * 100)
|
||||
self.artists_progress_bar.setValue(overall_progress)
|
||||
|
||||
if self.current_artist_name:
|
||||
self.current_action_label.setText(f"Scanning: {self.current_artist_name}")
|
||||
|
||||
# Update current artist progress bars
|
||||
if self.current_artist_total_singles_eps > 0:
|
||||
singles_progress = int((self.current_artist_completed_singles_eps / self.current_artist_total_singles_eps) * 100)
|
||||
self.singles_progress_bar.setValue(singles_progress)
|
||||
|
||||
if self.current_artist_total_albums > 0:
|
||||
albums_progress = int((self.current_artist_completed_albums / self.current_artist_total_albums) * 100)
|
||||
self.albums_progress_bar.setValue(albums_progress)
|
||||
|
||||
# Update scan summary and UI state based on scan status
|
||||
if is_completed:
|
||||
self.scan_summary_label.setText("Scan completed - viewing final results")
|
||||
self.scan_button.setText("Start Scan")
|
||||
self.scan_button.setEnabled(True)
|
||||
# Set progress bars to 100% for completed scans
|
||||
self.artists_progress_bar.setValue(100)
|
||||
if self.current_artist_total_singles_eps > 0:
|
||||
self.singles_progress_bar.setValue(100)
|
||||
if self.current_artist_total_albums > 0:
|
||||
self.albums_progress_bar.setValue(100)
|
||||
elif is_active:
|
||||
remaining_artists = self.total_artists - self.completed_artists
|
||||
self.scan_summary_label.setText(f"Reconnected to ongoing scan - {remaining_artists} artists remaining")
|
||||
self.scan_button.setText("Scanning...")
|
||||
self.scan_button.setEnabled(False)
|
||||
|
||||
logger.info(f"Restored progress state: {self.completed_artists}/{self.total_artists} artists, current: {self.current_artist_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring progress state: {e}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle modal close"""
|
||||
# Only stop the scan if this modal owns it and it's not a shared manual scan
|
||||
|
|
@ -1111,11 +1291,13 @@ class WatchlistStatusModal(QDialog):
|
|||
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)
|
||||
parent_widget.background_watchlist_worker.scan_started.disconnect(self.on_scan_started)
|
||||
parent_widget.background_watchlist_worker.artist_scan_started.disconnect(self.on_artist_scan_started)
|
||||
parent_widget.background_watchlist_worker.artist_totals_discovered.disconnect(self.on_artist_totals_discovered)
|
||||
parent_widget.background_watchlist_worker.album_scan_started.disconnect(self.on_album_scan_started)
|
||||
parent_widget.background_watchlist_worker.track_check_started.disconnect(self.on_track_check_started)
|
||||
parent_widget.background_watchlist_worker.release_completed.disconnect(self.on_release_completed)
|
||||
parent_widget.background_watchlist_worker.artist_scan_completed.disconnect(self.on_artist_scan_completed)
|
||||
except:
|
||||
pass # Ignore if signals weren't connected
|
||||
except:
|
||||
|
|
|
|||
|
|
@ -3947,22 +3947,23 @@ class DashboardPage(QWidget):
|
|||
# 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)
|
||||
# Start background watchlist scan using the same worker as manual scans for consistency
|
||||
from ui.components.watchlist_status_modal import WatchlistScanWorker
|
||||
self.background_watchlist_worker = WatchlistScanWorker(spotify_client)
|
||||
self.background_watchlist_worker.scan_completed.connect(self.on_auto_watchlist_scan_complete_unified)
|
||||
|
||||
# Connect detailed progress signals to modal if it's open
|
||||
if hasattr(self, 'watchlist_status_modal') and self.watchlist_status_modal and self.watchlist_status_modal.isVisible():
|
||||
self.background_watchlist_worker.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)
|
||||
self.background_watchlist_worker.scan_started.connect(self.watchlist_status_modal.on_scan_started)
|
||||
self.background_watchlist_worker.artist_scan_started.connect(self.watchlist_status_modal.on_artist_scan_started)
|
||||
self.background_watchlist_worker.artist_totals_discovered.connect(self.watchlist_status_modal.on_artist_totals_discovered)
|
||||
self.background_watchlist_worker.album_scan_started.connect(self.watchlist_status_modal.on_album_scan_started)
|
||||
self.background_watchlist_worker.track_check_started.connect(self.watchlist_status_modal.on_track_check_started)
|
||||
self.background_watchlist_worker.release_completed.connect(self.watchlist_status_modal.on_release_completed)
|
||||
self.background_watchlist_worker.artist_scan_completed.connect(self.watchlist_status_modal.on_artist_scan_completed)
|
||||
|
||||
QThreadPool.globalInstance().start(self.background_watchlist_worker)
|
||||
# Start the thread (not QThreadPool since this is now a QThread)
|
||||
self.background_watchlist_worker.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting automatic watchlist scanning: {e}")
|
||||
|
|
@ -3970,11 +3971,17 @@ class DashboardPage(QWidget):
|
|||
# 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"""
|
||||
def on_auto_watchlist_scan_complete_unified(self, scan_results):
|
||||
"""Handle completion of automatic watchlist scanning using unified WatchlistScanWorker"""
|
||||
try:
|
||||
self.auto_processing_watchlist = False
|
||||
|
||||
# Calculate summary from scan results (same as modal does)
|
||||
successful_scans = [r for r in scan_results if r.success]
|
||||
total_artists = len(scan_results)
|
||||
total_new_tracks = sum(r.new_tracks_found for r in successful_scans)
|
||||
total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans)
|
||||
|
||||
# Clear background worker reference
|
||||
if hasattr(self, 'background_watchlist_worker'):
|
||||
self.background_watchlist_worker = None
|
||||
|
|
@ -4000,217 +4007,11 @@ class DashboardPage(QWidget):
|
|||
|
||||
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
|
||||
# Ensure we reschedule even on 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):
|
||||
"""Background worker for automatic wishlist processing"""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QFrame, QPushButton, QLineEdit, QComboBox,
|
||||
QCheckBox, QSpinBox, QTextEdit, QGroupBox, QFormLayout, QMessageBox, QSizePolicy)
|
||||
QCheckBox, QSpinBox, QTextEdit, QGroupBox, QFormLayout, QMessageBox, QSizePolicy, QScrollArea)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
from config.settings import config_manager
|
||||
|
|
@ -679,28 +679,61 @@ class SettingsPage(QWidget):
|
|||
}
|
||||
""")
|
||||
|
||||
# Main container layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(20, 16, 20, 20)
|
||||
main_layout.setSpacing(16)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# Create scroll area
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
scroll_area.setStyleSheet("""
|
||||
QScrollArea {
|
||||
background: #191414;
|
||||
border: none;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #282828;
|
||||
width: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #535353;
|
||||
min-height: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #727272;
|
||||
}
|
||||
""")
|
||||
|
||||
# Create scrollable content widget
|
||||
scroll_content = QWidget()
|
||||
scroll_content.setStyleSheet("background: #191414;")
|
||||
content_layout = QVBoxLayout(scroll_content)
|
||||
content_layout.setContentsMargins(20, 16, 20, 20)
|
||||
content_layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
header = self.create_header()
|
||||
main_layout.addWidget(header)
|
||||
content_layout.addWidget(header)
|
||||
|
||||
# Settings content
|
||||
content_layout = QHBoxLayout()
|
||||
content_layout.setSpacing(24)
|
||||
settings_layout = QHBoxLayout()
|
||||
settings_layout.setSpacing(24)
|
||||
|
||||
# Left column
|
||||
left_column = self.create_left_column()
|
||||
content_layout.addWidget(left_column)
|
||||
settings_layout.addWidget(left_column)
|
||||
|
||||
# Right column
|
||||
right_column = self.create_right_column()
|
||||
content_layout.addWidget(right_column)
|
||||
settings_layout.addWidget(right_column)
|
||||
|
||||
main_layout.addLayout(content_layout)
|
||||
main_layout.addStretch()
|
||||
content_layout.addLayout(settings_layout)
|
||||
content_layout.addStretch()
|
||||
|
||||
# Save button
|
||||
self.save_btn = QPushButton("💾 Save Settings")
|
||||
|
|
@ -720,7 +753,11 @@ class SettingsPage(QWidget):
|
|||
}
|
||||
""")
|
||||
|
||||
main_layout.addWidget(self.save_btn)
|
||||
content_layout.addWidget(self.save_btn)
|
||||
|
||||
# Set scroll area content and add to main layout
|
||||
scroll_area.setWidget(scroll_content)
|
||||
main_layout.addWidget(scroll_area)
|
||||
|
||||
def load_config_values(self):
|
||||
"""Load current configuration values into form inputs"""
|
||||
|
|
@ -957,7 +994,7 @@ class SettingsPage(QWidget):
|
|||
# Status label
|
||||
self.status_label = QLabel("Checking local machine...")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setStyleSheet("color: #b3b3b3; font-size: 12px;")
|
||||
self.status_label.setStyleSheet("color: #ffffff; font-size: 12px; background: transparent;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Animated loading bar container
|
||||
|
|
@ -1061,7 +1098,7 @@ class SettingsPage(QWidget):
|
|||
# Status label
|
||||
self.status_label = QLabel("Checking local machine...")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setStyleSheet("color: #b3b3b3; font-size: 12px;")
|
||||
self.status_label.setStyleSheet("color: #ffffff; font-size: 12px; background: transparent;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Animated loading bar container
|
||||
|
|
@ -1301,7 +1338,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Info text
|
||||
info_label = QLabel("URL automatically filled in settings above.")
|
||||
info_label.setStyleSheet("color: #b3b3b3; font-size: 9px; font-style: italic;")
|
||||
info_label.setStyleSheet("color: #ffffff; font-size: 9px; font-style: italic; background: transparent;")
|
||||
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
|
|
@ -1412,7 +1449,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Info text
|
||||
info_label = QLabel("URL automatically filled in settings above.")
|
||||
info_label.setStyleSheet("color: #b3b3b3; font-size: 9px; font-style: italic;")
|
||||
info_label.setStyleSheet("color: #ffffff; font-size: 9px; font-style: italic; background: transparent;")
|
||||
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
|
|
@ -1494,12 +1531,12 @@ class SettingsPage(QWidget):
|
|||
# Title
|
||||
title_label = QLabel("Settings")
|
||||
title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold))
|
||||
title_label.setStyleSheet("color: #ffffff;")
|
||||
title_label.setStyleSheet("color: #ffffff; background: transparent;")
|
||||
|
||||
# Subtitle
|
||||
subtitle_label = QLabel("Configure your music sync and download preferences")
|
||||
subtitle_label.setFont(QFont("Arial", 14))
|
||||
subtitle_label.setStyleSheet("color: #b3b3b3;")
|
||||
subtitle_label.setStyleSheet("color: #ffffff; background: transparent;")
|
||||
|
||||
layout.addWidget(title_label)
|
||||
layout.addWidget(subtitle_label)
|
||||
|
|
@ -1519,6 +1556,14 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Spotify settings
|
||||
spotify_frame = QFrame()
|
||||
spotify_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background: #333333;
|
||||
border: 1px solid #444444;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
spotify_layout = QVBoxLayout(spotify_frame)
|
||||
spotify_layout.setSpacing(8)
|
||||
|
||||
|
|
@ -1529,7 +1574,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Client ID
|
||||
client_id_label = QLabel("Client ID:")
|
||||
client_id_label.setStyleSheet("color: #ffffff; font-size: 11px;")
|
||||
client_id_label.setStyleSheet(self.get_label_style(11))
|
||||
spotify_layout.addWidget(client_id_label)
|
||||
|
||||
self.client_id_input = QLineEdit()
|
||||
|
|
@ -1540,7 +1585,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Client Secret
|
||||
client_secret_label = QLabel("Client Secret:")
|
||||
client_secret_label.setStyleSheet("color: #ffffff; font-size: 11px;")
|
||||
client_secret_label.setStyleSheet(self.get_label_style(11))
|
||||
spotify_layout.addWidget(client_secret_label)
|
||||
|
||||
self.client_secret_input = QLineEdit()
|
||||
|
|
@ -1552,7 +1597,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Callback URL info
|
||||
callback_info_label = QLabel("Required Redirect URI:")
|
||||
callback_info_label.setStyleSheet("color: #b3b3b3; font-size: 11px; margin-top: 8px;")
|
||||
callback_info_label.setStyleSheet("color: #ffffff; font-size: 11px; margin-top: 8px; background: transparent;")
|
||||
spotify_layout.addWidget(callback_info_label)
|
||||
|
||||
callback_url_label = QLabel("http://127.0.0.1:8888/callback")
|
||||
|
|
@ -1571,12 +1616,20 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Helper text
|
||||
helper_text = QLabel("Add this URL to your Spotify app's 'Redirect URIs' in the Spotify Developer Dashboard")
|
||||
helper_text.setStyleSheet("color: #888888; font-size: 10px; font-style: italic;")
|
||||
helper_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;")
|
||||
helper_text.setWordWrap(True)
|
||||
spotify_layout.addWidget(helper_text)
|
||||
|
||||
# Plex settings
|
||||
plex_frame = QFrame()
|
||||
plex_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background: #333333;
|
||||
border: 1px solid #444444;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
plex_layout = QVBoxLayout(plex_frame)
|
||||
plex_layout.setSpacing(8)
|
||||
|
||||
|
|
@ -1587,7 +1640,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Server URL
|
||||
plex_url_label = QLabel("Server URL:")
|
||||
plex_url_label.setStyleSheet("color: #ffffff; font-size: 11px;")
|
||||
plex_url_label.setStyleSheet(self.get_label_style(11))
|
||||
plex_layout.addWidget(plex_url_label)
|
||||
|
||||
plex_url_input_layout = QHBoxLayout()
|
||||
|
|
@ -1607,7 +1660,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Token
|
||||
plex_token_label = QLabel("Token:")
|
||||
plex_token_label.setStyleSheet("color: #ffffff; font-size: 11px;")
|
||||
plex_token_label.setStyleSheet(self.get_label_style(11))
|
||||
plex_layout.addWidget(plex_token_label)
|
||||
|
||||
self.plex_token_input = QLineEdit()
|
||||
|
|
@ -1619,6 +1672,14 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Soulseek settings
|
||||
soulseek_frame = QFrame()
|
||||
soulseek_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background: #333333;
|
||||
border: 1px solid #444444;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
soulseek_layout = QVBoxLayout(soulseek_frame)
|
||||
soulseek_layout.setSpacing(8)
|
||||
|
||||
|
|
@ -1629,7 +1690,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# slskd URL
|
||||
slskd_url_label = QLabel("slskd URL:")
|
||||
slskd_url_label.setStyleSheet("color: #ffffff; font-size: 11px;")
|
||||
slskd_url_label.setStyleSheet(self.get_label_style(11))
|
||||
soulseek_layout.addWidget(slskd_url_label)
|
||||
|
||||
url_input_layout = QHBoxLayout()
|
||||
|
|
@ -1649,7 +1710,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# API Key
|
||||
api_key_label = QLabel("API Key:")
|
||||
api_key_label.setStyleSheet("color: #ffffff; font-size: 11px;")
|
||||
api_key_label.setStyleSheet(self.get_label_style(11))
|
||||
soulseek_layout.addWidget(api_key_label)
|
||||
|
||||
self.api_key_input = QLineEdit()
|
||||
|
|
@ -1701,16 +1762,14 @@ class SettingsPage(QWidget):
|
|||
# Log level (read-only)
|
||||
log_level_layout = QHBoxLayout()
|
||||
log_level_label = QLabel("Log Level:")
|
||||
log_level_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
log_level_label.setStyleSheet(self.get_label_style(12))
|
||||
|
||||
self.log_level_display = QLabel("DEBUG")
|
||||
self.log_level_display.setStyleSheet("""
|
||||
color: #b3b3b3;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
background-color: #404040;
|
||||
border: 1px solid #606060;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
""")
|
||||
self.log_level_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
|
|
@ -1720,18 +1779,18 @@ class SettingsPage(QWidget):
|
|||
# Log file path (read-only)
|
||||
log_path_container = QVBoxLayout()
|
||||
log_path_label = QLabel("Log File Path:")
|
||||
log_path_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
log_path_label.setStyleSheet(self.get_label_style(12))
|
||||
log_path_container.addWidget(log_path_label)
|
||||
|
||||
self.log_path_display = QLabel("logs/app.log")
|
||||
self.log_path_display.setStyleSheet("""
|
||||
color: #b3b3b3;
|
||||
color: #1db954;
|
||||
font-size: 11px;
|
||||
background-color: #404040;
|
||||
border: 1px solid #606060;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: rgba(29, 185, 84, 0.1);
|
||||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
""")
|
||||
self.log_path_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
log_path_container.addWidget(self.log_path_display)
|
||||
|
|
@ -1759,7 +1818,7 @@ class SettingsPage(QWidget):
|
|||
# Quality preference
|
||||
quality_layout = QHBoxLayout()
|
||||
quality_label = QLabel("Preferred Quality:")
|
||||
quality_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
quality_label.setStyleSheet(self.get_label_style(12))
|
||||
|
||||
self.quality_combo = QComboBox()
|
||||
self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "192 kbps MP3", "Any"])
|
||||
|
|
@ -1774,7 +1833,7 @@ class SettingsPage(QWidget):
|
|||
# Download path
|
||||
path_container = QVBoxLayout()
|
||||
path_label = QLabel("Slskd Download Dir:")
|
||||
path_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
path_label.setStyleSheet(self.get_label_style(12))
|
||||
path_container.addWidget(path_label)
|
||||
|
||||
path_input_layout = QHBoxLayout()
|
||||
|
|
@ -1794,7 +1853,7 @@ class SettingsPage(QWidget):
|
|||
# Transfer folder path
|
||||
transfer_path_container = QVBoxLayout()
|
||||
transfer_path_label = QLabel("Matched Transfer Dir (Plex Music Dir?):")
|
||||
transfer_path_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
transfer_path_label.setStyleSheet(self.get_label_style(12))
|
||||
transfer_path_container.addWidget(transfer_path_label)
|
||||
|
||||
transfer_input_layout = QHBoxLayout()
|
||||
|
|
@ -1824,7 +1883,7 @@ class SettingsPage(QWidget):
|
|||
# Max Workers
|
||||
workers_layout = QHBoxLayout()
|
||||
workers_label = QLabel("Concurrent Workers:")
|
||||
workers_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
workers_label.setStyleSheet(self.get_label_style(12))
|
||||
|
||||
self.max_workers_combo = QComboBox()
|
||||
self.max_workers_combo.addItems(["3", "4", "5", "6", "7", "8", "9", "10"])
|
||||
|
|
@ -1837,7 +1896,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Help text for workers
|
||||
workers_help = QLabel("Number of parallel threads for database updates. Higher values = faster updates but more server load.")
|
||||
workers_help.setStyleSheet("color: #888888; font-size: 10px; font-style: italic;")
|
||||
workers_help.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;")
|
||||
workers_help.setWordWrap(True)
|
||||
|
||||
database_layout.addLayout(workers_layout)
|
||||
|
|
@ -1857,6 +1916,7 @@ class SettingsPage(QWidget):
|
|||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
spacing: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
|
|
@ -1886,16 +1946,14 @@ class SettingsPage(QWidget):
|
|||
# Supported formats display
|
||||
supported_formats_layout = QHBoxLayout()
|
||||
formats_label = QLabel("Supported Formats:")
|
||||
formats_label.setStyleSheet("color: #ffffff; font-size: 12px;")
|
||||
formats_label.setStyleSheet(self.get_label_style(12))
|
||||
|
||||
formats_display = QLabel("MP3, FLAC, MP4/M4A, OGG")
|
||||
formats_display.setStyleSheet("""
|
||||
color: #b3b3b3;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
background-color: #404040;
|
||||
border: 1px solid #606060;
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
""")
|
||||
|
||||
supported_formats_layout.addWidget(formats_label)
|
||||
|
|
@ -1903,7 +1961,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Help text
|
||||
help_text = QLabel("Automatically enhances downloaded tracks with accurate Spotify metadata including artist, album, track numbers, genres, and release dates. Perfect for Plex libraries!")
|
||||
help_text.setStyleSheet("color: #888888; font-size: 10px; font-style: italic;")
|
||||
help_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;")
|
||||
help_text.setWordWrap(True)
|
||||
|
||||
metadata_layout.addWidget(self.metadata_enabled_checkbox)
|
||||
|
|
@ -1925,6 +1983,7 @@ class SettingsPage(QWidget):
|
|||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
spacing: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
|
|
@ -1945,7 +2004,7 @@ class SettingsPage(QWidget):
|
|||
|
||||
# Help text for playlist sync
|
||||
playlist_help_text = QLabel("When enabled, existing Plex playlists will be backed up as '[Playlist Name] Backup' before being overwritten during sync. Only one backup per playlist is maintained.")
|
||||
playlist_help_text.setStyleSheet("color: #888888; font-size: 10px; font-style: italic;")
|
||||
playlist_help_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;")
|
||||
playlist_help_text.setWordWrap(True)
|
||||
|
||||
playlist_sync_layout.addWidget(self.create_backup_checkbox)
|
||||
|
|
@ -2044,4 +2103,15 @@ class SettingsPage(QWidget):
|
|||
QPushButton:hover {
|
||||
background: rgba(29, 185, 84, 0.1);
|
||||
}
|
||||
"""
|
||||
|
||||
def get_label_style(self, font_size=12):
|
||||
"""Get consistent label style without background"""
|
||||
return f"""
|
||||
QLabel {{
|
||||
color: #ffffff;
|
||||
font-size: {font_size}px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}}
|
||||
"""
|
||||
Loading…
Reference in a new issue