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:
parent
0bd1cab9e2
commit
420044387e
4 changed files with 238 additions and 15 deletions
112
web_server.py
112
web_server.py
|
|
@ -3320,6 +3320,118 @@ def enhanced_search():
|
||||||
logger.error(f"Enhanced search error: {e}")
|
logger.error(f"Enhanced search error: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
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'])
|
@app.route('/api/download', methods=['POST'])
|
||||||
def start_download():
|
def start_download():
|
||||||
"""Simple download route"""
|
"""Simple download route"""
|
||||||
|
|
|
||||||
|
|
@ -1178,21 +1178,21 @@
|
||||||
|
|
||||||
<!-- Search Mode Toggle -->
|
<!-- Search Mode Toggle -->
|
||||||
<div class="search-mode-toggle-container">
|
<div class="search-mode-toggle-container">
|
||||||
<div class="search-mode-toggle">
|
<div class="search-mode-toggle" data-active="enhanced">
|
||||||
<button class="search-mode-btn active" data-mode="basic">
|
<button class="search-mode-btn active" data-mode="enhanced">
|
||||||
<span class="mode-icon">🔍</span>
|
|
||||||
<span class="mode-label">Basic Search</span>
|
|
||||||
</button>
|
|
||||||
<button class="search-mode-btn" data-mode="enhanced">
|
|
||||||
<span class="mode-icon">✨</span>
|
<span class="mode-icon">✨</span>
|
||||||
<span class="mode-label">Enhanced Search</span>
|
<span class="mode-label">Enhanced Search</span>
|
||||||
</button>
|
</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 class="toggle-slider"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Basic Search Section (Current) -->
|
<!-- 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() -->
|
<!-- Search Bar: Replicates create_elegant_search_bar() -->
|
||||||
<div class="search-bar-container">
|
<div class="search-bar-container">
|
||||||
<input type="text" id="downloads-search-input" placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')">
|
<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 -->
|
<!-- End Basic Search Section -->
|
||||||
|
|
||||||
<!-- Enhanced Search Section (New) -->
|
<!-- 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 -->
|
<!-- Enhanced Search Bar with Dropdown -->
|
||||||
<div class="enhanced-search-input-wrapper">
|
<div class="enhanced-search-input-wrapper">
|
||||||
<div class="enhanced-search-bar-container">
|
<div class="enhanced-search-bar-container">
|
||||||
|
|
|
||||||
|
|
@ -2615,7 +2615,8 @@ function initializeSearchModeToggle() {
|
||||||
name: track.name,
|
name: track.name,
|
||||||
meta: `${track.artist} • ${track.album}`,
|
meta: `${track.artist} • ${track.album}`,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
onClick: () => handleEnhancedSearchTrackClick(track)
|
onClick: () => handleEnhancedSearchTrackClick(track),
|
||||||
|
onPlay: () => streamEnhancedSearchTrack(track)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -2697,7 +2698,10 @@ function initializeSearchModeToggle() {
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const durationHtml = config.duration && isTrack
|
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 = `
|
elem.innerHTML = `
|
||||||
|
|
@ -2711,6 +2715,18 @@ function initializeSearchModeToggle() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
elem.addEventListener('click', config.onClick);
|
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);
|
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) {
|
async function handleEnhancedSearchTrackClick(track) {
|
||||||
console.log(`🎵 Enhanced search track clicked: ${track.name} by ${track.artist}`);
|
console.log(`🎵 Enhanced search track clicked: ${track.name} by ${track.artist}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18859,17 +18859,17 @@ body {
|
||||||
left: 4px;
|
left: 4px;
|
||||||
width: calc(50% - 4px);
|
width: calc(50% - 4px);
|
||||||
height: calc(100% - 8px);
|
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;
|
border-radius: 8px;
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
z-index: 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%);
|
transform: translateX(100%);
|
||||||
background: linear-gradient(135deg, rgba(138, 43, 226, 0.9), rgba(123, 31, 162, 0.9));
|
background: linear-gradient(135deg, rgba(29, 185, 84, 0.9), rgba(24, 156, 71, 0.9));
|
||||||
box-shadow: 0 2px 8px rgba(138, 43, 226, 0.4);
|
box-shadow: 0 2px 8px rgba(29, 185, 84, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search Section Visibility */
|
/* Search Section Visibility */
|
||||||
|
|
@ -19569,6 +19569,44 @@ body {
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: 8px;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================= */
|
/* ========================================= */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue