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
+374
-153
No files matched your search
+143
-138
@@ -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,64 +8850,71 @@ 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,
|
||||||
log::info!(
|
has_more: false,
|
||||||
"[V2] Scene cache hit for area {} ({} artists)",
|
});
|
||||||
area_name,
|
|
||||||
cached.artists.len()
|
|
||||||
);
|
|
||||||
return Ok(cached);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Browse MB artists by area (fetch up to 100 per page, get enough for scoring)
|
// Deduplicate candidates across genre queries: mbid -> (name, score_sum, genre_hits, tags)
|
||||||
let browse_limit = 100.min(limit + offset + 50); // Get extra for filtering
|
let mut candidate_map: HashMap<String, (String, i32, usize, Vec<String>)> = HashMap::new();
|
||||||
let browse_response = state
|
let per_genre_limit = 50; // Get up to 50 per genre query
|
||||||
|
|
||||||
|
for genre in &search_genres {
|
||||||
|
let search_result = state
|
||||||
.client
|
.client
|
||||||
.browse_artists_by_area(&resolved_area_id, browse_limit, offset)
|
.search_artists_by_tag_and_area(genre, &area_name, per_genre_limit, 0)
|
||||||
.await
|
.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;
|
|
||||||
|
|
||||||
|
match search_result {
|
||||||
|
Ok(response) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[V2] Browse returned {} artists from area {} (total: {})",
|
"[V2] tag:'{}' + area:'{}' returned {} artists",
|
||||||
mb_artists.len(),
|
genre,
|
||||||
area_name,
|
area_name,
|
||||||
total_mb
|
response.artists.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 4: Score candidates
|
for artist in &response.artists {
|
||||||
let mut scored: Vec<(String, String, Vec<String>, i32)> = Vec::new(); // (mbid, name, genres, score)
|
|
||||||
|
|
||||||
for artist in &mb_artists {
|
|
||||||
// Skip the source artist
|
// Skip the source artist
|
||||||
if artist.id == source_mbid {
|
if artist.id == source_mbid {
|
||||||
continue;
|
continue;
|
||||||
@@ -8924,24 +8932,68 @@ pub async fn v2_discover_artists_by_location(
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Determine location match for scoring
|
|
||||||
let same_city = artist
|
let same_city = artist
|
||||||
.begin_area
|
.begin_area
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ba| ba.id == resolved_area_id)
|
.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);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let same_country = artist
|
let same_country = artist
|
||||||
.area
|
.area
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.id == resolved_area_id)
|
.map(|a| a.name.eq_ignore_ascii_case(&area_name))
|
||||||
.unwrap_or(false)
|
.unwrap_or(false);
|
||||||
|| artist.country.is_some();
|
|
||||||
|
|
||||||
let score = compute_affinity_score(&candidate_tags, &source_seeds, same_city, same_country);
|
let score = compute_affinity_score(
|
||||||
|
&candidate_tags,
|
||||||
|
&source_seeds,
|
||||||
|
same_city,
|
||||||
|
same_country,
|
||||||
|
);
|
||||||
|
|
||||||
// Extract genre names for display
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[V2] Merged {} unique candidates from {} genre queries",
|
||||||
|
candidate_map.len(),
|
||||||
|
search_genres.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
let candidate_seeds = extract_affinity_seeds(
|
||||||
&candidate_tags
|
&tag_list
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| Tag {
|
.map(|name| Tag {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@@ -8949,67 +9001,37 @@ pub async fn v2_discover_artists_by_location(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
let multi_genre_bonus = ((genre_hits as i32) - 1) * 15;
|
||||||
scored.push((
|
(mbid, name, candidate_seeds.genres, score + multi_genre_bonus)
|
||||||
artist.id.clone(),
|
})
|
||||||
artist.name.clone(),
|
.collect();
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
Reference in new issue
Block a user