diff --git a/core/wishlist_service.py b/core/wishlist_service.py index 62d97cd0..c6d102f0 100644 --- a/core/wishlist_service.py +++ b/core/wishlist_service.py @@ -165,6 +165,52 @@ class WishlistService: """Clear all tracks from the wishlist""" return self.database.clear_wishlist() + def check_track_in_wishlist(self, spotify_track_id: str) -> bool: + """Check if a track exists in the wishlist by Spotify track ID""" + try: + wishlist_tracks = self.get_wishlist_tracks_for_download() + for track in wishlist_tracks: + if track.get('spotify_track_id') == spotify_track_id or track.get('id') == spotify_track_id: + return True + return False + except Exception as e: + logger.error(f"Error checking track in wishlist: {e}") + return False + + def find_matching_wishlist_track(self, track_name: str, artist_name: str) -> Optional[Dict[str, Any]]: + """ + Find a matching track in the wishlist using fuzzy matching on name and artist. + Returns the first matching wishlist track or None if no match found. + """ + try: + wishlist_tracks = self.get_wishlist_tracks_for_download() + + # Normalize input for comparison + normalized_track_name = track_name.lower().strip() + normalized_artist_name = artist_name.lower().strip() + + for wl_track in wishlist_tracks: + wl_name = wl_track.get('name', '').lower().strip() + wl_artists = wl_track.get('artists', []) + + # Extract artist name from wishlist track + wl_artist_name = '' + if wl_artists: + if isinstance(wl_artists[0], dict): + wl_artist_name = wl_artists[0].get('name', '').lower().strip() + else: + wl_artist_name = str(wl_artists[0]).lower().strip() + + # Simple exact matching (could be enhanced with fuzzy matching algorithms) + if wl_name == normalized_track_name and wl_artist_name == normalized_artist_name: + return wl_track + + return None + + except Exception as e: + logger.error(f"Error finding matching wishlist track: {e}") + return None + def get_wishlist_summary(self) -> Dict[str, Any]: """Get a summary of the wishlist for dashboard display""" try: diff --git a/web_server.py b/web_server.py index 1d79b064..ac2ef73f 100644 --- a/web_server.py +++ b/web_server.py @@ -4525,6 +4525,12 @@ def _post_process_matched_download(context_key, context, file_path): print(f"โœ… Post-processing complete for: {final_path}") + # WISHLIST REMOVAL: Check if this track should be removed from wishlist after successful download + try: + _check_and_remove_from_wishlist(context) + except Exception as wishlist_error: + print(f"โš ๏ธ [Post-Process] Error checking wishlist removal: {wishlist_error}") + # Call completion callback for missing downloads tasks to start next batch task_id = context.get('task_id') batch_id = context.get('batch_id') @@ -4559,6 +4565,232 @@ def _post_process_matched_download(context_key, context, file_path): # Keep track of processed downloads to avoid re-processing _processed_download_ids = set() +def _check_and_remove_from_wishlist(context): + """ + Check if a successfully downloaded track should be removed from wishlist. + Extracts Spotify track data from download context and removes from wishlist if found. + """ + try: + from core.wishlist_service import get_wishlist_service + wishlist_service = get_wishlist_service() + + # Try to extract Spotify track ID from various sources in the context + spotify_track_id = None + + # Method 1: Direct track_info with id + track_info = context.get('track_info', {}) + if track_info.get('id'): + spotify_track_id = track_info['id'] + print(f"๐Ÿ“‹ [Wishlist] Found Spotify ID from track_info: {spotify_track_id}") + + # Method 2: From original search result + elif context.get('original_search_result', {}).get('id'): + spotify_track_id = context['original_search_result']['id'] + print(f"๐Ÿ“‹ [Wishlist] Found Spotify ID from original_search_result: {spotify_track_id}") + + # Method 3: Check if this is a wishlist download (context has wishlist_id) + elif 'wishlist_id' in track_info: + wishlist_id = track_info['wishlist_id'] + print(f"๐Ÿ“‹ [Wishlist] Found wishlist_id in context: {wishlist_id}") + + # Get the Spotify track ID from the wishlist entry + wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + for wl_track in wishlist_tracks: + if wl_track.get('wishlist_id') == wishlist_id: + spotify_track_id = wl_track.get('spotify_track_id') or wl_track.get('id') + print(f"๐Ÿ“‹ [Wishlist] Found Spotify ID from wishlist entry: {spotify_track_id}") + break + + # Method 4: Try to construct ID from track metadata for fuzzy matching + if not spotify_track_id: + track_name = track_info.get('name') or context.get('original_search_result', {}).get('title', '') + artist_name = _get_track_artist_name(track_info) or _get_track_artist_name(context.get('original_search_result', {})) + + if track_name and artist_name: + print(f"๐Ÿ“‹ [Wishlist] No Spotify ID found, checking for fuzzy match: '{track_name}' by '{artist_name}'") + + # Get all wishlist tracks and find potential matches + wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + for wl_track in wishlist_tracks: + wl_name = wl_track.get('name', '').lower() + wl_artists = wl_track.get('artists', []) + wl_artist_name = '' + + # Extract artist name from wishlist track + if wl_artists: + if isinstance(wl_artists[0], dict): + wl_artist_name = wl_artists[0].get('name', '').lower() + else: + wl_artist_name = str(wl_artists[0]).lower() + + # Simple fuzzy matching + if (wl_name == track_name.lower() and wl_artist_name == artist_name.lower()): + spotify_track_id = wl_track.get('spotify_track_id') or wl_track.get('id') + print(f"๐Ÿ“‹ [Wishlist] Found fuzzy match - Spotify ID: {spotify_track_id}") + break + + # If we found a Spotify track ID, remove it from wishlist + if spotify_track_id: + print(f"๐Ÿ“‹ [Wishlist] Attempting to remove track from wishlist: {spotify_track_id}") + removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True) + if removed: + print(f"โœ… [Wishlist] Successfully removed track from wishlist: {spotify_track_id}") + else: + print(f"โ„น๏ธ [Wishlist] Track not found in wishlist or already removed: {spotify_track_id}") + else: + print(f"โ„น๏ธ [Wishlist] No Spotify track ID found for wishlist removal check") + + except Exception as e: + print(f"โŒ [Wishlist] Error in wishlist removal check: {e}") + import traceback + traceback.print_exc() + +def _check_and_remove_track_from_wishlist_by_metadata(track_data): + """ + Check if a track found during database analysis should be removed from wishlist. + Uses track metadata (name, artists, id) to find and remove from wishlist. + """ + try: + from core.wishlist_service import get_wishlist_service + wishlist_service = get_wishlist_service() + + # Extract track info + track_name = track_data.get('name', '') + track_id = track_data.get('id', '') + artists = track_data.get('artists', []) + + print(f"๐Ÿ“‹ [Analysis] Checking if track should be removed from wishlist: '{track_name}' (ID: {track_id})") + + # Method 1: Direct Spotify ID match + if track_id: + removed = wishlist_service.mark_track_download_result(track_id, success=True) + if removed: + print(f"โœ… [Analysis] Removed track from wishlist via direct ID match: {track_id}") + return True + + # Method 2: Fuzzy matching by name and artist if no direct ID match + if track_name and artists: + # Extract primary artist name + primary_artist = '' + if isinstance(artists[0], dict) and 'name' in artists[0]: + primary_artist = artists[0]['name'] + elif isinstance(artists[0], str): + primary_artist = artists[0] + else: + primary_artist = str(artists[0]) + + print(f"๐Ÿ“‹ [Analysis] No direct ID match, trying fuzzy match: '{track_name}' by '{primary_artist}'") + + # Get all wishlist tracks and find matches + wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + for wl_track in wishlist_tracks: + wl_name = wl_track.get('name', '').lower() + wl_artists = wl_track.get('artists', []) + wl_artist_name = '' + + # Extract artist name from wishlist track + if wl_artists: + if isinstance(wl_artists[0], dict): + wl_artist_name = wl_artists[0].get('name', '').lower() + else: + wl_artist_name = str(wl_artists[0]).lower() + + # Fuzzy matching - normalize strings for comparison + if (wl_name == track_name.lower() and wl_artist_name == primary_artist.lower()): + spotify_track_id = wl_track.get('spotify_track_id') or wl_track.get('id') + if spotify_track_id: + removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True) + if removed: + print(f"โœ… [Analysis] Removed track from wishlist via fuzzy match: {spotify_track_id}") + return True + + print(f"โ„น๏ธ [Analysis] Track not found in wishlist or already removed: '{track_name}'") + return False + + except Exception as e: + print(f"โŒ [Analysis] Error checking wishlist removal by metadata: {e}") + import traceback + traceback.print_exc() + return False + +def _automatic_wishlist_cleanup_after_db_update(): + """ + Automatic wishlist cleanup that runs after database updates. + This is a simplified version of the cleanup API endpoint designed for background execution. + """ + try: + from core.wishlist_service import get_wishlist_service + from database.music_database import MusicDatabase + + wishlist_service = get_wishlist_service() + db = MusicDatabase() + active_server = config_manager.get_active_media_server() + + print("๐Ÿ“‹ [Auto Cleanup] Starting automatic wishlist cleanup after database update...") + + # Get all wishlist tracks + wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + if not wishlist_tracks: + print("๐Ÿ“‹ [Auto Cleanup] No tracks in wishlist to clean up") + return + + print(f"๐Ÿ“‹ [Auto Cleanup] Found {len(wishlist_tracks)} tracks in wishlist") + + removed_count = 0 + + for track in wishlist_tracks: + track_name = track.get('name', '') + artists = track.get('artists', []) + spotify_track_id = track.get('spotify_track_id') or track.get('id') + + # Skip if no essential data + if not track_name or not artists or not spotify_track_id: + continue + + # Check each artist + found_in_db = False + for artist in artists: + # Handle both string format and dict format + if isinstance(artist, str): + artist_name = artist + elif isinstance(artist, dict) and 'name' in artist: + artist_name = artist['name'] + else: + artist_name = str(artist) + + try: + db_track, confidence = db.check_track_exists( + track_name, artist_name, + confidence_threshold=0.7, + server_source=active_server + ) + + if db_track and confidence >= 0.7: + found_in_db = True + print(f"๐Ÿ“‹ [Auto Cleanup] Track found in database: '{track_name}' by {artist_name} (confidence: {confidence:.2f})") + break + + except Exception as db_error: + print(f"โš ๏ธ [Auto Cleanup] Error checking database for track '{track_name}': {db_error}") + continue + + # If found in database, remove from wishlist + if found_in_db: + try: + removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True) + if removed: + removed_count += 1 + print(f"โœ… [Auto Cleanup] Removed track from wishlist: '{track_name}' ({spotify_track_id})") + except Exception as remove_error: + print(f"โŒ [Auto Cleanup] Error removing track from wishlist: {remove_error}") + + print(f"๐Ÿ“‹ [Auto Cleanup] Completed automatic cleanup: {removed_count} tracks removed from wishlist") + + except Exception as e: + print(f"โŒ [Auto Cleanup] Error in automatic wishlist cleanup: {e}") + import traceback + traceback.print_exc() + @app.route('/api/version-info', methods=['GET']) def get_version_info(): """ @@ -4846,6 +5078,14 @@ def _db_update_finished_callback(total_artists, total_albums, total_tracks, succ with db_update_lock: db_update_state["status"] = "finished" db_update_state["phase"] = f"Completed: {successful} successful, {failed} failed." + + # WISHLIST CLEANUP: Automatically clean up wishlist after database update + try: + print("๐Ÿ“‹ [DB Update] Database update completed, starting automatic wishlist cleanup...") + # Run cleanup in background to avoid blocking the UI + missing_download_executor.submit(_automatic_wishlist_cleanup_after_db_update) + except Exception as cleanup_error: + print(f"โš ๏ธ [DB Update] Error starting automatic wishlist cleanup: {cleanup_error}") def _db_update_error_callback(error_message): with db_update_lock: @@ -5002,6 +5242,95 @@ def clear_wishlist(): print(f"Error clearing wishlist: {e}") return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/wishlist/cleanup', methods=['POST']) +def cleanup_wishlist(): + """Endpoint to remove tracks from wishlist that already exist in the database.""" + try: + from core.wishlist_service import get_wishlist_service + from database.music_database import MusicDatabase + + wishlist_service = get_wishlist_service() + db = MusicDatabase() + active_server = config_manager.get_active_media_server() + + print("๐Ÿ“‹ [Wishlist Cleanup] Starting wishlist cleanup process...") + + # Get all wishlist tracks + wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + if not wishlist_tracks: + return jsonify({"success": True, "message": "No tracks in wishlist to clean up", "removed_count": 0}) + + print(f"๐Ÿ“‹ [Wishlist Cleanup] Found {len(wishlist_tracks)} tracks in wishlist") + + removed_count = 0 + processed_count = 0 + + for track in wishlist_tracks: + processed_count += 1 + track_name = track.get('name', '') + artists = track.get('artists', []) + spotify_track_id = track.get('spotify_track_id') or track.get('id') + + # Skip if no essential data + if not track_name or not artists or not spotify_track_id: + continue + + print(f"๐Ÿ“‹ [Wishlist Cleanup] Checking track {processed_count}/{len(wishlist_tracks)}: '{track_name}'") + + # Check each artist + found_in_db = False + for artist in artists: + # Handle both string format and dict format + if isinstance(artist, str): + artist_name = artist + elif isinstance(artist, dict) and 'name' in artist: + artist_name = artist['name'] + else: + artist_name = str(artist) + + try: + db_track, confidence = db.check_track_exists( + track_name, artist_name, + confidence_threshold=0.7, + server_source=active_server + ) + + if db_track and confidence >= 0.7: + found_in_db = True + print(f"๐Ÿ“‹ [Wishlist Cleanup] Track found in database: '{track_name}' by {artist_name} (confidence: {confidence:.2f})") + break + + except Exception as db_error: + print(f"โš ๏ธ [Wishlist Cleanup] Error checking database for track '{track_name}': {db_error}") + continue + + # If found in database, remove from wishlist + if found_in_db: + try: + removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True) + if removed: + removed_count += 1 + print(f"โœ… [Wishlist Cleanup] Removed track from wishlist: '{track_name}' ({spotify_track_id})") + else: + print(f"โš ๏ธ [Wishlist Cleanup] Failed to remove track from wishlist: '{track_name}' ({spotify_track_id})") + except Exception as remove_error: + print(f"โŒ [Wishlist Cleanup] Error removing track from wishlist: {remove_error}") + + print(f"๐Ÿ“‹ [Wishlist Cleanup] Completed cleanup: {removed_count} tracks removed from wishlist") + + return jsonify({ + "success": True, + "message": f"Wishlist cleanup completed: {removed_count} tracks removed", + "removed_count": removed_count, + "processed_count": processed_count + }) + + except Exception as e: + print(f"Error in wishlist cleanup: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/database/update', methods=['POST']) def start_database_update(): """Endpoint to start the database update process.""" @@ -5510,6 +5839,24 @@ def _on_download_completed(batch_id, task_id, success=True): download_batches[batch_id]['permanently_failed_tracks'].append(track_info) print(f"โŒ [Batch Manager] Added failed track to batch tracking: {track_info['track_name']}") + # WISHLIST REMOVAL: Handle successful downloads for wishlist removal + if success and task_id in download_tasks: + try: + task = download_tasks[task_id] + track_info = task.get('track_info', {}) + print(f"๐Ÿ“‹ [Batch Manager] Successful download - checking wishlist removal for task {task_id}") + + # Try to remove from wishlist using track info + if track_info: + # Create a context-like structure for the wishlist removal function + context = { + 'track_info': track_info, + 'original_search_result': track_info # fallback + } + _check_and_remove_from_wishlist(context) + except Exception as wishlist_error: + print(f"โš ๏ธ [Batch Manager] Error checking wishlist removal for successful download: {wishlist_error}") + # Decrement active count old_active = download_batches[batch_id]['active_count'] download_batches[batch_id]['active_count'] -= 1 @@ -5628,6 +5975,13 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): analysis_results.append({ 'track_index': i, 'track': track_data, 'found': found, 'confidence': confidence }) + + # WISHLIST REMOVAL: If track is found in database, check if it should be removed from wishlist + if found and confidence >= 0.7: + try: + _check_and_remove_track_from_wishlist_by_metadata(track_data) + except Exception as wishlist_error: + print(f"โš ๏ธ [Analysis] Error checking wishlist removal for found track: {wishlist_error}") with tasks_lock: if batch_id in download_batches: diff --git a/webui/static/script.js b/webui/static/script.js index e05ff9df..255e1d78 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -3405,6 +3405,9 @@ async function openDownloadMissingWishlistModal() { + @@ -8488,6 +8491,71 @@ async function handleWishlistButtonClick() { } } +async function cleanupWishlist(playlistId) { + try { + // Show information dialog + const confirmed = confirm( + "Cleanup Wishlist\n\n" + + "This will check all wishlist tracks against your music library and automatically remove " + + "any tracks that already exist in your database.\n\n" + + "This is a safe operation that only removes tracks you already have. " + + "Continue with cleanup?" + ); + + if (!confirmed) { + return; + } + + // Disable the cleanup button during the operation + const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`); + if (cleanupBtn) { + cleanupBtn.disabled = true; + cleanupBtn.textContent = '๐Ÿงน Cleaning...'; + } + + const response = await fetch('/api/wishlist/cleanup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (result.success) { + const removedCount = result.removed_count || 0; + const processedCount = result.processed_count || 0; + + if (removedCount > 0) { + showToast(`Wishlist cleanup completed: ${removedCount} tracks removed (${processedCount} checked)`, 'success'); + + // Refresh the modal content to show updated state + setTimeout(() => { + openDownloadMissingWishlistModal(); + }, 500); + + // Update the wishlist count in the main dashboard + await updateWishlistCount(); + } else { + showToast(`Wishlist cleanup completed: No tracks to remove (${processedCount} checked)`, 'info'); + } + } else { + showToast(`Error cleaning wishlist: ${result.error}`, 'error'); + } + + } catch (error) { + console.error('Error cleaning wishlist:', error); + showToast(`Error cleaning wishlist: ${error.message}`, 'error'); + } finally { + // Re-enable the cleanup button + const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`); + if (cleanupBtn) { + cleanupBtn.disabled = false; + cleanupBtn.textContent = '๐Ÿงน Cleanup Wishlist'; + } + } +} + async function clearWishlist(playlistId) { try { // Show confirmation dialog