feat(core): musicbrainz_resolve_musician + musician appearances

Two new QbzCore methods unblocking the MusicianPageView port — both
direct ports of v2_resolve_musician and v2_get_musician_appearances
from src-tauri/commands_v2/integrations.rs.

musicbrainz_resolve_musician(name, role):
  - MB client resolve_artist for the MBID, then a Qobuz artist
    search for an exact name match. Confirmed when the Qobuz
    artist matches; Contextual when there are appears-on albums
    only; Weak when MB knows the artist but Qobuz has nothing;
    None otherwise.
  - The sidebar's musician-clicked dispatcher uses the confidence
    to decide whether to route the click into the artist page
    (Confirmed) or into MusicianPageView (Contextual / Weak).

musicbrainz_get_musician_appearances(name, role, limit, offset):
  - Wraps QbzCore::search_albums with the same per-row mapping
    Tauri does: album_id, title, artwork (large/small fallback),
    artist name, year, and the caller's role string stamped onto
    each row as `role_on_album`. Returns
    `MusicianAppearances { albums, total }`.

cc
This commit is contained in:
vicrodh committed 2026-05-20 18:27:45 -06:00
1 parent c4b6acb760
commit 1b76ce3fa2
1 file changed
+96 -2
+96 -2
View File
@@ -16,8 +16,9 @@ use qbz_models::{
};
use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
use qbz_integrations::musicbrainz::{
location, ArtistMetadata, ArtistRelationships, DiscoveryArtist, DiscoveryResponse,
MusicBrainzClient, Period, RelatedArtist, ResolvedArtist,
location, AlbumAppearance, ArtistMetadata, ArtistRelationships, DiscoveryArtist,
DiscoveryResponse, MusicBrainzClient, MusicianAppearances, MusicianConfidence, Period,
RelatedArtist, ResolvedArtist, ResolvedMusician,
};
use qbz_player::{PlaybackState, Player, QueueManager};
use qbz_qobuz::QobuzClient;
@@ -1734,6 +1735,99 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
}
}
// ----- 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.