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:
Broque Thomas 2026-03-19 13:23:43 -07:00
parent b9c83a50fa
commit e715ceef3e
4 changed files with 453 additions and 131 deletions

View file

@ -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']

View file

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

View file

@ -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) {

View file

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