From 8f30f0ab91e44ae0b0455b7866e15b00401fabf9 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 12 Dec 2025 07:13:53 -0800 Subject: [PATCH] Enhance matched downloads with full Spotify metadata Adds robust track-to-track matching for album and single downloads, enabling enhanced metadata enrichment using Spotify data. Updates both backend and frontend to support matching Soulseek tracks to Spotify tracks, sending full Spotify track objects for improved organization and post-processing. Simplifies context handling for simple downloads and removes legacy flags, ensuring more accurate and consistent metadata for matched downloads. --- web_server.py | 210 +++++++++++++++++++++----- webui/static/script.js | 334 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 484 insertions(+), 60 deletions(-) diff --git a/web_server.py b/web_server.py index 665bbf95..b166da35 100644 --- a/web_server.py +++ b/web_server.py @@ -5427,6 +5427,101 @@ def search_match(): return jsonify({"error": str(e)}), 500 +def _start_enhanced_album_download(enhanced_tracks, unmatched_tracks, spotify_artist, spotify_album): + """ + Download album tracks that have been matched to Spotify with full track metadata. + This provides the best possible metadata enhancement and organization. + """ + logger.info(f"🎯 Processing enhanced album download for '{spotify_album['name']}' with {len(enhanced_tracks)} matched tracks") + + started_count = 0 + + # Process matched tracks with full Spotify metadata + for matched_item in enhanced_tracks: + try: + slskd_track = matched_item['slskd_track'] + spotify_track = matched_item['spotify_track'] + + username = slskd_track.get('username') + filename = slskd_track.get('filename') + size = slskd_track.get('size', 0) + + if not username or not filename: + logger.warning(f"Skipping track with missing username or filename: {slskd_track}") + continue + + # Start download + download_id = asyncio.run(soulseek_client.download(username, filename, size)) + + if download_id: + context_key = f"{username}::{filename}" + with matched_context_lock: + # Create context with FULL Spotify track metadata (like Download Missing Tracks modal) + matched_downloads_context[context_key] = { + "spotify_artist": spotify_artist, + "spotify_album": spotify_album, + "track_info": spotify_track, # Full Spotify track object! + "original_search_result": { + 'username': username, + 'filename': filename, + 'size': size, + 'title': spotify_track['name'], # Use Spotify title + 'artist': spotify_artist['name'], + 'album': spotify_album['name'], + 'track_number': spotify_track['track_number'], # Use Spotify track number + 'spotify_clean_title': spotify_track['name'] # For filename generation + }, + "is_album_download": True, + "has_full_spotify_metadata": True # Flag for robust processing + } + + logger.info(f"✅ Queued matched track: '{spotify_track['name']}' (track #{spotify_track['track_number']})") + started_count += 1 + else: + logger.error(f"Failed to queue track: {filename}") + + except Exception as e: + logger.error(f"Error processing matched track: {e}") + continue + + # Process unmatched tracks with basic cleanup + for slskd_track in unmatched_tracks: + try: + username = slskd_track.get('username') + filename = slskd_track.get('filename') + size = slskd_track.get('size', 0) + + if not username or not filename: + continue + + download_id = asyncio.run(soulseek_client.download(username, filename, size)) + + if download_id: + context_key = f"{username}::{filename}" + with matched_context_lock: + # Basic context for unmatched tracks (simple cleanup) + matched_downloads_context[context_key] = { + 'search_result': { + 'username': username, + 'filename': filename, + 'size': size, + 'is_simple_download': True # Falls back to simple transfer + }, + 'spotify_artist': None, + 'spotify_album': None, + 'track_info': None + } + + logger.info(f"⚠️ Queued unmatched track (basic cleanup): {filename}") + started_count += 1 + + except Exception as e: + logger.error(f"Error processing unmatched track: {e}") + continue + + return started_count + + def _start_album_download_tasks(album_result, spotify_artist, spotify_album): """ This final version now fetches the official Spotify tracklist and uses it to @@ -5489,8 +5584,7 @@ def _start_album_download_tasks(album_result, spotify_artist, spotify_album): "spotify_artist": spotify_artist, "spotify_album": spotify_album, "original_search_result": enhanced_context, # Contains corrected data + clean title - "is_album_download": True, - "is_search_page_matched_download": True # Flag for simple transfer (search page only) + "is_album_download": True } print(f" + Queued track: {filename} (Matched to: '{corrected_meta.get('title')}')") started_count += 1 @@ -5509,24 +5603,77 @@ def _start_album_download_tasks(album_result, spotify_artist, spotify_album): @app.route('/api/download/matched', methods=['POST']) def start_matched_download(): """ - Starts a matched download. This version corrects a bug where album context - was being discarded for individual album track downloads, ensuring they are - processed identically to single track downloads. + Starts a matched download. Supports: + 1. Enhanced album downloads with full Spotify track metadata + 2. Regular album downloads (fallback) + 3. Single track downloads """ try: data = request.get_json() download_payload = data.get('search_result', {}) spotify_artist = data.get('spotify_artist', {}) spotify_album = data.get('spotify_album', None) + enhanced_tracks = data.get('enhanced_tracks', []) # Album: Matched tracks with Spotify data + unmatched_tracks = data.get('unmatched_tracks', []) # Album: Tracks that didn't match + spotify_track = data.get('spotify_track', None) # Single: Full Spotify track object + is_single_track = data.get('is_single_track', False) # Single: Flag for single track if not download_payload or not spotify_artist: return jsonify({"success": False, "error": "Missing download payload or artist data"}), 400 - # This check is for full album downloads (when the main album card button is clicked) + # NEW: Enhanced single track with full Spotify metadata + if is_single_track and spotify_track: + logger.info(f"🎯 Starting enhanced single track download: '{spotify_track['name']}' by {spotify_artist['name']}") + + username = download_payload.get('username') + filename = download_payload.get('filename') + size = download_payload.get('size', 0) + + if not username or not filename: + return jsonify({"success": False, "error": "Missing username or filename"}), 400 + + download_id = asyncio.run(soulseek_client.download(username, filename, size)) + + if download_id: + context_key = f"{username}::{filename}" + with matched_context_lock: + # Create context with FULL Spotify track metadata (like Download Missing Tracks modal) + matched_downloads_context[context_key] = { + "spotify_artist": spotify_artist, + "spotify_album": spotify_track.get('album'), # Single's album from Spotify + "track_info": spotify_track, # Full Spotify track object! + "original_search_result": { + 'username': username, + 'filename': filename, + 'size': size, + 'title': spotify_track['name'], + 'artist': spotify_artist['name'], + 'album': spotify_track.get('album', {}).get('name', 'Unknown Album'), + 'track_number': spotify_track.get('track_number', 1), + 'spotify_clean_title': spotify_track['name'] + }, + "is_album_download": False, # It's a single + "has_full_spotify_metadata": True # Flag for robust processing + } + + logger.info(f"✅ Queued enhanced single track: '{spotify_track['name']}'") + return jsonify({"success": True, "message": "Enhanced single track download started"}) + else: + return jsonify({"success": False, "error": "Failed to start download via slskd"}), 500 + + # NEW: Enhanced album download with track-to-track matching + if enhanced_tracks: + logger.info(f"🎯 Starting enhanced album download: {len(enhanced_tracks)} matched tracks, {len(unmatched_tracks)} unmatched") + started_count = _start_enhanced_album_download(enhanced_tracks, unmatched_tracks, spotify_artist, spotify_album) + if started_count > 0: + return jsonify({"success": True, "message": f"Queued {started_count} tracks with full Spotify metadata."}) + else: + return jsonify({"success": False, "error": "Failed to queue any tracks from the album."}), 500 + + # Regular album download (fallback if matching fails) is_full_album_download = bool(spotify_album and download_payload.get('result_type') == 'album') if is_full_album_download: - # This logic for full album downloads is correct and remains unchanged. 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."}) @@ -5561,10 +5708,8 @@ def start_matched_download(): "spotify_artist": spotify_artist, "spotify_album": spotify_album, # PRESERVE album context "original_search_result": enhanced_payload, - "is_album_download": False, # It's a single track download, not a full album job. - "is_search_page_matched_download": True # Flag for simple transfer (search page only) + "is_album_download": False # It's a single track download, not a full album job. } - logger.info(f"Registered search page matched download for simple transfer: {context_key}") return jsonify({"success": True, "message": "Matched download started"}) else: return jsonify({"success": False, "error": "Failed to start download via slskd"}), 500 @@ -7212,42 +7357,27 @@ def _post_process_matched_download(context_key, context, file_path): # --- END OF FIX --- # --- SIMPLE DOWNLOAD HANDLING --- - # Check if this is a simple download (search page "Download ⬇" button) + # Check if this is a simple download (search page "Download ⬇" button only) search_result = context.get('search_result', {}) is_simple_download = search_result.get('is_simple_download', False) - # Check if this is a search page matched download (search page "Matched Download 🎯" button) - is_search_page_matched = context.get('is_search_page_matched_download', False) - - if is_simple_download or is_search_page_matched: + if is_simple_download: # Simple transfer: move to Transfer/AlbumName/ folder, no metadata enhancement - download_type = "simple download" if is_simple_download else "search page matched download" - logger.info(f"Processing {download_type} (no metadata enhancement): {file_path}") + logger.info(f"Processing simple download (no metadata enhancement): {file_path}") transfer_path = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) - # Extract album name from context + # Extract album name from filename path (parent directory) album_name = "Unknown Album" - if is_search_page_matched: - # Matched downloads have Spotify metadata - spotify_album = context.get('spotify_album') - if spotify_album and spotify_album.get('name'): - album_name = spotify_album['name'] - else: - # Fall back to original search result - original_search = context.get('original_search_result', {}) - album_name = original_search.get('album', 'Unknown Album') + original_filename = search_result.get('filename', '') + if '/' in original_filename or '\\' in original_filename: + # Get parent directory as album name + path_parts = original_filename.replace('\\', '/').split('/') + if len(path_parts) >= 2: + album_name = path_parts[-2] # Parent directory else: - # Simple downloads - extract from filename path (parent directory) - original_filename = search_result.get('filename', '') - if '/' in original_filename or '\\' in original_filename: - # Get parent directory as album name - path_parts = original_filename.replace('\\', '/').split('/') - if len(path_parts) >= 2: - album_name = path_parts[-2] # Parent directory - else: - # No path info, use album from search result if available - album_name = search_result.get('album', 'Unknown Album') + # No path info, use album from search result if available + album_name = search_result.get('album', 'Unknown Album') # Sanitize album name for file system import re @@ -7264,7 +7394,7 @@ def _post_process_matched_download(context_key, context, file_path): destination = album_folder / filename shutil.move(str(file_path), str(destination)) - logger.info(f"✅ Moved {download_type} to: {destination}") + logger.info(f"✅ Moved simple download to: {destination}") # Clean up context with matched_context_lock: @@ -7274,12 +7404,12 @@ def _post_process_matched_download(context_key, context, file_path): # Trigger library scan (using correct method name) if web_scan_manager: threading.Thread( - target=lambda: web_scan_manager.request_scan(f"{download_type.capitalize()} completed"), + target=lambda: web_scan_manager.request_scan("Simple download completed"), daemon=True ).start() add_activity_item("✅", "Download Complete", f"{album_name}/{filename}", "Now") - logger.info(f"✅ {download_type.capitalize()} post-processing complete: {album_name}/{filename}") + logger.info(f"✅ Simple download post-processing complete: {album_name}/{filename}") return # --- END SIMPLE DOWNLOAD HANDLING --- diff --git a/webui/static/script.js b/webui/static/script.js index 0ad38041..35502c8b 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -10429,6 +10429,157 @@ function skipMatching() { } } +function matchSlskdTracksToSpotify(slskdTracks, spotifyTracks) { + /** + * Matches Soulseek tracks to Spotify tracks based on filename analysis. + * Returns enhanced tracks with full Spotify metadata. + */ + console.log(`🎯 Starting track matching: ${slskdTracks.length} Soulseek tracks vs ${spotifyTracks.length} Spotify tracks`); + + const matched = []; + const unmatched = []; + + for (const slskdTrack of slskdTracks) { + const filename = slskdTrack.filename || slskdTrack.title || ''; + const parsedMeta = parseTrackFilename(filename); + + console.log(`🔍 Matching: "${filename}" -> parsed as: "${parsedMeta.title}" (track #${parsedMeta.trackNumber})`); + + // Find best matching Spotify track + let bestMatch = null; + let bestScore = 0; + + for (const spotifyTrack of spotifyTracks) { + let score = 0; + + // Match by track number (highest priority if available) + if (parsedMeta.trackNumber && spotifyTrack.track_number === parsedMeta.trackNumber) { + score += 50; + console.log(` ✓ Track number match: ${parsedMeta.trackNumber} == ${spotifyTrack.track_number} (+50)`); + } + + // Match by title similarity + const titleScore = calculateStringSimilarity( + parsedMeta.title.toLowerCase(), + spotifyTrack.name.toLowerCase() + ); + score += titleScore * 50; // Max 50 points for perfect title match + + console.log(` Spotify track "${spotifyTrack.name}" (${spotifyTrack.track_number}): score ${score.toFixed(2)}`); + + if (score > bestScore) { + bestScore = score; + bestMatch = spotifyTrack; + } + } + + // Accept match if score is above threshold (70/100) + if (bestMatch && bestScore >= 70) { + console.log(`✅ MATCHED: "${filename}" -> "${bestMatch.name}" (score: ${bestScore.toFixed(2)})`); + matched.push({ + slskd_track: slskdTrack, + spotify_track: bestMatch, + confidence: bestScore / 100 + }); + } else { + console.log(`❌ NO MATCH: "${filename}" (best score: ${bestScore.toFixed(2)})`); + unmatched.push(slskdTrack); + } + } + + console.log(`🎯 Matching complete: ${matched.length} matched, ${unmatched.length} unmatched`); + + return { + matched: matched, + unmatched: unmatched, + total: slskdTracks.length + }; +} + +function parseTrackFilename(filename) { + /** + * Parse track metadata from filename. + * Handles common patterns like: + * - "01 - Title.flac" + * - "01. Title.flac" + * - "Artist - Title.flac" + * - "Title.flac" + */ + // Remove file extension and path + let basename = filename.split('/').pop().split('\\').pop(); + basename = basename.replace(/\.(flac|mp3|m4a|ogg|wav)$/i, ''); + + let trackNumber = null; + let title = basename; + + // Pattern 1: "01 - Title" or "01. Title" + const pattern1 = /^(\d{1,2})\s*[-\.]\s*(.+)$/; + const match1 = basename.match(pattern1); + if (match1) { + trackNumber = parseInt(match1[1]); + title = match1[2].trim(); + return { title, trackNumber }; + } + + // Pattern 2: "Artist - Title" (extract title only) + const pattern2 = /^.+?\s*[-–]\s*(.+)$/; + const match2 = basename.match(pattern2); + if (match2) { + title = match2[1].trim(); + return { title, trackNumber }; + } + + // Fallback: use whole basename as title + return { title: basename.trim(), trackNumber }; +} + +function calculateStringSimilarity(str1, str2) { + /** + * Calculate similarity between two strings (0-1 range). + * Uses Levenshtein distance for fuzzy matching. + */ + // Normalize strings + str1 = str1.trim().toLowerCase(); + str2 = str2.trim().toLowerCase(); + + if (str1 === str2) return 1.0; + + // Simple contains check + if (str1.includes(str2) || str2.includes(str1)) { + return 0.9; + } + + // Levenshtein distance calculation + const matrix = []; + const len1 = str1.length; + const len2 = str2.length; + + for (let i = 0; i <= len1; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= len2; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost // substitution + ); + } + } + + const maxLen = Math.max(len1, len2); + const distance = matrix[len1][len2]; + const similarity = 1 - (distance / maxLen); + + return Math.max(0, similarity); +} + async function confirmMatch() { if (!currentMatchingData.selectedArtist) { showToast('⚠️ Please select an artist first', 'error'); @@ -10441,7 +10592,7 @@ async function confirmMatch() { } const confirmBtn = document.getElementById('confirm-match-btn'); - const originalText = confirmBtn.textContent; // FIX: Declare outside try block + const originalText = confirmBtn.textContent; try { console.log('🎯 Confirming match with:', { @@ -10452,37 +10603,180 @@ async function confirmMatch() { 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. + // Determine the correct data to send 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: downloadPayload, // Send the correct payload - spotify_artist: currentMatchingData.selectedArtist, - spotify_album: currentMatchingData.selectedAlbum || null - }) - }); + // --- NEW: For album downloads, fetch Spotify tracklist and match tracks --- + if (currentMatchingData.isAlbumDownload && currentMatchingData.selectedAlbum) { + confirmBtn.textContent = 'Matching tracks...'; + console.log('🎵 Fetching Spotify tracklist for album:', currentMatchingData.selectedAlbum.name); - const data = await response.json(); + try { + // Fetch Spotify album tracks + const artistId = currentMatchingData.selectedArtist.id; + const albumId = currentMatchingData.selectedAlbum.id; + const tracksResponse = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`); - if (data.success) { - showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success'); - closeMatchingModal(); + if (!tracksResponse.ok) { + throw new Error(`Failed to fetch Spotify tracks: ${tracksResponse.status}`); + } + + const tracksData = await tracksResponse.json(); + const spotifyTracks = tracksData.tracks || []; + + console.log(`✅ Fetched ${spotifyTracks.length} Spotify tracks for matching`); + + // Match each Soulseek track to a Spotify track + const enhancedTracks = matchSlskdTracksToSpotify( + downloadPayload.tracks || [], + spotifyTracks + ); + + console.log(`🎯 Matched ${enhancedTracks.matched.length}/${enhancedTracks.total} tracks to Spotify`); + + // Send enhanced data with full Spotify track objects + confirmBtn.textContent = 'Downloading...'; + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + spotify_artist: currentMatchingData.selectedArtist, + spotify_album: currentMatchingData.selectedAlbum, + enhanced_tracks: enhancedTracks.matched, // Send matched tracks with full Spotify data + unmatched_tracks: enhancedTracks.unmatched // Send unmatched tracks for basic processing + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`🎯 Matched ${enhancedTracks.matched.length} tracks to Spotify`, 'success'); + closeMatchingModal(); + } else { + throw new Error(data.error || 'Failed to start matched download'); + } + + } catch (trackMatchError) { + console.error('❌ Track matching failed, falling back to simple matching:', trackMatchError); + showToast('⚠️ Track matching failed, using basic matching', 'warning'); + + // Fallback to simple matching (current behavior) + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + 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'); + } + } } else { - throw new Error(data.error || 'Failed to start matched download'); + // Single track download - fetch Spotify track for full metadata + confirmBtn.textContent = 'Searching Spotify...'; + + try { + // Parse track name from Soulseek filename + const filename = downloadPayload.filename || downloadPayload.title || ''; + const parsedMeta = parseTrackFilename(filename); + + console.log(`🔍 Searching Spotify for: "${parsedMeta.title}" by ${currentMatchingData.selectedArtist.name}`); + + // Search Spotify for this track + const searchQuery = `track:${parsedMeta.title} artist:${currentMatchingData.selectedArtist.name}`; + const searchResponse = await fetch(`/api/spotify/search?q=${encodeURIComponent(searchQuery)}&type=track&limit=5`); + + if (!searchResponse.ok) { + throw new Error('Failed to search Spotify for track'); + } + + const searchData = await searchResponse.json(); + const spotifyTracks = searchData.tracks?.items || []; + + if (spotifyTracks.length === 0) { + throw new Error('No Spotify tracks found for this search'); + } + + // Find best match (prefer exact artist match) + let bestMatch = spotifyTracks.find(track => + track.artists.some(artist => artist.id === currentMatchingData.selectedArtist.id) + ) || spotifyTracks[0]; + + console.log(`✅ Found Spotify track: "${bestMatch.name}" (${bestMatch.id})`); + + // Get full track details with album info + const trackResponse = await fetch(`/api/spotify/track/${bestMatch.id}`); + if (!trackResponse.ok) { + throw new Error('Failed to fetch Spotify track details'); + } + + const fullTrack = await trackResponse.json(); + + // Send with full Spotify metadata (single track enhanced) + confirmBtn.textContent = 'Downloading...'; + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + spotify_artist: currentMatchingData.selectedArtist, + spotify_album: null, // Singles don't have album context + spotify_track: fullTrack, // Full Spotify track object + is_single_track: true // Flag for single track processing + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`🎯 Matched single: "${fullTrack.name}"`, 'success'); + closeMatchingModal(); + } else { + throw new Error(data.error || 'Failed to start matched download'); + } + + } catch (singleMatchError) { + console.error('❌ Spotify track matching failed, falling back to basic:', singleMatchError); + showToast('⚠️ Spotify matching failed, using basic metadata', 'warning'); + + // Fallback to basic matching (current behavior) + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + 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 on failure confirmBtn.disabled = false; confirmBtn.textContent = originalText;