//! QBZ Core Orchestrator //! //! The main orchestrator that connects all QBZ subsystems and provides //! a unified API for frontends. use std::sync::Arc; use tokio::sync::RwLock; use qbz_models::{ Album, Artist, ArtistAlbums, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, FrontendAdapter, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, MostPopularItem, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, ReleasesGridResponse, RepeatMode, SearchAllResults, SearchResultsPage, StreamUrl, Track, TracksContainer, UserSession, }; use qbz_integrations::musicbrainz::cache::MusicBrainzCache; use qbz_integrations::musicbrainz::genre::{extract_affinity_seeds, genre_summary, is_broad_genre}; use qbz_integrations::musicbrainz::location::compute_affinity_score; use qbz_integrations::musicbrainz::{ location, AffinitySeeds, AlbumAppearance, ArtistMetadata, ArtistRelationships, DiscoveryArtist, DiscoveryResponse, LocationCandidate, LocationDiscoveryResponse, MusicBrainzClient, MusicianAppearances, MusicianConfidence, Period, RelatedArtist, ResolvedArtist, ResolvedMusician, Tag, }; use qbz_player::{PlaybackState, Player, QueueManager}; use qbz_qobuz::QobuzClient; use crate::error::CoreError; /// Set of blacklisted artist ids. Empty until the blacklist module is /// migrated out of src-tauri (roadmap task #9). pub type BlacklistFilter = std::collections::HashSet; fn parse_page( value: &serde_json::Value, key: &str, ) -> SearchResultsPage { value .get(key) .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or(SearchResultsPage { items: Vec::new(), total: 0, offset: 0, limit: 0, }) } /// Pick the first `most_popular` entry that survives the blacklist. fn pick_most_popular( value: &serde_json::Value, blacklist: &BlacklistFilter, ) -> Option { let items = value.get("most_popular")?.get("items")?.as_array()?; for entry in items { let Some(kind) = entry.get("type").and_then(|v| v.as_str()) else { continue; }; let Some(content) = entry.get("content") else { continue; }; match kind { "artists" => { if let Ok(a) = serde_json::from_value::(content.clone()) { if !blacklist.contains(&a.id) { return Some(MostPopularItem::Artists(a)); } } } "albums" => { if let Ok(al) = serde_json::from_value::(content.clone()) { if !blacklist.contains(&al.artist.id) { return Some(MostPopularItem::Albums(al)); } } } "tracks" => { if let Ok(t) = serde_json::from_value::(content.clone()) { let blocked = t .performer .as_ref() .map_or(false, |p| blacklist.contains(&p.id)); if !blocked { return Some(MostPopularItem::Tracks(t)); } } } _ => {} } } None } /// Parse a `catalog_search` JSON payload into typed category pages, /// dropping any item whose artist id is blacklisted and adjusting totals. pub(crate) fn parse_search_all( value: &serde_json::Value, blacklist: &BlacklistFilter, ) -> SearchAllResults { let mut albums = parse_page::(value, "albums"); let mut tracks = parse_page::(value, "tracks"); let mut artists = parse_page::(value, "artists"); let playlists = parse_page::(value, "playlists"); let before = artists.items.len(); artists.items.retain(|a| !blacklist.contains(&a.id)); artists.total = artists .total .saturating_sub((before - artists.items.len()) as u32); let before = albums.items.len(); albums.items.retain(|al| !blacklist.contains(&al.artist.id)); albums.total = albums .total .saturating_sub((before - albums.items.len()) as u32); let before = tracks.items.len(); tracks .items .retain(|t| t.performer.as_ref().map_or(true, |p| !blacklist.contains(&p.id))); tracks.total = tracks .total .saturating_sub((before - tracks.items.len()) as u32); SearchAllResults { albums, tracks, artists, playlists, most_popular: pick_most_popular(value, blacklist), } } /// Core orchestrator for QBZ /// /// This is the main entry point for any frontend (Tauri, Slint, Iced, CLI, etc.) /// It provides a unified API and emits events through the FrontendAdapter. pub struct QbzCore { /// Frontend adapter for event emission adapter: Arc, /// Qobuz API client client: Arc>>, /// Queue manager queue: Arc>, /// Audio player player: Arc, /// MusicBrainz client (always present; enable/disable toggle lives inside) musicbrainz: Arc, /// Persistent MB cache. Opened by the frontend (which owns the /// data-dir path) via `set_musicbrainz_cache`. Methods read the /// cache before hitting the network and persist on miss. musicbrainz_cache: Arc>>, /// Whether the core is initialized initialized: Arc>, } impl QbzCore { /// Create a new QbzCore instance with the given frontend adapter and player /// /// The Player must be created by the frontend with appropriate audio settings. /// QbzCore orchestrates playback through this player. pub fn new(adapter: A, player: Player) -> Self { Self { adapter: Arc::new(adapter), client: Arc::new(RwLock::new(None)), queue: Arc::new(RwLock::new(QueueManager::new())), player: Arc::new(player), musicbrainz: Arc::new(MusicBrainzClient::new()), musicbrainz_cache: Arc::new(std::sync::Mutex::new(None)), initialized: Arc::new(RwLock::new(false)), } } /// Install a MusicBrainz cache. The frontend owns the data path /// and opens the cache; QbzCore just stores the handle and uses /// it transparently in `musicbrainz_get_artist_metadata` and /// `musicbrainz_get_artist_relationships`. pub fn set_musicbrainz_cache(&self, cache: MusicBrainzCache) { if let Ok(mut guard) = self.musicbrainz_cache.lock() { *guard = Some(cache); } } /// Initialize the core /// /// This should be called once at startup to set up all subsystems. /// Best-effort: if bundle token extraction fails (e.g. no network), the /// core still finishes initialization so queue manager and player remain /// usable for offline/local playback. API calls will then return /// `CoreError::NotInitialized` until the client is rebuilt with tokens. pub async fn init(&self) -> Result<(), CoreError> { let mut initialized = self.initialized.write().await; if *initialized { return Ok(()); } let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?; match client.init().await { Ok(_) => { *self.client.write().await = Some(client); log::info!("QbzCore initialized with bundle tokens"); } Err(e) => { log::warn!( "QbzCore: bundle token extraction failed ({}). Starting in offline-tolerant mode; API calls will be unavailable until next online start.", e ); } } *initialized = true; Ok(()) } /// Whether the Qobuz API client is initialized (bundle tokens extracted). /// Returns false when the core is running in offline-tolerant mode after /// a failed bundle extraction at startup. pub async fn is_api_initialized(&self) -> bool { self.client.read().await.is_some() } /// Best-effort attempt to rebuild the Qobuz API client. Useful when the /// initial `init()` ran offline and the host has since regained network. /// No-op when the client is already initialized. pub async fn try_init_api(&self) -> Result<(), CoreError> { { let guard = self.client.read().await; if guard.is_some() { return Ok(()); } } let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?; client .init() .await .map_err(|e| CoreError::Internal(format!("Failed to extract bundle tokens: {}", e)))?; *self.client.write().await = Some(client); log::info!("QbzCore: API client initialized lazily"); Ok(()) } /// Check if a user session exists pub async fn has_session(&self) -> bool { let client = self.client.read().await; if let Some(c) = client.as_ref() { c.is_logged_in().await } else { false } } /// Login with email and password pub async fn login(&self, email: &str, password: &str) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; match client.login(email, password).await { Ok(session) => { self.emit(CoreEvent::LoggedIn { session: session.clone(), }) .await; Ok(session) } Err(e) => { self.emit(CoreEvent::Error { code: "AUTH_FAILED".to_string(), message: e.to_string(), recoverable: true, }) .await; Err(CoreError::AuthFailed(e.to_string())) } } } /// Restore a session from a saved OAuth user_auth_token. pub async fn login_with_token(&self, token: &str) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; match client.login_with_token(token).await { Ok(session) => { self.emit(CoreEvent::LoggedIn { session: session.clone(), }) .await; Ok(session) } Err(e) => { self.emit(CoreEvent::Error { code: "OAUTH_TOKEN_FAILED".to_string(), message: e.to_string(), recoverable: true, }) .await; Err(CoreError::AuthFailed(e.to_string())) } } } /// Inject an already-authenticated session (e.g. from OAuth flow). /// Emits a LoggedIn event so the rest of the system knows auth state changed. pub async fn set_session(&self, session: UserSession) -> Result<(), CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client.set_session(session.clone()).await; self.emit(CoreEvent::LoggedIn { session }).await; Ok(()) } /// Logout the current user pub async fn logout(&self) -> Result<(), CoreError> { let client = self.client.read().await; if let Some(c) = client.as_ref() { c.logout().await; self.emit(CoreEvent::LoggedOut).await; } Ok(()) } // ==================== Queue Operations ==================== /// Get current queue state pub async fn get_queue_state(&self) -> QueueState { let queue = self.queue.read().await; queue.get_state() } /// Get all queue tracks and current index (for session persistence) pub async fn get_all_queue_tracks(&self) -> (Vec, Option) { let queue = self.queue.read().await; queue.get_all_tracks() } /// Get the full queue state without the upcoming/history caps that /// `get_queue_state` applies. Used by clients that paginate the /// upcoming list and need the complete play history (Queue sidebar). pub async fn get_queue_state_full(&self) -> QueueState { let queue = self.queue.read().await; queue.get_state_full() } /// Set repeat mode pub async fn set_repeat_mode(&self, mode: RepeatMode) { let queue = self.queue.write().await; queue.set_repeat(mode.clone()); self.emit(CoreEvent::RepeatModeChanged { mode }).await; } /// Set shuffle pub async fn set_shuffle(&self, enabled: bool) { let queue = self.queue.write().await; queue.set_shuffle(enabled); self.emit(CoreEvent::ShuffleChanged { enabled }).await; self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Set shuffle mode using an authoritative order. pub async fn set_shuffle_with_order(&self, enabled: bool, shuffle_order: Option>) { let queue = self.queue.write().await; queue.set_shuffle_with_order(enabled, shuffle_order); self.emit(CoreEvent::ShuffleChanged { enabled }).await; self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Toggle shuffle and return new state pub async fn toggle_shuffle(&self) -> bool { let queue = self.queue.write().await; let was_enabled = queue.is_shuffle(); let new_enabled = !was_enabled; queue.set_shuffle(new_enabled); self.emit(CoreEvent::ShuffleChanged { enabled: new_enabled, }) .await; self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; new_enabled } /// Clear the queue. `keep_current=true` preserves the now-playing track /// (historical behavior); `false` wipes everything including the current /// slot — use when nothing is actively playing and the user wants a full /// reset. pub async fn clear_queue(&self, keep_current: bool) { let queue = self.queue.write().await; queue.clear(keep_current); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Add a track to the end of the queue pub async fn add_track(&self, track: QueueTrack) { let queue = self.queue.write().await; queue.add_track(track); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Add multiple tracks to the queue pub async fn add_tracks(&self, tracks: Vec) { let queue = self.queue.write().await; queue.add_tracks(tracks); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Add a track to play next (after current) pub async fn add_track_next(&self, track: QueueTrack) { let queue = self.queue.write().await; queue.add_track_next(track); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Set the entire queue (replaces existing) pub async fn set_queue(&self, tracks: Vec, start_index: Option) { let queue = self.queue.write().await; queue.set_queue(tracks, start_index); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Replace queue contents and playback order atomically. pub async fn set_queue_with_order( &self, tracks: Vec, start_index: Option, shuffle_enabled: bool, shuffle_order: Option>, ) { let queue = self.queue.write().await; queue.set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } /// Remove a track by index pub async fn remove_track(&self, index: usize) -> Option { let queue = self.queue.write().await; let removed = queue.remove_track(index); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; removed } /// Remove a track from the upcoming list by position pub async fn remove_upcoming_track(&self, upcoming_index: usize) -> Option { let queue = self.queue.write().await; let removed = queue.remove_upcoming_track(upcoming_index); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; removed } /// Move a track from one position to another pub async fn move_track(&self, from_index: usize, to_index: usize) -> bool { let queue = self.queue.write().await; let success = queue.move_track(from_index, to_index); if success { self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; } success } /// Jump to a specific track by index pub async fn play_index(&self, index: usize) -> Option { let queue = self.queue.write().await; let track = queue.play_index(index); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; track } /// Jump to a track by its position in the upcoming list (as shown in the /// Queue sidebar). Shuffle-aware: resolves through `shuffle_order` when /// shuffle is active. pub async fn play_upcoming_at(&self, upcoming_index: usize) -> Option { let queue = self.queue.write().await; let track = queue.play_upcoming_at(upcoming_index); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; track } /// Advance to next track in queue pub async fn next_track(&self) -> Option { let queue = self.queue.write().await; let track = queue.next(); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; track } /// Go to previous track in queue pub async fn previous_track(&self) -> Option { let queue = self.queue.write().await; let track = queue.previous(); self.emit(CoreEvent::QueueUpdated { state: queue.get_state(), }) .await; track } /// Get multiple upcoming tracks without advancing (for prefetching) pub async fn peek_upcoming(&self, count: usize) -> Vec { let queue = self.queue.read().await; queue.peek_upcoming(count) } // ==================== Search & Catalog ==================== /// Search for albums pub async fn search_albums( &self, query: &str, limit: u32, offset: u32, search_type: Option<&str>, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .search_albums(query, limit, offset, search_type) .await .map_err(CoreError::Api) } /// Search for tracks pub async fn search_tracks( &self, query: &str, limit: u32, offset: u32, search_type: Option<&str>, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .search_tracks(query, limit, offset, search_type) .await .map_err(CoreError::Api) } /// Search for artists pub async fn search_artists( &self, query: &str, limit: u32, offset: u32, search_type: Option<&str>, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .search_artists(query, limit, offset, search_type) .await .map_err(CoreError::Api) } /// Catalog search (combined: albums, tracks, artists, playlists, most_popular). /// Returns raw JSON for the caller to parse. pub async fn catalog_search( &self, query: &str, limit: u32, offset: u32, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .catalog_search(query, limit, offset) .await .map_err(CoreError::Api) } /// Combined search: `catalog_search` plus parsing of the four category /// pages and the `most_popular` hero, with blacklist filtering applied. /// The blacklist is a parameter so Search does not depend on the /// un-migrated `artist_blacklist` module. pub async fn search_all( &self, query: &str, blacklist: &BlacklistFilter, ) -> Result { let json = self.catalog_search(query, 30, 0).await?; Ok(parse_search_all(&json, blacklist)) } /// Get album by ID pub async fn get_album(&self, album_id: &str) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client.get_album(album_id).await.map_err(CoreError::Api) } /// Get track by ID pub async fn get_track(&self, track_id: u64) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client.get_track(track_id).await.map_err(CoreError::Api) } /// Get artist by ID pub async fn get_artist(&self, artist_id: u64) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_artist_basic(artist_id) .await .map_err(CoreError::Api) } // ==================== Streaming ==================== /// Get stream URL for a track with quality fallback pub async fn get_stream_url( &self, track_id: u64, quality: Quality, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_stream_url_with_fallback(track_id, quality) .await .map_err(CoreError::Api) } // ==================== Playback Operations ==================== /// Pause playback pub fn pause(&self) -> Result<(), CoreError> { self.player.pause().map_err(|e| CoreError::Playback(e)) } /// Resume playback pub fn resume(&self) -> Result<(), CoreError> { self.player.resume().map_err(|e| CoreError::Playback(e)) } /// Stop playback pub fn stop(&self) -> Result<(), CoreError> { self.player.stop().map_err(|e| CoreError::Playback(e)) } /// Seek to position in seconds pub fn seek(&self, position: u64) -> Result<(), CoreError> { self.player .seek(position) .map_err(|e| CoreError::Playback(e)) } /// Set volume (0.0 - 1.0) pub fn set_volume(&self, volume: f32) -> Result<(), CoreError> { self.player .set_volume(volume) .map_err(|e| CoreError::Playback(e)) } /// Get current playback state pub fn get_playback_state(&self) -> PlaybackState { let state = &self.player.state; PlaybackState { is_playing: state.is_playing(), position: state.current_position(), duration: state.duration(), track_id: state.current_track_id(), volume: state.volume(), } } /// Get the player (for advanced usage) pub fn player(&self) -> Arc { Arc::clone(&self.player) } // ==================== Favorites ==================== /// Get favorites (albums, tracks, or artists) pub async fn get_favorites( &self, fav_type: &str, limit: u32, offset: u32, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_favorites(fav_type, limit, offset) .await .map_err(CoreError::Api) } /// Add item to favorites pub async fn add_favorite(&self, fav_type: &str, item_id: &str) -> Result<(), CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .add_favorite(fav_type, item_id) .await .map_err(CoreError::Api) } /// Remove item from favorites pub async fn remove_favorite(&self, fav_type: &str, item_id: &str) -> Result<(), CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .remove_favorite(fav_type, item_id) .await .map_err(CoreError::Api) } /// Fetch the set of the user's favorite track IDs. Pages through the /// favorites endpoint until exhausted. Used by clients that need to /// reflect favorite state on individual tracks (e.g. the Queue /// sidebar's now-playing heart). pub async fn favorite_track_ids(&self) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let mut ids = std::collections::HashSet::new(); let page_size: u32 = 500; let mut offset: u32 = 0; loop { let value = client .get_favorites("tracks", page_size, offset) .await .map_err(CoreError::Api)?; let items = value .get("tracks") .and_then(|t| t.get("items")) .and_then(|i| i.as_array()) .cloned() .unwrap_or_default(); let count = items.len() as u32; for item in &items { if let Some(id) = item.get("id").and_then(|v| v.as_u64()) { ids.insert(id); } } if count < page_size { break; } offset += page_size; } Ok(ids) } /// Fetch the set of the user's favorite (followed) artist IDs. Pages /// through the favorites endpoint until exhausted. Used to reflect /// follow state on artist cards. pub async fn favorite_artist_ids(&self) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let mut ids = std::collections::HashSet::new(); let page_size: u32 = 500; let mut offset: u32 = 0; loop { let value = client .get_favorites("artists", page_size, offset) .await .map_err(CoreError::Api)?; let items = value .get("artists") .and_then(|a| a.get("items")) .and_then(|i| i.as_array()) .cloned() .unwrap_or_default(); let count = items.len() as u32; for item in &items { if let Some(id) = item.get("id").and_then(|v| v.as_u64()) { ids.insert(id); } } if count < page_size { break; } offset += page_size; } Ok(ids) } /// Toggle the favorite state of a track. `make_favorite = true` adds it, /// `false` removes it. Thin convenience over `add_favorite` / /// `remove_favorite` so callers do not duplicate the type string. pub async fn set_track_favorite( &self, track_id: u64, make_favorite: bool, ) -> Result<(), CoreError> { let id = track_id.to_string(); if make_favorite { self.add_favorite("track", &id).await } else { self.remove_favorite("track", &id).await } } // ==================== Playlists ==================== /// Get user playlists pub async fn get_user_playlists(&self) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client.get_user_playlists().await.map_err(CoreError::Api) } /// Get playlist by ID pub async fn get_playlist(&self, playlist_id: u64) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_playlist(playlist_id) .await .map_err(CoreError::Api) } /// Add tracks to playlist pub async fn add_tracks_to_playlist( &self, playlist_id: u64, track_ids: &[u64], ) -> Result<(), CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .add_tracks_to_playlist(playlist_id, track_ids) .await .map_err(CoreError::Api) } /// Remove tracks from playlist pub async fn remove_tracks_from_playlist( &self, playlist_id: u64, playlist_track_ids: &[u64], ) -> Result<(), CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .remove_tracks_from_playlist(playlist_id, playlist_track_ids) .await .map_err(CoreError::Api) } /// Create a new playlist pub async fn create_playlist( &self, name: &str, description: Option<&str>, is_public: bool, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .create_playlist(name, description, is_public) .await .map_err(CoreError::Api) } /// Delete a playlist pub async fn delete_playlist(&self, playlist_id: u64) -> Result<(), CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .delete_playlist(playlist_id) .await .map_err(CoreError::Api) } /// Update a playlist pub async fn update_playlist( &self, playlist_id: u64, name: Option<&str>, description: Option<&str>, is_public: Option, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .update_playlist(playlist_id, name, description, is_public) .await .map_err(CoreError::Api) } /// Search playlists pub async fn search_playlists( &self, query: &str, limit: u32, offset: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .search_playlists(query, limit, offset) .await .map_err(CoreError::Api) } /// Get tracks batch by IDs pub async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_tracks_batch(track_ids) .await .map_err(CoreError::Api) } /// Get genres pub async fn get_genres(&self, parent_id: Option) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client.get_genres(parent_id).await.map_err(CoreError::Api) } /// Get discover index pub async fn get_discover_index( &self, genre_ids: Option>, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_discover_index(genre_ids) .await .map_err(CoreError::Api) } /// Get discover playlists pub async fn get_discover_playlists( &self, tag: Option, genre_ids: Option>, limit: Option, offset: Option, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_discover_playlists(tag, genre_ids, limit, offset) .await .map_err(CoreError::Api) } /// Get playlist tags pub async fn get_playlist_tags(&self) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client.get_playlist_tags().await.map_err(CoreError::Api) } /// Get discover albums from a specific browse endpoint pub async fn get_discover_albums( &self, endpoint: &str, genre_ids: Option>, offset: u32, limit: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_discover_albums(endpoint, genre_ids, offset, limit) .await .map_err(CoreError::Api) } /// Get featured albums pub async fn get_featured_albums( &self, featured_type: &str, limit: u32, offset: u32, genre_id: Option, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_featured_albums(featured_type, limit, offset, genre_id) .await .map_err(CoreError::Api) } /// Get Release Watch — new releases from followed artists/labels/awards. /// `release_type` must be one of "artists" | "labels" | "awards". pub async fn get_release_watch( &self, release_type: &str, limit: u32, offset: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_release_watch(release_type, limit, offset) .await .map_err(CoreError::Api) } /// Get artist page (full artist details with albums, tracks, similar) pub async fn get_artist_page( &self, artist_id: u64, sort: Option<&str>, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_artist_page(artist_id, sort) .await .map_err(CoreError::Api) } /// Get similar artists pub async fn get_similar_artists( &self, artist_id: u64, limit: u32, offset: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_similar_artists(artist_id, limit, offset) .await .map_err(CoreError::Api) } /// Albums similar to a seed album (`/album/suggest`). pub async fn get_album_suggest( &self, album_id: &str, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_album_suggest(album_id) .await .map_err(CoreError::Api) } /// Qobuz artist radio (`/radio/artist`) — a generated track list. pub async fn get_radio_artist( &self, artist_id: &str, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_radio_artist(artist_id) .await .map_err(CoreError::Api) } /// Qobuz album radio (`/radio/album`). pub async fn get_radio_album( &self, album_id: &str, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_radio_album(album_id) .await .map_err(CoreError::Api) } /// Dynamic mix suggestions (`/dynamic/suggest`) seeded from /// recently-listened track ids. Returns the suggested tracks. pub async fn get_dynamic_suggest( &self, listened_track_ids: &[u64], limit: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_dynamic_suggest(listened_track_ids, limit) .await .map_err(CoreError::Api) } /// Smart artist radio via the local `qbz-radio` pool builder — a /// richer alternative to the Qobuz `/radio/artist` endpoint. Builds /// a session pool (seed tracks + similar artists + second-degree) /// in the local radio DB, pulls up to 50 track ids from the engine, /// and resolves them to full tracks. /// /// The radio DB uses a `!Send` rusqlite connection, so the pool /// build + pull run on blocking threads (mirroring the Tauri /// command); the async builder is driven there via `block_on`. pub async fn create_smart_artist_radio( &self, artist_id: u64, ) -> Result, CoreError> { let client = { let guard = self.client.read().await; guard.as_ref().ok_or(CoreError::NotInitialized)?.clone() }; let build_client = client.clone(); let session_id = tokio::task::spawn_blocking(move || -> Result { let db = qbz_radio::RadioDb::open_default()?; let builder = qbz_radio::RadioPoolBuilder::new( &db, &build_client, qbz_radio::BuildRadioOptions::default(), ); let session = tokio::runtime::Handle::current() .block_on(builder.create_artist_radio(artist_id))?; Ok(session.id) }) .await .map_err(|e| CoreError::Internal(format!("radio build task: {e}")))? .map_err(CoreError::Internal)?; let ids = tokio::task::spawn_blocking(move || -> Result, String> { let db = qbz_radio::RadioDb::open_default()?; let engine = qbz_radio::RadioEngine::new(db); let mut ids = Vec::new(); for _ in 0..60 { match engine.next_track(&session_id) { Ok(track) => ids.push(track.track_id), Err(_) => break, } } Ok(ids.into_iter().take(50).collect()) }) .await .map_err(|e| CoreError::Internal(format!("radio pull task: {e}")))? .map_err(CoreError::Internal)?; let mut tracks = Vec::new(); for id in ids { if let Ok(track) = client.get_track(id).await { tracks.push(track); } } Ok(tracks) } /// Get artist with albums (for album pagination) pub async fn get_artist_with_albums( &self, artist_id: u64, limit: Option, offset: Option, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_artist_with_pagination(artist_id, true, limit, offset) .await .map_err(CoreError::Api) } /// Get an artist's albums collection (paginated `ArtistAlbums` only). /// /// Equivalent to `get_artist_with_albums` but projects only the `albums` /// field for callers that don't need the full artist envelope. pub async fn get_artist_albums( &self, artist_id: u64, limit: Option, offset: Option, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let artist = client .get_artist_with_pagination(artist_id, true, limit, offset) .await .map_err(CoreError::Api)?; artist .albums .ok_or_else(|| CoreError::Api(qbz_qobuz::ApiError::ApiResponse( "No albums in artist response".to_string(), ))) } /// Get artist detail with albums, playlists and appears-on tracks. /// /// Backs the suggestions panel: requests `extra=albums,tracks_appears_on,playlists` /// from `/artist/get` so callers can read `playlists` and `tracks_appears_on` /// without a second round-trip. pub async fn get_artist_detail( &self, artist_id: u64, limit: Option, offset: Option, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_artist_detail(artist_id, limit, offset) .await .map_err(CoreError::Api) } /// Get an artist's popular/top tracks (`/artist/get?extra=tracks`). pub async fn get_artist_tracks( &self, artist_id: u64, limit: u32, offset: u32, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_artist_tracks(artist_id, limit, offset) .await .map_err(CoreError::Api) } /// Get an artist's releases grid (paginated by `release_type`). pub async fn get_releases_grid( &self, artist_id: u64, release_type: &str, limit: u32, offset: u32, sort: Option<&str>, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_releases_grid(artist_id, release_type, limit, offset, sort) .await .map_err(CoreError::Api) } /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_page(label_id) .await .map_err(CoreError::Api) } /// Enumerate award catalog (/award/explore). pub async fn get_award_explore( &self, limit: u32, offset: u32, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_award_explore(limit, offset) .await .map_err(CoreError::Api) } /// Get award page — hero info + award-winning releases. pub async fn get_award_page( &self, award_id: &str, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_award_page(award_id) .await .map_err(CoreError::Api) } /// Get paginated albums for an award (/award/getAlbums). pub async fn get_award_albums( &self, award_id: &str, limit: u32, offset: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_award_albums(award_id, limit, offset) .await .map_err(CoreError::Api) } /// Get label explore (discover more labels) pub async fn get_label_explore( &self, limit: u32, offset: u32, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_explore(limit, offset) .await .map_err(CoreError::Api) } /// Get a label's album catalog (paginated, replaces legacy /label/get). #[allow(clippy::too_many_arguments)] pub async fn get_label_albums( &self, label_id: u64, limit: u32, offset: u32, sort: Option, order: Option, genre_ids: Option, from_date: Option, to_date: Option, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_albums( label_id, limit, offset, sort.as_deref(), order.as_deref(), genre_ids.as_deref(), from_date.as_deref(), to_date.as_deref(), ) .await .map_err(CoreError::Api) } /// Get a label's upcoming releases. pub async fn get_label_next_releases( &self, label_id: u64, limit: u32, offset: u32, genre_ids: Option, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_next_releases(label_id, limit, offset, genre_ids.as_deref()) .await .map_err(CoreError::Api) } /// Get a label's press-awarded releases. pub async fn get_label_awarded_releases( &self, label_id: u64, limit: u32, offset: u32, sort: Option, order: Option, genre_ids: Option, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_awarded_releases( label_id, limit, offset, sort.as_deref(), order.as_deref(), genre_ids.as_deref(), ) .await .map_err(CoreError::Api) } /// Get a label's curated playlists. pub async fn get_label_playlists( &self, label_id: u64, limit: u32, offset: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_playlists(label_id, limit, offset) .await .map_err(CoreError::Api) } /// Get a label's top artists. pub async fn get_label_top_artists( &self, label_id: u64, limit: u32, offset: u32, ) -> Result, CoreError> { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_top_artists(label_id, limit, offset) .await .map_err(CoreError::Api) } /// Get a label's editorial story. pub async fn get_label_story( &self, label_id: u64, limit: u32, offset: u32, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_story(label_id, limit, offset) .await .map_err(CoreError::Api) } /// Bulk hydrate labels by ID list. pub async fn get_label_list( &self, label_ids: Vec, ) -> Result { let client = self.client.read().await; let client = client.as_ref().ok_or(CoreError::NotInitialized)?; client .get_label_list(&label_ids) .await .map_err(CoreError::Api) } // ==================== Event Emission ==================== /// Emit an event to the frontend adapter async fn emit(&self, event: CoreEvent) { self.adapter.on_event(event).await; } /// Get the frontend adapter (for external event emission) pub fn adapter(&self) -> Arc { Arc::clone(&self.adapter) } /// Get the Qobuz client (for advanced usage) pub fn client(&self) -> Arc>> { Arc::clone(&self.client) } /// Get the queue manager (for advanced usage) pub fn queue(&self) -> Arc> { Arc::clone(&self.queue) } // ----- MusicBrainz ------------------------------------------------------- /// Whether MusicBrainz integration is currently enabled. pub async fn musicbrainz_is_enabled(&self) -> bool { self.musicbrainz.is_enabled().await } /// Enable or disable MusicBrainz integration. pub async fn musicbrainz_set_enabled(&self, enabled: bool) { self.musicbrainz.set_enabled(enabled).await; } /// Resolve an artist name to a MusicBrainz id. Returns `None` if no /// confident match is found. pub async fn musicbrainz_resolve_artist( &self, name: &str, ) -> Result, CoreError> { self.musicbrainz .resolve_artist(name) .await .map_err(|e| CoreError::Internal(e.to_string())) } /// Fetch the artist metadata (location, life_span, genre seeds) for /// the Origin section of the artist network sidebar. Resolves the /// real country from the begin_area hierarchy when a city-level /// location is found, because MB's `country` field is where the /// artist is active, not where they were born/formed. pub async fn musicbrainz_get_artist_metadata( &self, mbid: &str, ) -> Result { // Cache lookup — same behavior as Tauri's v2 command. if let Ok(guard) = self.musicbrainz_cache.lock() { if let Some(cache) = guard.as_ref() { if let Ok(Some(cached)) = cache.get_artist_metadata(mbid) { return Ok(cached); } } } let artist = self .musicbrainz .get_artist_with_relations(mbid) .await .map_err(|e| CoreError::Internal(e.to_string()))?; let mut metadata = location::extract_metadata(&artist); if let Some(ref mut loc) = metadata.location { if loc.city.is_some() { if let Some(ref area_id) = loc.area_id { if let Ok(Some((country_name, country_code))) = self.musicbrainz.resolve_area_country(area_id).await { loc.display_name = format!("{}, {}", loc.display_name, country_name); loc.country = Some(country_name); loc.country_code = country_code; } } } } if let Ok(guard) = self.musicbrainz_cache.lock() { if let Some(cache) = guard.as_ref() { let _ = cache.set_artist_metadata(mbid, &metadata); } } Ok(metadata) } /// Fetch the artist relationships (band members, member-of groups, /// collaborators) for the Relationships section of the sidebar. /// Splits `member of band` by direction: backward direction lists /// members of *this* artist (still-active vs ended -> past), forward /// direction lists groups this artist is a member of. pub async fn musicbrainz_get_artist_relationships( &self, mbid: &str, ) -> Result { if let Ok(guard) = self.musicbrainz_cache.lock() { if let Some(cache) = guard.as_ref() { if let Ok(Some(cached)) = cache.get_artist_relations(mbid) { return Ok(cached); } } } let artist = self .musicbrainz .get_artist_with_relations(mbid) .await .map_err(|e| CoreError::Internal(e.to_string()))?; let mut members = Vec::new(); let mut past_members = Vec::new(); let mut groups = Vec::new(); let mut collaborators = Vec::new(); if let Some(relations) = &artist.relations { for relation in relations { let Some(related_artist) = &relation.artist else { continue; }; let related = RelatedArtist { mbid: related_artist.id.clone(), name: related_artist.name.clone(), role: relation .attributes .as_ref() .and_then(|a| a.first().cloned()), period: Some(Period { begin: relation.begin.clone(), end: relation.end.clone(), }), ended: relation.ended.unwrap_or(false), }; match relation.relation_type.as_str() { "member of band" => { if relation.direction.as_deref() == Some("backward") { if related.ended { past_members.push(related); } else { members.push(related); } } else { groups.push(related); } } "collaboration" => { collaborators.push(related); } _ => {} } } } let result = ArtistRelationships { members, past_members, groups, collaborators, }; if let Ok(guard) = self.musicbrainz_cache.lock() { if let Some(cache) = guard.as_ref() { let _ = cache.set_artist_relations(mbid, &result); } } Ok(result) } /// "You may also like" tag-based discovery — finds artists that /// share the seed artist's primary genre tag on MusicBrainz, then /// validates exact name matches on Qobuz so the row can actually /// open the artist page. Filters out the seed itself, the artists /// already shown in the Similar section, and any dismissed /// artists (passed in by the caller; the dismiss store lives at /// the frontend layer). /// /// When the primary tag does not return enough validated results, /// the pipeline falls back to the secondary tag, dedupes against /// the primary's results, and tops up. Result ordering is /// deterministic per seed_mbid (same artist page = same shuffle). pub async fn musicbrainz_discover_artists( &self, seed_mbid: &str, seed_name: &str, similar_names: &[String], dismissed_per_tag: &(dyn Fn(&str) -> std::collections::HashSet + Send + Sync), known_artists: &(dyn Fn() -> ( std::collections::HashSet, std::collections::HashSet, ) + Send + Sync), ) -> Result { use std::collections::HashSet; if !self.musicbrainz.is_enabled().await { return Ok(DiscoveryResponse { artists: Vec::new(), primary_tag: String::new(), }); } let seed_tags = self .musicbrainz .get_artist_tags(seed_mbid) .await .unwrap_or_default(); if seed_tags.is_empty() { return Ok(DiscoveryResponse { artists: Vec::new(), primary_tag: String::new(), }); } let primary_tag = seed_tags[0].clone(); let mb_results = self .musicbrainz .search_artists_by_tag(&primary_tag, 50) .await .map_err(|e| CoreError::Internal(e.to_string()))?; if mb_results.artists.is_empty() { return Ok(DiscoveryResponse { artists: Vec::new(), primary_tag, }); } let seed_norm = normalize_artist_name(seed_name); let similar_norm: HashSet = similar_names.iter().map(|n| normalize_artist_name(n)).collect(); let dismissed_primary = dismissed_per_tag(&primary_tag.to_lowercase()); let (known_ids, known_names) = known_artists(); let mut candidates: Vec<(String, String)> = Vec::new(); for artist in &mb_results.artists { let n = normalize_artist_name(&artist.name); if n == seed_norm || artist.id.eq_ignore_ascii_case(seed_mbid) || similar_norm.contains(&n) || dismissed_primary.contains(&n) || known_names.contains(&n) { continue; } candidates.push((artist.id.clone(), artist.name.clone())); } shuffle_with_seed(&mut candidates, seed_mbid, None); let max_results = 8; let min_results = 5; let mut results = self .validate_discovery_on_qobuz(&candidates, max_results, &known_ids) .await; if results.len() < min_results && seed_tags.len() > 1 { let secondary_tag = seed_tags[1].clone(); let dismissed_secondary = dismissed_per_tag(&secondary_tag.to_lowercase()); let existing_mbids: HashSet = results.iter().map(|r| r.mbid.clone()).collect(); if let Ok(secondary) = self .musicbrainz .search_artists_by_tag(&secondary_tag, 30) .await { let mut secondary_candidates: Vec<(String, String)> = Vec::new(); for a in &secondary.artists { let n = normalize_artist_name(&a.name); if n == seed_norm || a.id.eq_ignore_ascii_case(seed_mbid) || similar_norm.contains(&n) || dismissed_primary.contains(&n) || dismissed_secondary.contains(&n) || known_names.contains(&n) || existing_mbids.contains(&a.id) { continue; } secondary_candidates.push((a.id.clone(), a.name.clone())); } shuffle_with_seed(&mut secondary_candidates, seed_mbid, Some(&secondary_tag)); let remaining = max_results.saturating_sub(results.len()); let mut more = self .validate_discovery_on_qobuz( &secondary_candidates, remaining, &known_ids, ) .await; results.append(&mut more); } } Ok(DiscoveryResponse { artists: results, primary_tag, }) } async fn validate_discovery_on_qobuz( &self, candidates: &[(String, String)], max: usize, known_ids: &std::collections::HashSet, ) -> Vec { let mut out: Vec = Vec::new(); for (mbid, name) in candidates { if out.len() >= max { break; } let Ok(page) = self.search_artists(name, 1, 0, None).await else { continue; }; let Some(first) = page.items.first() else { continue; }; if normalize_artist_name(&first.name) != normalize_artist_name(name) { continue; } // Tauri's `!local_known_qobuz_ids.contains(&artist.id)` // gate — never suggest an artist the user has already // listened to >2 times. if known_ids.contains(&first.id) { continue; } out.push(DiscoveryArtist { mbid: mbid.clone(), name: first.name.clone(), qobuz_id: Some(first.id), }); } out } } // ----- Location / scene discovery ------------------------------------------- impl QbzCore { /// "Artists from the same place" — given a source artist's MBID, /// area and genre/tag seeds, find other artists from that area /// who share the genres, validated against Qobuz. Ports /// v2_discover_artists_by_location's core pipeline (the scene /// cache + progress events are omitted; subdivision resolution /// and affinity scoring are kept). #[allow(clippy::too_many_arguments)] pub async fn discover_artists_by_location( &self, source_mbid: &str, area_id: Option<&str>, area_name: &str, country: Option<&str>, genres: Vec, tags: Vec, limit: usize, offset: usize, ) -> Result { use std::collections::HashMap; // Step 0: smart area resolution — city → parent subdivision // for broader results (Leyton → England, Seattle → // Washington). let (search_name, display_name) = match area_id { Some(aid) => match self.musicbrainz.resolve_parent_subdivision(aid).await { Ok(Some((subdivision, _))) => { let display = country .map(|c| format!("{}, {}", c, subdivision)) .unwrap_or_else(|| subdivision.clone()); (subdivision, display) } _ => { let display = country .map(|c| format!("{}, {}", c, area_name)) .unwrap_or_else(|| area_name.to_string()); (area_name.to_string(), display) } }, None => { let display = country .map(|c| format!("{}, {}", c, area_name)) .unwrap_or_else(|| area_name.to_string()); (area_name.to_string(), display) } }; let source_seeds = AffinitySeeds { genres: genres.clone(), tags: tags.clone(), normalized_seeds: genres.iter().chain(tags.iter()).cloned().collect(), }; // Step 2: pick search genres, dropping overly broad tags that // would return the whole country's catalog. let mut search_genres: Vec = if genres.is_empty() { tags.iter() .filter(|s| !is_broad_genre(s)) .take(3) .cloned() .collect() } else { genres .iter() .chain(tags.iter().take(2)) .filter(|s| !is_broad_genre(s)) .cloned() .collect() }; if search_genres.is_empty() { // Everything was broad — fall back to the raw list. search_genres = if genres.is_empty() { tags.iter().take(3).cloned().collect() } else { genres.iter().take(3).cloned().collect() }; } if search_genres.is_empty() { return Ok(LocationDiscoveryResponse { artists: Vec::new(), scene_label: format!("{} scene", display_name), genre_summary: String::new(), total_candidates: 0, has_more: false, next_offset: 0, }); } // Step 2/3: MB tag+area search per genre, dedupe + score. // candidate_map: mbid -> (name, score_sum, genre_hits, tags) let mut candidate_map: HashMap)> = HashMap::new(); let per_genre_limit = 200usize; for genre in &search_genres { let result = self .musicbrainz .search_artists_by_tag_and_area(genre, &search_name, country, per_genre_limit, 0) .await; let Ok(response) = result else { continue; }; for artist in &response.artists { if artist.id == source_mbid { continue; } let candidate_tags: Vec = artist .tags .as_ref() .map(|list| { list.iter() .filter(|t| t.count.unwrap_or(0) > 0) .map(|t| t.name.clone()) .collect() }) .unwrap_or_default(); let same_city = artist .begin_area .as_ref() .map(|ba| { ba.name.eq_ignore_ascii_case(&search_name) || area_id.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(&search_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; for tag in &candidate_tags { if !entry.3.contains(tag) { entry.3.push(tag.clone()); } } } } // Step 3: score + sort. Final = affinity + (genre_hits-1)*15. 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() .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(); scored.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0))); let total_candidates = scored.len(); let to_validate: Vec<_> = scored.into_iter().skip(offset).take(limit).collect(); // Step 4: validate against Qobuz (exact normalized-name match, // pick the one with the most albums as a popularity proxy). let mut validated: Vec = Vec::new(); for (mbid, mb_name, candidate_genres, score) in &to_validate { let Ok(results) = self.search_artists(mb_name, 5, 0, None).await else { continue; }; let mb_norm = normalize_artist_name(mb_name); let best = results .items .iter() .filter(|a| normalize_artist_name(&a.name) == mb_norm) .max_by_key(|a| a.albums_count.unwrap_or(0)); if let Some(qobuz_artist) = best { let image_url = qobuz_artist .image .as_ref() .and_then(|img| img.small.as_ref().or(img.thumbnail.as_ref()).cloned()); validated.push(LocationCandidate { mbid: mbid.clone(), mb_name: mb_name.clone(), qobuz_id: Some(qobuz_artist.id as i64), qobuz_name: Some(qobuz_artist.name.clone()), qobuz_image: image_url, score: *score, genres: candidate_genres.clone(), qobuz_albums_count: qobuz_artist.albums_count, }); } } let scene_label = country .map(|c| c.to_string()) .unwrap_or_else(|| display_name.clone()); let next_offset = offset + to_validate.len(); Ok(LocationDiscoveryResponse { artists: validated, scene_label, genre_summary: genre_summary(&source_seeds), total_candidates, has_more: next_offset < total_candidates, next_offset, }) } } // ----- Musician resolution + appearances ----------------------------------- impl QbzCore { /// Resolve a musician (band member / collaborator) to the /// strongest available identity: MBID via MusicBrainz, exact- /// name Qobuz artist match, or just an appears-on count. Ports /// v2_resolve_musician end to end so the artist-network sidebar /// click can route the user to the right view (artist page when /// confident, MusicianPageView otherwise). pub async fn musicbrainz_resolve_musician( &self, name: &str, role: &str, ) -> Result { let resolved_artist = self .musicbrainz .resolve_artist(name) .await .map_err(|e| CoreError::Internal(e.to_string()))?; let normalized_target = name.trim().to_lowercase(); let artist_results = self.search_artists(name, 10, 0, None).await?; let exact = artist_results .items .iter() .find(|artist| artist.name.trim().to_lowercase() == normalized_target); if let Some(artist) = exact { let qobuz_artist_id = i64::try_from(artist.id).ok(); return Ok(ResolvedMusician { name: name.to_string(), role: role.to_string(), mbid: None, qobuz_artist_id, confidence: MusicianConfidence::Confirmed, bands: Vec::new(), appears_on_count: 0, }); } let album_results = self.search_albums(name, 20, 0, None).await?; let appears_on_count = album_results.total as usize; let confidence = if appears_on_count > 0 { MusicianConfidence::Contextual } else if resolved_artist.is_some() { MusicianConfidence::Weak } else { MusicianConfidence::None }; Ok(ResolvedMusician { name: name.to_string(), role: role.to_string(), mbid: resolved_artist.as_ref().map(|a| a.mbid.clone()), qobuz_artist_id: None, confidence, bands: Vec::new(), appears_on_count, }) } /// Fetch the paginated list of albums on which `name` appears, /// for the Appears On grid in MusicianPageView. Ports /// v2_get_musician_appearances — same Qobuz album search + /// per-row mapping with `role_on_album = role`. pub async fn musicbrainz_get_musician_appearances( &self, name: &str, role: &str, limit: u32, offset: u32, ) -> Result { let results = self.search_albums(name, limit, offset, None).await?; let albums = results .items .into_iter() .map(|album| AlbumAppearance { album_id: album.id, album_title: album.title, album_artwork: album.image.large.or(album.image.small).unwrap_or_default(), artist_name: album.artist.name, year: album.release_date_original, role_on_album: role.to_string(), }) .collect::>(); Ok(MusicianAppearances { albums, total: results.total as usize, }) } } /// Normalize an artist name for dedupe: trim, lowercase, collapse /// whitespace. Used by the discovery pipeline so "Iron Maiden" and /// "iron maiden" hash to the same key in the dismiss store. pub fn normalize_artist_name(name: &str) -> String { name.trim() .to_lowercase() .split_whitespace() .collect::>() .join(" ") } /// Deterministic shuffle keyed by `seed_mbid` (and optionally a tag). /// Same artist page produces the same order across runs; different /// artist or different fallback tag produces a different order. fn shuffle_with_seed(items: &mut Vec, seed_mbid: &str, secondary_tag: Option<&str>) { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); seed_mbid.hash(&mut hasher); if let Some(t) = secondary_tag { t.hash(&mut hasher); } let mut h = hasher.finish(); // Fisher-Yates with a simple xorshift PRNG seeded from the hash — // keeps qbz-core free of the rand dep. let n = items.len(); for i in (1..n).rev() { h ^= h << 13; h ^= h >> 7; h ^= h << 17; let j = (h % ((i + 1) as u64)) as usize; items.swap(i, j); } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_search_all_filters_blacklisted_artist() { let json = serde_json::json!({ "albums": { "items": [], "total": 0, "offset": 0, "limit": 30 }, "tracks": { "items": [], "total": 0, "offset": 0, "limit": 30 }, "artists": { "items": [ { "id": 1, "name": "Keep" }, { "id": 999, "name": "Blocked" } ], "total": 2, "offset": 0, "limit": 30 }, "playlists": { "items": [], "total": 0, "offset": 0, "limit": 30 } }); let blocked: BlacklistFilter = [999].into_iter().collect(); let out = parse_search_all(&json, &blocked); assert_eq!(out.artists.items.len(), 1); assert_eq!(out.artists.items[0].name, "Keep"); assert_eq!(out.artists.total, 1); assert!(out.most_popular.is_none()); } }