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.
This commit is contained in:
Broque Thomas 2025-12-29 18:13:50 -08:00
parent 0bd1cab9e2
commit 420044387e
4 changed files with 238 additions and 15 deletions

View file

@ -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"""

View file

@ -1178,21 +1178,21 @@
<!-- Search Mode Toggle -->
<div class="search-mode-toggle-container">
<div class="search-mode-toggle">
<button class="search-mode-btn active" data-mode="basic">
<span class="mode-icon">🔍</span>
<span class="mode-label">Basic Search</span>
</button>
<button class="search-mode-btn" data-mode="enhanced">
<div class="search-mode-toggle" data-active="enhanced">
<button class="search-mode-btn active" data-mode="enhanced">
<span class="mode-icon"></span>
<span class="mode-label">Enhanced Search</span>
</button>
<button class="search-mode-btn" data-mode="basic">
<span class="mode-icon">🔍</span>
<span class="mode-label">Basic Search</span>
</button>
<div class="toggle-slider"></div>
</div>
</div>
<!-- Basic Search Section (Current) -->
<div id="basic-search-section" class="search-section active">
<div id="basic-search-section" class="search-section">
<!-- Search Bar: Replicates create_elegant_search_bar() -->
<div class="search-bar-container">
<input type="text" id="downloads-search-input" placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')">
@ -1272,7 +1272,7 @@
<!-- End Basic Search Section -->
<!-- Enhanced Search Section (New) -->
<div id="enhanced-search-section" class="search-section">
<div id="enhanced-search-section" class="search-section active">
<!-- Enhanced Search Bar with Dropdown -->
<div class="enhanced-search-input-wrapper">
<div class="enhanced-search-bar-container">

View file

@ -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
? `<div class="enh-item-duration">${escapeHtml(config.duration)}</div>`
? `<div class="enh-item-duration">
${escapeHtml(config.duration)}
<button class="enh-item-play-btn" title="Stream this track"></button>
</div>`
: '';
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}`);

View file

@ -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);
}
/* ========================================= */