From 694fd8dd9b5411c47ca08ffb098a57e8e76ca788 Mon Sep 17 00:00:00 2001 From: vicrodh Date: Mon, 9 Mar 2026 12:55:17 -0600 Subject: [PATCH] feat: rewrite discovery to use genre+area Lucene search instead of blind browse The browse API returns ALL artists from an area alphabetically with no genre filtering. For LA that's 15K+ random artists. Now we search tag:"thrash metal" AND beginarea:"Los Angeles" for each genre, giving genre-relevant results directly from MusicBrainz. Also adds dynamic loading messages that cycle through discovery steps and temporarily disables all scene/validation caching for tuning. --- src-tauri/src/commands_v2.rs | 343 +++++++++--------- src-tauri/src/musicbrainz/client.rs | 60 +++ .../views/ArtistsByLocationView.svelte | 162 ++++++++- src/lib/i18n/locales/de.json | 6 +- src/lib/i18n/locales/en.json | 6 +- src/lib/i18n/locales/es.json | 6 +- src/lib/i18n/locales/fr.json | 6 +- 7 files changed, 405 insertions(+), 184 deletions(-) diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index bd57435d6..85093b752 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -8801,8 +8801,8 @@ pub async fn v2_musicbrainz_get_artist_metadata( /// /// Pipeline: /// 1. Check scene cache (30-day TTL) -/// 2. Browse MB artists by area MBID (with tags) -/// 3. Score candidates using genre affinity +/// 2. For each source genre, search MB: tag:"genre" AND beginarea:"area" +/// 3. Merge + deduplicate across genres, score with affinity /// 4. Validate top candidates against Qobuz catalog /// 5. Cache and return sorted results #[tauri::command] @@ -8824,6 +8824,7 @@ pub async fn v2_discover_artists_by_location( use crate::musicbrainz::models::{ AffinitySeeds, LocationCandidate, LocationDiscoveryResponse, Tag, }; + use std::collections::HashMap; runtime .manager() @@ -8849,167 +8850,188 @@ pub async fn v2_discover_artists_by_location( .collect(), }; - // Step 1: Resolve area ID if not provided - let resolved_area_id = if let Some(ref aid) = area_id { - aid.clone() - } else { - // Search MB for the area by name - let area_response = state - .client - .search_area(&area_name, None) - .await - .map_err(|e| RuntimeError::Internal(format!("Area search failed: {}", e)))?; + // Build cache key from area + seeds — TEMPORARILY UNUSED + let _cache_key_area = area_id.as_deref().unwrap_or(&area_name); + let _cache_key = build_scene_cache_key(_cache_key_area, &source_seeds); - area_response - .areas - .first() - .map(|a| a.id.clone()) - .ok_or_else(|| { - RuntimeError::Internal(format!("No area found for '{}'", area_name)) - })? + // Step 1: Check scene cache — TEMPORARILY DISABLED for tuning + // if offset == 0 { + // let cache_opt = state.cache.lock().await; + // if let Some(cache) = cache_opt.as_ref() { + // if let Ok(Some(cached)) = cache.get_scene_cache(&cache_key) { + // log::info!( + // "[V2] Scene cache hit for {} ({} artists)", + // area_name, + // cached.artists.len() + // ); + // return Ok(cached); + // } + // } + // } + + // Step 2: Search MB for each genre + area combination + // This is the key insight: instead of browsing ALL artists from an area + // and hoping they have relevant tags, we search for artists that are BOTH + // tagged with the genre AND from the area. + let search_genres: Vec<&str> = if genres.is_empty() { + // If no genres, use tags as fallback + tags.iter().take(3).map(|s| s.as_str()).collect() + } else { + // Use all genres (typically up to 5) + top 2 tags for broader coverage + genres + .iter() + .chain(tags.iter().take(2)) + .map(|s| s.as_str()) + .collect() }; - // Step 2: Check scene cache (only for offset=0, cached results include all candidates) - let cache_key = build_scene_cache_key(&resolved_area_id, &source_seeds); - if offset == 0 { - let cache_opt = state.cache.lock().await; - if let Some(cache) = cache_opt.as_ref() { - if let Ok(Some(cached)) = cache.get_scene_cache(&cache_key) { + if search_genres.is_empty() { + return Ok(LocationDiscoveryResponse { + artists: Vec::new(), + scene_label: format!("{} scene", area_name), + genre_summary: String::new(), + total_candidates: 0, + has_more: false, + }); + } + + // Deduplicate candidates across genre queries: mbid -> (name, score_sum, genre_hits, tags) + let mut candidate_map: HashMap)> = HashMap::new(); + let per_genre_limit = 50; // Get up to 50 per genre query + + for genre in &search_genres { + let search_result = state + .client + .search_artists_by_tag_and_area(genre, &area_name, per_genre_limit, 0) + .await; + + match search_result { + Ok(response) => { log::info!( - "[V2] Scene cache hit for area {} ({} artists)", + "[V2] tag:'{}' + area:'{}' returned {} artists", + genre, area_name, - cached.artists.len() + response.artists.len() + ); + + for artist in &response.artists { + // Skip the source artist + if artist.id == source_mbid { + continue; + } + + let candidate_tags: Vec = artist + .tags + .as_ref() + .map(|tag_list| { + tag_list + .iter() + .filter(|tag| tag.count.unwrap_or(0) > 0) + .map(|tag| tag.name.clone()) + .collect() + }) + .unwrap_or_default(); + + let same_city = artist + .begin_area + .as_ref() + .map(|ba| { + ba.name.eq_ignore_ascii_case(&area_name) + || area_id + .as_deref() + .map(|aid| ba.id == aid) + .unwrap_or(false) + }) + .unwrap_or(false); + + let same_country = artist + .area + .as_ref() + .map(|a| a.name.eq_ignore_ascii_case(&area_name)) + .unwrap_or(false); + + let score = compute_affinity_score( + &candidate_tags, + &source_seeds, + same_city, + same_country, + ); + + let entry = candidate_map + .entry(artist.id.clone()) + .or_insert_with(|| (artist.name.clone(), 0, 0, Vec::new())); + entry.1 += score; + entry.2 += 1; // appeared in N genre queries = more relevant + // Merge tags + for tag in &candidate_tags { + if !entry.3.contains(tag) { + entry.3.push(tag.clone()); + } + } + } + } + Err(e) => { + log::warn!( + "[V2] Genre+area search failed for tag:'{}' area:'{}': {}", + genre, + area_name, + e ); - return Ok(cached); } } } - // Step 3: Browse MB artists by area (fetch up to 100 per page, get enough for scoring) - let browse_limit = 100.min(limit + offset + 50); // Get extra for filtering - let browse_response = state - .client - .browse_artists_by_area(&resolved_area_id, browse_limit, offset) - .await - .map_err(|e| RuntimeError::Internal(format!("Browse failed: {}", e)))?; - - let total_mb = browse_response.artist_count.unwrap_or(0) as usize; - let mb_artists = browse_response.artists; - log::info!( - "[V2] Browse returned {} artists from area {} (total: {})", - mb_artists.len(), - area_name, - total_mb + "[V2] Merged {} unique candidates from {} genre queries", + candidate_map.len(), + search_genres.len() ); - // Step 4: Score candidates - let mut scored: Vec<(String, String, Vec, i32)> = Vec::new(); // (mbid, name, genres, score) - - for artist in &mb_artists { - // Skip the source artist - if artist.id == source_mbid { - continue; - } - - let candidate_tags: Vec = artist - .tags - .as_ref() - .map(|tag_list| { - tag_list + // Step 3: Score and sort + // Final score = affinity_score + (genre_hit_count * 15) bonus for appearing in multiple queries + let mut scored: Vec<(String, String, Vec, i32)> = candidate_map + .into_iter() + .map(|(mbid, (name, score, genre_hits, tag_list))| { + let candidate_seeds = extract_affinity_seeds( + &tag_list .iter() - .filter(|tag| tag.count.unwrap_or(0) > 0) - .map(|tag| tag.name.clone()) - .collect() - }) - .unwrap_or_default(); - - // Determine location match for scoring - let same_city = artist - .begin_area - .as_ref() - .map(|ba| ba.id == resolved_area_id) - .unwrap_or(false); - let same_country = artist - .area - .as_ref() - .map(|a| a.id == resolved_area_id) - .unwrap_or(false) - || artist.country.is_some(); - - let score = compute_affinity_score(&candidate_tags, &source_seeds, same_city, same_country); - - // Extract genre names for display - let candidate_seeds = extract_affinity_seeds( - &candidate_tags - .iter() - .map(|name| Tag { - name: name.clone(), - count: Some(1), - }) - .collect::>(), - ); - - scored.push(( - artist.id.clone(), - artist.name.clone(), - candidate_seeds.genres, - score, - )); - } + .map(|name| Tag { + name: name.clone(), + count: Some(1), + }) + .collect::>(), + ); + let multi_genre_bonus = ((genre_hits as i32) - 1) * 15; + (mbid, name, candidate_seeds.genres, score + multi_genre_bonus) + }) + .collect(); // Sort by score descending scored.sort_by(|a, b| b.3.cmp(&a.3)); - // Take top candidates for Qobuz validation - let candidates_to_validate: Vec<_> = scored.iter().take(limit).cloned().collect(); + // Apply offset and limit + let total_candidates = scored.len(); + let candidates_to_validate: Vec<_> = scored + .into_iter() + .skip(offset) + .take(limit) + .collect(); log::info!( - "[V2] Scored {} candidates, validating top {} against Qobuz", - scored.len(), - candidates_to_validate.len() + "[V2] Validating {} candidates against Qobuz (total pool: {})", + candidates_to_validate.len(), + total_candidates ); - // Step 5: Validate against Qobuz + // Step 4: Validate against Qobuz let bridge_guard = bridge.try_get().await; let mut validated: Vec = Vec::new(); if let Some(ref core_bridge) = bridge_guard { for (mbid, mb_name, candidate_genres, score) in &candidates_to_validate { - // Check Qobuz validation cache first - let name_normalized = + // Qobuz validation cache — TEMPORARILY DISABLED for tuning + let _name_normalized = crate::musicbrainz::cache::MusicBrainzCache::normalize_name(mb_name); - let cached_validation = { - let cache_opt = state.cache.lock().await; - if let Some(cache) = cache_opt.as_ref() { - cache.get_qobuz_validation(&name_normalized).ok().flatten() - } else { - None - } - }; - - if let Some(cached_json) = cached_validation { - // Parse cached validation result - if let Ok(candidate) = - serde_json::from_str::(&cached_json) - { - if candidate.qobuz_id.is_some() { - let qobuz_id = candidate.qobuz_id.unwrap(); - if !blacklist_state.is_blacklisted(qobuz_id as u64) { - validated.push(LocationCandidate { - mbid: mbid.clone(), - mb_name: mb_name.clone(), - qobuz_id: candidate.qobuz_id, - qobuz_name: candidate.qobuz_name, - qobuz_image: candidate.qobuz_image, - score: *score, - genres: candidate_genres.clone(), - }); - } - } - continue; - } - } // Search Qobuz for this artist match core_bridge.search_artists(mb_name, 1, 0, None).await { @@ -9041,34 +9063,17 @@ pub async fn v2_discover_artists_by_location( genres: candidate_genres.clone(), }; - // Cache the validation result - if let Ok(json) = serde_json::to_string(&candidate) { - let cache_opt = state.cache.lock().await; - if let Some(cache) = cache_opt.as_ref() { - let _ = - cache.set_qobuz_validation(&name_normalized, &json); - } - } + // Qobuz validation write cache — TEMPORARILY DISABLED + // if let Ok(json) = serde_json::to_string(&candidate) { + // let cache_opt = state.cache.lock().await; + // if let Some(cache) = cache_opt.as_ref() { + // let _ = cache.set_qobuz_validation(&name_normalized, &json); + // } + // } validated.push(candidate); } else { - // Cache negative result - let neg = LocationCandidate { - mbid: mbid.clone(), - mb_name: mb_name.clone(), - qobuz_id: None, - qobuz_name: None, - qobuz_image: None, - score: *score, - genres: candidate_genres.clone(), - }; - if let Ok(json) = serde_json::to_string(&neg) { - let cache_opt = state.cache.lock().await; - if let Some(cache) = cache_opt.as_ref() { - let _ = - cache.set_qobuz_validation(&name_normalized, &json); - } - } + // Negative cache — TEMPORARILY DISABLED } } } @@ -9091,23 +9096,23 @@ pub async fn v2_discover_artists_by_location( let scene_label = format!("{} scene", area_name); let genre_sum = genre_summary(&source_seeds); - let has_more = offset + mb_artists.len() < total_mb; + let has_more = offset + candidates_to_validate.len() < total_candidates; let response = LocationDiscoveryResponse { artists: validated, scene_label, genre_summary: genre_sum, - total_candidates: total_mb, + total_candidates, has_more, }; - // Cache the full response (only for offset=0) - if offset == 0 { - let cache_opt = state.cache.lock().await; - if let Some(cache) = cache_opt.as_ref() { - let _ = cache.set_scene_cache(&cache_key, &response); - } - } + // Cache the full response — TEMPORARILY DISABLED for tuning + // if offset == 0 && !response.artists.is_empty() { + // let cache_opt = state.cache.lock().await; + // if let Some(cache) = cache_opt.as_ref() { + // let _ = cache.set_scene_cache(&cache_key, &response); + // } + // } Ok(response) } diff --git a/src-tauri/src/musicbrainz/client.rs b/src-tauri/src/musicbrainz/client.rs index dc820307b..408ee91eb 100644 --- a/src-tauri/src/musicbrainz/client.rs +++ b/src-tauri/src/musicbrainz/client.rs @@ -384,6 +384,66 @@ impl MusicBrainzClient { .map_err(|e| format!("Failed to parse MusicBrainz response: {}", e)) } + /// Search artists by tag AND area (genre + location combined). + /// + /// Uses MB Lucene query: `tag:"thrash metal" AND beginarea:"Los Angeles"` + /// This is much more precise than browse-all-then-filter. + pub async fn search_artists_by_tag_and_area( + &self, + tag: &str, + area_name: &str, + limit: usize, + offset: usize, + ) -> Result { + if !self.is_enabled().await { + return Err("MusicBrainz integration is disabled".to_string()); + } + + self.rate_limiter.wait().await; + + let base_url = self.base_url().await; + let limit = limit.min(100).max(1); + let query = format!( + "tag:\"{}\" AND (beginarea:\"{}\" OR area:\"{}\")", + Self::escape_query(tag), + Self::escape_query(area_name), + Self::escape_query(area_name), + ); + let url = format!( + "{}/artist?query={}&fmt=json&limit={}&offset={}", + base_url, + urlencoding::encode(&query), + limit, + offset + ); + + log::debug!( + "MusicBrainz artist search by tag '{}' + area '{}' (limit {}, offset {})", + tag, + area_name, + limit, + offset + ); + + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("MusicBrainz request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(format!("MusicBrainz API error {}: {}", status, text)); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse MusicBrainz response: {}", e)) + } + /// Search releases by barcode (UPC/EAN) pub async fn search_release_by_barcode( &self, diff --git a/src/lib/components/views/ArtistsByLocationView.svelte b/src/lib/components/views/ArtistsByLocationView.svelte index f58c9de0a..24a11b64b 100644 --- a/src/lib/components/views/ArtistsByLocationView.svelte +++ b/src/lib/components/views/ArtistsByLocationView.svelte @@ -1,8 +1,8 @@
@@ -161,13 +201,28 @@
{#if loading}
- - {$t('actions.loading')} +
+
+ +
+
+
+ {#key loadingStep} + + {loadingSteps[loadingStep]()} + + {/key} +
+
+ + + +
{:else if error}

{error}

-
@@ -268,19 +323,104 @@ flex-direction: column; } + /* Loading state with dynamic messages */ .scene-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 12px; - padding: 80px 0; - color: var(--text-muted); - font-size: 14px; + gap: 20px; + padding: 100px 0; } - :global(.scene-loading .spin) { - animation: spin 1s linear infinite; + .loading-visual { + display: flex; + align-items: center; + justify-content: center; + } + + .loading-pulse { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--bg-secondary); + color: var(--accent-primary); + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.08); + opacity: 1; + } + } + + .loading-status { + min-height: 20px; + display: flex; + align-items: center; + justify-content: center; + } + + .loading-text { + font-size: 14px; + color: var(--text-secondary); + text-align: center; + line-height: 1.4; + } + + .fade-in { + animation: fadeIn 400ms ease-out; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .loading-dots { + display: flex; + gap: 6px; + } + + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted); + animation: dotBounce 1.4s ease-in-out infinite; + } + + .dot:nth-child(2) { + animation-delay: 0.2s; + } + + .dot:nth-child(3) { + animation-delay: 0.4s; + } + + @keyframes dotBounce { + 0%, 80%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 40% { + opacity: 1; + transform: scale(1); + } } .scene-error { diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 5baacbc6c..8ff164841 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -292,7 +292,11 @@ "loadingOrigin": "Herkunft wird geladen...", "sceneFrom": "{location}-Szene", "sceneBased": "Basierend auf {artist} · {genres}", - "noSceneResults": "Keine Künstler dieser Szene sind derzeit auf Qobuz verfügbar." + "noSceneResults": "Keine Künstler dieser Szene sind derzeit auf Qobuz verfügbar.", + "sceneStep1": "Suche nach {genre}-Künstlern auf MusicBrainz...", + "sceneStep2": "Analyse der Genre-Affinität von {count} Kandidaten...", + "sceneStep3": "Überprüfe Verfügbarkeit auf Qobuz...", + "sceneStep4": "Ergebnisse werden vorbereitet..." }, "sort": { "nameAZ": "Name (A-Z)", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index dfad6015d..2940ee8b6 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -304,7 +304,11 @@ "loadingOrigin": "Loading origin...", "sceneFrom": "{location} scene", "sceneBased": "Based on {artist} · {genres}", - "noSceneResults": "No matching artists from this scene are currently available on Qobuz." + "noSceneResults": "No matching artists from this scene are currently available on Qobuz.", + "sceneStep1": "Searching MusicBrainz for {genre} artists...", + "sceneStep2": "Analyzing genre affinity across {count} candidates...", + "sceneStep3": "Verifying availability on Qobuz...", + "sceneStep4": "Preparing results..." }, "sort": { "nameAZ": "Name (A-Z)", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 12ebf7ac0..89691a571 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -304,7 +304,11 @@ "loadingOrigin": "Cargando origen...", "sceneFrom": "Escena de {location}", "sceneBased": "Basado en {artist} · {genres}", - "noSceneResults": "No hay artistas de esta escena disponibles actualmente en Qobuz." + "noSceneResults": "No hay artistas de esta escena disponibles actualmente en Qobuz.", + "sceneStep1": "Buscando artistas de {genre} en MusicBrainz...", + "sceneStep2": "Analizando afinidad de género en {count} candidatos...", + "sceneStep3": "Verificando disponibilidad en Qobuz...", + "sceneStep4": "Preparando resultados..." }, "sort": { "nameAZ": "Nombre (A-Z)", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 1e572c2b0..1bd6b7be1 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -292,7 +292,11 @@ "loadingOrigin": "Chargement de l'origine...", "sceneFrom": "Scène de {location}", "sceneBased": "Basé sur {artist} · {genres}", - "noSceneResults": "Aucun artiste de cette scène n'est actuellement disponible sur Qobuz." + "noSceneResults": "Aucun artiste de cette scène n'est actuellement disponible sur Qobuz.", + "sceneStep1": "Recherche d'artistes {genre} sur MusicBrainz...", + "sceneStep2": "Analyse de l'affinité de genre de {count} candidats...", + "sceneStep3": "Vérification de la disponibilité sur Qobuz...", + "sceneStep4": "Préparation des résultats..." }, "sort": { "nameAZ": "Nom (A-Z)",