From 8248fab16e93ca74b19040994a29c0069d766ea6 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 26 Jan 2026 17:00:51 -0800 Subject: [PATCH] Fix iTunes wishlist and remove Single suffix iTunes tracks now include album field for proper wishlist support. Strip " - Single" and " - EP" suffixes from iTunes album names. --- core/itunes_client.py | 57 ++++++++++++++++++++++++++++++++++++------ web_server.py | 26 ++++++++++++++++--- webui/static/script.js | 23 +++++++++++++++-- 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/core/itunes_client.py b/core/itunes_client.py index 040ef4e7..9858578d 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -40,6 +40,23 @@ def rate_limited(func): raise e return wrapper +def _clean_itunes_album_name(album_name: str) -> str: + """ + Remove iTunes-specific suffixes like " - Single", " - EP" from album names. + iTunes API adds these suffixes but users don't want them displayed. + """ + if not album_name: + return album_name + + # List of suffixes to remove + suffixes_to_remove = [' - Single', ' - EP'] + + for suffix in suffixes_to_remove: + if album_name.endswith(suffix): + return album_name[:-len(suffix)] + + return album_name + @dataclass class Track: id: str @@ -75,7 +92,7 @@ class Track: id=str(track_data.get('trackId', '')), name=track_data.get('trackName', ''), artists=artists, - album=track_data.get('collectionName', ''), + album=_clean_itunes_album_name(track_data.get('collectionName', '')), duration_ms=track_data.get('trackTimeMillis', 0), popularity=0, # iTunes doesn't provide popularity preview_url=track_data.get('previewUrl'), @@ -161,7 +178,7 @@ class Album: return cls( id=str(album_data.get('collectionId', '')), - name=album_data.get('collectionName', ''), + name=_clean_itunes_album_name(album_data.get('collectionName', '')), artists=[album_data.get('artistName', 'Unknown Artist')], release_date=album_data.get('releaseDate', ''), total_tracks=track_count, @@ -370,7 +387,7 @@ class iTunesClient: 'primary_artist': clean_artist_name, 'album': { 'id': str(track_data.get('collectionId', '')), - 'name': track_data.get('collectionName', ''), + 'name': _clean_itunes_album_name(track_data.get('collectionName', '')), 'total_tracks': track_data.get('trackCount', 0), 'release_date': track_data.get('releaseDate', ''), 'album_type': 'album', # iTunes doesn't distinguish clearly @@ -408,7 +425,8 @@ class iTunesClient: continue # Get album name and explicitness - album_name = album_data.get('collectionName', '').lower().strip() + # Clean album name before comparison for better deduplication + album_name = _clean_itunes_album_name(album_data.get('collectionName', '')).lower().strip() artist_name = album_data.get('artistName', '').lower().strip() is_explicit = album_data.get('collectionExplicitness') == 'explicit' @@ -466,7 +484,7 @@ class iTunesClient: album_result = { 'id': str(album_data.get('collectionId', '')), - 'name': album_data.get('collectionName', ''), + 'name': _clean_itunes_album_name(album_data.get('collectionName', '')), 'images': images, 'artists': [{'name': album_data.get('artistName', 'Unknown Artist'), 'id': str(album_data.get('artistId', ''))}], 'release_date': album_data.get('releaseDate', '')[:10] if album_data.get('releaseDate') else '', # YYYY-MM-DD format @@ -498,8 +516,22 @@ class iTunesClient: return None # First result is usually the album/collection info - # Remaining results are tracks - + # Extract album information to include in each track (like Spotify does) + album_info = None + album_images = [] + for item in results: + if item.get('wrapperType') == 'collection': + album_info = item + # Build album images array + if item.get('artworkUrl100'): + base_url = item['artworkUrl100'].replace('100x100bb', '{size}x{size}bb') + album_images = [ + {'url': base_url.replace('{size}x{size}bb', '600x600bb'), 'height': 600, 'width': 600}, + {'url': base_url.replace('{size}x{size}bb', '300x300bb'), 'height': 300, 'width': 300}, + {'url': item['artworkUrl100'], 'height': 100, 'width': 100} + ] + break + # Collect artist IDs for batch lookup artist_ids = set() for item in results: @@ -518,12 +550,21 @@ class iTunesClient: if item.get('wrapperType') == 'track' and item.get('kind') == 'song': artist_id = str(item.get('artistId', '')) clean_artist = clean_artist_map.get(artist_id, item.get('artistName', 'Unknown Artist')) - + + # Build album object for this track (like Spotify format) + track_album = { + 'id': str(item.get('collectionId', album_id)), + 'name': _clean_itunes_album_name(item.get('collectionName', 'Unknown Album')), + 'images': album_images, + 'release_date': item.get('releaseDate', '')[:10] if item.get('releaseDate') else '' + } + # Normalize each track to Spotify-compatible format normalized_track = { 'id': str(item.get('trackId', '')), 'name': item.get('trackName', ''), 'artists': [{'name': clean_artist}], # List of dicts like Spotify + 'album': track_album, # CRITICAL: Include album info like Spotify does 'duration_ms': item.get('trackTimeMillis', 0), 'track_number': item.get('trackNumber', 0), 'disc_number': item.get('discNumber', 1), diff --git a/web_server.py b/web_server.py index 0729de97..f4369dfd 100644 --- a/web_server.py +++ b/web_server.py @@ -11447,8 +11447,9 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): with tasks_lock: if batch_id in download_batches: download_batches[batch_id]['wishlist_summary'] = completion_summary + download_batches[batch_id]['wishlist_processing_complete'] = True # Phase already set to 'complete' in _on_download_completed - + print(f"✅ [Wishlist Processing] Completed wishlist processing for batch {batch_id}") return completion_summary @@ -11469,6 +11470,7 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): 'total_failed': 0, 'error_message': str(e) } + download_batches[batch_id]['wishlist_processing_complete'] = True except Exception as lock_error: print(f"❌ [Wishlist Processing] Failed to update batch after error: {lock_error}") @@ -11717,7 +11719,10 @@ def _on_download_completed(batch_id, task_id, success=True): print(f"🎉 [Batch Manager] Batch {batch_id} complete - stopping monitor") download_monitor.stop_monitoring(batch_id) - + + # Mark that wishlist processing is starting (prevents premature cleanup) + batch['wishlist_processing_started'] = True + # Process wishlist outside of the lock to prevent threading issues if is_auto_batch: # For auto-initiated batches, handle completion and schedule next cycle @@ -13824,9 +13829,22 @@ def cleanup_batch(): with tasks_lock: # Check if the batch exists before trying to delete if batch_id in download_batches: + batch = download_batches[batch_id] + + # CRITICAL: Don't allow cleanup if wishlist processing is in progress + # This prevents a race condition where cleanup deletes the batch before + # the wishlist processing thread can access it + if batch.get('wishlist_processing_started') and not batch.get('wishlist_processing_complete'): + print(f"⏳ [Cleanup] Batch {batch_id} cleanup deferred - wishlist processing in progress") + return jsonify({ + "success": False, + "error": "Batch cleanup deferred - wishlist processing in progress", + "deferred": True + }), 202 # 202 = Accepted but not yet processed + # Get the list of task IDs before deleting the batch - task_ids_to_remove = download_batches[batch_id].get('queue', []) - + task_ids_to_remove = batch.get('queue', []) + # Delete the batch record del download_batches[batch_id] diff --git a/webui/static/script.js b/webui/static/script.js index 1b0fdaf2..1cb6434b 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -5068,12 +5068,31 @@ async function cleanupDownloadProcess(playlistId) { if (process.batchId) { try { console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`); - await fetch('/api/playlists/cleanup_batch', { + const response = await fetch('/api/playlists/cleanup_batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ batch_id: process.batchId }) }); - console.log(`✅ Server cleanup completed for batch: ${process.batchId}`); + + // Handle deferred cleanup (202 = wishlist processing in progress) + if (response.status === 202) { + console.log(`⏳ Wishlist processing in progress for batch ${process.batchId}, will retry cleanup in 2s...`); + // Retry cleanup after delay to allow wishlist processing to complete + setTimeout(async () => { + try { + await fetch('/api/playlists/cleanup_batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ batch_id: process.batchId }) + }); + console.log(`✅ Delayed cleanup completed for batch: ${process.batchId}`); + } catch (error) { + console.warn(`⚠️ Delayed cleanup failed:`, error); + } + }, 2000); // 2 second delay + } else { + console.log(`✅ Server cleanup completed for batch: ${process.batchId}`); + } } catch (error) { console.warn(`⚠️ Failed to send cleanup request to server:`, error); // Don't show toast for cleanup failures - they're not user-facing