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'
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/<source_name>', 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"

View file

@ -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)