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.
This commit is contained in:
vicrodh committed 2026-03-09 12:55:17 -06:00
1 parent 82775ce63a
commit 694fd8dd9b
7 files changed
+405 -184

No files matched your search

+174 -169
View File
@@ -8801,8 +8801,8 @@ pub async fn v2_musicbrainz_get_artist_metadata(
/// ///
/// Pipeline: /// Pipeline:
/// 1. Check scene cache (30-day TTL) /// 1. Check scene cache (30-day TTL)
/// 2. Browse MB artists by area MBID (with tags) /// 2. For each source genre, search MB: tag:"genre" AND beginarea:"area"
/// 3. Score candidates using genre affinity /// 3. Merge + deduplicate across genres, score with affinity
/// 4. Validate top candidates against Qobuz catalog /// 4. Validate top candidates against Qobuz catalog
/// 5. Cache and return sorted results /// 5. Cache and return sorted results
#[tauri::command] #[tauri::command]
@@ -8824,6 +8824,7 @@ pub async fn v2_discover_artists_by_location(
use crate::musicbrainz::models::{ use crate::musicbrainz::models::{
AffinitySeeds, LocationCandidate, LocationDiscoveryResponse, Tag, AffinitySeeds, LocationCandidate, LocationDiscoveryResponse, Tag,
}; };
use std::collections::HashMap;
runtime runtime
.manager() .manager()
@@ -8849,167 +8850,188 @@ pub async fn v2_discover_artists_by_location(
.collect(), .collect(),
}; };
// Step 1: Resolve area ID if not provided // Build cache key from area + seeds — TEMPORARILY UNUSED
let resolved_area_id = if let Some(ref aid) = area_id { let _cache_key_area = area_id.as_deref().unwrap_or(&area_name);
aid.clone() let _cache_key = build_scene_cache_key(_cache_key_area, &source_seeds);
} 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)))?;
area_response // Step 1: Check scene cache — TEMPORARILY DISABLED for tuning
.areas // if offset == 0 {
.first() // let cache_opt = state.cache.lock().await;
.map(|a| a.id.clone()) // if let Some(cache) = cache_opt.as_ref() {
.ok_or_else(|| { // if let Ok(Some(cached)) = cache.get_scene_cache(&cache_key) {
RuntimeError::Internal(format!("No area found for '{}'", area_name)) // 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) if search_genres.is_empty() {
let cache_key = build_scene_cache_key(&resolved_area_id, &source_seeds); return Ok(LocationDiscoveryResponse {
if offset == 0 { artists: Vec::new(),
let cache_opt = state.cache.lock().await; scene_label: format!("{} scene", area_name),
if let Some(cache) = cache_opt.as_ref() { genre_summary: String::new(),
if let Ok(Some(cached)) = cache.get_scene_cache(&cache_key) { total_candidates: 0,
has_more: false,
});
}
// Deduplicate candidates across genre queries: mbid -> (name, score_sum, genre_hits, tags)
let mut candidate_map: HashMap<String, (String, i32, usize, Vec<String>)> = 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!( log::info!(
"[V2] Scene cache hit for area {} ({} artists)", "[V2] tag:'{}' + area:'{}' returned {} artists",
genre,
area_name, 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<String> = 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!( log::info!(
"[V2] Browse returned {} artists from area {} (total: {})", "[V2] Merged {} unique candidates from {} genre queries",
mb_artists.len(), candidate_map.len(),
area_name, search_genres.len()
total_mb
); );
// Step 4: Score candidates // Step 3: Score and sort
let mut scored: Vec<(String, String, Vec<String>, i32)> = Vec::new(); // (mbid, name, genres, score) // Final score = affinity_score + (genre_hit_count * 15) bonus for appearing in multiple queries
let mut scored: Vec<(String, String, Vec<String>, i32)> = candidate_map
for artist in &mb_artists { .into_iter()
// Skip the source artist .map(|(mbid, (name, score, genre_hits, tag_list))| {
if artist.id == source_mbid { let candidate_seeds = extract_affinity_seeds(
continue; &tag_list
}
let candidate_tags: Vec<String> = artist
.tags
.as_ref()
.map(|tag_list| {
tag_list
.iter() .iter()
.filter(|tag| tag.count.unwrap_or(0) > 0) .map(|name| Tag {
.map(|tag| tag.name.clone()) name: name.clone(),
.collect() count: Some(1),
}) })
.unwrap_or_default(); .collect::<Vec<_>>(),
);
// Determine location match for scoring let multi_genre_bonus = ((genre_hits as i32) - 1) * 15;
let same_city = artist (mbid, name, candidate_seeds.genres, score + multi_genre_bonus)
.begin_area })
.as_ref() .collect();
.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::<Vec<_>>(),
);
scored.push((
artist.id.clone(),
artist.name.clone(),
candidate_seeds.genres,
score,
));
}
// Sort by score descending // Sort by score descending
scored.sort_by(|a, b| b.3.cmp(&a.3)); scored.sort_by(|a, b| b.3.cmp(&a.3));
// Take top candidates for Qobuz validation // Apply offset and limit
let candidates_to_validate: Vec<_> = scored.iter().take(limit).cloned().collect(); let total_candidates = scored.len();
let candidates_to_validate: Vec<_> = scored
.into_iter()
.skip(offset)
.take(limit)
.collect();
log::info!( log::info!(
"[V2] Scored {} candidates, validating top {} against Qobuz", "[V2] Validating {} candidates against Qobuz (total pool: {})",
scored.len(), candidates_to_validate.len(),
candidates_to_validate.len() total_candidates
); );
// Step 5: Validate against Qobuz // Step 4: Validate against Qobuz
let bridge_guard = bridge.try_get().await; let bridge_guard = bridge.try_get().await;
let mut validated: Vec<LocationCandidate> = Vec::new(); let mut validated: Vec<LocationCandidate> = Vec::new();
if let Some(ref core_bridge) = bridge_guard { if let Some(ref core_bridge) = bridge_guard {
for (mbid, mb_name, candidate_genres, score) in &candidates_to_validate { for (mbid, mb_name, candidate_genres, score) in &candidates_to_validate {
// Check Qobuz validation cache first // Qobuz validation cache — TEMPORARILY DISABLED for tuning
let name_normalized = let _name_normalized =
crate::musicbrainz::cache::MusicBrainzCache::normalize_name(mb_name); 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::<LocationCandidate>(&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 // Search Qobuz for this artist
match core_bridge.search_artists(mb_name, 1, 0, None).await { 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(), genres: candidate_genres.clone(),
}; };
// Cache the validation result // Qobuz validation write cache — TEMPORARILY DISABLED
if let Ok(json) = serde_json::to_string(&candidate) { // if let Ok(json) = serde_json::to_string(&candidate) {
let cache_opt = state.cache.lock().await; // let cache_opt = state.cache.lock().await;
if let Some(cache) = cache_opt.as_ref() { // if let Some(cache) = cache_opt.as_ref() {
let _ = // let _ = cache.set_qobuz_validation(&name_normalized, &json);
cache.set_qobuz_validation(&name_normalized, &json); // }
} // }
}
validated.push(candidate); validated.push(candidate);
} else { } else {
// Cache negative result // Negative cache — TEMPORARILY DISABLED
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);
}
}
} }
} }
} }
@@ -9091,23 +9096,23 @@ pub async fn v2_discover_artists_by_location(
let scene_label = format!("{} scene", area_name); let scene_label = format!("{} scene", area_name);
let genre_sum = genre_summary(&source_seeds); 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 { let response = LocationDiscoveryResponse {
artists: validated, artists: validated,
scene_label, scene_label,
genre_summary: genre_sum, genre_summary: genre_sum,
total_candidates: total_mb, total_candidates,
has_more, has_more,
}; };
// Cache the full response (only for offset=0) // Cache the full response — TEMPORARILY DISABLED for tuning
if offset == 0 { // if offset == 0 && !response.artists.is_empty() {
let cache_opt = state.cache.lock().await; // let cache_opt = state.cache.lock().await;
if let Some(cache) = cache_opt.as_ref() { // if let Some(cache) = cache_opt.as_ref() {
let _ = cache.set_scene_cache(&cache_key, &response); // let _ = cache.set_scene_cache(&cache_key, &response);
} // }
} // }
Ok(response) Ok(response)
} }
+60
View File
@@ -384,6 +384,66 @@ impl MusicBrainzClient {
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e)) .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<ArtistSearchResponse, String> {
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::<ArtistSearchResponse>()
.await
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
}
/// Search releases by barcode (UPC/EAN) /// Search releases by barcode (UPC/EAN)
pub async fn search_release_by_barcode( pub async fn search_release_by_barcode(
&self, &self,
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, Loader2, MapPin } from 'lucide-svelte'; import { ArrowLeft, Loader2, MapPin, Music } from 'lucide-svelte';
import VirtualizedFavoritesArtistGrid from '../VirtualizedFavoritesArtistGrid.svelte'; import VirtualizedFavoritesArtistGrid from '../VirtualizedFavoritesArtistGrid.svelte';
interface LocationContext { interface LocationContext {
@@ -71,6 +71,40 @@
let hasMore = $state(false); let hasMore = $state(false);
let loadingMore = $state(false); let loadingMore = $state(false);
// Dynamic loading state
let loadingStep = $state(0);
let loadingStepTimer: ReturnType<typeof setInterval> | null = null;
const loadingSteps = [
() => {
const genre = context.affinitySeeds.genres[0] || context.affinitySeeds.tags[0] || 'music';
return $t('artist.sceneStep1', { values: { genre } });
},
() => {
const genres = context.affinitySeeds.genres.slice(0, 3);
const count = genres.length * 50;
return $t('artist.sceneStep2', { values: { count: String(count) } });
},
() => $t('artist.sceneStep3'),
() => $t('artist.sceneStep4'),
];
function startLoadingAnimation() {
loadingStep = 0;
loadingStepTimer = setInterval(() => {
if (loadingStep < loadingSteps.length - 1) {
loadingStep++;
}
}, 3000);
}
function stopLoadingAnimation() {
if (loadingStepTimer) {
clearInterval(loadingStepTimer);
loadingStepTimer = null;
}
}
function candidatesToGroups(candidates: LocationCandidate[]): ArtistGroup[] { function candidatesToGroups(candidates: LocationCandidate[]): ArtistGroup[] {
const validArtists: FavoriteArtist[] = candidates const validArtists: FavoriteArtist[] = candidates
.filter((candidate) => candidate.qobuz_id != null) .filter((candidate) => candidate.qobuz_id != null)
@@ -128,9 +162,15 @@
onMount(async () => { onMount(async () => {
loading = true; loading = true;
error = null; error = null;
startLoadingAnimation();
await discoverArtists(); await discoverArtists();
stopLoadingAnimation();
loading = false; loading = false;
}); });
onDestroy(() => {
stopLoadingAnimation();
});
</script> </script>
<div class="scene-view"> <div class="scene-view">
@@ -161,13 +201,28 @@
<div class="scene-content"> <div class="scene-content">
{#if loading} {#if loading}
<div class="scene-loading"> <div class="scene-loading">
<Loader2 size={32} class="spin" /> <div class="loading-visual">
<span>{$t('actions.loading')}</span> <div class="loading-pulse">
<Music size={28} />
</div>
</div>
<div class="loading-status">
{#key loadingStep}
<span class="loading-text fade-in">
{loadingSteps[loadingStep]()}
</span>
{/key}
</div>
<div class="loading-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div> </div>
{:else if error} {:else if error}
<div class="scene-error"> <div class="scene-error">
<p>{error}</p> <p>{error}</p>
<button class="retry-button" onclick={() => { loading = true; error = null; discoverArtists().then(() => { loading = false; }); }}> <button class="retry-button" onclick={() => { loading = true; error = null; startLoadingAnimation(); discoverArtists().then(() => { stopLoadingAnimation(); loading = false; }); }}>
{$t('actions.retry')} {$t('actions.retry')}
</button> </button>
</div> </div>
@@ -268,19 +323,104 @@
flex-direction: column; flex-direction: column;
} }
/* Loading state with dynamic messages */
.scene-loading { .scene-loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; gap: 20px;
padding: 80px 0; padding: 100px 0;
color: var(--text-muted);
font-size: 14px;
} }
:global(.scene-loading .spin) { .loading-visual {
animation: spin 1s linear infinite; 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 { .scene-error {
+5 -1
View File
@@ -292,7 +292,11 @@
"loadingOrigin": "Herkunft wird geladen...", "loadingOrigin": "Herkunft wird geladen...",
"sceneFrom": "{location}-Szene", "sceneFrom": "{location}-Szene",
"sceneBased": "Basierend auf {artist} · {genres}", "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": { "sort": {
"nameAZ": "Name (A-Z)", "nameAZ": "Name (A-Z)",
+5 -1
View File
@@ -304,7 +304,11 @@
"loadingOrigin": "Loading origin...", "loadingOrigin": "Loading origin...",
"sceneFrom": "{location} scene", "sceneFrom": "{location} scene",
"sceneBased": "Based on {artist} · {genres}", "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": { "sort": {
"nameAZ": "Name (A-Z)", "nameAZ": "Name (A-Z)",
+5 -1
View File
@@ -304,7 +304,11 @@
"loadingOrigin": "Cargando origen...", "loadingOrigin": "Cargando origen...",
"sceneFrom": "Escena de {location}", "sceneFrom": "Escena de {location}",
"sceneBased": "Basado en {artist} · {genres}", "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": { "sort": {
"nameAZ": "Nombre (A-Z)", "nameAZ": "Nombre (A-Z)",
+5 -1
View File
@@ -292,7 +292,11 @@
"loadingOrigin": "Chargement de l'origine...", "loadingOrigin": "Chargement de l'origine...",
"sceneFrom": "Scène de {location}", "sceneFrom": "Scène de {location}",
"sceneBased": "Basé sur {artist} · {genres}", "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": { "sort": {
"nameAZ": "Nom (A-Z)", "nameAZ": "Nom (A-Z)",