From 02f190efc662b4d6fbf9476e78bc3306c55fc6cc Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 11:36:36 +0300 Subject: [PATCH] Reduce enhanced search stalls Delay alternate-source fan-out until the primary enhanced-search response arrives, and stagger those follow-up requests so they do not all compete at once. Also parallelize artist, album, and track lookups inside each metadata source request to shorten the time the UI thread spends waiting on remote APIs. This keeps the single-worker web UI more responsive under the app's chatty search flow. --- web_server.py | 188 ++++++++++++++++++++--------------------- webui/static/script.js | 41 ++++++--- 2 files changed, 121 insertions(+), 108 deletions(-) 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) {