Files
qbz/crates/qbz-core/src/core.rs
T
vicrodh 3b8f0a4597 feat(slint): Qobuz mix detail views (DailyQ / WeeklyQ / FavQ / TopQ)
The For You Qobuz Mixes tiles now open functional mix detail pages.

  - New MixView: header (mix name + description + Play all) over the
    mix's track list (reusing SearchTrackRow). Clicking a track plays
    the whole mix from there; Play all starts at the top.
  - crate::mix resolves each mix to a track list from available
    backend: DailyQ/WeeklyQ -> /dynamic/suggest seeded from the local
    play-history track ids; FavQ -> favorite tracks shuffled (xorshift,
    no rng dep); TopQ -> tracks aggregated from the user's playlists.
    The loaded list is cached so play-all / play-from-track build the
    queue without re-fetching.
  - Backend: ported /dynamic/suggest into qbz-qobuz
    (QobuzClient::get_dynamic_suggest — POST, items parsed leniently
    into Vec<Track>) + QbzCore::get_dynamic_suggest.
  - ContentView.mix + NavEntry::Mix { kind } so each mix is a history
    entry; the tile click records + navigates. New MixTrack artwork
    target. playback::play_tracks generalizes the radio queue builder
    with a start index (radio reuses it at 0).

Tauri's exact generation (listened-track analysis payloads, playlist
play-stats ranking) is approximated; same surfaces + playback, sourced
from the backend the MVP has.

cc
2026-05-21 08:16:31 -06:00

2221 lines
76 KiB
Rust

