Merge branch 'feature/musician-page-modal' into pre-release

This commit is contained in:
Victor RH committed 2026-01-25 23:25:35 -06:00
commit a1dd57e7a8
14 files changed
+1656 -14

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ base64 = "0.22"
# Regex (for bundle parsing)
regex = "1"
lazy_static = "1.4"
# Time
chrono = "0.4"
+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::*;
+614
View File
@@ -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
}
}
}
+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,
}
+10 -5
View File
@@ -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}
+319
View File
@@ -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>
+14 -3
View File
@@ -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>
+1
View File
@@ -14,6 +14,7 @@ export type ViewType =
| 'settings'
| 'album'
| 'artist'
| 'musician'
| 'label'
| 'playlist'
| 'playlist-manager'
+45
View File
@@ -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
View File
@@ -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}