Add multi-source search tabs for enhanced search (Spotify/iTunes/Deezer)
Search results now show switchable tabs for alternate metadata sources. Primary source renders immediately, alternate sources load in parallel and tabs appear progressively as each completes. - New /api/enhanced-search/source/<name> endpoint for per-source queries - Source-aware routing via ?source= param on discography, album tracks, album detail, and artist image endpoints (prevents numeric ID misrouting between iTunes and Deezer) - Source override stored on artistsPageState for consistent navigation - Tabs styled with source brand colors, show result counts - All additive — users who ignore tabs see zero behavioral change
This commit is contained in:
parent
b9c83a50fa
commit
e715ceef3e
4 changed files with 453 additions and 131 deletions
344
web_server.py
344
web_server.py
|
|
@ -6759,18 +6759,27 @@ def search_music():
|
|||
@app.route('/api/enhanced-search', methods=['POST'])
|
||||
def enhanced_search():
|
||||
"""
|
||||
Unified search across Spotify and local database for enhanced search mode.
|
||||
Returns categorized results: DB artists, Spotify artists, albums, and tracks.
|
||||
Unified search across metadata sources and local database for enhanced search mode.
|
||||
Returns categorized results: DB artists, source artists, albums, and tracks.
|
||||
|
||||
Fires parallel queries against all available sources (Spotify, iTunes, Deezer)
|
||||
and returns results keyed by source, plus backward-compatible top-level keys
|
||||
mapped from the primary source.
|
||||
"""
|
||||
data = request.get_json()
|
||||
query = data.get('query', '').strip()
|
||||
|
||||
empty_source = {"artists": [], "albums": [], "tracks": [], "available": False}
|
||||
|
||||
if not query:
|
||||
return jsonify({
|
||||
"db_artists": [],
|
||||
"spotify_artists": [],
|
||||
"spotify_albums": [],
|
||||
"spotify_tracks": []
|
||||
"spotify_tracks": [],
|
||||
"sources": {},
|
||||
"primary_source": "spotify",
|
||||
"metadata_source": "spotify"
|
||||
})
|
||||
|
||||
logger.info(f"Enhanced search initiated for: '{query}'")
|
||||
|
|
@ -6785,130 +6794,175 @@ def enhanced_search():
|
|||
image_url = None
|
||||
if hasattr(artist, 'thumb_url') and artist.thumb_url:
|
||||
image_url = fix_artist_image_url(artist.thumb_url)
|
||||
|
||||
db_artists.append({
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"image_url": image_url
|
||||
})
|
||||
logger.debug(f"DB Artist: {artist.name}, thumb_url: {artist.thumb_url if hasattr(artist, 'thumb_url') else None}, fixed_url: {image_url}")
|
||||
|
||||
spotify_artists = []
|
||||
spotify_albums = []
|
||||
spotify_tracks = []
|
||||
metadata_source = "spotify"
|
||||
# ── Determine primary source and search it synchronously ──
|
||||
primary_source = "spotify"
|
||||
primary_results = empty_source
|
||||
|
||||
if _is_hydrabase_active():
|
||||
# ── Hydrabase is primary metadata source ──
|
||||
metadata_source = "hydrabase"
|
||||
primary_source = "hydrabase"
|
||||
try:
|
||||
artist_objs = hydrabase_client.search_artists(query, limit=10)
|
||||
for artist in artist_objs:
|
||||
spotify_artists.append({
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url
|
||||
})
|
||||
|
||||
album_objs = hydrabase_client.search_albums(query, limit=10)
|
||||
for album in album_objs:
|
||||
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
|
||||
spotify_albums.append({
|
||||
"id": album.id,
|
||||
"name": album.name,
|
||||
"artist": artist_name,
|
||||
"image_url": album.image_url,
|
||||
"release_date": album.release_date,
|
||||
"total_tracks": album.total_tracks,
|
||||
"album_type": album.album_type
|
||||
})
|
||||
|
||||
track_objs = hydrabase_client.search_tracks(query, limit=10)
|
||||
for track in track_objs:
|
||||
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
|
||||
spotify_tracks.append({
|
||||
"id": track.id,
|
||||
"name": track.name,
|
||||
"artist": artist_name,
|
||||
"album": track.album,
|
||||
"duration_ms": track.duration_ms,
|
||||
"image_url": track.image_url,
|
||||
"release_date": track.release_date
|
||||
})
|
||||
|
||||
logger.info(f"Hydrabase search results: {len(spotify_artists)} artists, {len(spotify_albums)} albums, {len(spotify_tracks)} tracks")
|
||||
|
||||
# Fire off background comparison with pre-computed Hydrabase counts
|
||||
primary_results = _enhanced_search_source(query, hydrabase_client)
|
||||
# Fire off background comparison
|
||||
_run_background_comparison(query, hydrabase_counts={
|
||||
'tracks': len(track_objs),
|
||||
'artists': len(artist_objs),
|
||||
'albums': len(album_objs)
|
||||
'tracks': len(primary_results['tracks']),
|
||||
'artists': len(primary_results['artists']),
|
||||
'albums': len(primary_results['albums'])
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Hydrabase search failed, falling back to Spotify/iTunes: {e}")
|
||||
metadata_source = "spotify"
|
||||
spotify_artists = []
|
||||
spotify_albums = []
|
||||
spotify_tracks = []
|
||||
logger.error(f"Hydrabase search failed: {e}")
|
||||
primary_source = "spotify"
|
||||
primary_results = empty_source
|
||||
|
||||
if metadata_source != "hydrabase":
|
||||
# ── Standard Spotify/iTunes path ──
|
||||
if primary_source != "hydrabase":
|
||||
# Mirror to Hydrabase worker (fire-and-forget)
|
||||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, 'track')
|
||||
hydrabase_worker.enqueue(query, 'album')
|
||||
hydrabase_worker.enqueue(query, 'artists')
|
||||
|
||||
if spotify_client and spotify_client.is_authenticated():
|
||||
artist_objs = spotify_client.search_artists(query, limit=10)
|
||||
for artist in artist_objs:
|
||||
spotify_artists.append({
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url
|
||||
})
|
||||
# Search primary source synchronously — use is_spotify_authenticated()
|
||||
# (is_authenticated() always returns True because fallback is always available)
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
try:
|
||||
primary_results = _enhanced_search_source(query, spotify_client)
|
||||
primary_source = "spotify"
|
||||
except Exception as e:
|
||||
logger.debug(f"Spotify search failed: {e}")
|
||||
|
||||
album_objs = spotify_client.search_albums(query, limit=10)
|
||||
for album in album_objs:
|
||||
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
|
||||
spotify_albums.append({
|
||||
"id": album.id,
|
||||
"name": album.name,
|
||||
"artist": artist_name,
|
||||
"image_url": album.image_url,
|
||||
"release_date": album.release_date,
|
||||
"total_tracks": album.total_tracks,
|
||||
"album_type": album.album_type
|
||||
})
|
||||
# If Spotify unavailable, use configured fallback as primary
|
||||
if primary_results is empty_source:
|
||||
fb_source = _get_metadata_fallback_source()
|
||||
try:
|
||||
primary_results = _enhanced_search_source(query, _get_metadata_fallback_client())
|
||||
primary_source = fb_source
|
||||
except Exception as e:
|
||||
logger.debug(f"Fallback ({fb_source}) search failed: {e}")
|
||||
|
||||
track_objs = spotify_client.search_tracks(query, limit=10)
|
||||
for track in track_objs:
|
||||
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
|
||||
spotify_tracks.append({
|
||||
"id": track.id,
|
||||
"name": track.name,
|
||||
"artist": artist_name,
|
||||
"album": track.album,
|
||||
"duration_ms": track.duration_ms,
|
||||
"image_url": track.image_url,
|
||||
"release_date": track.release_date
|
||||
})
|
||||
# Determine which alternate sources are available (for frontend to fetch async)
|
||||
spotify_available = bool(spotify_client and spotify_client.is_spotify_authenticated())
|
||||
alternate_sources = []
|
||||
if primary_source != 'spotify' and spotify_available:
|
||||
alternate_sources.append('spotify')
|
||||
if primary_source != 'itunes':
|
||||
alternate_sources.append('itunes')
|
||||
if primary_source != 'deezer':
|
||||
alternate_sources.append('deezer')
|
||||
|
||||
logger.info(f"Enhanced search results ({metadata_source}): {len(db_artists)} DB artists, {len(spotify_artists)} artists, {len(spotify_albums)} albums, {len(spotify_tracks)} tracks")
|
||||
logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
|
||||
f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, "
|
||||
f"{len(primary_results['tracks'])} tracks | "
|
||||
f"Alt sources available: {alternate_sources}")
|
||||
|
||||
return jsonify({
|
||||
# Backward compat — same shape as before
|
||||
"db_artists": db_artists,
|
||||
"spotify_artists": spotify_artists,
|
||||
"spotify_albums": spotify_albums,
|
||||
"spotify_tracks": spotify_tracks,
|
||||
"metadata_source": metadata_source
|
||||
"spotify_artists": primary_results["artists"],
|
||||
"spotify_albums": primary_results["albums"],
|
||||
"spotify_tracks": primary_results["tracks"],
|
||||
"metadata_source": primary_source,
|
||||
# New multi-source data
|
||||
"primary_source": primary_source,
|
||||
"alternate_sources": alternate_sources,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Enhanced search error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
def _enhanced_search_source(query, client):
|
||||
"""Search a single metadata source and return normalized results dict."""
|
||||
artists = []
|
||||
albums = []
|
||||
tracks = []
|
||||
|
||||
try:
|
||||
artist_objs = client.search_artists(query, limit=10)
|
||||
for artist in artist_objs:
|
||||
artists.append({
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Artist search failed for {type(client).__name__}: {e}")
|
||||
|
||||
try:
|
||||
album_objs = client.search_albums(query, limit=10)
|
||||
for album in album_objs:
|
||||
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
|
||||
albums.append({
|
||||
"id": album.id,
|
||||
"name": album.name,
|
||||
"artist": artist_name,
|
||||
"image_url": album.image_url,
|
||||
"release_date": album.release_date,
|
||||
"total_tracks": album.total_tracks,
|
||||
"album_type": album.album_type
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Album search failed for {type(client).__name__}: {e}")
|
||||
|
||||
try:
|
||||
track_objs = client.search_tracks(query, limit=10)
|
||||
for track in track_objs:
|
||||
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
|
||||
tracks.append({
|
||||
"id": track.id,
|
||||
"name": track.name,
|
||||
"artist": artist_name,
|
||||
"album": track.album,
|
||||
"duration_ms": track.duration_ms,
|
||||
"image_url": track.image_url,
|
||||
"release_date": track.release_date
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Track search failed for {type(client).__name__}: {e}")
|
||||
|
||||
return {"artists": artists, "albums": albums, "tracks": tracks, "available": True}
|
||||
|
||||
|
||||
@app.route('/api/enhanced-search/source/<source_name>', methods=['POST'])
|
||||
def enhanced_search_source(source_name):
|
||||
"""Fetch search results from a specific alternate metadata source.
|
||||
|
||||
Called asynchronously by the frontend after the primary search returns.
|
||||
Each source tab fires its own request so slow sources don't block fast ones.
|
||||
"""
|
||||
if source_name not in ('spotify', 'itunes', 'deezer'):
|
||||
return jsonify({"error": f"Unknown source: {source_name}"}), 400
|
||||
|
||||
data = request.get_json()
|
||||
query = (data.get('query', '') if data else '').strip()
|
||||
if not query:
|
||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||
|
||||
try:
|
||||
client = None
|
||||
if source_name == 'spotify':
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
client = spotify_client
|
||||
else:
|
||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||
elif source_name == 'itunes':
|
||||
from core.itunes_client import iTunesClient
|
||||
client = iTunesClient()
|
||||
elif source_name == 'deezer':
|
||||
client = _get_deezer_client()
|
||||
|
||||
result = _enhanced_search_source(query, client)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Enhanced search source ({source_name}) error: {e}")
|
||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||
|
||||
|
||||
@app.route('/api/enhanced-search/stream-track', methods=['POST'])
|
||||
def stream_enhanced_search_track():
|
||||
"""
|
||||
|
|
@ -8700,7 +8754,19 @@ def get_artist_image(artist_id):
|
|||
if artist_id.startswith('soul_'):
|
||||
return jsonify({"success": True, "image_url": None})
|
||||
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
# Source override from multi-source search tabs
|
||||
source_override = request.args.get('source', '')
|
||||
|
||||
if source_override == 'itunes':
|
||||
from core.itunes_client import iTunesClient
|
||||
client = iTunesClient()
|
||||
image_url = client._get_artist_image_from_albums(artist_id)
|
||||
return jsonify({"success": True, "image_url": image_url})
|
||||
elif source_override == 'deezer':
|
||||
client = _get_deezer_client()
|
||||
image_url = client._get_artist_image_from_albums(artist_id)
|
||||
return jsonify({"success": True, "image_url": image_url})
|
||||
elif spotify_client and spotify_client.is_spotify_authenticated() and source_override != 'itunes':
|
||||
# Use Spotify directly
|
||||
artist_data = spotify_client.sp.artist(artist_id)
|
||||
if artist_data and artist_data.get('images'):
|
||||
|
|
@ -8722,6 +8788,8 @@ def get_artist_discography(artist_id):
|
|||
try:
|
||||
# Get optional artist name for fallback searches
|
||||
artist_name = request.args.get('artist_name', '')
|
||||
# Optional source override from multi-source search tabs
|
||||
source_override = request.args.get('source', '')
|
||||
|
||||
# Mirror to Hydrabase P2P network
|
||||
if hydrabase_worker and dev_mode_enabled and artist_name:
|
||||
|
|
@ -8734,13 +8802,57 @@ def get_artist_discography(artist_id):
|
|||
fallback_client = _get_metadata_fallback_client()
|
||||
fallback_source = _get_metadata_fallback_source()
|
||||
|
||||
print(f"🎤 Fetching discography for artist: {artist_id} (name: {artist_name}, spotify: {spotify_available})")
|
||||
print(f"🎤 Fetching discography for artist: {artist_id} (name: {artist_name}, spotify: {spotify_available}, source_override: {source_override or 'auto'})")
|
||||
|
||||
albums = []
|
||||
active_source = None
|
||||
|
||||
# Try Hydrabase first when active
|
||||
if _is_hydrabase_active() and artist_name:
|
||||
# Source override: when user clicked from a specific search tab, use that source directly
|
||||
if source_override and source_override in ('spotify', 'itunes', 'deezer'):
|
||||
try:
|
||||
if source_override == 'spotify' and spotify_available:
|
||||
albums = spotify_client.get_artist_albums(artist_id, album_type='album,single')
|
||||
if albums:
|
||||
active_source = 'spotify'
|
||||
elif source_override == 'itunes':
|
||||
from core.itunes_client import iTunesClient
|
||||
itunes_cl = iTunesClient()
|
||||
albums = itunes_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
||||
if albums:
|
||||
active_source = 'itunes'
|
||||
elif source_override == 'deezer':
|
||||
deezer_cl = _get_deezer_client()
|
||||
albums = deezer_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
||||
if albums:
|
||||
active_source = 'deezer'
|
||||
|
||||
# If direct ID lookup failed but we have artist name, search by name
|
||||
if not albums and artist_name:
|
||||
if source_override == 'itunes':
|
||||
from core.itunes_client import iTunesClient
|
||||
cl = iTunesClient()
|
||||
elif source_override == 'deezer':
|
||||
cl = _get_deezer_client()
|
||||
elif source_override == 'spotify' and spotify_available:
|
||||
cl = spotify_client
|
||||
else:
|
||||
cl = None
|
||||
|
||||
if cl:
|
||||
search_artists = cl.search_artists(artist_name, limit=5)
|
||||
if search_artists:
|
||||
best = next((a for a in search_artists if a.name.lower() == artist_name.lower()), search_artists[0])
|
||||
albums = cl.get_artist_albums(best.id, album_type='album,single', limit=50)
|
||||
if albums:
|
||||
active_source = source_override
|
||||
|
||||
if albums:
|
||||
print(f"📊 Got {len(albums)} albums from {active_source} (source override)")
|
||||
except Exception as e:
|
||||
print(f"Source override ({source_override}) lookup failed: {e}")
|
||||
|
||||
# Try Hydrabase first when active (and no source override)
|
||||
if not albums and _is_hydrabase_active() and artist_name:
|
||||
try:
|
||||
albums = hydrabase_client.search_discography(artist_name, limit=50)
|
||||
if albums:
|
||||
|
|
@ -8975,10 +9087,19 @@ def get_artist_album_tracks(artist_id, album_id):
|
|||
if not spotify_client or not spotify_client.is_authenticated():
|
||||
return jsonify({"error": "Spotify not authenticated"}), 401
|
||||
|
||||
print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id}")
|
||||
# Source override: when user navigated from a specific search tab
|
||||
source_override = request.args.get('source', '')
|
||||
client = spotify_client
|
||||
if source_override == 'itunes':
|
||||
from core.itunes_client import iTunesClient
|
||||
client = iTunesClient()
|
||||
elif source_override == 'deezer':
|
||||
client = _get_deezer_client()
|
||||
|
||||
print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id} (source: {source_override or 'auto'})")
|
||||
|
||||
# Get album information first
|
||||
album_data = spotify_client.get_album(album_id)
|
||||
album_data = client.get_album(album_id)
|
||||
resolved_album_id = album_id
|
||||
|
||||
# If direct lookup failed, the album_id might be a database ID — resolve it
|
||||
|
|
@ -8986,13 +9107,13 @@ def get_artist_album_tracks(artist_id, album_id):
|
|||
resolved_album_id = _resolve_db_album_id(album_id, artist_id)
|
||||
if resolved_album_id and resolved_album_id != album_id:
|
||||
print(f"🔄 Resolved DB album ID {album_id} -> external ID {resolved_album_id}")
|
||||
album_data = spotify_client.get_album(resolved_album_id)
|
||||
album_data = client.get_album(resolved_album_id)
|
||||
|
||||
if not album_data:
|
||||
return jsonify({"error": "Album not found"}), 404
|
||||
|
||||
# Get album tracks
|
||||
tracks_data = spotify_client.get_album_tracks(resolved_album_id)
|
||||
tracks_data = client.get_album_tracks(resolved_album_id)
|
||||
if not tracks_data or 'items' not in tracks_data:
|
||||
return jsonify({"error": "No tracks found for album"}), 404
|
||||
|
||||
|
|
@ -24951,10 +25072,21 @@ def get_album_tracks(album_id):
|
|||
except Exception as e:
|
||||
logger.warning(f"Hydrabase album_tracks failed for '{album_id}', falling back to Spotify: {e}")
|
||||
|
||||
# Source override: when user clicked from a specific search tab
|
||||
source_override = request.args.get('source', '')
|
||||
|
||||
if not spotify_client or not spotify_client.is_authenticated():
|
||||
return jsonify({"error": "Spotify not authenticated."}), 401
|
||||
try:
|
||||
album_data = spotify_client.get_album(album_id)
|
||||
# Use explicit source client when overridden (prevents numeric ID misrouting)
|
||||
client = spotify_client
|
||||
if source_override == 'itunes':
|
||||
from core.itunes_client import iTunesClient
|
||||
client = iTunesClient()
|
||||
elif source_override == 'deezer':
|
||||
client = _get_deezer_client()
|
||||
|
||||
album_data = client.get_album(album_id)
|
||||
if not album_data:
|
||||
return jsonify({"error": "Album not found"}), 404
|
||||
|
||||
|
|
@ -24963,7 +25095,7 @@ def get_album_tracks(album_id):
|
|||
|
||||
# If no tracks in album data (iTunes format), fetch them separately
|
||||
if not tracks:
|
||||
tracks_data = spotify_client.get_album_tracks(album_id)
|
||||
tracks_data = client.get_album_tracks(album_id)
|
||||
if tracks_data and 'items' in tracks_data:
|
||||
tracks = tracks_data['items']
|
||||
|
||||
|
|
|
|||
|
|
@ -2085,6 +2085,9 @@
|
|||
<!-- Results Container -->
|
||||
<div id="enhanced-results-container" class="enhanced-results-container hidden">
|
||||
|
||||
<!-- Source Tabs -->
|
||||
<div id="enh-source-tabs" class="enh-source-tabs hidden"></div>
|
||||
|
||||
<!-- Artists Container (Side by Side) -->
|
||||
<div class="enh-artists-wrapper">
|
||||
<!-- DB Artists -->
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ let artistsPageState = {
|
|||
searchQuery: '',
|
||||
searchResults: [],
|
||||
selectedArtist: null,
|
||||
sourceOverride: null, // Set when navigating from an alternate search tab
|
||||
artistDiscography: {
|
||||
albums: [],
|
||||
singles: []
|
||||
|
|
@ -6799,6 +6800,18 @@ function initializeSearchModeToggle() {
|
|||
let debounceTimer = null;
|
||||
let abortController = null;
|
||||
|
||||
// Multi-source search state
|
||||
let _enhancedSearchData = null; // Full response with all sources
|
||||
let _activeSearchSource = null; // Currently displayed source tab
|
||||
let _altSourceController = null; // AbortController for alternate source fetches
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' },
|
||||
itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' },
|
||||
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
|
||||
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
|
||||
};
|
||||
|
||||
// Live search with debouncing
|
||||
if (enhancedInput) {
|
||||
enhancedInput.addEventListener('input', (e) => {
|
||||
|
|
@ -6905,11 +6918,25 @@ function initializeSearchModeToggle() {
|
|||
emptyState.classList.add('hidden');
|
||||
resultsContainer.classList.add('hidden');
|
||||
|
||||
// Abort previous request
|
||||
// Abort previous requests (primary + alternates)
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
if (_altSourceController) {
|
||||
_altSourceController.abort();
|
||||
}
|
||||
abortController = new AbortController();
|
||||
_altSourceController = new AbortController();
|
||||
|
||||
// Initialize multi-source state early so alternate fetches can write to it
|
||||
_enhancedSearchData = { db_artists: [], primary_source: null, sources: {} };
|
||||
|
||||
// Fire ALL source fetches immediately in parallel with the primary endpoint.
|
||||
// Don't guess which is primary — the main endpoint response will tell us.
|
||||
// If an alternate duplicates the primary, it just overwrites with same data.
|
||||
for (const srcName of ['spotify', 'itunes', 'deezer']) {
|
||||
_fetchAlternateSource(srcName, query);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/enhanced-search', {
|
||||
|
|
@ -6924,7 +6951,21 @@ function initializeSearchModeToggle() {
|
|||
const data = await response.json();
|
||||
console.log('Enhanced results:', data);
|
||||
|
||||
// Calculate total
|
||||
// Store multi-source state
|
||||
const primarySource = data.primary_source || data.metadata_source || 'spotify';
|
||||
_activeSearchSource = primarySource;
|
||||
_enhancedSearchData = _enhancedSearchData || {};
|
||||
_enhancedSearchData.db_artists = data.db_artists;
|
||||
_enhancedSearchData.primary_source = primarySource;
|
||||
if (!_enhancedSearchData.sources) _enhancedSearchData.sources = {};
|
||||
_enhancedSearchData.sources[primarySource] = {
|
||||
artists: data.spotify_artists || [],
|
||||
albums: data.spotify_albums || [],
|
||||
tracks: data.spotify_tracks || [],
|
||||
available: true,
|
||||
};
|
||||
|
||||
// Calculate total from primary source
|
||||
const total = (data.db_artists?.length || 0) +
|
||||
(data.spotify_artists?.length || 0) +
|
||||
(data.spotify_albums?.length || 0) +
|
||||
|
|
@ -6936,6 +6977,7 @@ function initializeSearchModeToggle() {
|
|||
if (total === 0) {
|
||||
emptyState.classList.remove('hidden');
|
||||
} else {
|
||||
renderSourceTabs(_enhancedSearchData);
|
||||
renderDropdownResults(data);
|
||||
resultsContainer.classList.remove('hidden');
|
||||
}
|
||||
|
|
@ -6950,11 +6992,10 @@ function initializeSearchModeToggle() {
|
|||
}
|
||||
|
||||
function renderDropdownResults(data) {
|
||||
// Determine source badge
|
||||
const metadataSource = data.metadata_source || 'spotify';
|
||||
const sourceBadge = metadataSource === 'hydrabase'
|
||||
? { text: 'Hydrabase', class: 'enh-badge-hydrabase' }
|
||||
: { text: 'Spotify', class: 'enh-badge-spotify' };
|
||||
// Determine source badge from active tab (not just primary)
|
||||
const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
|
||||
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
|
||||
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
|
||||
|
||||
// Render DB Artists
|
||||
renderCompactSection(
|
||||
|
|
@ -6989,7 +7030,8 @@ function initializeSearchModeToggle() {
|
|||
meta: 'Artist',
|
||||
badge: sourceBadge,
|
||||
onClick: async () => {
|
||||
console.log(`🎵 Opening Spotify artist detail: ${artist.name} (ID: ${artist.id})`);
|
||||
const sourceOverride = _activeSearchSource;
|
||||
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
|
||||
hideDropdown();
|
||||
|
||||
// Navigate to Artists page
|
||||
|
|
@ -6998,8 +7040,8 @@ function initializeSearchModeToggle() {
|
|||
// Small delay to let the page load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Load the artist details (same pattern as discover page)
|
||||
await selectArtistForDetail(artist);
|
||||
// Load the artist details with source context
|
||||
await selectArtistForDetail(artist, { source: sourceOverride });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
@ -7063,6 +7105,92 @@ function initializeSearchModeToggle() {
|
|||
lazyLoadEnhancedSearchArtistImages();
|
||||
}
|
||||
|
||||
async function _fetchAlternateSource(sourceName, query) {
|
||||
try {
|
||||
const response = await fetch(`/api/enhanced-search/source/${sourceName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: _altSourceController?.signal,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (!result.available) return;
|
||||
|
||||
// Store in multi-source state
|
||||
if (_enhancedSearchData) {
|
||||
_enhancedSearchData.sources[sourceName] = result;
|
||||
// Re-render tabs if primary has loaded (primary_source is set)
|
||||
if (_enhancedSearchData.primary_source) {
|
||||
renderSourceTabs(_enhancedSearchData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.debug(`Alternate source ${sourceName} failed:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSourceTabs(data) {
|
||||
const tabBar = document.getElementById('enh-source-tabs');
|
||||
if (!tabBar) return;
|
||||
|
||||
const sources = data.sources || {};
|
||||
const primary = data.primary_source || 'spotify';
|
||||
|
||||
// Build tab list: primary first, then alternates sorted alphabetically
|
||||
const sourceNames = Object.keys(sources).filter(s => sources[s].available);
|
||||
if (sourceNames.length <= 1) {
|
||||
tabBar.classList.add('hidden');
|
||||
tabBar.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Primary tab first, then others
|
||||
const ordered = [primary, ...sourceNames.filter(s => s !== primary).sort()];
|
||||
|
||||
tabBar.innerHTML = ordered.map(name => {
|
||||
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
|
||||
const src = sources[name] || {};
|
||||
const count = (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
|
||||
const isActive = name === _activeSearchSource;
|
||||
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
|
||||
onclick="window._switchEnhSourceTab('${name}')"
|
||||
data-source="${name}">
|
||||
${info.text}<span class="enh-tab-count">(${count})</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
tabBar.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Expose tab switch globally (onclick from HTML)
|
||||
window._switchEnhSourceTab = function(sourceName) {
|
||||
if (!_enhancedSearchData || !_enhancedSearchData.sources) return;
|
||||
const src = _enhancedSearchData.sources[sourceName];
|
||||
if (!src) return;
|
||||
|
||||
_activeSearchSource = sourceName;
|
||||
|
||||
// Update tab active states
|
||||
document.querySelectorAll('.enh-source-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.source === sourceName);
|
||||
});
|
||||
|
||||
// Build data in the shape renderDropdownResults expects
|
||||
const viewData = {
|
||||
db_artists: _enhancedSearchData.db_artists,
|
||||
spotify_artists: src.artists || [],
|
||||
spotify_albums: src.albums || [],
|
||||
spotify_tracks: src.tracks || [],
|
||||
metadata_source: sourceName,
|
||||
};
|
||||
|
||||
renderDropdownResults(viewData);
|
||||
resultsContainer.classList.remove('hidden');
|
||||
};
|
||||
|
||||
// Lazy load artist images for enhanced search results
|
||||
async function lazyLoadEnhancedSearchArtistImages() {
|
||||
const artistLists = [
|
||||
|
|
@ -7083,7 +7211,10 @@ function initializeSearchModeToggle() {
|
|||
if (!artistId) continue;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/artist/${artistId}/image`);
|
||||
const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify'
|
||||
? `/api/artist/${artistId}/image?source=${_activeSearchSource}`
|
||||
: `/api/artist/${artistId}/image`;
|
||||
const response = await fetch(imgUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.image_url) {
|
||||
|
|
@ -7239,8 +7370,11 @@ function initializeSearchModeToggle() {
|
|||
showLoadingOverlay('Loading album...');
|
||||
|
||||
try {
|
||||
// Fetch full album data with tracks (Hydrabase or Spotify)
|
||||
// Fetch full album data with tracks — pass source for correct routing
|
||||
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
|
||||
if (_activeSearchSource && _activeSearchSource !== 'spotify') {
|
||||
albumParams.set('source', _activeSearchSource);
|
||||
}
|
||||
const response = await fetch(`/api/spotify/album/${album.id}?${albumParams}`);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -30856,7 +30990,7 @@ function createArtistCardHTML(artist) {
|
|||
/**
|
||||
* Select an artist and show their discography
|
||||
*/
|
||||
async function selectArtistForDetail(artist) {
|
||||
async function selectArtistForDetail(artist, options = {}) {
|
||||
console.log(`🎤 Selected artist: ${artist.name}`);
|
||||
|
||||
// Cancel any ongoing completion check from previous artist
|
||||
|
|
@ -30876,6 +31010,7 @@ async function selectArtistForDetail(artist) {
|
|||
// Update state
|
||||
artistsPageState.selectedArtist = artist;
|
||||
artistsPageState.currentView = 'detail';
|
||||
artistsPageState.sourceOverride = options.source || null;
|
||||
|
||||
// Show detail state
|
||||
showArtistDetailState();
|
||||
|
|
@ -30884,7 +31019,7 @@ async function selectArtistForDetail(artist) {
|
|||
updateArtistDetailHeader(artist);
|
||||
|
||||
// Load discography (pass artist name for cross-source fallback)
|
||||
await loadArtistDiscography(artist.id, artist.name);
|
||||
await loadArtistDiscography(artist.id, artist.name, options.source);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -30892,16 +31027,19 @@ async function selectArtistForDetail(artist) {
|
|||
* @param {string} artistId - Artist ID (Spotify or iTunes format)
|
||||
* @param {string} [artistName] - Optional artist name for fallback searches
|
||||
*/
|
||||
async function loadArtistDiscography(artistId, artistName = null) {
|
||||
console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName})`);
|
||||
async function loadArtistDiscography(artistId, artistName = null, sourceOverride = null) {
|
||||
console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName}, source: ${sourceOverride || 'auto'})`);
|
||||
|
||||
// Use source-prefixed cache key to avoid ID collisions between sources
|
||||
const cacheKey = sourceOverride ? `${sourceOverride}:${artistId}` : artistId;
|
||||
|
||||
// Check cache first
|
||||
if (artistsPageState.cache.discography[artistId]) {
|
||||
if (artistsPageState.cache.discography[cacheKey]) {
|
||||
console.log('📦 Using cached discography');
|
||||
const cachedDiscography = artistsPageState.cache.discography[artistId];
|
||||
const cachedDiscography = artistsPageState.cache.discography[cacheKey];
|
||||
displayArtistDiscography(cachedDiscography);
|
||||
|
||||
// Load similar artists in parallel (don't wait)
|
||||
// Load similar artists in parallel (don't wait) — always uses primary source
|
||||
loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => {
|
||||
console.error('❌ Error loading similar artists:', err);
|
||||
});
|
||||
|
|
@ -30915,11 +31053,12 @@ async function loadArtistDiscography(artistId, artistName = null) {
|
|||
// Show loading states
|
||||
showDiscographyLoading();
|
||||
|
||||
// Build URL with optional artist name for fallback
|
||||
// Build URL with optional artist name and source override for fallback
|
||||
let url = `/api/artist/${artistId}/discography`;
|
||||
if (artistName) {
|
||||
url += `?artist_name=${encodeURIComponent(artistName)}`;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (artistName) params.set('artist_name', artistName);
|
||||
if (sourceOverride) params.set('source', sourceOverride);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
// Call the real API endpoint
|
||||
const response = await fetch(url);
|
||||
|
|
@ -30955,8 +31094,8 @@ async function loadArtistDiscography(artistId, artistName = null) {
|
|||
|
||||
console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`);
|
||||
|
||||
// Cache the results
|
||||
artistsPageState.cache.discography[artistId] = discography;
|
||||
// Cache the results (use source-prefixed key if source override active)
|
||||
artistsPageState.cache.discography[cacheKey] = discography;
|
||||
artistsPageState.artistDiscography = discography;
|
||||
|
||||
// Display results
|
||||
|
|
@ -32366,6 +32505,9 @@ async function createArtistAlbumVirtualPlaylist(album, albumType) {
|
|||
|
||||
// Fetch album tracks from backend (pass name/artist for Hydrabase support)
|
||||
const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' });
|
||||
if (artistsPageState.sourceOverride) {
|
||||
_aat1.set('source', artistsPageState.sourceOverride);
|
||||
}
|
||||
const response = await fetch(`/api/artist/${artist.id}/album/${album.id}/tracks?${_aat1}`);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -27803,6 +27803,51 @@ body {
|
|||
}
|
||||
|
||||
/* Dropdown Sections - Enhanced with Premium Design */
|
||||
/* Source tabs for multi-source search */
|
||||
.enh-source-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.enh-source-tab {
|
||||
padding: 5px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 0.78em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enh-source-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.enh-source-tab.active {
|
||||
background: var(--accent-color, #1db954);
|
||||
border-color: var(--accent-color, #1db954);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.enh-source-tab .enh-tab-count {
|
||||
margin-left: 5px;
|
||||
opacity: 0.7;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.enh-source-tab.enh-tab-spotify.active { background: #1db954; border-color: #1db954; }
|
||||
.enh-source-tab.enh-tab-itunes.active { background: #fc3c44; border-color: #fc3c44; }
|
||||
.enh-source-tab.enh-tab-deezer.active { background: #a238ff; border-color: #a238ff; }
|
||||
.enh-source-tab.enh-tab-hydrabase.active { background: #00b4d8; border-color: #00b4d8; }
|
||||
|
||||
.enh-dropdown-section {
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg,
|
||||
|
|
|
|||
Loading…
Reference in a new issue