//! 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<u64>;
fn parse_page<T: serde::de::DeserializeOwned>(
value: &serde_json::Value,
key: &str,
) -> SearchResultsPage<T> {
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<MostPopularItem> {
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::<Artist>(content.clone()) {
if !blacklist.contains(&a.id) {
return Some(MostPopularItem::Artists(a));
}
}
}
"albums" => {
if let Ok(al) = serde_json::from_value::<Album>(content.clone()) {
if !blacklist.contains(&al.artist.id) {
return Some(MostPopularItem::Albums(al));
}
}
}
"tracks" => {
if let Ok(t) = serde_json::from_value::<Track>(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::<Album>(value, "albums");
let mut tracks = parse_page::<Track>(value, "tracks");
let mut artists = parse_page::<Artist>(value, "artists");
let playlists = parse_page::<Playlist>(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<A: FrontendAdapter> {
/// Frontend adapter for event emission
adapter: Arc<A>,
/// Qobuz API client
client: Arc<RwLock<Option<QobuzClient>>>,
/// Queue manager
queue: Arc<RwLock<QueueManager>>,
/// Audio player
player: Arc<Player>,
/// MusicBrainz client (always present; enable/disable toggle lives inside)
musicbrainz: Arc<MusicBrainzClient>,
/// 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<std::sync::Mutex<Option<MusicBrainzCache>>>,
/// Whether the core is initialized
initialized: Arc<RwLock<bool>>,
}
impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// 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<UserSession, CoreError> {
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<UserSession, CoreError> {
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<QueueTrack>, Option<usize>) {
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<Vec<usize>>) {
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<QueueTrack>) {
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<QueueTrack>, start_index: Option<usize>) {
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<QueueTrack>,
start_index: Option<usize>,
shuffle_enabled: bool,
shuffle_order: Option<Vec<usize>>,
) {
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<QueueTrack> {
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<QueueTrack> {
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<QueueTrack> {
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<QueueTrack> {
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<QueueTrack> {
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<QueueTrack> {
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<QueueTrack> {
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<SearchResultsPage<Album>, 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<SearchResultsPage<Track>, 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<SearchResultsPage<Artist>, 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<serde_json::Value, CoreError> {
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<SearchAllResults, CoreError> {
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<Album, CoreError> {
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<Track, CoreError> {
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<Artist, CoreError> {
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<StreamUrl, CoreError> {
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<Player> {
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<serde_json::Value, CoreError> {
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<std::collections::HashSet<u64>, 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<std::collections::HashSet<u64>, 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<Vec<Playlist>, 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<Playlist, CoreError> {
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<Playlist, CoreError> {
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<bool>,
) -> Result<Playlist, CoreError> {
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<SearchResultsPage<Playlist>, 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<Vec<Track>, 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<u64>) -> Result<Vec<GenreInfo>, 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<Vec<u64>>,
) -> Result<DiscoverResponse, CoreError> {
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<String>,
genre_ids: Option<Vec<u64>>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<DiscoverPlaylistsResponse, CoreError> {
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<Vec<PlaylistTag>, 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<Vec<u64>>,
offset: u32,
limit: u32,
) -> Result<DiscoverData<DiscoverAlbum>, 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<u64>,
) -> Result<SearchResultsPage<Album>, 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<SearchResultsPage<Album>, 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<PageArtistResponse, CoreError> {
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<SearchResultsPage<Artist>, 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<qbz_models::AlbumSuggestResponse, CoreError> {
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<qbz_models::RadioResponse, CoreError> {
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<qbz_models::RadioResponse, CoreError> {
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<Vec<Track>, 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<Vec<Track>, 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<String, String> {
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<Vec<u64>, 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<u32>,
offset: Option<u32>,
) -> Result<Artist, CoreError> {
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<u32>,
offset: Option<u32>,
) -> Result<ArtistAlbums, CoreError> {
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<u32>,
offset: Option<u32>,
) -> Result<Artist, CoreError> {
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<TracksContainer, CoreError> {
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<ReleasesGridResponse, CoreError> {
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<LabelPageData, CoreError> {
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<serde_json::Value, CoreError> {
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<qbz_models::AwardPageData, CoreError> {
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<SearchResultsPage<Album>, 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<LabelExploreResponse, CoreError> {
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<String>,
order: Option<String>,
genre_ids: Option<String>,
from_date: Option<String>,
to_date: Option<String>,
) -> Result<LabelListPage<Album>, 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<String>,
) -> Result<LabelListPage<Album>, 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<String>,
order: Option<String>,
genre_ids: Option<String>,
) -> Result<LabelListPage<Album>, 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<LabelListPage<Playlist>, 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<LabelListPage<Artist>, 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<LabelStoryResponse, CoreError> {
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<u64>,
) -> Result<LabelGetListResponse, CoreError> {
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<A> {
Arc::clone(&self.adapter)
}
/// Get the Qobuz client (for advanced usage)
pub fn client(&self) -> Arc<RwLock<Option<QobuzClient>>> {
Arc::clone(&self.client)
}
/// Get the queue manager (for advanced usage)
pub fn queue(&self) -> Arc<RwLock<QueueManager>> {
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<Option<ResolvedArtist>, 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<ArtistMetadata, CoreError> {
// 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<ArtistRelationships, CoreError> {
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<String> + Send + Sync),
known_artists: &(dyn Fn() -> (
std::collections::HashSet<u64>,
std::collections::HashSet<String>,
)
+ Send
+ Sync),
) -> Result<DiscoveryResponse, CoreError> {
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<String> =
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<String> =
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<u64>,
) -> Vec<DiscoveryArtist> {
let mut out: Vec<DiscoveryArtist> = 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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// "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<String>,
tags: Vec<String>,
limit: usize,
offset: usize,
) -> Result<LocationDiscoveryResponse, CoreError> {
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<String> = 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<String, (String, i32, usize, Vec<String>)> =
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<String> = 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<String>, 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::<Vec<_>>(),
);
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<LocationCandidate> = 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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// 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<ResolvedMusician, CoreError> {
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<MusicianAppearances, CoreError> {
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::<Vec<_>>();
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::<Vec<_>>()
.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<T>(items: &mut Vec<T>, 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());
}
}