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}")
|
||||
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"""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/* ========================================= */
|
||||
|
|
|
|||
Loading…
Reference in a new issue