From 420044387ede95710f29f5471cce06f3c69c99bf Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 29 Dec 2025 18:13:50 -0800 Subject: [PATCH] Add streaming for enhanced search tracks Introduces a backend API endpoint and frontend logic to allow users to stream individual tracks directly from enhanced search results. Updates the UI to include a play button for each track, adjusts the search mode toggle to default to enhanced search, and refines related styles for improved user experience. --- web_server.py | 112 +++++++++++++++++++++++++++++++++++++++++ webui/index.html | 16 +++--- webui/static/script.js | 77 +++++++++++++++++++++++++++- webui/static/style.css | 48 ++++++++++++++++-- 4 files changed, 238 insertions(+), 15 deletions(-) diff --git a/web_server.py b/web_server.py index 0e3c932e..3563bb5f 100644 --- a/web_server.py +++ b/web_server.py @@ -3320,6 +3320,118 @@ def enhanced_search(): logger.error(f"Enhanced search error: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/enhanced-search/stream-track', methods=['POST']) +def stream_enhanced_search_track(): + """ + Quick slskd search for a single track to stream from enhanced search. + Uses multi-query retry strategy to work around Soulseek keyword filtering. + Returns the best matching result from Soulseek. + """ + data = request.get_json() + track_name = data.get('track_name', '').strip() + artist_name = data.get('artist_name', '').strip() + album_name = data.get('album_name', '').strip() + duration_ms = data.get('duration_ms', 0) + + if not track_name or not artist_name: + return jsonify({"error": "Track name and artist name are required"}), 400 + + logger.info(f"▶️ Enhanced search stream request: '{track_name}' by '{artist_name}'") + + try: + # Create a temporary SpotifyTrack-like object for the matching engine + temp_track = type('TempTrack', (), { + 'name': track_name, + 'artists': [artist_name], + 'album': album_name if album_name else None, + 'duration_ms': duration_ms + })() + + # Generate search queries - TRACK NAME ONLY (artist is used for matching, not searching) + # This avoids Soulseek keyword filtering on artist names like "Kendrick Lamar" + search_queries = [] + import re + + # Primary query: Full track name + if track_name.strip(): + search_queries.append(track_name.strip()) + + # Cleaned query: Remove parentheses and brackets + cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() + cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() + + if cleaned_name and cleaned_name.lower() != track_name.lower(): + search_queries.append(cleaned_name.strip()) + + # Remove duplicates while preserving order + unique_queries = [] + seen = set() + for query in search_queries: + if query and query.lower() not in seen: + unique_queries.append(query) + seen.add(query.lower()) + + search_queries = unique_queries + logger.info(f"🔍 Searching by track name only (will match with artist): {search_queries}") + + # Try queries sequentially until we find a good match + for query_index, query in enumerate(search_queries): + logger.info(f"🔍 Query {query_index + 1}/{len(search_queries)}: '{query}'") + + try: + # Search slskd with timeout + tracks_result, _ = asyncio.run(soulseek_client.search(query, timeout=15)) + + if tracks_result: + logger.info(f"✅ Found {len(tracks_result)} results for query: '{query}'") + + # Use matching engine to find best match + best_matches = matching_engine.find_best_slskd_matches_enhanced(temp_track, tracks_result) + + if best_matches: + # Get the first (best) result + best_result = best_matches[0] + + # Convert to dictionary for JSON response (same format as basic search) + result_dict = { + "username": best_result.username, + "filename": best_result.filename, + "size": best_result.size, + "bitrate": best_result.bitrate, + "duration": best_result.duration, + "quality": best_result.quality, + "free_upload_slots": best_result.free_upload_slots, + "upload_speed": best_result.upload_speed, + "queue_length": best_result.queue_length, + "result_type": "track" + } + + logger.info(f"✅ Returning best match from query '{query}': {best_result.filename} ({best_result.quality})") + + return jsonify({ + "success": True, + "result": result_dict + }) + else: + logger.info(f"⏭️ No suitable matches for query '{query}', trying next query...") + else: + logger.info(f"⏭️ No results for query '{query}', trying next query...") + + except Exception as search_error: + logger.warning(f"⚠️ Error searching with query '{query}': {search_error}") + continue + + # If we get here, none of the queries found a suitable match + logger.warning(f"❌ No suitable matches found after trying {len(search_queries)} queries") + return jsonify({ + "success": False, + "error": "No suitable track found on Soulseek after trying multiple search strategies" + }), 404 + + except Exception as e: + logger.error(f"❌ Error streaming enhanced search track: {e}", exc_info=True) + return jsonify({"error": str(e)}), 500 + @app.route('/api/download', methods=['POST']) def start_download(): """Simple download route""" diff --git a/webui/index.html b/webui/index.html index adddc418..a0353f8a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1178,21 +1178,21 @@
-
- - +
-
+
@@ -1272,7 +1272,7 @@ -
+
diff --git a/webui/static/script.js b/webui/static/script.js index a34a5731..3de81627 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -2615,7 +2615,8 @@ function initializeSearchModeToggle() { name: track.name, meta: `${track.artist} • ${track.album}`, duration: duration, - onClick: () => handleEnhancedSearchTrackClick(track) + onClick: () => handleEnhancedSearchTrackClick(track), + onPlay: () => streamEnhancedSearchTrack(track) }; } ); @@ -2697,7 +2698,10 @@ function initializeSearchModeToggle() { : ''; const durationHtml = config.duration && isTrack - ? `
${escapeHtml(config.duration)}
` + ? `
+ ${escapeHtml(config.duration)} + +
` : ''; elem.innerHTML = ` @@ -2711,6 +2715,18 @@ function initializeSearchModeToggle() { `; elem.addEventListener('click', config.onClick); + + // Add play button handler for tracks + if (isTrack && config.onPlay) { + const playBtn = elem.querySelector('.enh-item-play-btn'); + if (playBtn) { + playBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Don't trigger main onClick + config.onPlay(); + }); + } + } + list.appendChild(elem); }); } @@ -2825,6 +2841,63 @@ function initializeSearchModeToggle() { } } + async function streamEnhancedSearchTrack(track) { + console.log(`▶️ Stream enhanced search track: ${track.name} by ${track.artist}`); + + hideDropdown(); + showLoadingOverlay(`Searching for ${track.name}...`); + + try { + // Send track metadata to backend for quick slskd search + const response = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_name: track.name, + artist_name: track.artist, + album_name: track.album, + duration_ms: track.duration_ms + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to search for track'); + } + + const data = await response.json(); + + if (!data.success || !data.result) { + throw new Error('No suitable track found on Soulseek'); + } + + const slskdResult = data.result; + + // Check if audio format is supported + if (slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) { + const format = getFileExtension(slskdResult.filename); + hideLoadingOverlay(); + showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); + return; + } + + console.log(`✅ Found track to stream:`, slskdResult); + console.log(`🎵 Track details - Username: ${slskdResult.username}, Filename: ${slskdResult.filename}`); + + hideLoadingOverlay(); + + // Use existing startStream function to play the track + console.log(`📡 Calling startStream() with result...`); + await startStream(slskdResult); + console.log(`✅ startStream() completed`); + + } catch (error) { + hideLoadingOverlay(); + console.error('❌ Error streaming enhanced search track:', error); + showToast(`Failed to stream track: ${error.message}`, 'error'); + } + } + async function handleEnhancedSearchTrackClick(track) { console.log(`🎵 Enhanced search track clicked: ${track.name} by ${track.artist}`); diff --git a/webui/static/style.css b/webui/static/style.css index 5e2019bd..5220d659 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -18859,17 +18859,17 @@ body { left: 4px; width: calc(50% - 4px); height: calc(100% - 8px); - background: linear-gradient(135deg, rgba(29, 185, 84, 0.9), rgba(24, 156, 71, 0.9)); + background: linear-gradient(135deg, rgba(138, 43, 226, 0.9), rgba(123, 31, 162, 0.9)); border-radius: 8px; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1; - box-shadow: 0 2px 8px rgba(29, 185, 84, 0.4); + box-shadow: 0 2px 8px rgba(138, 43, 226, 0.4); } -.search-mode-toggle[data-active="enhanced"] .toggle-slider { +.search-mode-toggle[data-active="basic"] .toggle-slider { transform: translateX(100%); - background: linear-gradient(135deg, rgba(138, 43, 226, 0.9), rgba(123, 31, 162, 0.9)); - box-shadow: 0 2px 8px rgba(138, 43, 226, 0.4); + background: linear-gradient(135deg, rgba(29, 185, 84, 0.9), rgba(24, 156, 71, 0.9)); + box-shadow: 0 2px 8px rgba(29, 185, 84, 0.4); } /* Search Section Visibility */ @@ -19569,6 +19569,44 @@ body { color: rgba(255, 255, 255, 0.5); flex-shrink: 0; margin-left: 8px; + position: relative; + display: flex; + align-items: center; + gap: 8px; +} + +/* Play button that appears on hover */ +.enh-item-play-btn { + opacity: 0; + padding: 4px 10px; + background: linear-gradient(135deg, rgba(29, 185, 84, 0.9), rgba(26, 166, 75, 0.9)); + color: #ffffff; + border: none; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 8px rgba(29, 185, 84, 0.3); + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + z-index: 10; +} + +.enh-compact-item.track-item:hover .enh-item-play-btn { + opacity: 1; +} + +.enh-item-play-btn:hover { + background: linear-gradient(135deg, rgba(29, 185, 84, 1), rgba(26, 166, 75, 1)); + box-shadow: 0 4px 12px rgba(29, 185, 84, 0.5); + transform: translateY(-50%) scale(1.05); +} + +.enh-item-play-btn:active { + transform: translateY(-50%) scale(0.95); } /* ========================================= */