feat(backend): add musician resolution types and commands

Phase 1 of Musician Page implementation:

Types (musicbrainz/models.rs):
- MusicianConfidence enum (Confirmed/Contextual/Weak/None)
- ResolvedMusician struct with confidence assessment
- AlbumAppearance and MusicianAppearances for 'Appears On'

Commands (commands/musician.rs):
- resolve_musician: Determines confidence level and destination
- get_musician_appearances: Returns deduplicated album appearances

Confidence logic:
- Level 3 (Confirmed): Has Qobuz artist page with albums
- Level 2 (Contextual): Appears in 3+ album credits
- Level 1 (Weak): Sparse Qobuz results
- Level 0 (None): No safe match
This commit is contained in:
vicrodh committed 2026-01-25 22:06:16 -06:00
1 parent 596f8ab524
commit fc138d801c
5 files changed
+452 -2

No files matched your search

+2
View File
@@ -10,6 +10,7 @@ pub mod credits;
pub mod favorites;
pub mod lastfm;
pub mod listenbrainz;
pub mod musician;
pub mod musicbrainz;
pub mod notification;
pub mod playback;
@@ -30,6 +31,7 @@ pub use credits::*;
pub use favorites::*;
pub use lastfm::*;
pub use listenbrainz::*;
pub use musician::*;
pub use musicbrainz::*;
pub use notification::*;
pub use playback::*;
+354
View File
@@ -0,0 +1,354 @@
//! Musician resolution commands
//!
//! Handles musician discovery and confidence assessment for the
//! Musician Page and Informational Modal features.
use std::collections::HashSet;
use tauri::State;
// Note: We use Qobuz search APIs which return these types internally
use crate::musicbrainz::{
AlbumAppearance, MusicBrainzSharedState, MusicianAppearances, MusicianConfidence,
ResolvedMusician,
};
use crate::AppState;
/// Resolve a musician from credits to determine navigation destination
///
/// Returns a ResolvedMusician with:
/// - Confidence level (determines UI: Artist Page, Musician Page, or Modal)
/// - Qobuz artist ID if found (for navigation to Artist Page)
/// - MusicBrainz data if available (bands, MBID)
/// - Appears-on count from Qobuz
#[tauri::command]
pub async fn resolve_musician(
name: String,
role: String,
state: State<'_, AppState>,
mb_state: State<'_, MusicBrainzSharedState>,
) -> Result<ResolvedMusician, String> {
log::info!("Resolving musician: {} (role: {})", name, role);
let mut musician = ResolvedMusician::empty(name.clone(), role.clone());
// Step 1: Search Qobuz for artist by exact name
let qobuz_artist = {
let client = state.client.lock().await;
match client.search_artists(&name, 5, 0, None).await {
Ok(results) => results
.items
.into_iter()
.find(|a| a.name.to_lowercase() == name.to_lowercase()),
Err(e) => {
log::warn!("Qobuz artist search failed: {}", e);
None
}
}
};
// Step 2: If found, check if they have a real catalog
if let Some(artist) = qobuz_artist {
let has_catalog = artist.albums_count.unwrap_or(0) > 0;
if has_catalog {
// Confidence = 3: Has a dedicated Qobuz artist page
musician.qobuz_artist_id = Some(artist.id as i64);
musician.confidence = MusicianConfidence::Confirmed;
log::info!(
"Musician {} resolved as Confirmed (Qobuz artist ID: {})",
name,
artist.id
);
}
}
// Step 3: If no artist page, search for appearances in track credits
if musician.confidence == MusicianConfidence::None {
let appearances_count = count_track_appearances(&state, &name).await;
musician.appears_on_count = appearances_count;
if appearances_count >= 3 {
// Confidence = 2: Appears in multiple album credits
musician.confidence = MusicianConfidence::Contextual;
log::info!(
"Musician {} resolved as Contextual ({} appearances)",
name,
appearances_count
);
} else if appearances_count > 0 {
// Confidence = 1: Sparse results
musician.confidence = MusicianConfidence::Weak;
log::info!(
"Musician {} resolved as Weak ({} appearances)",
name,
appearances_count
);
}
}
// Step 4: Try MusicBrainz enrichment (if enabled)
if mb_state.client.is_enabled().await {
match enrich_from_musicbrainz(&mb_state, &name).await {
Ok((mbid, bands)) => {
musician.mbid = mbid;
musician.bands = bands;
}
Err(e) => {
log::warn!("MusicBrainz enrichment failed for {}: {}", name, e);
// Continue without MB data - don't block
}
}
}
log::info!(
"Musician {} final confidence: {:?}",
name,
musician.confidence
);
Ok(musician)
}
/// Get albums a musician appears on (Qobuz only)
///
/// Returns deduplicated album appearances sorted by year.
/// This is the data source for the "Appears On" section of the Musician Page.
#[tauri::command]
pub async fn get_musician_appearances(
name: String,
_role: Option<String>,
limit: u32,
offset: u32,
state: State<'_, AppState>,
) -> Result<MusicianAppearances, String> {
log::info!(
"Getting musician appearances: {} (limit: {}, offset: {})",
name,
limit,
offset
);
// Search for tracks by performer name
let tracks = {
let client = state.client.lock().await;
client
.search_tracks(&name, 100, 0, None)
.await
.map_err(|e| e.to_string())?
};
// Deduplicate by album, keeping track of roles
let mut seen_albums = HashSet::new();
let mut albums: Vec<AlbumAppearance> = Vec::new();
for track in tracks.items {
// Check if performer matches
let performer_name = track
.performer
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or("");
// Also check the performers string for session musicians
let performers_str = track.performers.as_deref().unwrap_or("");
let name_lower = name.to_lowercase();
let matches = performer_name.to_lowercase() == name_lower
|| performers_str.to_lowercase().contains(&name_lower);
if !matches {
continue;
}
if let Some(album) = &track.album {
if seen_albums.contains(&album.id) {
continue;
}
seen_albums.insert(album.id.clone());
// Extract role from performers string if possible
let role = extract_role_from_performers(performers_str, &name);
albums.push(AlbumAppearance {
album_id: album.id.clone(),
album_title: album.title.clone().unwrap_or_default(),
album_artwork: album
.image
.as_ref()
.and_then(|i| i.large.clone())
.or_else(|| album.image.as_ref().and_then(|i| i.thumbnail.clone()))
.unwrap_or_default(),
artist_name: track
.album
.as_ref()
.and_then(|a| a.artist.as_ref())
.map(|a| a.name.clone())
.unwrap_or_else(|| performer_name.to_string()),
year: album
.release_date_original
.as_ref()
.and_then(|d| d.split('-').next())
.map(|s| s.to_string()),
role_on_album: role,
});
}
}
// Sort by year descending
albums.sort_by(|a, b| {
let year_a = a.year.as_deref().unwrap_or("0");
let year_b = b.year.as_deref().unwrap_or("0");
year_b.cmp(year_a)
});
let total = albums.len();
// Apply pagination
let start = offset as usize;
let end = (start + limit as usize).min(albums.len());
let paginated = if start < albums.len() {
albums[start..end].to_vec()
} else {
Vec::new()
};
Ok(MusicianAppearances {
albums: paginated,
total,
})
}
// ============ Helper Functions ============
/// Count how many unique albums a musician appears on
async fn count_track_appearances(state: &State<'_, AppState>, name: &str) -> usize {
let client = state.client.lock().await;
match client.search_tracks(name, 50, 0, None).await {
Ok(results) => {
let mut seen_albums = HashSet::new();
let name_lower = name.to_lowercase();
for track in results.items {
// Check performer name
let performer_name = track
.performer
.as_ref()
.map(|p| p.name.to_lowercase())
.unwrap_or_default();
// Check performers string
let performers_str = track
.performers
.as_ref()
.map(|s| s.to_lowercase())
.unwrap_or_default();
let matches =
performer_name == name_lower || performers_str.contains(&name_lower);
if matches {
if let Some(album) = &track.album {
seen_albums.insert(album.id.clone());
}
}
}
seen_albums.len()
}
Err(e) => {
log::warn!("Failed to count appearances for {}: {}", name, e);
0
}
}
}
/// Enrich musician data from MusicBrainz
async fn enrich_from_musicbrainz(
mb_state: &State<'_, MusicBrainzSharedState>,
name: &str,
) -> Result<(Option<String>, Vec<String>), String> {
// Resolve artist to get MBID
let resolved = {
let cache = mb_state.cache.lock().await;
cache.get_artist(name).ok().flatten()
};
let mbid = if let Some(cached) = resolved {
cached.mbid
} else {
// Search MusicBrainz for the artist
match mb_state.client.search_artist(name).await {
Ok(response) => {
// Find best match (score >= 90)
response
.artists
.into_iter()
.find(|a| a.score.unwrap_or(0) >= 90)
.map(|a| a.id)
}
Err(e) => {
log::warn!("MusicBrainz artist search failed: {}", e);
None
}
}
};
let mut bands = Vec::new();
// If we have an MBID, get relationships to find bands
if let Some(ref mbid) = mbid {
match mb_state.client.get_artist_with_relations(mbid).await {
Ok(response) => {
if let Some(relations) = response.relations {
for relation in relations {
// "member of band" relation with forward direction means this person is in a band
if relation.relation_type == "member of band"
&& relation.direction.as_deref() != Some("backward")
{
if let Some(artist) = relation.artist {
bands.push(artist.name);
}
}
}
}
}
Err(e) => {
log::warn!("MusicBrainz relations fetch failed: {}", e);
}
}
}
Ok((mbid, bands))
}
/// Extract role from performers string
/// e.g., "John Smith, Piano - Jane Doe, Vocals" -> "Piano" for "John Smith"
fn extract_role_from_performers(performers: &str, name: &str) -> String {
let name_lower = name.to_lowercase();
// Split by common delimiters
for segment in performers.split(&['-', '–', '|', ';'][..]) {
let segment = segment.trim();
// Check if this segment contains the name
if segment.to_lowercase().contains(&name_lower) {
// Try to extract role after comma
if let Some(comma_pos) = segment.find(',') {
let after_comma = segment[comma_pos + 1..].trim();
// Clean up - take first word or phrase before next punctuation
let role = after_comma
.split(&[',', '(', ')'][..])
.next()
.unwrap_or(after_comma)
.trim();
if !role.is_empty() {
return role.to_string();
}
}
}
}
// Fallback
"Performer".to_string()
}
+3
View File
@@ -763,6 +763,9 @@ pub fn run() {
commands::musicbrainz_get_cache_stats,
commands::musicbrainz_clear_cache,
commands::musicbrainz_cleanup_cache,
// Musician resolution commands
commands::resolve_musician,
commands::get_musician_appearances,
// ListenBrainz integration commands
commands::listenbrainz_get_status,
commands::listenbrainz_is_enabled,
+3 -2
View File
@@ -22,8 +22,9 @@ pub mod smart_playlists;
pub use cache::{CacheStats, MusicBrainzCache, MusicBrainzCacheState};
pub use client::{MusicBrainzClient, MusicBrainzConfig};
pub use models::{
ArtistRelationships, ArtistType, MatchConfidence, Period, RelatedArtist, ResolvedArtist,
ResolvedRelease, ResolvedTrack,
AlbumAppearance, ArtistRelationships, ArtistType, MatchConfidence, MusicianAppearances,
MusicianConfidence, Period, RelatedArtist, ResolvedArtist, ResolvedMusician, ResolvedRelease,
ResolvedTrack,
};
use std::sync::Arc;
+90
View File
@@ -364,3 +364,93 @@ impl ArtistRelationships {
&& self.collaborators.is_empty()
}
}
// ============ Musician Types ============
/// Musician confidence level for MusicBrainz ↔ Qobuz matching
///
/// This determines what UI is shown when a musician is clicked:
/// - Confirmed (3): Navigate to Qobuz Artist Page
/// - Contextual (2): Navigate to Musician Page
/// - Weak (1): Show Informational Modal only
/// - None (0): Show Informational Modal only
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MusicianConfidence {
/// Level 3: Dedicated Qobuz artist page exists
Confirmed,
/// Level 2: Appears in Qobuz credits, no standalone artist page
Contextual,
/// Level 1: MusicBrainz entity exists, sparse Qobuz results
Weak,
/// Level 0: No safe match, high risk of incorrect association
None,
}
impl MusicianConfidence {
pub fn level(&self) -> u8 {
match self {
Self::Confirmed => 3,
Self::Contextual => 2,
Self::Weak => 1,
Self::None => 0,
}
}
}
impl Default for MusicianConfidence {
fn default() -> Self {
Self::None
}
}
/// Resolved musician with confidence assessment
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedMusician {
/// Musician name (from credits or MusicBrainz)
pub name: String,
/// Primary role (e.g., "drums", "piano")
pub role: String,
/// MusicBrainz ID (if resolved)
pub mbid: Option<String>,
/// Qobuz artist ID (if exists)
pub qobuz_artist_id: Option<i64>,
/// Confidence level
pub confidence: MusicianConfidence,
/// Known bands/projects (from MusicBrainz)
pub bands: Vec<String>,
/// Number of albums the musician appears on (Qobuz)
pub appears_on_count: usize,
}
impl ResolvedMusician {
pub fn empty(name: String, role: String) -> Self {
Self {
name,
role,
mbid: None,
qobuz_artist_id: None,
confidence: MusicianConfidence::None,
bands: Vec::new(),
appears_on_count: 0,
}
}
}
/// Album appearance for a musician
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumAppearance {
pub album_id: String,
pub album_title: String,
pub album_artwork: String,
pub artist_name: String,
pub year: Option<String>,
pub role_on_album: String,
}
/// Musician appearances response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MusicianAppearances {
pub albums: Vec<AlbumAppearance>,
pub total: usize,
}