Merge pull request #316 from kettui/fix/reduce-ui-stalls

Reduce UI stalling during enhanced search, add caching & small concurrency optimizations
This commit is contained in:
BoulderBadgeDad 2026-04-18 12:32:55 -07:00 committed by GitHub
commit f636014b9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 238 additions and 118 deletions

View file

@ -2764,6 +2764,63 @@ def add_cache_headers(response, cache_duration=300):
response.headers['Pragma'] = 'cache' response.headers['Pragma'] = 'cache'
return response 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) --- # --- Background Download Monitoring (GUI Parity) ---
class WebUIDownloadMonitor: class WebUIDownloadMonitor:
""" """
@ -8514,6 +8571,7 @@ def enhanced_search():
""" """
data = request.get_json() data = request.get_json()
query = data.get('query', '').strip() query = data.get('query', '').strip()
cache_key = _get_enhanced_search_cache_key(query)
empty_source = {"artists": [], "albums": [], "tracks": [], "available": False} empty_source = {"artists": [], "albums": [], "tracks": [], "available": False}
@ -8528,6 +8586,11 @@ def enhanced_search():
"metadata_source": "spotify" "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}'") logger.info(f"Enhanced search initiated for: '{query}'")
try: try:
@ -8547,6 +8610,23 @@ def enhanced_search():
"image_url": image_url "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 ── # ── Determine primary source and search it synchronously ──
primary_source = "spotify" primary_source = "spotify"
primary_results = empty_source primary_results = empty_source
@ -8576,7 +8656,7 @@ def enhanced_search():
# Search using the user's configured primary metadata source # Search using the user's configured primary metadata source
fb_source = _get_metadata_fallback_source() fb_source = _get_metadata_fallback_source()
try: 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 primary_source = fb_source
except Exception as e: except Exception as e:
logger.debug(f"Primary source ({fb_source}) search failed: {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 primary_results is empty_source and fb_source != 'spotify':
if spotify_client and spotify_client.is_spotify_authenticated(): if spotify_client and spotify_client.is_spotify_authenticated():
try: try:
primary_results = _enhanced_search_source(query, spotify_client) primary_results = _enhanced_search_source(query, spotify_client, "spotify")
primary_source = "spotify" primary_source = "spotify"
except Exception as e: except Exception as e:
logger.debug(f"Spotify fallback search failed: {e}") logger.debug(f"Spotify fallback search failed: {e}")
@ -8615,7 +8695,7 @@ def enhanced_search():
f"{len(primary_results['tracks'])} tracks | " f"{len(primary_results['tracks'])} tracks | "
f"Alt sources available: {alternate_sources}") f"Alt sources available: {alternate_sources}")
return jsonify({ response_data = {
# Backward compat — same shape as before # Backward compat — same shape as before
"db_artists": db_artists, "db_artists": db_artists,
"spotify_artists": primary_results["artists"], "spotify_artists": primary_results["artists"],
@ -8625,66 +8705,95 @@ def enhanced_search():
# New multi-source data # New multi-source data
"primary_source": primary_source, "primary_source": primary_source,
"alternate_sources": alternate_sources, "alternate_sources": alternate_sources,
}) }
_set_cached_enhanced_search_response(cache_key, response_data)
return jsonify(response_data)
except Exception as e: except Exception as e:
logger.error(f"Enhanced search error: {e}") logger.error(f"Enhanced search error: {e}")
return jsonify({"error": str(e)}), 500 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.""" """Search a single metadata source and return normalized results dict."""
artists = [] results = {"artists": [], "albums": [], "tracks": []}
albums = [] with ThreadPoolExecutor(max_workers=3) as executor:
tracks = [] 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: return {"artists": results["artists"], "albums": results["albums"], "tracks": results["tracks"], "available": True}
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}
@app.route('/api/enhanced-search/source/<source_name>', methods=['POST']) @app.route('/api/enhanced-search/source/<source_name>', methods=['POST'])
@ -8766,51 +8875,20 @@ def enhanced_search_source(source_name):
def generate(): def generate():
# Stream each search type as it completes # Stream each search type as it completes
try: with ThreadPoolExecutor(max_workers=3) as executor:
artist_objs = client.search_artists(query, limit=10) futures = {
artists = [] executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists",
for artist in artist_objs: executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums",
artists.append({ executor.submit(_search_metadata_source_kind, client, query, "tracks", source_name): "tracks",
"id": artist.id, "name": artist.name, }
"image_url": artist.image_url, for future in as_completed(futures):
"external_urls": artist.external_urls or {}, kind = futures[future]
}) try:
yield json.dumps({"type": "artists", "data": artists}) + "\n" payload = future.result()
except Exception as e: except Exception as e:
logger.debug(f"Artist search failed for {source_name}: {e}") logger.warning(f"{kind.title()} search failed for {source_name}: {e}", exc_info=True)
yield json.dumps({"type": "artists", "data": []}) + "\n" payload = []
yield json.dumps({"type": kind, "data": payload}) + "\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"
yield json.dumps({"type": "done"}) + "\n" yield json.dumps({"type": "done"}) + "\n"

View file

@ -8672,6 +8672,7 @@ function initializeSearchModeToggle() {
async function performEnhancedSearch(query) { async function performEnhancedSearch(query) {
console.log('Enhanced search:', query); console.log('Enhanced search:', query);
const searchId = Date.now() + Math.random();
// Show loading state with correct source name // Show loading state with correct source name
showDropdown(); showDropdown();
@ -8694,14 +8695,7 @@ function initializeSearchModeToggle() {
_altSourceController = new AbortController(); _altSourceController = new AbortController();
// Initialize multi-source state early so alternate fetches can write to it // Initialize multi-source state early so alternate fetches can write to it
_enhancedSearchData = { db_artists: [], primary_source: null, sources: {} }; _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query };
// 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);
}
try { try {
const response = await fetch('/api/enhanced-search', { const response = await fetch('/api/enhanced-search', {
@ -8717,7 +8711,7 @@ function initializeSearchModeToggle() {
console.log('Enhanced results:', data); console.log('Enhanced results:', data);
// Store multi-source state // Store multi-source state
const primarySource = data.primary_source || data.metadata_source || 'spotify'; const primarySource = data.primary_source || data.metadata_source || 'deezer';
_activeSearchSource = primarySource; _activeSearchSource = primarySource;
_enhancedSearchData = _enhancedSearchData || {}; _enhancedSearchData = _enhancedSearchData || {};
_enhancedSearchData.db_artists = data.db_artists; _enhancedSearchData.db_artists = data.db_artists;
@ -8747,6 +8741,10 @@ function initializeSearchModeToggle() {
resultsContainer.classList.remove('hidden'); 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) { } catch (error) {
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
console.error('Enhanced search error:', error); 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 { try {
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
const response = await fetch(`/api/enhanced-search/source/${sourceName}`, { const response = await fetch(`/api/enhanced-search/source/${sourceName}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -8970,9 +8986,9 @@ function initializeSearchModeToggle() {
signal: _altSourceController?.signal, signal: _altSourceController?.signal,
}); });
if (!response.ok) return; if (!response.ok) return;
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives // Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
if (!_enhancedSearchData) return;
if (!_enhancedSearchData.sources[sourceName]) { if (!_enhancedSearchData.sources[sourceName]) {
const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']); const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet }; _enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
@ -8993,6 +9009,7 @@ function initializeSearchModeToggle() {
const line = buffer.slice(0, newlineIdx).trim(); const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1); buffer = buffer.slice(newlineIdx + 1);
if (!line) continue; if (!line) continue;
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
try { try {
const chunk = JSON.parse(line); const chunk = JSON.parse(line);
@ -9016,7 +9033,7 @@ function initializeSearchModeToggle() {
} }
// Final render // Final render
if (_enhancedSearchData.primary_source) { if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) {
renderSourceTabs(_enhancedSearchData); renderSourceTabs(_enhancedSearchData);
} }
} catch (e) { } catch (e) {
@ -9033,16 +9050,25 @@ function initializeSearchModeToggle() {
const sources = data.sources || {}; const sources = data.sources || {};
const primary = data.primary_source || 'spotify'; 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); 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.classList.add('hidden');
tabBar.innerHTML = ''; tabBar.innerHTML = '';
return; return;
} }
// Primary tab first, then others // 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 => { tabBar.innerHTML = ordered.map(name => {
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' }; const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
@ -18169,10 +18195,26 @@ function _gsRenderTabs() {
const el = document.getElementById('gsearch-tabs'); const el = document.getElementById('gsearch-tabs');
if (!el) return; if (!el) return;
const sources = Object.keys(_gsState.sources); const sources = Object.keys(_gsState.sources);
if (sources.length < 2) { el.style.display = 'none'; return; } const labels = {
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' }; 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.style.display = 'flex';
el.innerHTML = sources.map(s => { el.innerHTML = visibleSources.map(s => {
const d = _gsState.sources[s]; const d = _gsState.sources[s];
const c = s === 'youtube_videos' const c = s === 'youtube_videos'
? (d.videos?.length || 0) ? (d.videos?.length || 0)