From 02f190efc662b4d6fbf9476e78bc3306c55fc6cc Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 11:36:36 +0300 Subject: [PATCH 1/3] 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 | 192 ++++++++++++++++++++--------------------- webui/static/script.js | 41 ++++++--- 2 files changed, 123 insertions(+), 110 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): +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__ + + 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 + + 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 + + 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}") + + +def _enhanced_search_source(query, client, source_name=None): """Search a single metadata source and return normalized results dict.""" - artists = [] - albums = [] - tracks = [] + 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] = [] - 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}") - - 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) - - 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) - - return {"artists": artists, "albums": albums, "tracks": tracks, "available": True} + 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) { From 688e84ce4d86970d25948bc1f09a288e815fbfa6 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 14:57:12 +0300 Subject: [PATCH 2/3] Fix MusicBrainz label, primarily show tabs with fetched results only --- webui/static/script.js | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/webui/static/script.js b/webui/static/script.js index 1e056eee..176d7d3b 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -9049,16 +9049,25 @@ function initializeSearchModeToggle() { const sources = data.sources || {}; const primary = data.primary_source || 'spotify'; - // Build tab list: primary first, then alternates sorted alphabetically + // Build tab list: primary first, then alternates sorted alphabetically. + // Hide completed zero-result sources so the bar stays focused. const sourceNames = Object.keys(sources).filter(s => sources[s].available); - if (sourceNames.length <= 1) { + const visibleSources = sourceNames.filter(name => { + const src = sources[name] || {}; + const count = name === 'youtube_videos' + ? (src.videos?.length || 0) + : (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0); + const isLoading = !!(src._loading && src._loading.size > 0); + return isLoading || count > 0 || name === _activeSearchSource; + }); + if (visibleSources.length <= 1) { tabBar.classList.add('hidden'); tabBar.innerHTML = ''; return; } // Primary tab first, then others - const ordered = [primary, ...sourceNames.filter(s => s !== primary).sort()]; + const ordered = [primary, ...visibleSources.filter(s => s !== primary).sort()]; tabBar.innerHTML = ordered.map(name => { const info = SOURCE_LABELS[name] || { text: name, tabClass: '' }; @@ -18183,10 +18192,26 @@ function _gsRenderTabs() { const el = document.getElementById('gsearch-tabs'); if (!el) return; const sources = Object.keys(_gsState.sources); - if (sources.length < 2) { el.style.display = 'none'; return; } - const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' }; + const labels = { + spotify: 'Spotify', + itunes: 'Apple Music', + deezer: 'Deezer', + discogs: 'Discogs', + hydrabase: 'Hydrabase', + youtube_videos: 'Music Videos', + musicbrainz: 'MusicBrainz', + }; + const visibleSources = sources.filter(s => { + const d = _gsState.sources[s] || {}; + const count = s === 'youtube_videos' + ? (d.videos?.length || 0) + : (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); + const isLoading = !!(d._loading && d._loading.size > 0); + return isLoading || count > 0 || s === _gsState.activeSource; + }); + if (visibleSources.length < 2) { el.style.display = 'none'; return; } el.style.display = 'flex'; - el.innerHTML = sources.map(s => { + el.innerHTML = visibleSources.map(s => { const d = _gsState.sources[s]; const c = s === 'youtube_videos' ? (d.videos?.length || 0) From e6874865618ed5775e141e316bceb9a5a6ab2ab0 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 18 Apr 2026 15:01:32 +0300 Subject: [PATCH 3/3] Add caching for enhanced search, don't attempt searches with 2 characters or less --- web_server.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/web_server.py b/web_server.py index cb268810..0e89cfb5 100644 --- a/web_server.py +++ b/web_server.py @@ -2724,6 +2724,63 @@ def add_cache_headers(response, cache_duration=300): response.headers['Pragma'] = 'cache' return response + +_enhanced_search_cache = collections.OrderedDict() +_enhanced_search_cache_lock = threading.Lock() +_ENHANCED_SEARCH_CACHE_TTL = 600 +_ENHANCED_SEARCH_CACHE_MAX_ENTRIES = 100 + + +def _get_enhanced_search_cache_key(query): + """Build a cache key that follows the current metadata/search configuration.""" + normalized_query = (query or '').strip().lower() + + try: + active_server = config_manager.get_active_media_server() + except Exception: + active_server = 'unknown' + + try: + fallback_source = _get_metadata_fallback_source() + except Exception: + fallback_source = 'unknown' + + try: + hydrabase_active = _is_hydrabase_active() + except Exception: + hydrabase_active = False + + return (normalized_query, active_server, fallback_source, hydrabase_active) + + +def _get_cached_enhanced_search_response(cache_key): + """Return a cached enhanced-search response if it is still fresh.""" + now = time.time() + with _enhanced_search_cache_lock: + entry = _enhanced_search_cache.get(cache_key) + if not entry: + return None + + if now - entry['timestamp'] < _ENHANCED_SEARCH_CACHE_TTL: + _enhanced_search_cache.move_to_end(cache_key) + return entry['data'] + + _enhanced_search_cache.pop(cache_key, None) + return None + + +def _set_cached_enhanced_search_response(cache_key, response_data): + """Store an enhanced-search response in the short-lived in-memory cache.""" + with _enhanced_search_cache_lock: + _enhanced_search_cache[cache_key] = { + 'timestamp': time.time(), + 'data': response_data, + } + _enhanced_search_cache.move_to_end(cache_key) + + while len(_enhanced_search_cache) > _ENHANCED_SEARCH_CACHE_MAX_ENTRIES: + _enhanced_search_cache.popitem(last=False) + # --- Background Download Monitoring (GUI Parity) --- class WebUIDownloadMonitor: """ @@ -8474,6 +8531,7 @@ def enhanced_search(): """ data = request.get_json() query = data.get('query', '').strip() + cache_key = _get_enhanced_search_cache_key(query) empty_source = {"artists": [], "albums": [], "tracks": [], "available": False} @@ -8488,6 +8546,11 @@ def enhanced_search(): "metadata_source": "spotify" }) + cached_response = _get_cached_enhanced_search_response(cache_key) + if cached_response is not None: + logger.info(f"Enhanced search cache hit for: '{query}'") + return jsonify(cached_response) + logger.info(f"Enhanced search initiated for: '{query}'") try: @@ -8507,6 +8570,23 @@ def enhanced_search(): "image_url": image_url }) + # Very short queries are usually broad enough that remote metadata searches + # just add latency without improving the result quality much. Keep them local. + if len(query) < 3: + fb_source = _get_metadata_fallback_source() + response_data = { + "db_artists": db_artists, + "spotify_artists": [], + "spotify_albums": [], + "spotify_tracks": [], + "metadata_source": fb_source, + "primary_source": fb_source, + "alternate_sources": [], + "sources": {}, + } + _set_cached_enhanced_search_response(cache_key, response_data) + return jsonify(response_data) + # ── Determine primary source and search it synchronously ── primary_source = "spotify" primary_results = empty_source @@ -8575,7 +8655,7 @@ def enhanced_search(): f"{len(primary_results['tracks'])} tracks | " f"Alt sources available: {alternate_sources}") - return jsonify({ + response_data = { # Backward compat — same shape as before "db_artists": db_artists, "spotify_artists": primary_results["artists"], @@ -8585,7 +8665,9 @@ def enhanced_search(): # New multi-source data "primary_source": primary_source, "alternate_sources": alternate_sources, - }) + } + _set_cached_enhanced_search_response(cache_key, response_data) + return jsonify(response_data) except Exception as e: logger.error(f"Enhanced search error: {e}")