better
This commit is contained in:
parent
b7ef260609
commit
32d1cfd06f
7 changed files with 9685 additions and 68 deletions
|
|
@ -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"}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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():
|
||||
|
|
|
|||
9388
logs/app.log
9388
logs/app.log
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -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)
|
||||
|
||||
# Assign matched artist to download items after they're created
|
||||
QTimer.singleShot(500, lambda: self._assign_matched_artist_to_album_downloads(album_result, artist))
|
||||
print(f"✅ Successfully queued {len(download_items)}/{len(album_result.tracks)} tracks with matched 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…
Reference in a new issue