From 724f3857684bb14f0df8018be5113fa175b495c6 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sat, 23 Aug 2025 23:27:30 -0700 Subject: [PATCH] matched album downloads --- web_server.py | 339 ++++++++++++++++++++++++++++++----------- webui/static/script.js | 34 +++-- 2 files changed, 269 insertions(+), 104 deletions(-) diff --git a/web_server.py b/web_server.py index 50810cac..4fb597bb 100644 --- a/web_server.py +++ b/web_server.py @@ -1444,64 +1444,147 @@ def search_match(): print(f"❌ Error in match search: {e}") return jsonify({"error": str(e)}), 500 + +def _start_album_download_tasks(album_result, spotify_artist, spotify_album): + """ + This final version now fetches the official Spotify tracklist and uses it to + match and correct the metadata for each individual track before downloading, + ensuring perfect tagging and naming. + """ + print(f"đŸŽĩ Processing matched album download for '{spotify_album['name']}' with {len(album_result.get('tracks', []))} tracks.") + + tracks_to_download = album_result.get('tracks', []) + if not tracks_to_download: + print("âš ī¸ Album result contained no tracks. Aborting.") + return 0 + + # --- THIS IS THE NEW LOGIC --- + # Fetch the official tracklist from Spotify ONCE for the entire album. + official_spotify_tracks = _get_spotify_album_tracks(spotify_album) + if not official_spotify_tracks: + print("âš ī¸ Could not fetch official tracklist from Spotify. Metadata may be inaccurate.") + # --- END OF NEW LOGIC --- + + started_count = 0 + for track_data in tracks_to_download: + try: + username = track_data.get('username') or album_result.get('username') + filename = track_data.get('filename') + size = track_data.get('size', 0) + + if not username or not filename: + continue + + # Pre-parse the filename to get a baseline for metadata + parsed_meta = _parse_filename_metadata(filename) + + # --- THIS IS THE CRITICAL MATCHING STEP --- + # Match the parsed metadata against the official Spotify tracklist + corrected_meta = _match_track_to_spotify_title(parsed_meta, official_spotify_tracks) + # --- END OF CRITICAL STEP --- + + # Create a clean context object using the CORRECTED metadata + individual_track_context = { + 'username': username, + 'filename': filename, + 'size': size, + 'title': corrected_meta.get('title'), + 'artist': corrected_meta.get('artist') or spotify_artist['name'], + 'album': spotify_album['name'], + 'track_number': corrected_meta.get('track_number') + } + + download_id = asyncio.run(soulseek_client.download(username, filename, size)) + + if download_id: + context_key = f"{username}::{filename}" + with matched_context_lock: + matched_downloads_context[context_key] = { + "spotify_artist": spotify_artist, + "spotify_album": spotify_album, + "original_search_result": individual_track_context, # Contains corrected data + "is_album_download": True + } + print(f" + Queued track: {filename} (Matched to: '{corrected_meta.get('title')}')") + started_count += 1 + else: + print(f" - Failed to queue track: {filename}") + + except Exception as e: + print(f"❌ Error processing track in album batch: {track_data.get('filename')}. Error: {e}") + continue + + return started_count + + + + @app.route('/api/download/matched', methods=['POST']) def start_matched_download(): - """Start a matched download with Spotify metadata context""" + """ + Starts a matched download. For albums, it now delegates to the new + _start_album_download_tasks function to process each track individually, + perfectly mirroring the robust logic of downloads.py. + """ try: data = request.get_json() - search_result = data.get('search_result', {}) + # Rename for clarity: this payload is either a single track or a full album object + download_payload = data.get('search_result', {}) spotify_artist = data.get('spotify_artist', {}) - spotify_album = data.get('spotify_album', {}) - - if not search_result or not spotify_artist: - return jsonify({"success": False, "error": "Missing search result or artist data"}), 400 + spotify_album = data.get('spotify_album', None) # Can be None for singles - username = search_result.get('username') - filename = search_result.get('filename') - size = search_result.get('size', 0) + if not download_payload or not spotify_artist: + return jsonify({"success": False, "error": "Missing download payload or artist data"}), 400 - if not username or not filename: - return jsonify({"success": False, "error": "Missing username or filename in search result"}), 400 + # Check if this is an album download (user selected an album in the modal) + # This is the most reliable way to determine the intent from the frontend. + is_album_download = bool(spotify_album and spotify_album.get('id')) - print(f"đŸŽ¯ Starting matched download for: {filename} from {username}") - - # --- THIS IS THE CRITICAL FIX --- - # Pre-parse the filename to get clean metadata BEFORE starting the download. - parsed_meta = _parse_filename_metadata(filename) - - # Update the search_result with the clean, parsed data. - search_result['title'] = parsed_meta.get('title') or search_result.get('title') - search_result['artist'] = parsed_meta.get('artist') or search_result.get('artist') - search_result['album'] = parsed_meta.get('album') or search_result.get('album') - search_result['track_number'] = parsed_meta.get('track_number') or search_result.get('track_number') - # --- END OF FIX --- + if is_album_download: + # It's an album. The download_payload is the full album object. + # Delegate to the dedicated album processor. + started_count = _start_album_download_tasks(download_payload, spotify_artist, spotify_album) + if started_count > 0: + return jsonify({"success": True, "message": f"Queued {started_count} tracks for matched album download."}) + else: + return jsonify({"success": False, "error": "Failed to queue any tracks from the album."}), 500 + else: + # It's a single track. The download_payload is a single track object. + username = download_payload.get('username') + filename = download_payload.get('filename') + size = download_payload.get('size', 0) - download_id_from_client = asyncio.run(soulseek_client.download(username, filename, size)) + if not username or not filename: + return jsonify({"success": False, "error": "Missing username or filename"}), 400 - if download_id_from_client: - context_key = f"{username}::{filename}" - with matched_context_lock: - matched_downloads_context[context_key] = { - "spotify_artist": spotify_artist, - "spotify_album": spotify_album or None, - "original_search_result": search_result, # Now contains cleaned data - "is_album_download": bool(spotify_album) - } + # Pre-parse the single track's metadata + parsed_meta = _parse_filename_metadata(filename) + download_payload['title'] = parsed_meta.get('title') or download_payload.get('title') + download_payload['artist'] = parsed_meta.get('artist') or download_payload.get('artist') + + download_id = asyncio.run(soulseek_client.download(username, filename, size)) - print(f"✅ Context saved for matched download with key: {context_key}") - return jsonify({"success": True, "message": "Matched download started"}) - else: - error_msg = 'Failed to start download via slskd API' - print(f"❌ Failed to start matched download: {error_msg}") - return jsonify({"success": False, "error": error_msg}), 500 + if download_id: + context_key = f"{username}::{filename}" + with matched_context_lock: + matched_downloads_context[context_key] = { + "spotify_artist": spotify_artist, + "spotify_album": None, # Explicitly null for singles + "original_search_result": download_payload, + "is_album_download": False + } + return jsonify({"success": True, "message": "Matched download started"}) + else: + return jsonify({"success": False, "error": "Failed to start download via slskd"}), 500 except Exception as e: import traceback traceback.print_exc() - print(f"❌ Error starting matched download: {e}") return jsonify({"success": False, "error": str(e)}), 500 + + def _parse_filename_metadata(filename: str) -> dict: """ A direct port of the metadata parsing logic from the GUI's soulseek_client.py. @@ -1658,31 +1741,39 @@ def _search_track_in_album_context(original_search: dict, artist: dict) -> dict: def _detect_album_info_web(context: dict, artist: dict) -> dict: """ - This is a complete replacement that mirrors the multi-priority logic from downloads.py, - ensuring consistent and accurate album/single detection. + This is the final, corrected version that ensures the official Spotify track + number from the context is always prioritized for matched album downloads, + fixing the track numbering issue by mirroring the logic from downloads.py. """ try: original_search = context.get("original_search_result", {}) spotify_album_context = context.get("spotify_album") - - # Priority 1: User-provided album context from the matching modal - if spotify_album_context and spotify_album_context.get('id'): - print("✅ Using user-provided album context.") + is_album_download = context.get("is_album_download", False) + + # --- THIS IS THE CRITICAL FIX --- + # If this is part of a matched album download, we TRUST the context data completely. + # This is the exact logic from downloads.py. + if is_album_download and spotify_album_context: + print("✅ Matched Album context found. Prioritizing pre-matched Spotify data.") + + # We exclusively use the track number and title that were matched + # *before* the download started. We do not try to re-parse the filename. + track_number = original_search.get('track_number', 1) + clean_track_name = original_search.get('title', 'Unknown Track') + + print(f" -> Using pre-matched Track #{track_number} and Title '{clean_track_name}'") + return { 'is_album': True, 'album_name': spotify_album_context['name'], - 'track_number': _extract_track_number_from_filename(original_search.get('filename', ''), original_search.get('title')), - 'clean_track_name': _clean_track_title(original_search.get('title', ''), artist['name']), + 'track_number': track_number, + 'clean_track_name': clean_track_name, 'album_image_url': spotify_album_context.get('image_url') } - # Priority 2: Album-aware search using album name from Soulseek tags - if original_search.get('album'): - album_result = _search_track_in_album_context(original_search, artist) - if album_result: - return album_result - - # Priority 3: Fallback to individual track search for clean metadata + # This fallback block handles single tracks. It was already working correctly. + # It performs a live Spotify search to determine if a single is part of an album. + print("â„šī¸ Single track context. Performing live Spotify search for album info.") cleaned_title = _clean_track_title(original_search.get('title', ''), artist['name']) query = f"artist:\"{artist['name']}\" track:\"{cleaned_title}\"" tracks = spotify_client.search_tracks(query, limit=1) @@ -1702,7 +1793,6 @@ def _detect_album_info_web(context: dict, artist: dict) -> dict: album_type = api_album.get('album_type', 'single') total_tracks = api_album.get('total_tracks', 1) is_album = (album_type == 'album' and total_tracks > 1 and matching_engine.similarity_score(api_album.get('name'), best_match.name) < 0.9) - album_image_url = api_album.get('images', [{}])[0].get('url') if api_album.get('images') else None return { @@ -1719,6 +1809,7 @@ def _detect_album_info_web(context: dict, artist: dict) -> dict: + def _cleanup_empty_directories(download_path, moved_file_path): """Cleans up empty directories after a file move, ignoring hidden files.""" import os @@ -1890,23 +1981,90 @@ def _download_cover_art(album_info: dict, target_dir: str): print(f"❌ Error downloading cover.jpg: {e}") -# --- Post-Processing Logic --- +def _get_spotify_album_tracks(spotify_album: dict) -> list: + """Fetches all tracks for a given Spotify album ID.""" + if not spotify_album or not spotify_album.get('id'): + return [] + try: + tracks_data = spotify_client.get_album_tracks(spotify_album['id']) + if tracks_data and 'items' in tracks_data: + return [{ + 'name': item.get('name'), + 'track_number': item.get('track_number'), + 'id': item.get('id') + } for item in tracks_data['items']] + return [] + except Exception as e: + print(f"❌ Error fetching Spotify album tracks: {e}") + return [] -def _post_process_matched_download(context_key, context, file_path): +def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) -> dict: + """ + Intelligently matches a Soulseek track to a track from the official Spotify + tracklist using track numbers and title similarity. Returns the matched Spotify track data. """ - This is the new, robust post-processing function for matched downloads, - ported from downloads.py. It handles file organization, renaming, metadata - tagging, and cover art downloading for both singles and albums. + if not spotify_tracks: + return slsk_track_meta # Return original if no list to match against + + # Priority 1: Match by track number + if slsk_track_meta.get('track_number'): + track_num = slsk_track_meta['track_number'] + for sp_track in spotify_tracks: + if sp_track.get('track_number') == track_num: + print(f"✅ Matched track by number ({track_num}): '{slsk_track_meta['title']}' -> '{sp_track['name']}'") + # Return a new dict with the corrected title and number + return { + 'title': sp_track['name'], + 'artist': slsk_track_meta.get('artist'), + 'album': slsk_track_meta.get('album'), + 'track_number': sp_track['track_number'] + } + + # Priority 2: Match by title similarity (if track number fails) + best_match = None + best_score = 0.6 # Require a decent similarity + for sp_track in spotify_tracks: + score = matching_engine.similarity_score( + matching_engine.normalize_string(slsk_track_meta.get('title', '')), + matching_engine.normalize_string(sp_track.get('name', '')) + ) + if score > best_score: + best_score = score + best_match = sp_track - NOTE: The primary fixes for the reported issues are in the helper functions - this function calls: `_detect_album_info_web` and the file finder used in the - `/api/downloads/status` route. + if best_match: + print(f"✅ Matched track by title similarity ({best_score:.2f}): '{slsk_track_meta['title']}' -> '{best_match['name']}'") + return { + 'title': best_match['name'], + 'artist': slsk_track_meta.get('artist'), + 'album': slsk_track_meta.get('album'), + 'track_number': best_match['track_number'] + } + + print(f"âš ī¸ Could not confidently match track '{slsk_track_meta['title']}'. Using original metadata.") + return slsk_track_meta # Fallback to original + + +# --- Post-Processing Logic --- +def _post_process_matched_download(context_key, context, file_path): + """ + This is the final, corrected post-processing function. It now mirrors the + GUI's logic by trusting the pre-matched context for album downloads, which + solves the track numbering issue. """ try: import os import shutil + import time from pathlib import Path + # --- GUI PARITY FIX: Add a delay to prevent file lock race conditions --- + # The GUI app waits 1 second to ensure the file handle is released by + # the download client before attempting to move or modify it. + print(f"âŗ Waiting 1 second for file handle release for: {os.path.basename(file_path)}") + time.sleep(1) + # --- END OF FIX --- + print(f"đŸŽ¯ Starting robust post-processing for: {context_key}") spotify_artist = context.get("spotify_artist") @@ -1914,26 +2072,37 @@ def _post_process_matched_download(context_key, context, file_path): print(f"❌ Post-processing failed: Missing spotify_artist context.") return + is_album_download = context.get("is_album_download", False) + if is_album_download: + # For matched album downloads, we build album_info directly from the + # trusted context, bypassing the problematic _detect_album_info_web function. + print("✅ Matched Album context found. Building info directly from context.") + original_search = context.get("original_search_result", {}) + spotify_album = context.get("spotify_album", {}) + album_info = { + 'is_album': True, + 'album_name': spotify_album.get('name'), + 'track_number': original_search.get('track_number', 1), + 'clean_track_name': original_search.get('title', 'Unknown Track'), + 'album_image_url': spotify_album.get('image_url') + } + else: + # For singles, we still need to detect if they belong to an album. + album_info = _detect_album_info_web(context, spotify_artist) + # 1. Get transfer path and create artist directory transfer_dir = config_manager.get('soulseek.transfer_path', './Transfer') artist_name_sanitized = _sanitize_filename(spotify_artist["name"]) artist_dir = os.path.join(transfer_dir, artist_name_sanitized) os.makedirs(artist_dir, exist_ok=True) - # 2. Determine if it's a single or album track using the NEW, robust Spotify API logic - album_info = _detect_album_info_web(context, spotify_artist) - file_ext = os.path.splitext(file_path)[1] - # 3. Build the final path based on whether it's an album or single + # 2. Build the final path (this logic is now correct because album_info is correct) if album_info and album_info['is_album']: - # --- ALBUM LOGIC --- - album_name = album_info['album_name'] + album_name_sanitized = _sanitize_filename(album_info['album_name']) + final_track_name_sanitized = _sanitize_filename(album_info['clean_track_name']) track_number = album_info['track_number'] - final_track_name = album_info['clean_track_name'] - - album_name_sanitized = _sanitize_filename(album_name) - final_track_name_sanitized = _sanitize_filename(final_track_name) album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}" album_dir = os.path.join(artist_dir, album_folder_name) @@ -1941,37 +2110,24 @@ def _post_process_matched_download(context_key, context, file_path): new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}" final_path = os.path.join(album_dir, new_filename) - print(f"📁 Determined album path: {final_path}") - else: - # --- SINGLE LOGIC --- - final_track_name = album_info['clean_track_name'] - final_track_name_sanitized = _sanitize_filename(final_track_name) - + final_track_name_sanitized = _sanitize_filename(album_info['clean_track_name']) single_folder_name = f"{artist_name_sanitized} - {final_track_name_sanitized}" single_dir = os.path.join(artist_dir, single_folder_name) os.makedirs(single_dir, exist_ok=True) - new_filename = f"{final_track_name_sanitized}{file_ext}" final_path = os.path.join(single_dir, new_filename) - print(f"đŸŽĩ Determined single path: {final_path}") - # 4. Enhance metadata BEFORE moving the file - print(f"âœī¸ Attempting metadata enhancement on: {file_path}") + # 3. Enhance metadata, move file, download art, and cleanup _enhance_file_metadata(file_path, context, spotify_artist, album_info) - - # 5. Move the file to its new, organized location + print(f"🚚 Moving '{os.path.basename(file_path)}' to '{final_path}'") if os.path.exists(final_path): - print(f"âš ī¸ Destination file exists, overwriting: {final_path}") os.remove(final_path) shutil.move(file_path, final_path) - # 6. Download cover.jpg to the final directory - final_directory = os.path.dirname(final_path) - _download_cover_art(album_info, final_directory) + _download_cover_art(album_info, os.path.dirname(final_path)) - # 7. Clean up empty source directories downloads_path = config_manager.get('soulseek.download_path', './downloads') _cleanup_empty_directories(downloads_path, file_path) @@ -1984,6 +2140,7 @@ def _post_process_matched_download(context_key, context, file_path): + # Keep track of processed downloads to avoid re-processing _processed_download_ids = set() diff --git a/webui/static/script.js b/webui/static/script.js index f0471331..df0f7ef5 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -3046,54 +3046,62 @@ async function confirmMatch() { showToast('âš ī¸ Please select an artist first', 'error'); return; } - + if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) { showToast('âš ī¸ Please select an album first', 'error'); return; } - + + const confirmBtn = document.getElementById('confirm-match-btn'); + const originalText = confirmBtn.textContent; // FIX: Declare outside try block + try { console.log('đŸŽ¯ Confirming match with:', { artist: currentMatchingData.selectedArtist.name, album: currentMatchingData.selectedAlbum?.name }); - - // Disable confirm button to prevent double-clicks - const confirmBtn = document.getElementById('confirm-match-btn'); - const originalText = confirmBtn.textContent; + confirmBtn.disabled = true; confirmBtn.textContent = 'Starting...'; - + + // --- THIS IS THE CRITICAL FIX --- + // Determine the correct data to send. For albums, we send the full albumResult + // which contains the complete list of tracks. + const downloadPayload = currentMatchingData.isAlbumDownload + ? currentMatchingData.albumResult + : currentMatchingData.searchResult; + // --- END OF FIX --- + const response = await fetch('/api/download/matched', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - search_result: currentMatchingData.searchResult, + search_result: downloadPayload, // Send the correct payload spotify_artist: currentMatchingData.selectedArtist, spotify_album: currentMatchingData.selectedAlbum || null }) }); - + const data = await response.json(); - + if (data.success) { showToast(`đŸŽ¯ Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success'); closeMatchingModal(); } else { throw new Error(data.error || 'Failed to start matched download'); } - + } catch (error) { console.error('Error starting matched download:', error); showToast(`❌ Error starting matched download: ${error.message}`, 'error'); - // Re-enable confirm button - const confirmBtn = document.getElementById('confirm-match-btn'); + // Re-enable confirm button on failure confirmBtn.disabled = false; confirmBtn.textContent = originalText; } } + function matchedDownloadTrack(trackIndex) { const results = window.currentSearchResults; if (!results || !results[trackIndex]) {