diff --git a/web_server.py b/web_server.py index 7b47b66a..cb268810 100644 --- a/web_server.py +++ b/web_server.py @@ -8536,7 +8536,7 @@ def enhanced_search(): # Search using the user's configured primary metadata source fb_source = _get_metadata_fallback_source() try: - primary_results = _enhanced_search_source(query, _get_metadata_fallback_client()) + primary_results = _enhanced_search_source(query, _get_metadata_fallback_client(), fb_source) primary_source = fb_source except Exception as e: logger.debug(f"Primary source ({fb_source}) search failed: {e}") @@ -8545,7 +8545,7 @@ def enhanced_search(): if primary_results is empty_source and fb_source != 'spotify': if spotify_client and spotify_client.is_spotify_authenticated(): try: - primary_results = _enhanced_search_source(query, spotify_client) + primary_results = _enhanced_search_source(query, spotify_client, "spotify") primary_source = "spotify" except Exception as e: logger.debug(f"Spotify fallback search failed: {e}") @@ -8592,59 +8592,86 @@ def enhanced_search(): 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 = [] +def _search_metadata_source_kind(client, query, kind, source_name=None): + """Search one result type from a metadata source and normalize it.""" + source_label = source_name or type(client).__name__ - 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, - "external_urls": artist.external_urls or {}, - }) - except Exception as e: - logger.debug(f"Artist search failed for {type(client).__name__}: {e}") + if kind == "artists": + artists = [] + 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, + "external_urls": artist.external_urls or {}, + }) + except Exception as e: + logger.debug(f"Artist search failed for {source_label}: {e}") + return artists - 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, - "external_urls": album.external_urls or {}, - }) - except Exception as e: - logger.warning(f"Album search failed for {type(client).__name__}: {e}", exc_info=True) + if kind == "albums": + albums = [] + 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, + "external_urls": album.external_urls or {}, + }) + except Exception as e: + logger.warning(f"Album search failed for {source_label}: {e}", exc_info=True) + return albums - 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, - "external_urls": track.external_urls or {}, - }) - except Exception as e: - logger.warning(f"Track search failed for {type(client).__name__}: {e}", exc_info=True) + if kind == "tracks": + tracks = [] + 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, + "external_urls": track.external_urls or {}, + }) + except Exception as e: + logger.warning(f"Track search failed for {source_label}: {e}", exc_info=True) + return tracks + + raise ValueError(f"Unknown metadata search kind: {kind}") - return {"artists": artists, "albums": albums, "tracks": tracks, "available": True} + +def _enhanced_search_source(query, client, source_name=None): + """Search a single metadata source and return normalized results dict.""" + results = {"artists": [], "albums": [], "tracks": []} + with ThreadPoolExecutor(max_workers=3) as executor: + futures = { + executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists", + executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums", + executor.submit(_search_metadata_source_kind, client, query, "tracks", source_name): "tracks", + } + for future in as_completed(futures): + kind = futures[future] + try: + results[kind] = future.result() + except Exception as e: + logger.warning(f"{kind.title()} search failed for {source_name or type(client).__name__}: {e}", exc_info=True) + results[kind] = [] + + return {"artists": results["artists"], "albums": results["albums"], "tracks": results["tracks"], "available": True} @app.route('/api/enhanced-search/source/', methods=['POST']) @@ -8726,51 +8753,20 @@ def enhanced_search_source(source_name): def generate(): # Stream each search type as it completes - try: - artist_objs = client.search_artists(query, limit=10) - artists = [] - for artist in artist_objs: - artists.append({ - "id": artist.id, "name": artist.name, - "image_url": artist.image_url, - "external_urls": artist.external_urls or {}, - }) - yield json.dumps({"type": "artists", "data": artists}) + "\n" - except Exception as e: - logger.debug(f"Artist search failed for {source_name}: {e}") - yield json.dumps({"type": "artists", "data": []}) + "\n" - - try: - album_objs = client.search_albums(query, limit=10) - albums = [] - 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, - "external_urls": album.external_urls or {}, - }) - yield json.dumps({"type": "albums", "data": albums}) + "\n" - except Exception as e: - logger.warning(f"Album search failed for {source_name}: {e}") - yield json.dumps({"type": "albums", "data": []}) + "\n" - - try: - track_objs = client.search_tracks(query, limit=10) - tracks = [] - 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, - "external_urls": track.external_urls or {}, - }) - yield json.dumps({"type": "tracks", "data": tracks}) + "\n" - except Exception as e: - logger.warning(f"Track search failed for {source_name}: {e}") - yield json.dumps({"type": "tracks", "data": []}) + "\n" + with ThreadPoolExecutor(max_workers=3) as executor: + futures = { + executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists", + executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums", + executor.submit(_search_metadata_source_kind, client, query, "tracks", source_name): "tracks", + } + for future in as_completed(futures): + kind = futures[future] + try: + payload = future.result() + except Exception as e: + logger.warning(f"{kind.title()} search failed for {source_name}: {e}", exc_info=True) + payload = [] + yield json.dumps({"type": kind, "data": payload}) + "\n" yield json.dumps({"type": "done"}) + "\n" diff --git a/webui/static/script.js b/webui/static/script.js index 8193eb39..1e056eee 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -8671,6 +8671,7 @@ function initializeSearchModeToggle() { async function performEnhancedSearch(query) { console.log('Enhanced search:', query); + const searchId = Date.now() + Math.random(); // Show loading state with correct source name showDropdown(); @@ -8693,14 +8694,7 @@ function initializeSearchModeToggle() { _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', 'discogs', 'hydrabase', 'youtube_videos', 'musicbrainz']) { - _fetchAlternateSource(srcName, query); - } + _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query }; try { const response = await fetch('/api/enhanced-search', { @@ -8716,7 +8710,7 @@ function initializeSearchModeToggle() { console.log('Enhanced results:', data); // Store multi-source state - const primarySource = data.primary_source || data.metadata_source || 'spotify'; + const primarySource = data.primary_source || data.metadata_source || 'deezer'; _activeSearchSource = primarySource; _enhancedSearchData = _enhancedSearchData || {}; _enhancedSearchData.db_artists = data.db_artists; @@ -8746,6 +8740,10 @@ function initializeSearchModeToggle() { resultsContainer.classList.remove('hidden'); } + // Alternate sources now start after the primary response has landed. + // This avoids speculative fan-out for short or aborted searches. + _queueAlternateSourceFetches(data.alternate_sources || [], query, searchId); + } catch (error) { if (error.name !== 'AbortError') { console.error('Enhanced search error:', error); @@ -8960,8 +8958,26 @@ function initializeSearchModeToggle() { } } - async function _fetchAlternateSource(sourceName, query) { + function _queueAlternateSourceFetches(alternateSources, query, searchId) { + if (!Array.isArray(alternateSources) || alternateSources.length === 0) return; + + // Fetch metadata sources first, then YouTube last so it does not compete + // with the primary artist/album/track results for early attention. + const orderedSources = ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos'] + .filter(src => alternateSources.includes(src) && src !== _activeSearchSource); + + orderedSources.forEach((src, index) => { + setTimeout(() => { + if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; + _fetchAlternateSource(src, query, searchId); + }, index * 150); + }); + } + + async function _fetchAlternateSource(sourceName, query, searchId) { try { + if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; + const response = await fetch(`/api/enhanced-search/source/${sourceName}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -8969,9 +8985,9 @@ function initializeSearchModeToggle() { signal: _altSourceController?.signal, }); if (!response.ok) return; + if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; // Stream NDJSON — render each search type (artists, albums, tracks) as it arrives - if (!_enhancedSearchData) return; if (!_enhancedSearchData.sources[sourceName]) { const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']); _enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet }; @@ -8992,6 +9008,7 @@ function initializeSearchModeToggle() { const line = buffer.slice(0, newlineIdx).trim(); buffer = buffer.slice(newlineIdx + 1); if (!line) continue; + if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; try { const chunk = JSON.parse(line); @@ -9015,7 +9032,7 @@ function initializeSearchModeToggle() { } // Final render - if (_enhancedSearchData.primary_source) { + if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) { renderSourceTabs(_enhancedSearchData); } } catch (e) {