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;