diff --git a/web_server.py b/web_server.py index 0e55ea72..8be5cc66 100644 --- a/web_server.py +++ b/web_server.py @@ -2764,6 +2764,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: """ @@ -8514,6 +8571,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} @@ -8528,6 +8586,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: @@ -8547,6 +8610,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 @@ -8576,7 +8656,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}") @@ -8585,7 +8665,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}") @@ -8615,7 +8695,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"], @@ -8625,66 +8705,95 @@ 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}") 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']) @@ -8766,51 +8875,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 84998221..974bbfce 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -8672,6 +8672,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(); @@ -8694,14 +8695,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', { @@ -8717,7 +8711,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; @@ -8747,6 +8741,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); @@ -8961,8 +8959,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' }, @@ -8970,9 +8986,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 }; @@ -8993,6 +9009,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); @@ -9016,7 +9033,7 @@ function initializeSearchModeToggle() { } // Final render - if (_enhancedSearchData.primary_source) { + if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) { renderSourceTabs(_enhancedSearchData); } } catch (e) { @@ -9033,16 +9050,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: '' }; @@ -18169,10 +18195,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)