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:
7 files changed
+405
-184
No files matched your search
+174
-169
@@ -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<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!(
|
||||
"[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<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!(
|
||||
"[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<String>, 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<String> = 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<String>, 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::<Vec<_>>(),
|
||||
);
|
||||
|
||||
scored.push((
|
||||
artist.id.clone(),
|
||||
artist.name.clone(),
|
||||
candidate_seeds.genres,
|
||||
score,
|
||||
));
|
||||
}
|
||||
.map(|name| Tag {
|
||||
name: name.clone(),
|
||||
count: Some(1),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
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<LocationCandidate> = 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::<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
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
pub async fn search_release_by_barcode(
|
||||
&self,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
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';
|
||||
|
||||
interface LocationContext {
|
||||
@@ -71,6 +71,40 @@
|
||||
let hasMore = $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[] {
|
||||
const validArtists: FavoriteArtist[] = candidates
|
||||
.filter((candidate) => candidate.qobuz_id != null)
|
||||
@@ -128,9 +162,15 @@
|
||||
onMount(async () => {
|
||||
loading = true;
|
||||
error = null;
|
||||
startLoadingAnimation();
|
||||
await discoverArtists();
|
||||
stopLoadingAnimation();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopLoadingAnimation();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="scene-view">
|
||||
@@ -161,13 +201,28 @@
|
||||
<div class="scene-content">
|
||||
{#if loading}
|
||||
<div class="scene-loading">
|
||||
<Loader2 size={32} class="spin" />
|
||||
<span>{$t('actions.loading')}</span>
|
||||
<div class="loading-visual">
|
||||
<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>
|
||||
{:else if error}
|
||||
<div class="scene-error">
|
||||
<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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user