pull/2/head
Broque Thomas 10 months ago
parent b7ef260609
commit 32d1cfd06f

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

@ -2,12 +2,51 @@ import spotipy
from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials
from typing import Dict, List, Optional, Any
import time
import threading
from functools import wraps
from dataclasses import dataclass
from utils.logging_config import get_logger
from config.settings import config_manager
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
class Track:
id: str
@ -180,6 +219,7 @@ class SpotifyClient:
logger.error(f"Error fetching playlist {playlist_id}: {e}")
return None
@rate_limited
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
if not self.is_authenticated():
return []
@ -198,6 +238,7 @@ class SpotifyClient:
logger.error(f"Error searching tracks: {e}")
return []
@rate_limited
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
"""Search for artists using Spotify API"""
if not self.is_authenticated():
@ -217,6 +258,7 @@ class SpotifyClient:
logger.error(f"Error searching artists: {e}")
return []
@rate_limited
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed track information including album data and track number"""
if not self.is_authenticated():

File diff suppressed because it is too large Load Diff

@ -178,10 +178,36 @@ class SpotifyMatchingModal(QDialog):
loading_label.setStyleSheet("color: #ccc; padding: 20px;")
self.auto_layout.addWidget(loading_label)
# Start suggestion generation in background
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()
# Use thread pool instead of creating new thread
if hasattr(self.parent(), 'api_thread_pool'):
future = self.parent().api_thread_pool.submit(self._generate_suggestions_worker)
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]):
"""Display the generated auto suggestions"""
@ -3857,9 +3883,17 @@ class DownloadsPage(QWidget):
self.matching_engine = MusicMatchingEngine()
# 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_artists = {} # Maps original album name -> artist for consistency
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
self.audio_player = AudioPlayer(self)
@ -5866,17 +5900,145 @@ class DownloadsPage(QWidget):
search_result.matched_artist = artist
# 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
# We need to do this after the download is started so the download item exists
QTimer.singleShot(100, lambda: self._assign_matched_artist_to_download_item(search_result, artist))
if download_item:
print(f"✅ Successfully created matched download for '{download_item.title}'")
else:
print(f"❌ Failed to create matched download, falling back to normal download")
self.start_download(search_result)
except Exception as e:
print(f"❌ Error handling matched download: {e}")
# Fallback to normal download
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):
"""Assign matched artist to the corresponding download item"""
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}")
# 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):
print(f"🎬 Starting download {track_index}/{len(album_result.tracks)}: {track.title}")
self.start_download(track)
# Add a small delay between downloads to avoid overwhelming the system
QTimer.singleShot(200, lambda: None) # 200ms delay
download_item = self._start_download_with_artist(track, artist)
if download_item:
download_items.append(download_item)
# Small delay between downloads to avoid overwhelming Soulseek
time.sleep(0.1)
print(f"✅ Successfully queued {len(download_items)}/{len(album_result.tracks)} tracks with matched artist")
# Assign matched artist to download items after they're created
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"🎯 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.
"""
try:
# Get the original album name from the download item (if it has one)
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'):
# Use Spotify album name for base
base_album = self._get_base_album_name(detected_album)
elif original_album:
# Clean the original Soulseek album name
cleaned_original = self._clean_album_title(original_album, artist.name)
base_album = self._get_base_album_name(cleaned_original)
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)
# 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 this track indicates a deluxe edition
is_deluxe_track = False
if album_info.get('spotify_track'):
is_deluxe_track = self._detect_deluxe_edition(detected_album)
elif original_album:
is_deluxe_track = self._detect_deluxe_edition(original_album)
# Get current edition level for this album group (default to standard)
current_edition = self.album_editions.get(album_key, "standard")
# SMART ALGORITHM: Upgrade to deluxe if this track is deluxe
if is_deluxe_track and current_edition == "standard":
print(f"🎯 UPGRADE: Album '{base_album}' upgraded from standard to deluxe!")
self.album_editions[album_key] = "deluxe"
current_edition = "deluxe"
# Build final album name based on edition level
if current_edition == "deluxe":
final_album_name = f"{base_album} (Deluxe Edition)"
else:
final_album_name = base_album
# Store the resolution
self.album_groups[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
with self.album_cache_lock:
# Get the original album name from the download item (if it has one)
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'):
# Use Spotify album name for base
base_album = self._get_base_album_name(detected_album)
elif original_album:
# Clean the original Soulseek album name
cleaned_original = self._clean_album_title(original_album, artist.name)
base_album = self._get_base_album_name(cleaned_original)
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)
# Create a key for this album group (artist + base album)
album_key = f"{artist.name}::{base_album}"
# Check if we already have a cached result for this album
if album_key in self.album_name_cache:
cached_name = self.album_name_cache[album_key]
print(f"🔍 Using cached album name for '{album_key}': '{cached_name}'")
return cached_name
print(f"🔍 Album grouping - Key: '{album_key}', Detected: '{detected_album}'")
# Check if this track indicates a deluxe edition
is_deluxe_track = False
if album_info.get('spotify_track'):
is_deluxe_track = self._detect_deluxe_edition(detected_album)
elif original_album:
is_deluxe_track = self._detect_deluxe_edition(original_album)
# Get current edition level for this album group (default to standard)
current_edition = self.album_editions.get(album_key, "standard")
# SMART ALGORITHM: Upgrade to deluxe if this track is deluxe
if is_deluxe_track and current_edition == "standard":
print(f"🎯 UPGRADE: Album '{base_album}' upgraded from standard to deluxe!")
self.album_editions[album_key] = "deluxe"
current_edition = "deluxe"
# Build final album name based on edition level
if current_edition == "deluxe":
final_album_name = f"{base_album} (Deluxe Edition)"
else:
final_album_name = base_album
# 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:
print(f"❌ Error resolving album group: {e}")
@ -8392,4 +8569,14 @@ class DownloadsPage(QWidget):
layout.addStretch()
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}")
Loading…
Cancel
Save