Merge branch 'feature/musician-page-modal' into pre-release
This commit is contained in:
commit
a1dd57e7a8
14 files changed
+1656
-14
No files matched your search
@@ -33,6 +33,7 @@ base64 = "0.22"
|
||||
|
||||
# Regex (for bundle parsing)
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
|
||||
# Time
|
||||
chrono = "0.4"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
//! 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, handling "The" prefix variations
|
||||
// MusicBrainz often has "Beatles" but Qobuz has "The Beatles"
|
||||
let qobuz_artist = {
|
||||
let client = state.client.lock().await;
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
// Search with original name
|
||||
let mut best_match: Option<crate::api::Artist> = None;
|
||||
|
||||
if let Ok(results) = client.search_artists(&name, 10, 0, None).await {
|
||||
// Look for exact match first
|
||||
for artist in &results.items {
|
||||
let artist_lower = artist.name.to_lowercase();
|
||||
if artist_lower == name_lower {
|
||||
best_match = Some(artist.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, check for "The X" variant
|
||||
if best_match.is_none() {
|
||||
let the_name = format!("the {}", name_lower);
|
||||
for artist in &results.items {
|
||||
let artist_lower = artist.name.to_lowercase();
|
||||
if artist_lower == the_name {
|
||||
log::info!("Found 'The' variant: {} -> {}", name, artist.name);
|
||||
best_match = Some(artist.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always check if "The X" variant has more STUDIO albums
|
||||
// Total album count is misleading (tributes, compilations inflate it)
|
||||
if let Some(ref current) = best_match {
|
||||
// Count studio albums for current match
|
||||
let current_studio = count_studio_albums(&client, current.id).await;
|
||||
log::info!(
|
||||
"Match '{}' has {} studio albums (total: {})",
|
||||
current.name, current_studio, current.albums_count.unwrap_or(0)
|
||||
);
|
||||
|
||||
// If few studio albums, check "The X" variant
|
||||
if current_studio < 10 {
|
||||
let the_name = format!("The {}", name);
|
||||
if let Ok(the_results) = client.search_artists(&the_name, 5, 0, None).await {
|
||||
for artist in the_results.items {
|
||||
let artist_lower = artist.name.to_lowercase();
|
||||
let the_name_lower = the_name.to_lowercase();
|
||||
if artist_lower == the_name_lower {
|
||||
let the_studio = count_studio_albums(&client, artist.id).await;
|
||||
|
||||
log::info!(
|
||||
"Comparing studio albums: '{}' has {} studio, 'The {}' has {} studio",
|
||||
current.name, current_studio, name, the_studio
|
||||
);
|
||||
|
||||
if the_studio > current_studio {
|
||||
log::info!(
|
||||
"Preferring 'The {}' ({} studio albums) over '{}' ({} studio albums)",
|
||||
name, the_studio, current.name, current_studio
|
||||
);
|
||||
best_match = Some(artist);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_match
|
||||
};
|
||||
|
||||
// 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: {})",
|
||||
name,
|
||||
artist.id,
|
||||
artist.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
album_artwork: album
|
||||
.image
|
||||
.large
|
||||
.clone()
|
||||
.or_else(|| album.image.thumbnail.clone())
|
||||
.unwrap_or_default(),
|
||||
// AlbumSummary doesn't have artist field, use track's performer
|
||||
artist_name: track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| performer_name.to_string()),
|
||||
// AlbumSummary doesn't have release_date_original
|
||||
year: None,
|
||||
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()
|
||||
}
|
||||
|
||||
// ============ Album Categorization (mirrors qobuzAdapters.ts logic) ============
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
lazy_static! {
|
||||
// Unofficial releases patterns
|
||||
static ref BROADCAST_PATTERNS: Regex = Regex::new(r"(?i)\b(fm broadcasts?|radio broadcasts?|broadcasts?|bootleg|unofficial|pirate)\b").unwrap();
|
||||
static ref NON_MUSIC_PATTERNS: Regex = Regex::new(r"(?i)\b(interviews?|speaks|talking|in their own words|the interviews?|memories:?\s*the)\b").unwrap();
|
||||
static ref BOOTLEG_LABELS: Regex = Regex::new(r"(?i)\b(leftfield media|purple pyramid|cleopatra|laser media|radio lu|broadcast archives)\b").unwrap();
|
||||
|
||||
// Compilation patterns
|
||||
static ref COMPILATION_PATTERNS: Regex = Regex::new(r"(?i)\b(greatest hits|best of|anthology|the very best|essentials?|definitive collection|gold|platinum|hits collection|complete collection|hit collection|b-sides|rarities)\b").unwrap();
|
||||
static ref VARIOUS_PATTERNS: Regex = Regex::new(r"(?i)\b(various artists|v\.?a\.?|original soundtrack|ost)\b").unwrap();
|
||||
|
||||
// Live album patterns
|
||||
static ref LIVE_PATTERNS: Regex = Regex::new(r"(?i)\blive\b|\blive at\b|\blive in\b|\bin concert\b|\bunplugged\b|\bmtv unplugged\b|\bacoustic live\b|\balive\b|\bon stage\b").unwrap();
|
||||
static ref LIVE_MULTILANG: Regex = Regex::new(r"(?i)\b(en vivo|en directo|ao vivo|dal vivo|en direct|konzert|liveopname)\b|ライブ|라이브|콘서트").unwrap();
|
||||
static ref FM_BROADCAST: Regex = Regex::new(r"(?i)\b(fm broadcasts?|radio broadcasts?|broadcasts?)\b").unwrap();
|
||||
|
||||
// Studio album variant patterns
|
||||
static ref STUDIO_VARIANT: Regex = Regex::new(r"(?i)\b(deluxe|remaster|anniversary|expanded|special edition|collector|box set)\b").unwrap();
|
||||
|
||||
// EP/Single patterns
|
||||
static ref EP_PATTERNS: Regex = Regex::new(r"(?i)\b(- ep|\.ep|\(ep\))\b").unwrap();
|
||||
static ref EP_SUFFIX: Regex = Regex::new(r"(?i)\bep\s*$").unwrap();
|
||||
static ref SINGLE_PATTERNS: Regex = Regex::new(r"(?i)\b(- single|\(single\))\b").unwrap();
|
||||
}
|
||||
|
||||
/// Album category for filtering
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum AlbumCategory {
|
||||
Albums, // Studio albums (main discography)
|
||||
Tributes, // Albums by other artists
|
||||
Others, // Compilations, bootlegs, unofficial
|
||||
Live, // Live recordings
|
||||
Eps, // EPs and singles
|
||||
}
|
||||
|
||||
/// Check if album is by a different artist
|
||||
fn is_different_artist(album: &crate::api::Album, main_artist_id: u64) -> bool {
|
||||
album.artist.id != main_artist_id
|
||||
}
|
||||
|
||||
/// Check if this is an unofficial/bootleg release
|
||||
fn is_unofficial_release(title: &str, label: &str, track_count: u32, duration: u32) -> bool {
|
||||
// FM Broadcasts, bootlegs
|
||||
if BROADCAST_PATTERNS.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Interviews, spoken word
|
||||
if NON_MUSIC_PATTERNS.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Known bootleg labels
|
||||
if BOOTLEG_LABELS.is_match(label) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Podcast-like: single long track
|
||||
if track_count == 1 && duration >= 1200 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if this is a compilation/greatest hits
|
||||
fn is_compilation_album(title: &str) -> bool {
|
||||
COMPILATION_PATTERNS.is_match(title) || VARIOUS_PATTERNS.is_match(title)
|
||||
}
|
||||
|
||||
/// Check if this is a live album
|
||||
fn is_live_album(title: &str) -> bool {
|
||||
// Don't classify FM broadcasts as live
|
||||
if FM_BROADCAST.is_match(title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if LIVE_PATTERNS.is_match(title) || LIVE_MULTILANG.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if this is a studio album (main discography material)
|
||||
fn is_studio_album(title: &str, track_count: u32, duration: u32) -> bool {
|
||||
// Single-track releases over 20 min are likely podcasts
|
||||
if track_count == 1 && duration >= 1200 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deluxe/remastered editions are studio albums
|
||||
if STUDIO_VARIANT.is_match(title) && (track_count >= 7 || duration >= 2100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Standard album: 7+ tracks or 25+ minutes
|
||||
if track_count >= 7 {
|
||||
return true;
|
||||
}
|
||||
if duration >= 1500 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if this is an EP or Single
|
||||
fn is_ep_or_single(title: &str, track_count: u32, duration: u32) -> bool {
|
||||
// Explicitly marked as EP or Single
|
||||
if EP_PATTERNS.is_match(title) || EP_SUFFIX.is_match(title) || SINGLE_PATTERNS.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Very short releases: 1-4 tracks
|
||||
if track_count > 0 && track_count <= 4 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Under 15 minutes
|
||||
if duration > 0 && duration <= 900 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5-6 tracks AND under 20 minutes
|
||||
if track_count >= 5 && track_count <= 6 && duration > 0 && duration <= 1200 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Categorize an album (mirrors TypeScript categorizeAlbum)
|
||||
fn categorize_album(album: &crate::api::Album, main_artist_id: u64) -> AlbumCategory {
|
||||
let title = album.title.to_lowercase();
|
||||
let label = album.label.as_ref().map(|l| l.name.as_str()).unwrap_or("");
|
||||
let track_count = album.tracks_count.unwrap_or(0);
|
||||
let duration = album.duration.unwrap_or(0);
|
||||
|
||||
// 1. Albums by different artists -> Tributes
|
||||
if is_different_artist(album, main_artist_id) {
|
||||
return AlbumCategory::Tributes;
|
||||
}
|
||||
|
||||
// 2. Unofficial releases -> Others
|
||||
if is_unofficial_release(&title, label, track_count, duration) {
|
||||
return AlbumCategory::Others;
|
||||
}
|
||||
|
||||
// 3. Compilations -> Others
|
||||
if is_compilation_album(&title) {
|
||||
return AlbumCategory::Others;
|
||||
}
|
||||
|
||||
// 4. Live albums -> Live
|
||||
if is_live_album(&title) {
|
||||
return AlbumCategory::Live;
|
||||
}
|
||||
|
||||
// 5. Check if it's a studio album first
|
||||
if is_studio_album(&title, track_count, duration) {
|
||||
return AlbumCategory::Albums;
|
||||
}
|
||||
|
||||
// 6. EPs and Singles
|
||||
if is_ep_or_single(&title, track_count, duration) {
|
||||
return AlbumCategory::Eps;
|
||||
}
|
||||
|
||||
// 7. Default: treat as studio album
|
||||
AlbumCategory::Albums
|
||||
}
|
||||
|
||||
/// Count studio albums for an artist (fetches albums and filters)
|
||||
async fn count_studio_albums(
|
||||
client: &crate::api::QobuzClient,
|
||||
artist_id: u64,
|
||||
) -> usize {
|
||||
// Fetch artist with albums (limit to 50 for performance)
|
||||
match client.get_artist_with_pagination(artist_id, true, Some(50), None).await {
|
||||
Ok(artist) => {
|
||||
if let Some(albums) = artist.albums {
|
||||
albums
|
||||
.items
|
||||
.iter()
|
||||
.filter(|album| categorize_album(album, artist_id) == AlbumCategory::Albums)
|
||||
.count()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch albums for artist {}: {}", artist_id, e);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -9,13 +9,18 @@
|
||||
onClose: () => void;
|
||||
onTrackPlay?: (track: TrackCredits) => void;
|
||||
onPerformerSearch?: (name: string) => void;
|
||||
onMusicianClick?: (name: string, role: string) => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
}
|
||||
|
||||
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch, onLabelClick }: Props = $props();
|
||||
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch, onMusicianClick, onLabelClick }: Props = $props();
|
||||
|
||||
function handlePerformerClick(name: string) {
|
||||
if (onPerformerSearch) {
|
||||
function handlePerformerClick(name: string, roles: string[]) {
|
||||
const role = roles.length > 0 ? roles[0] : 'Performer';
|
||||
if (onMusicianClick) {
|
||||
onMusicianClick(name, role);
|
||||
onClose();
|
||||
} else if (onPerformerSearch) {
|
||||
onPerformerSearch(name);
|
||||
onClose();
|
||||
}
|
||||
@@ -241,8 +246,8 @@
|
||||
<div class="track-credits">
|
||||
{#each track.performers as performer}
|
||||
<div class="performer-row">
|
||||
{#if onPerformerSearch}
|
||||
<button class="performer-link" onclick={() => handlePerformerClick(performer.name)}>{performer.name}</button>
|
||||
{#if onMusicianClick || onPerformerSearch}
|
||||
<button class="performer-link" onclick={() => handlePerformerClick(performer.name, performer.roles)}>{performer.name}</button>
|
||||
{:else}
|
||||
<span class="performer-name">{performer.name}</span>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Musician Informational Modal
|
||||
*
|
||||
* Displays contextual information about a musician when navigation
|
||||
* to a full Musician Page is not appropriate (confidence level 0-1).
|
||||
*
|
||||
* This modal:
|
||||
* - Is NOT a page, provides context only
|
||||
* - Returns user to previous view on close
|
||||
* - Does NOT show albums, tracks, or playlists
|
||||
* - Does NOT allow deep navigation
|
||||
*/
|
||||
import { X, User, Music, Info } from 'lucide-svelte';
|
||||
import type { ResolvedMusician } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
musician: ResolvedMusician;
|
||||
onClose: () => void;
|
||||
onNavigateToArtist?: (artistId: number) => void;
|
||||
}
|
||||
|
||||
let { musician, onClose, onNavigateToArtist }: Props = $props();
|
||||
|
||||
// Explanatory copy based on confidence level
|
||||
const explanatoryCopy = $derived(() => {
|
||||
switch (musician.confidence) {
|
||||
case 'weak':
|
||||
return 'This musician appears in album credits but has limited catalog information available in Qobuz.';
|
||||
case 'none':
|
||||
return 'Unable to verify this musician\'s catalog. The information shown is based on album credits.';
|
||||
default:
|
||||
return 'This musician appears in album credits.';
|
||||
}
|
||||
});
|
||||
|
||||
// Show CTA only if we have a Qobuz artist ID (confidence = confirmed)
|
||||
const showArtistCta = $derived(
|
||||
musician.confidence === 'confirmed' && musician.qobuz_artist_id
|
||||
);
|
||||
|
||||
function handleNavigateToArtist() {
|
||||
if (musician.qobuz_artist_id && onNavigateToArtist) {
|
||||
onNavigateToArtist(musician.qobuz_artist_id);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="musician-modal-title"
|
||||
>
|
||||
<div class="modal">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="musician-info">
|
||||
<div class="musician-icon">
|
||||
<User size={24} />
|
||||
</div>
|
||||
<div class="musician-details">
|
||||
<h2 id="musician-modal-title">{musician.name}</h2>
|
||||
<span class="role">{musician.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} title="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-content">
|
||||
<!-- Bands & Projects -->
|
||||
{#if musician.bands.length > 0}
|
||||
<div class="section">
|
||||
<h3>Known for</h3>
|
||||
<div class="bands-list">
|
||||
{#each musician.bands as band}
|
||||
<div class="band-item">
|
||||
<Music size={14} />
|
||||
<span>{band}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Explanatory Copy -->
|
||||
<div class="section info-section">
|
||||
<div class="info-box">
|
||||
<Info size={16} />
|
||||
<p>{explanatoryCopy()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button (only for confirmed matches) -->
|
||||
{#if showArtistCta}
|
||||
<div class="section cta-section">
|
||||
<button class="cta-btn" onclick={handleNavigateToArtist}>
|
||||
View Artist Page
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4);
|
||||
animation: slideUp 200ms ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.musician-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.musician-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.musician-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.musician-details h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.bands-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.band-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.band-item :global(svg) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.info-box :global(svg) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.cta-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -9,10 +9,21 @@
|
||||
onClose: () => void;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
onPerformerSearch?: (name: string) => void;
|
||||
onMusicianClick?: (name: string, role: string) => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
}
|
||||
|
||||
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch, onLabelClick }: Props = $props();
|
||||
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch, onMusicianClick, onLabelClick }: Props = $props();
|
||||
|
||||
function handlePerformerClick(name: string, role: string) {
|
||||
if (onMusicianClick) {
|
||||
onMusicianClick(name, role);
|
||||
onClose();
|
||||
} else if (onPerformerSearch) {
|
||||
onPerformerSearch(name);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -217,10 +228,10 @@
|
||||
<span class="credit-label">{formatRole(role)}</span>
|
||||
<span class="credit-value">
|
||||
{#each names as name, i}
|
||||
{#if onPerformerSearch}
|
||||
{#if onMusicianClick || onPerformerSearch}
|
||||
<button
|
||||
class="performer-link"
|
||||
onclick={() => { onPerformerSearch(name); onClose(); }}
|
||||
onclick={() => handlePerformerClick(name, role)}
|
||||
>{name}</button>{#if i < names.length - 1}, {/if}
|
||||
{:else}
|
||||
{name}{#if i < names.length - 1}, {/if}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
onTrackGoToAlbum?: (albumId: string) => void;
|
||||
onTrackGoToArtist?: (artistId: number) => void;
|
||||
onLabelClick?: (labelId: number, labelName?: string) => void;
|
||||
onMusicianClick?: (name: string, role: string) => void;
|
||||
activeTrackId?: number | null;
|
||||
isPlaybackActive?: boolean;
|
||||
}
|
||||
@@ -107,6 +108,7 @@
|
||||
onTrackGoToAlbum,
|
||||
onTrackGoToArtist,
|
||||
onLabelClick,
|
||||
onMusicianClick,
|
||||
activeTrackId = null,
|
||||
isPlaybackActive = false
|
||||
}: Props = $props();
|
||||
@@ -1995,9 +1997,10 @@
|
||||
{@const tooltip = tooltipParts.length > 0
|
||||
? (periodStr ? `${tooltipParts.join(', ')} (${periodStr})` : tooltipParts.join(', '))
|
||||
: (periodStr || member.name)}
|
||||
{@const memberRole = member.roles[0] || 'Band Member'}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => navigateToRelatedArtist(member.name)}
|
||||
onclick={() => onMusicianClick ? onMusicianClick(member.name, memberRole) : navigateToRelatedArtist(member.name)}
|
||||
title={tooltip}
|
||||
>
|
||||
<User size={12} />
|
||||
@@ -2012,7 +2015,7 @@
|
||||
{#each groupedGroups as group}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => navigateToRelatedArtist(group.name)}
|
||||
onclick={() => onMusicianClick ? onMusicianClick(group.name, 'Band') : navigateToRelatedArtist(group.name)}
|
||||
title={group.roles.length > 0 ? group.roles.join(', ') : group.name}
|
||||
>
|
||||
<Music size={12} />
|
||||
@@ -2025,9 +2028,10 @@
|
||||
<div class="relationship-group">
|
||||
<span class="relationship-label">Collaborators</span>
|
||||
{#each groupedCollaborators as collab}
|
||||
{@const collabRole = collab.roles[0] || 'Collaborator'}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => navigateToRelatedArtist(collab.name)}
|
||||
onclick={() => onMusicianClick ? onMusicianClick(collab.name, collabRole) : navigateToRelatedArtist(collab.name)}
|
||||
title={collab.roles.length > 0 ? collab.roles.join(', ') : collab.name}
|
||||
>
|
||||
<User size={12} />
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Musician Page View
|
||||
*
|
||||
* Full page view for musicians with confidence level 2 (Contextual).
|
||||
* Shows musician info and "Appears On" albums from Qobuz.
|
||||
*
|
||||
* This page:
|
||||
* - Is rendered only for confidence >= 2
|
||||
* - Shows bands/projects from MusicBrainz (if available)
|
||||
* - Shows "Appears On" albums from Qobuz ONLY
|
||||
* - Does NOT invent discographies
|
||||
* - Does NOT show raw search results
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowLeft, User, Music, Disc } from 'lucide-svelte';
|
||||
import type { ResolvedMusician, AlbumAppearance, MusicianAppearances } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
musician: ResolvedMusician;
|
||||
onBack: () => void;
|
||||
onAlbumClick: (albumId: string) => void;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
}
|
||||
|
||||
let { musician, onBack, onAlbumClick, onArtistClick }: Props = $props();
|
||||
|
||||
// State
|
||||
let appearances = $state<AlbumAppearance[]>([]);
|
||||
let totalAppearances = $state(0);
|
||||
let isLoading = $state(true);
|
||||
let isLoadingMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
let offset = $state(0);
|
||||
|
||||
const hasMore = $derived(appearances.length < totalAppearances);
|
||||
|
||||
onMount(() => {
|
||||
loadAppearances();
|
||||
});
|
||||
|
||||
async function loadAppearances() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await invoke<MusicianAppearances>('get_musician_appearances', {
|
||||
name: musician.name,
|
||||
role: musician.role,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
appearances = result.albums;
|
||||
totalAppearances = result.total;
|
||||
offset = result.albums.length;
|
||||
} catch (err) {
|
||||
console.error('Failed to load musician appearances:', err);
|
||||
error = 'Failed to load appearances';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
|
||||
isLoadingMore = true;
|
||||
|
||||
try {
|
||||
const result = await invoke<MusicianAppearances>('get_musician_appearances', {
|
||||
name: musician.name,
|
||||
role: musician.role,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset
|
||||
});
|
||||
|
||||
appearances = [...appearances, ...result.albums];
|
||||
offset += result.albums.length;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more appearances:', err);
|
||||
} finally {
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="musician-page">
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" onclick={onBack} title="Go back">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
|
||||
<div class="musician-header">
|
||||
<div class="musician-avatar">
|
||||
<User size={32} />
|
||||
</div>
|
||||
<div class="musician-info">
|
||||
<h1>{musician.name}</h1>
|
||||
<span class="role">{musician.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Bands & Projects Section -->
|
||||
{#if musician.bands.length > 0}
|
||||
<section class="section">
|
||||
<h2 class="section-title">Bands & Projects</h2>
|
||||
<div class="bands-grid">
|
||||
{#each musician.bands as band}
|
||||
<button
|
||||
class="band-card"
|
||||
onclick={() => {
|
||||
// TODO: Search for band as artist
|
||||
console.log('Search for band:', band);
|
||||
}}
|
||||
>
|
||||
<div class="band-icon">
|
||||
<Music size={16} />
|
||||
</div>
|
||||
<span class="band-name">{band}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Appears On Section -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
Appears On
|
||||
{#if totalAppearances > 0}
|
||||
<span class="count">({totalAppearances})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<span>Loading appearances...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{:else if appearances.length === 0}
|
||||
<div class="empty-state">
|
||||
<Disc size={32} />
|
||||
<span>No album appearances found</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="albums-grid">
|
||||
{#each appearances as album}
|
||||
<button
|
||||
class="album-card"
|
||||
onclick={() => onAlbumClick(album.album_id)}
|
||||
>
|
||||
{#if album.album_artwork}
|
||||
<img
|
||||
src={album.album_artwork}
|
||||
alt={album.album_title}
|
||||
class="album-artwork"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="album-artwork placeholder">
|
||||
<Disc size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="album-info">
|
||||
<span class="album-title" title={album.album_title}>
|
||||
{album.album_title}
|
||||
</span>
|
||||
<span class="album-artist" title={album.artist_name}>
|
||||
{album.artist_name}
|
||||
</span>
|
||||
<div class="album-meta">
|
||||
{#if album.year}
|
||||
<span class="year">{album.year}</span>
|
||||
{/if}
|
||||
<span class="role-badge">{album.role_on_album}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasMore}
|
||||
<div class="load-more">
|
||||
<button
|
||||
class="load-more-btn"
|
||||
onclick={loadMore}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.musician-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-primary);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.musician-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.musician-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.musician-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.musician-info h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title .count {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Bands Grid */
|
||||
.bands-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.band-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.band-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.band-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.band-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Albums Grid */
|
||||
.albums-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.album-artwork {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.album-artwork.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.album-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.year {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Load More */
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
padding: 10px 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.load-more-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.load-more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ export type ViewType =
|
||||
| 'settings'
|
||||
| 'album'
|
||||
| 'artist'
|
||||
| 'musician'
|
||||
| 'label'
|
||||
| 'playlist'
|
||||
| 'playlist-manager'
|
||||
|
||||
@@ -324,6 +324,51 @@ export interface SongLinkResponse {
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
// ============ Musician Types ============
|
||||
|
||||
/**
|
||||
* Musician confidence level for MusicBrainz ↔ Qobuz matching
|
||||
* 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
|
||||
*/
|
||||
export type MusicianConfidence = 'confirmed' | 'contextual' | 'weak' | 'none';
|
||||
|
||||
/**
|
||||
* Resolved musician with confidence assessment
|
||||
*/
|
||||
export interface ResolvedMusician {
|
||||
name: string;
|
||||
role: string;
|
||||
mbid?: string;
|
||||
qobuz_artist_id?: number;
|
||||
confidence: MusicianConfidence;
|
||||
bands: string[];
|
||||
appears_on_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Album appearance for a musician
|
||||
*/
|
||||
export interface AlbumAppearance {
|
||||
album_id: string;
|
||||
album_title: string;
|
||||
album_artwork: string;
|
||||
artist_name: string;
|
||||
year?: string;
|
||||
role_on_album: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Musician appearances response
|
||||
*/
|
||||
export interface MusicianAppearances {
|
||||
albums: AlbumAppearance[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ============ Preferences Types ============
|
||||
|
||||
export interface FavoritesPreferences {
|
||||
|
||||
+79
-1
@@ -93,7 +93,7 @@
|
||||
// Favorites state management
|
||||
import { loadFavorites } from '$lib/stores/favoritesStore';
|
||||
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
||||
import type { FavoritesPreferences } from '$lib/types';
|
||||
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
||||
|
||||
// Navigation state management
|
||||
import {
|
||||
@@ -279,6 +279,7 @@
|
||||
import SettingsView from '$lib/components/views/SettingsView.svelte';
|
||||
import AlbumDetailView from '$lib/components/views/AlbumDetailView.svelte';
|
||||
import ArtistDetailView from '$lib/components/views/ArtistDetailView.svelte';
|
||||
import MusicianPageView from '$lib/components/views/MusicianPageView.svelte';
|
||||
import LabelView from '$lib/components/views/LabelView.svelte';
|
||||
import PlaylistDetailView from '$lib/components/views/PlaylistDetailView.svelte';
|
||||
import FavoritesView from '$lib/components/views/FavoritesView.svelte';
|
||||
@@ -293,6 +294,7 @@
|
||||
import PlaylistImportModal from '$lib/components/PlaylistImportModal.svelte';
|
||||
import TrackInfoModal from '$lib/components/TrackInfoModal.svelte';
|
||||
import AlbumCreditsModal from '$lib/components/AlbumCreditsModal.svelte';
|
||||
import MusicianModal from '$lib/components/MusicianModal.svelte';
|
||||
import CastPicker from '$lib/components/CastPicker.svelte';
|
||||
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
|
||||
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
|
||||
@@ -328,6 +330,8 @@
|
||||
let selectedAlbum = $state<AlbumDetail | null>(null);
|
||||
let selectedArtist = $state<ArtistDetail | null>(null);
|
||||
let selectedLabel = $state<{ id: number; name: string } | null>(null);
|
||||
let selectedMusician = $state<ResolvedMusician | null>(null);
|
||||
let musicianModalData = $state<ResolvedMusician | null>(null);
|
||||
let isArtistAlbumsLoading = $state(false);
|
||||
|
||||
// Artist albums for "By the same artist" section in album view
|
||||
@@ -510,6 +514,61 @@
|
||||
navigateTo('label');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle musician click from credits
|
||||
* Resolves musician and routes based on confidence level:
|
||||
* - Confirmed (3): Navigate to Qobuz Artist Page
|
||||
* - Contextual (2): Navigate to Musician Page
|
||||
* - Weak (1), None (0): Show Informational Modal
|
||||
*/
|
||||
async function handleMusicianClick(name: string, role: string) {
|
||||
showToast(`Looking up ${name}...`, 'info');
|
||||
try {
|
||||
const musician = await invoke<ResolvedMusician>('resolve_musician', { name, role });
|
||||
console.log('Resolved musician:', musician);
|
||||
|
||||
switch (musician.confidence) {
|
||||
case 'confirmed':
|
||||
// Has a Qobuz artist page - navigate there
|
||||
if (musician.qobuz_artist_id) {
|
||||
handleArtistClick(musician.qobuz_artist_id);
|
||||
} else {
|
||||
// Fallback: show modal
|
||||
musicianModalData = musician;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'contextual':
|
||||
// Show full Musician Page
|
||||
selectedMusician = musician;
|
||||
navigateTo('musician');
|
||||
break;
|
||||
|
||||
case 'weak':
|
||||
case 'none':
|
||||
default:
|
||||
// Show Informational Modal only
|
||||
musicianModalData = musician;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve musician:', err);
|
||||
showToast('Could not look up musician info', 'error');
|
||||
// Fallback: open modal with basic info
|
||||
musicianModalData = {
|
||||
name,
|
||||
role,
|
||||
confidence: 'none',
|
||||
bands: [],
|
||||
appears_on_count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function closeMusicianModal() {
|
||||
musicianModalData = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a performer by name (from track credits)
|
||||
*/
|
||||
@@ -2597,9 +2656,17 @@
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
onPlaylistClick={selectPlaylist}
|
||||
onLabelClick={handleLabelClick}
|
||||
onMusicianClick={handleMusicianClick}
|
||||
activeTrackId={currentTrack?.id ?? null}
|
||||
isPlaybackActive={isPlaying}
|
||||
/>
|
||||
{:else if activeView === 'musician' && selectedMusician}
|
||||
<MusicianPageView
|
||||
musician={selectedMusician}
|
||||
onBack={navGoBack}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
onArtistClick={handleArtistClick}
|
||||
/>
|
||||
{:else if activeView === 'label' && selectedLabel}
|
||||
<LabelView
|
||||
labelId={selectedLabel.id}
|
||||
@@ -2940,6 +3007,7 @@
|
||||
}}
|
||||
onArtistClick={handleArtistClick}
|
||||
onLabelClick={handleLabelClick}
|
||||
onMusicianClick={handleMusicianClick}
|
||||
/>
|
||||
|
||||
<!-- Album Credits Modal -->
|
||||
@@ -2960,8 +3028,18 @@
|
||||
}
|
||||
}}
|
||||
onLabelClick={handleLabelClick}
|
||||
onMusicianClick={handleMusicianClick}
|
||||
/>
|
||||
|
||||
<!-- Musician Modal (for confidence level 0-1) -->
|
||||
{#if musicianModalData}
|
||||
<MusicianModal
|
||||
musician={musicianModalData}
|
||||
onClose={closeMusicianModal}
|
||||
onNavigateToArtist={handleArtistClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Cast Picker -->
|
||||
<CastPicker
|
||||
isOpen={isCastPickerOpen}
|
||||
|
||||
Reference in new issue
Block a user