diff --git a/.spotify_cache b/.spotify_cache index 88e57138..e988b969 100644 --- a/.spotify_cache +++ b/.spotify_cache @@ -1 +1 @@ -{"access_token": "BQBSqtKDkqV6UKUvbjiQwVB9gKAkFH77HtkUMg1ZL6xiqnBvWilNsahaVug2bEetLc6yJ-kQYSlihYdduklgYnWk4Yy-S985W8l6-4YeiBUTBUauA33_mEUHZm7Fsl_cU-3OMiWexQSyuEN-Mzi_dKqSQyISZSItvgMnuBpM8lMuqeANGmX_mQeYFVztUaSFTmuXOE5j1-UIiz18vHJzpCUc34cZcIJ1SUzxbLY9JJLxrX81MTMCfoynkGepJWne", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1752516257, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/ui/pages/__pycache__/downloads.cpython-310.pyc b/ui/pages/__pycache__/downloads.cpython-310.pyc index 84134e5a..9983b2ae 100644 Binary files a/ui/pages/__pycache__/downloads.cpython-310.pyc and b/ui/pages/__pycache__/downloads.cpython-310.pyc differ diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc index 8e4e2cd0..366e913a 100644 Binary files a/ui/pages/__pycache__/downloads.cpython-312.pyc and b/ui/pages/__pycache__/downloads.cpython-312.pyc differ diff --git a/ui/pages/downloads.py b/ui/pages/downloads.py index c87a9f1a..8646c5b2 100644 --- a/ui/pages/downloads.py +++ b/ui/pages/downloads.py @@ -3856,6 +3856,11 @@ class DownloadsPage(QWidget): self.spotify_client = SpotifyClient() self.matching_engine = MusicMatchingEngine() + # Album grouping system to ensure tracks from same album go to same folder + 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") + # Initialize audio player for streaming self.audio_player = AudioPlayer(self) self.audio_player.playback_finished.connect(self.on_audio_playback_finished) @@ -6058,6 +6063,18 @@ class DownloadsPage(QWidget): # Determine if this is a single or album track album_info = self._detect_album_info(download_item, artist) + # Resolve consistent album name for grouping tracks from same album + if album_info and album_info['is_album']: + print(f"\n๐ŸŽฏ SMART ALBUM GROUPING for track: '{download_item.title}'") + print(f" Original album: '{getattr(download_item, 'album', 'None')}'") + print(f" Detected album: '{album_info.get('album_name', 'None')}'") + + consistent_album_name = self._resolve_album_group(download_item, artist, album_info) + album_info['album_name'] = consistent_album_name + + print(f" Final album name: '{consistent_album_name}'") + print(f"๐Ÿ”— โœ… Album grouping complete!\n") + if album_info and album_info['is_album']: # Album track structure: Transfer/ARTIST/ARTIST - ALBUM/TRACK# TRACK.ext print(f"๐Ÿ” Creating album folder:") @@ -6189,7 +6206,7 @@ class DownloadsPage(QWidget): for pattern in quality_patterns: cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE) - # Remove source/torrent indicators + # Remove source/torrent indicators source_patterns = [ r'\s*[\[\(]album[\]\)]\s*', r'\s*[\[\(]itunes[\]\)]\s*', @@ -6200,7 +6217,10 @@ class DownloadsPage(QWidget): r'\s*[\[\(]bonus\s*tracks?[\]\)]\s*', r'\s*[\[\(]remaster(ed)?[\]\)]\s*', r'\s*[\[\(]clean[\]\)]\s*', - r'\s*[\[\(]explicit[\]\)]\s*' + r'\s*[\[\(]explicit[\]\)]\s*', + r'\s*[\[\(]album\+itunes\+bonus\s*tracks?[\]\)]\s*', # [Album+iTunes+Bonus Tracks] + r'\s*[\[\(]itunes\+bonus[\]\)]\s*', + r'\s*[\[\(]bonus[\]\)]\s*' ] for pattern in source_patterns: cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE) @@ -6276,6 +6296,193 @@ class DownloadsPage(QWidget): print(f"๐Ÿงน Track Title Result: '{original}' -> '{cleaned}'") return cleaned + def _resolve_album_group(self, download_item, artist: Artist, album_info: dict) -> str: + """ + Smart album grouping: Start with standard, upgrade to deluxe if ANY track is deluxe. + 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 + + except Exception as e: + print(f"โŒ Error resolving album group: {e}") + return album_info.get('album_name', download_item.title) + + def _normalize_base_album_name(self, base_album: str, artist_name: str) -> str: + """ + Normalize the base album name to handle case variations and known corrections. + """ + import re + + # Apply known album corrections for consistent naming + normalized_lower = base_album.lower().strip() + + known_corrections = { + 'good kid maad city': 'good kid, m.A.A.d city', + 'good kid m.a.a.d city': 'good kid, m.A.A.d city', + 'good kid m.a.a.d. city': 'good kid, m.A.A.d city', + 'good kid m a a d city': 'good kid, m.A.A.d city', + 'good kid m.a.a.d city': 'good kid, m.A.A.d city' + } + + for key, correction in known_corrections.items(): + if key == normalized_lower: + print(f"๐Ÿ“ Base album correction: '{base_album}' -> '{correction}'") + return correction + + # If no specific correction, return cleaned version + return base_album.strip() + + def _normalize_spotify_album_variants(self, album_name: str, artist_name: str) -> str: + """ + Normalize different Spotify album variants to a consistent name. + E.g., 'good kid, m.A.A.d city' and 'good kid, m.A.A.d city (Deluxe)' + should both resolve to 'good kid, m.A.A.d city (Deluxe Edition)' + """ + import re + + normalized = album_name.strip() + + # Convert various deluxe indicators to standard format + deluxe_patterns = [ + (r'\s*\(deluxe\)\s*$', ' (Deluxe Edition)'), + (r'\s*\[deluxe\]\s*$', ' (Deluxe Edition)'), + (r'\s*deluxe\s*$', ' (Deluxe Edition)'), + (r'\s*\(deluxe\s+edition\)\s*$', ' (Deluxe Edition)'), + (r'\s*\[deluxe\s+edition\]\s*$', ' (Deluxe Edition)') + ] + + for pattern, replacement in deluxe_patterns: + if re.search(pattern, normalized, re.IGNORECASE): + normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE) + break + + # Normalize case inconsistencies for known albums + # This handles cases like "good kid, m.A.A.d city" vs "Good Kid M.A.A.D City" + known_album_corrections = { + 'good kid maad city': 'good kid, m.A.A.d city', + 'good kid m.a.a.d city': 'good kid, m.A.A.d city', + 'good kid m.a.a.d. city': 'good kid, m.A.A.d city', + 'good kid m.a.a.d city': 'good kid, m.A.A.d city', + 'good kid m a a d city': 'good kid, m.A.A.d city' + } + + normalized_lower = normalized.lower() + for key, correction in known_album_corrections.items(): + if key in normalized_lower: + # Preserve any edition suffix + suffix = '' + if '(deluxe edition)' in normalized_lower: + suffix = ' (Deluxe Edition)' + elif '(deluxe)' in normalized_lower: + suffix = ' (Deluxe Edition)' + + normalized = correction + suffix + break + + print(f"๐Ÿ“€ Album variant normalization: '{album_name}' -> '{normalized}'") + return normalized + + def _detect_deluxe_edition(self, album_name: str) -> bool: + """ + Detect if an album name indicates a deluxe/special edition. + Returns True if it's a deluxe variant, False for standard. + """ + if not album_name: + return False + + album_lower = album_name.lower() + + # Check for deluxe indicators + deluxe_indicators = [ + 'deluxe', + 'deluxe edition', + 'special edition', + 'expanded edition', + 'extended edition', + 'bonus', + 'remastered', + 'anniversary', + 'collectors edition', + 'limited edition' + ] + + for indicator in deluxe_indicators: + if indicator in album_lower: + print(f"๐ŸŽฏ Detected deluxe edition: '{album_name}' contains '{indicator}'") + return True + + return False + + def _get_base_album_name(self, album_name: str) -> str: + """ + Extract the base album name without edition indicators. + E.g., 'good kid, m.A.A.d city (Deluxe Edition)' -> 'good kid, m.A.A.d city' + """ + import re + + # Remove common edition suffixes + base_name = album_name + + # Remove edition indicators in parentheses or brackets + base_name = re.sub(r'\s*[\[\(](deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited).*?[\]\)]\s*$', '', base_name, flags=re.IGNORECASE) + + # Remove standalone edition words at the end + base_name = re.sub(r'\s+(deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited)\s*(edition)?\s*$', '', base_name, flags=re.IGNORECASE) + + return base_name.strip() + def _detect_album_info(self, download_item, artist: Artist) -> Optional[dict]: """Detect if track is part of an album using Spotify API as primary source""" try: