This commit is contained in:
Broque Thomas 2025-07-14 13:26:12 -07:00
parent b7ef260609
commit 32d1cfd06f
7 changed files with 9685 additions and 68 deletions

View file

@ -1 +1 @@
{"access_token": "BQDXSOsKBrR2sR0Bf23nD1sV3aZh52IJ9vp7SV1X7ETZHjQFSDN3adTAlMKi8XKtTnpgx402dvCMt-IFyhFD5aX6rs6GmEAFlw49sFxlPZbaoHPoXMXsXZDoxLWDJE8rGGQmOce86VDqJXBqRWzlqIdhxYkHvRjNk1ouYZvwEj1Bq8k15zBxBHR89dIsyI_kSBeXWjt_yHzD8VWX7VLUeFILVn3TTeMtYRho7XB8D4hibhBnjWD2YrQPXIU10HPk", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752520100, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"} {"access_token": "BQDf6gK9LVTuA9IzYMV-DfgRBFjNlkLHCp_lN76-Fn0Fe2pLarZFYsYGbO7IX2ZEjJAB3hzzACkmqr4t3q48W-U9WBFcgNzExwUpJcLFi4xupyHKtcDeWRn7xWZ3_60mQStSax_wbZB7m5iU0_kzJv-r-ObWnW3q7yWeQfUTcjP3B9xfLyqcw0FFSv578C4nJ3XmPhhybgiDRkZUlf89SO24VFqcDcfZOZaiUAD4JG0Ypt_6F3PQsJOsITzz1zMn", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752527488, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}

View file

@ -2,12 +2,51 @@ import spotipy
from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
import time import time
import threading
from functools import wraps
from dataclasses import dataclass from dataclasses import dataclass
from utils.logging_config import get_logger from utils.logging_config import get_logger
from config.settings import config_manager from config.settings import config_manager
logger = get_logger("spotify_client") 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
# Request queuing for burst handling
import queue
_request_queue = queue.Queue()
_queue_processor_running = False
def rate_limited(func):
"""Decorator to enforce rate limiting on Spotify API calls"""
@wraps(func)
def wrapper(*args, **kwargs):
global _last_api_call_time
with _api_call_lock:
current_time = time.time()
time_since_last_call = current_time - _last_api_call_time
if time_since_last_call < MIN_API_INTERVAL:
sleep_time = MIN_API_INTERVAL - time_since_last_call
time.sleep(sleep_time)
_last_api_call_time = time.time()
try:
result = func(*args, **kwargs)
return result
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
raise e
return wrapper
@dataclass @dataclass
class Track: class Track:
id: str id: str
@ -180,6 +219,7 @@ class SpotifyClient:
logger.error(f"Error fetching playlist {playlist_id}: {e}") logger.error(f"Error fetching playlist {playlist_id}: {e}")
return None return None
@rate_limited
def search_tracks(self, query: str, limit: int = 20) -> List[Track]: def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
if not self.is_authenticated(): if not self.is_authenticated():
return [] return []
@ -198,6 +238,7 @@ class SpotifyClient:
logger.error(f"Error searching tracks: {e}") logger.error(f"Error searching tracks: {e}")
return [] return []
@rate_limited
def search_artists(self, query: str, limit: int = 20) -> List[Artist]: def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
"""Search for artists using Spotify API""" """Search for artists using Spotify API"""
if not self.is_authenticated(): if not self.is_authenticated():
@ -217,6 +258,7 @@ class SpotifyClient:
logger.error(f"Error searching artists: {e}") logger.error(f"Error searching artists: {e}")
return [] return []
@rate_limited
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]: def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed track information including album data and track number""" """Get detailed track information including album data and track number"""
if not self.is_authenticated(): if not self.is_authenticated():

File diff suppressed because it is too large Load diff

View file

@ -178,10 +178,36 @@ class SpotifyMatchingModal(QDialog):
loading_label.setStyleSheet("color: #ccc; padding: 20px;") loading_label.setStyleSheet("color: #ccc; padding: 20px;")
self.auto_layout.addWidget(loading_label) self.auto_layout.addWidget(loading_label)
# Start suggestion generation in background # Use thread pool instead of creating new thread
self.suggestion_thread = ArtistSuggestionThread(self.track_result, self.spotify_client, self.matching_engine) if hasattr(self.parent(), 'api_thread_pool'):
self.suggestion_thread.suggestions_ready.connect(self.display_auto_suggestions) future = self.parent().api_thread_pool.submit(self._generate_suggestions_worker)
self.suggestion_thread.start() future.add_done_callback(self._on_suggestions_ready)
else:
# Fallback to original thread approach
self.suggestion_thread = ArtistSuggestionThread(self.track_result, self.spotify_client, self.matching_engine)
self.suggestion_thread.suggestions_ready.connect(self.display_auto_suggestions)
self.suggestion_thread.start()
def _generate_suggestions_worker(self):
"""Worker function for generating suggestions in thread pool"""
try:
# Create suggestion thread instance for logic reuse
thread = ArtistSuggestionThread(self.track_result, self.spotify_client, self.matching_engine)
suggestions = thread.generate_artist_suggestions()
return suggestions
except Exception as e:
print(f"❌ Error generating suggestions: {e}")
return []
def _on_suggestions_ready(self, future):
"""Callback when suggestions are ready from thread pool"""
try:
suggestions = future.result()
# Use QTimer to safely update UI from worker thread
QTimer.singleShot(0, lambda: self.display_auto_suggestions(suggestions))
except Exception as e:
print(f"❌ Error in suggestion callback: {e}")
QTimer.singleShot(0, lambda: self.display_auto_suggestions([]))
def display_auto_suggestions(self, suggestions: List[ArtistMatch]): def display_auto_suggestions(self, suggestions: List[ArtistMatch]):
"""Display the generated auto suggestions""" """Display the generated auto suggestions"""
@ -3857,9 +3883,17 @@ class DownloadsPage(QWidget):
self.matching_engine = MusicMatchingEngine() self.matching_engine = MusicMatchingEngine()
# Album grouping system to ensure tracks from same album go to same folder # Album grouping system to ensure tracks from same album go to same folder
import threading
from concurrent.futures import ThreadPoolExecutor
self.album_cache_lock = threading.Lock()
self.album_groups = {} # Maps original album name -> resolved clean album name self.album_groups = {} # Maps original album name -> resolved clean album name
self.album_artists = {} # Maps original album name -> artist for consistency self.album_artists = {} # Maps original album name -> artist for consistency
self.album_editions = {} # Maps original album name -> edition level ("standard", "deluxe") self.album_editions = {} # Maps original album name -> edition level ("standard", "deluxe")
self.album_name_cache = {} # Pre-calculated consistent album names for batch downloads
# Thread pool for API requests to prevent excessive thread creation
self.api_thread_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SpotifyAPI")
self.active_suggestion_threads = set() # Track active threads for cleanup
# Initialize audio player for streaming # Initialize audio player for streaming
self.audio_player = AudioPlayer(self) self.audio_player = AudioPlayer(self)
@ -5866,17 +5900,145 @@ class DownloadsPage(QWidget):
search_result.matched_artist = artist search_result.matched_artist = artist
# Start the download with normal process but enhanced with Spotify metadata # Start the download with normal process but enhanced with Spotify metadata
self.start_download(search_result) download_item = self._start_download_with_artist(search_result, artist)
# Find the download item that was just created and add the matched artist info if download_item:
# We need to do this after the download is started so the download item exists print(f"✅ Successfully created matched download for '{download_item.title}'")
QTimer.singleShot(100, lambda: self._assign_matched_artist_to_download_item(search_result, artist)) else:
print(f"❌ Failed to create matched download, falling back to normal download")
self.start_download(search_result)
except Exception as e: except Exception as e:
print(f"❌ Error handling matched download: {e}") print(f"❌ Error handling matched download: {e}")
# Fallback to normal download # Fallback to normal download
self.start_download(search_result) self.start_download(search_result)
def _start_download_with_artist(self, search_result, artist: Artist):
"""Start download and immediately assign matched artist - no race conditions"""
try:
# Extract track info for queue display (same as start_download)
full_filename = search_result.filename
import os
filename = os.path.basename(full_filename)
# Use TrackResult's parsed metadata if available, otherwise parse filename
if hasattr(search_result, 'title') and search_result.title:
title = search_result.title
else:
# Fallback parsing logic (copied from start_download)
name_without_ext = filename
if '.' in name_without_ext:
name_without_ext = '.'.join(name_without_ext.split('.')[:-1])
import re
track_number_match = re.match(r'^(\d+)\.\s*(.+)', name_without_ext)
if track_number_match:
name_without_track_num = track_number_match.group(2)
else:
name_without_track_num = name_without_ext
parts = name_without_track_num.split(' - ')
if len(parts) >= 2:
title = ' - '.join(parts[1:]).strip()
else:
title = name_without_track_num.strip()
# Use TrackResult's artist if available, otherwise parse or use username
if hasattr(search_result, 'artist') and search_result.artist:
original_artist = search_result.artist
else:
# Same fallback logic as start_download
name_without_ext = filename
if '.' in name_without_ext:
name_without_ext = '.'.join(name_without_ext.split('.')[:-1])
import re
track_number_match = re.match(r'^(\d+)\.\s*(.+)', name_without_ext)
if track_number_match:
name_without_track_num = track_number_match.group(2)
else:
name_without_track_num = name_without_ext
parts = name_without_track_num.split(' - ')
if len(parts) >= 2:
original_artist = parts[0].strip()
else:
original_artist = search_result.username
# Final cleanup
if not title or title == '':
title = filename
if not original_artist or original_artist == '':
original_artist = search_result.username
# Extract album context
album_name = getattr(search_result, 'album', None)
track_number = getattr(search_result, 'track_number', None)
# Generate download ID
import time
download_id = f"{search_result.username}_{filename}_{int(time.time())}"
# Create download item with matched artist immediately
download_item = self.download_queue.add_download_item(
title=title,
artist=original_artist,
status="downloading",
progress=0,
file_size=search_result.size,
download_id=download_id,
username=search_result.username,
file_path=full_filename,
soulseek_client=self.soulseek_client,
album=album_name,
track_number=track_number
)
# Immediately assign the matched artist - no timing delays
if download_item:
download_item.matched_artist = artist
print(f"✅ Matched artist '{artist.name}' assigned to download item '{download_item.title}'")
# Start the download thread
download_thread = DownloadThread(self.soulseek_client, search_result, download_item)
download_thread.download_completed.connect(self.on_download_completed, Qt.ConnectionType.QueuedConnection)
download_thread.download_failed.connect(self.on_download_failed, Qt.ConnectionType.QueuedConnection)
download_thread.download_progress.connect(self.on_download_progress, Qt.ConnectionType.QueuedConnection)
download_thread.finished.connect(
functools.partial(self.on_download_thread_finished, download_thread),
Qt.ConnectionType.QueuedConnection
)
self.download_threads.append(download_thread)
download_thread.start()
return download_item
except Exception as e:
print(f"❌ Failed to start download with artist: {str(e)}")
return None
def _ensure_album_consistency(self, download_items, artist: Artist, album_name: str):
"""Ensure all download items have consistent album naming for proper grouping"""
try:
with self.album_cache_lock:
# Create cache key for this album
album_key = f"{artist.name}::{album_name}"
# Store the definitive album name
self.album_name_cache[album_key] = album_name
print(f"🔒 Cached album name: '{album_name}' for key: '{album_key}'")
# Ensure all download items use the same album name
for download_item in download_items:
if hasattr(download_item, 'album'):
download_item.album = album_name
print(f" ✅ Set album name for '{download_item.title}': '{album_name}'")
except Exception as e:
print(f"❌ Error ensuring album consistency: {e}")
def _assign_matched_artist_to_download_item(self, search_result, artist: Artist): def _assign_matched_artist_to_download_item(self, search_result, artist: Artist):
"""Assign matched artist to the corresponding download item""" """Assign matched artist to the corresponding download item"""
try: try:
@ -5940,15 +6102,22 @@ class DownloadsPage(QWidget):
print(f" 🎵 Track {track_index}: '{clean_track_title}' -> Artist: '{artist.name}', Album: '{clean_album_title}', Track#: {track_index}") print(f" 🎵 Track {track_index}: '{clean_track_title}' -> Artist: '{artist.name}', Album: '{clean_album_title}', Track#: {track_index}")
# Start downloading all tracks with normal process but enhanced with Spotify metadata # Start downloading all tracks with matched artist immediately
import time
download_items = []
for track_index, track in enumerate(album_result.tracks, 1): for track_index, track in enumerate(album_result.tracks, 1):
print(f"🎬 Starting download {track_index}/{len(album_result.tracks)}: {track.title}") print(f"🎬 Starting download {track_index}/{len(album_result.tracks)}: {track.title}")
self.start_download(track) download_item = self._start_download_with_artist(track, artist)
# Add a small delay between downloads to avoid overwhelming the system if download_item:
QTimer.singleShot(200, lambda: None) # 200ms delay download_items.append(download_item)
# Small delay between downloads to avoid overwhelming Soulseek
time.sleep(0.1)
# Assign matched artist to download items after they're created print(f"✅ Successfully queued {len(download_items)}/{len(album_result.tracks)} tracks with matched artist")
QTimer.singleShot(500, lambda: self._assign_matched_artist_to_album_downloads(album_result, artist))
# Pre-calculate and cache the album name to ensure consistency
if download_items:
self._ensure_album_consistency(download_items, artist, clean_album_title)
print(f"✓ Queued {len(album_result.tracks)} tracks for matched download from album: {album_result.album_title}") print(f"✓ Queued {len(album_result.tracks)} tracks for matched download from album: {album_result.album_title}")
print(f"🎯 All tracks have album context preserved: '{album_result.album_title}'") print(f"🎯 All tracks have album context preserved: '{album_result.album_title}'")
@ -6302,58 +6471,66 @@ class DownloadsPage(QWidget):
This ensures all tracks from the same album get the same folder name. This ensures all tracks from the same album get the same folder name.
""" """
try: try:
# Get the original album name from the download item (if it has one) with self.album_cache_lock:
original_album = getattr(download_item, 'album', None) # Get the original album name from the download item (if it has one)
detected_album = album_info.get('album_name', '') original_album = getattr(download_item, 'album', None)
detected_album = album_info.get('album_name', '')
# Extract base album name (without edition indicators)
if album_info.get('spotify_track'): # Extract base album name (without edition indicators)
# Use Spotify album name for base if album_info.get('spotify_track'):
base_album = self._get_base_album_name(detected_album) # Use Spotify album name for base
elif original_album: base_album = self._get_base_album_name(detected_album)
# Clean the original Soulseek album name elif original_album:
cleaned_original = self._clean_album_title(original_album, artist.name) # Clean the original Soulseek album name
base_album = self._get_base_album_name(cleaned_original) cleaned_original = self._clean_album_title(original_album, artist.name)
else: base_album = self._get_base_album_name(cleaned_original)
base_album = self._get_base_album_name(detected_album) else:
base_album = self._get_base_album_name(detected_album)
# Normalize the base name (handle case variations, etc.)
base_album = self._normalize_base_album_name(base_album, artist.name) # Normalize the base name (handle case variations, etc.)
base_album = self._normalize_base_album_name(base_album, artist.name)
# Create a key for this album group (artist + base album)
album_key = f"{artist.name}::{base_album}" # Create a key for this album group (artist + base album)
album_key = f"{artist.name}::{base_album}"
print(f"🔍 Album grouping - Key: '{album_key}', Detected: '{detected_album}'")
# Check if we already have a cached result for this album
# Check if this track indicates a deluxe edition if album_key in self.album_name_cache:
is_deluxe_track = False cached_name = self.album_name_cache[album_key]
if album_info.get('spotify_track'): print(f"🔍 Using cached album name for '{album_key}': '{cached_name}'")
is_deluxe_track = self._detect_deluxe_edition(detected_album) return cached_name
elif original_album:
is_deluxe_track = self._detect_deluxe_edition(original_album) print(f"🔍 Album grouping - Key: '{album_key}', Detected: '{detected_album}'")
# Get current edition level for this album group (default to standard) # Check if this track indicates a deluxe edition
current_edition = self.album_editions.get(album_key, "standard") is_deluxe_track = False
if album_info.get('spotify_track'):
# SMART ALGORITHM: Upgrade to deluxe if this track is deluxe is_deluxe_track = self._detect_deluxe_edition(detected_album)
if is_deluxe_track and current_edition == "standard": elif original_album:
print(f"🎯 UPGRADE: Album '{base_album}' upgraded from standard to deluxe!") is_deluxe_track = self._detect_deluxe_edition(original_album)
self.album_editions[album_key] = "deluxe"
current_edition = "deluxe" # Get current edition level for this album group (default to standard)
current_edition = self.album_editions.get(album_key, "standard")
# Build final album name based on edition level
if current_edition == "deluxe": # SMART ALGORITHM: Upgrade to deluxe if this track is deluxe
final_album_name = f"{base_album} (Deluxe Edition)" if is_deluxe_track and current_edition == "standard":
else: print(f"🎯 UPGRADE: Album '{base_album}' upgraded from standard to deluxe!")
final_album_name = base_album self.album_editions[album_key] = "deluxe"
current_edition = "deluxe"
# Store the resolution
self.album_groups[album_key] = final_album_name # Build final album name based on edition level
self.album_artists[album_key] = artist.name if current_edition == "deluxe":
final_album_name = f"{base_album} (Deluxe Edition)"
print(f"🔗 Album resolution: '{detected_album}' -> '{final_album_name}' (edition: {current_edition})") else:
final_album_name = base_album
return final_album_name
# Store the resolution in both caches
self.album_groups[album_key] = final_album_name
self.album_name_cache[album_key] = final_album_name
self.album_artists[album_key] = artist.name
print(f"🔗 Album resolution: '{detected_album}' -> '{final_album_name}' (edition: {current_edition})")
return final_album_name
except Exception as e: except Exception as e:
print(f"❌ Error resolving album group: {e}") print(f"❌ Error resolving album group: {e}")
@ -8392,4 +8569,14 @@ class DownloadsPage(QWidget):
layout.addStretch() layout.addStretch()
layout.addWidget(download_btn) layout.addWidget(download_btn)
return item return item
def cleanup_resources(self):
"""Clean up resources when page is destroyed"""
try:
# Shutdown thread pool
if hasattr(self, 'api_thread_pool'):
self.api_thread_pool.shutdown(wait=False)
print("🧹 API thread pool shutdown")
except Exception as e:
print(f"❌ Error during resource cleanup: {e}")