diff --git a/src-tauri/src/artist_vectors/suggestions.rs b/src-tauri/src/artist_vectors/suggestions.rs index 8fbec2125..7b9b181e7 100644 --- a/src-tauri/src/artist_vectors/suggestions.rs +++ b/src-tauri/src/artist_vectors/suggestions.rs @@ -60,12 +60,16 @@ pub struct SuggestedTrack { pub title: String, /// Artist name pub artist_name: String, + /// Artist Qobuz ID (for navigation) + pub artist_id: Option, /// Artist MBID (if known) pub artist_mbid: Option, /// Album title pub album_title: String, /// Album ID for cover art pub album_id: String, + /// Direct URL to album cover image + pub album_image_url: Option, /// Duration in seconds pub duration: u32, /// Similarity score (higher = more similar to playlist) @@ -118,18 +122,18 @@ impl SuggestionsEngine { /// Generate suggestions for a playlist /// /// # Arguments - /// * `playlist_artist_mbids` - MBIDs of artists in the playlist + /// * `playlist_artists` - Artist info (MBID, name) from the playlist /// * `exclude_track_ids` - Track IDs to exclude (already in playlist) /// * `include_reasons` - Whether to include reason strings (dev mode) pub async fn generate_suggestions( &self, - playlist_artist_mbids: &[String], + playlist_artists: &[(String, String)], // (mbid, name) exclude_track_ids: &HashSet, include_reasons: bool, ) -> Result { use std::time::Instant; - if playlist_artist_mbids.is_empty() { + if playlist_artists.is_empty() { log::debug!("[SuggestionsEngine] Empty playlist, returning empty"); return Ok(SuggestionResult { tracks: Vec::new(), @@ -139,19 +143,22 @@ impl SuggestionsEngine { }); } + // Extract MBIDs for vector operations + let playlist_artist_mbids: Vec = playlist_artists.iter().map(|(mbid, _)| mbid.clone()).collect(); + // 1. Ensure vectors exist for playlist artists (skip if configured) let step1_start = Instant::now(); if self.config.skip_vector_build { log::info!("[SuggestionsEngine] Step 1: SKIPPED (skip_vector_build=true), using only cached vectors"); } else { - log::info!("[SuggestionsEngine] Step 1: Ensuring vectors for {} artists", playlist_artist_mbids.len()); - for (i, mbid) in playlist_artist_mbids.iter().enumerate() { + log::info!("[SuggestionsEngine] Step 1: Ensuring vectors for {} artists", playlist_artists.len()); + for (i, (mbid, name)) in playlist_artists.iter().enumerate() { let artist_start = Instant::now(); let _ = self .builder - .ensure_vector(mbid, None, None, self.config.vector_max_age_days) + .ensure_vector(mbid, Some(name), None, self.config.vector_max_age_days) .await; - log::debug!("[SuggestionsEngine] ensure_vector {}/{} took {:?}", i + 1, playlist_artist_mbids.len(), artist_start.elapsed()); + log::debug!("[SuggestionsEngine] ensure_vector {}/{} took {:?}", i + 1, playlist_artists.len(), artist_start.elapsed()); } log::info!("[SuggestionsEngine] Step 1 completed in {:?}", step1_start.elapsed()); } @@ -159,7 +166,7 @@ impl SuggestionsEngine { // 2. Compute combined playlist vector log::info!("[SuggestionsEngine] Step 2: Computing playlist vector"); let step2_start = Instant::now(); - let playlist_vector = self.compute_playlist_vector(playlist_artist_mbids).await?; + let playlist_vector = self.compute_playlist_vector(&playlist_artist_mbids).await?; log::info!("[SuggestionsEngine] Step 2 completed in {:?}, vector empty={}", step2_start.elapsed(), playlist_vector.is_empty()); if playlist_vector.is_empty() { @@ -172,37 +179,68 @@ impl SuggestionsEngine { }); } - // 3. Find nearest artists (excluding playlist artists) - log::info!("[SuggestionsEngine] Step 3: Finding nearest artists"); + // 3. Find related artists (using direct relationships, not vector similarity) + log::info!("[SuggestionsEngine] Step 3: Finding related artists"); let step3_start = Instant::now(); let exclude_vec: Vec = playlist_artist_mbids.to_vec(); let similar_artists = { let store = self.store.lock().await; - store.find_nearest(&playlist_vector, self.config.max_artists, &exclude_vec)? + // Use direct relationship lookup instead of vector similarity + // This finds members, collaborators, etc. from the MusicBrainz data + store.get_all_related_artists(&playlist_artist_mbids, &exclude_vec, self.config.max_artists)? }; - log::info!("[SuggestionsEngine] Step 3 completed in {:?}, found {} similar artists", step3_start.elapsed(), similar_artists.len()); + log::info!("[SuggestionsEngine] Step 3 completed in {:?}, found {} related artists", step3_start.elapsed(), similar_artists.len()); let similar_artists_count = similar_artists.len(); let mut source_artists = Vec::new(); let mut all_tracks = Vec::new(); - // 4. Search for tracks by similar artists - log::info!("[SuggestionsEngine] Step 4: Searching tracks for similar artists"); - let step4_start = Instant::now(); + // 4a. First, search for tracks by playlist artists themselves (highest relevance) + log::info!("[SuggestionsEngine] Step 4a: Searching tracks for {} playlist artists", playlist_artists.len()); + let step4a_start = Instant::now(); + + for (mbid, name) in playlist_artists { + source_artists.push(name.clone()); + + // Search Qobuz for tracks by this playlist artist (similarity = 1.0) + // Fetch 1.5x more tracks for playlist artists (higher priority) + let playlist_artist_limit = (self.config.tracks_per_artist as f32 * 1.5).ceil() as usize; + let tracks = self.search_artist_tracks_with_limit(mbid, Some(name), 1.0, playlist_artist_limit).await; + + for mut track in tracks { + // Skip if already in playlist + if exclude_track_ids.contains(&track.track_id) { + continue; + } + + if include_reasons { + track.reason = Some(format!("More from {}", name)); + } + + all_tracks.push(track); + } + } + log::info!("[SuggestionsEngine] Step 4a completed in {:?}, got {} tracks from playlist artists", step4a_start.elapsed(), all_tracks.len()); + + // 4b. Then search for tracks by related/similar artists + log::info!("[SuggestionsEngine] Step 4b: Searching tracks for {} related artists", similar_artists.len()); + let step4b_start = Instant::now(); + for (i, artist) in similar_artists.iter().enumerate() { if artist.similarity < self.config.min_similarity { continue; } - source_artists.push(artist.name.clone().unwrap_or_else(|| artist.mbid.clone())); + if let Some(name) = &artist.name { + if !source_artists.contains(name) { + source_artists.push(name.clone()); + } + } - // Search Qobuz for tracks by this artist - let search_start = Instant::now(); + // Search Qobuz for tracks by this related artist let tracks = self .search_artist_tracks(&artist.mbid, artist.name.as_deref(), artist.similarity) .await; - log::debug!("[SuggestionsEngine] search_artist_tracks {}/{} took {:?}, got {} tracks", - i + 1, similar_artists.len(), search_start.elapsed(), tracks.len()); for mut track in tracks { // Skip if already in playlist @@ -216,7 +254,7 @@ impl SuggestionsEngine { &artist.mbid, artist.name.as_deref(), artist.similarity, - playlist_artist_mbids, + &playlist_artist_mbids, )); } @@ -224,19 +262,26 @@ impl SuggestionsEngine { } // Stop if we have enough tracks - if all_tracks.len() >= self.config.max_pool_size { - log::info!("[SuggestionsEngine] Reached pool size {} after {} artists", all_tracks.len(), i + 1); + if all_tracks.len() >= self.config.max_pool_size * 2 { + log::info!("[SuggestionsEngine] Reached extended pool size {} after {} related artists", all_tracks.len(), i + 1); break; } } - log::info!("[SuggestionsEngine] Step 4 completed in {:?}, got {} tracks", step4_start.elapsed(), all_tracks.len()); + log::info!("[SuggestionsEngine] Step 4b completed in {:?}, got {} total tracks", step4b_start.elapsed(), all_tracks.len()); - // 5. Sort by similarity score and limit pool size - all_tracks.sort_by(|a, b| { - b.similarity_score - .partial_cmp(&a.similarity_score) - .unwrap_or(std::cmp::Ordering::Equal) + // 5. Deduplicate by title+artist (keeps highest similarity version) + let mut seen_titles: HashSet = HashSet::new(); + all_tracks.retain(|track| { + let key = format!("{}|{}", track.title.to_lowercase(), track.artist_name.to_lowercase()); + seen_titles.insert(key) }); + + // 6. Shuffle tracks for variety (so same artist doesn't dominate) + use rand::seq::SliceRandom; + let mut rng = rand::thread_rng(); + all_tracks.shuffle(&mut rng); + + // 7. Limit pool size all_tracks.truncate(self.config.max_pool_size); Ok(SuggestionResult { @@ -265,12 +310,23 @@ impl SuggestionsEngine { Ok(combined.normalize()) } - /// Search Qobuz for tracks by an artist + /// Search Qobuz for tracks by an artist (uses default tracks_per_artist limit) async fn search_artist_tracks( &self, artist_mbid: &str, artist_name: Option<&str>, similarity: f32, + ) -> Vec { + self.search_artist_tracks_with_limit(artist_mbid, artist_name, similarity, self.config.tracks_per_artist).await + } + + /// Search Qobuz for tracks by an artist with custom limit + async fn search_artist_tracks_with_limit( + &self, + artist_mbid: &str, + artist_name: Option<&str>, + similarity: f32, + limit: usize, ) -> Vec { let search_query = match artist_name { Some(name) => name.to_string(), @@ -285,16 +341,34 @@ impl SuggestionsEngine { let client = self.qobuz_client.lock().await; - // Search for tracks by artist name + // Search for tracks by artist name (fetch 3x limit to have room for filtering) match client - .search_tracks(&search_query, self.config.tracks_per_artist as u32, 0, None) + .search_tracks(&search_query, (limit * 3) as u32, 0, None) .await { Ok(results) => { let mut tracks = Vec::new(); + let search_name_lower = search_query.to_lowercase(); for item in results.items { - tracks.push(self.track_to_suggested(&item, artist_mbid, similarity)); + // Verify the track's performer matches the artist we searched for + let performer_name = item + .performer + .as_ref() + .map(|p| p.name.to_lowercase()) + .unwrap_or_default(); + + // Check if performer name matches (fuzzy: contains or is contained) + let is_match = performer_name.contains(&search_name_lower) + || search_name_lower.contains(&performer_name) + || names_similar(&performer_name, &search_name_lower); + + if is_match { + tracks.push(self.track_to_suggested(&item, artist_mbid, similarity)); + if tracks.len() >= limit { + break; + } + } } tracks @@ -308,26 +382,35 @@ impl SuggestionsEngine { /// Convert a Track to a SuggestedTrack fn track_to_suggested(&self, track: &Track, artist_mbid: &str, similarity: f32) -> SuggestedTrack { - // Extract album info - let (album_title, album_id) = match &track.album { - Some(album) => (album.title.clone(), album.id.clone()), - None => (String::new(), String::new()), + // Extract album info including image URL + let (album_title, album_id, album_image_url) = match &track.album { + Some(album) => { + let image_url = album.image.thumbnail + .as_ref() + .or(album.image.small.as_ref()) + .or(album.image.large.as_ref()) + .cloned(); + (album.title.clone(), album.id.clone(), image_url) + } + None => (String::new(), String::new(), None), }; - // Extract artist name from track performer - let track_artist = track + // Extract artist name and ID from track performer + let (track_artist, artist_id) = track .performer .as_ref() - .map(|p| p.name.clone()) + .map(|p| (p.name.clone(), p.id)) .unwrap_or_default(); SuggestedTrack { track_id: track.id, title: track.title.clone(), artist_name: track_artist, + artist_id, artist_mbid: Some(artist_mbid.to_string()), album_title, album_id, + album_image_url, duration: track.duration, similarity_score: similarity, reason: None, @@ -349,6 +432,37 @@ impl SuggestionsEngine { } } +/// Check if two artist names are similar enough to be considered a match +/// +/// Handles cases like "George Harrison" vs "Harrison, George" or slight variations +fn names_similar(name1: &str, name2: &str) -> bool { + // Exact match + if name1 == name2 { + return true; + } + + // Split into words and check overlap + let words1: HashSet<&str> = name1.split_whitespace().collect(); + let words2: HashSet<&str> = name2.split_whitespace().collect(); + + if words1.is_empty() || words2.is_empty() { + return false; + } + + // Count matching words + let matches = words1.intersection(&words2).count(); + let min_words = words1.len().min(words2.len()); + + // If most words match, consider it a match + // For 2-word names (like "George Harrison"), need both words + // For longer names, allow some flexibility + if min_words <= 2 { + matches >= min_words + } else { + matches as f32 / min_words as f32 >= 0.7 + } +} + /// Extract unique artist MBIDs from playlist tracks /// /// This is a helper function that should be called from the Tauri command diff --git a/src/lib/components/PlaylistSuggestions.svelte b/src/lib/components/PlaylistSuggestions.svelte index bb232d376..c89902cb0 100644 --- a/src/lib/components/PlaylistSuggestions.svelte +++ b/src/lib/components/PlaylistSuggestions.svelte @@ -10,12 +10,15 @@ dismissTrack, formatDuration } from '$lib/services/playlistSuggestionsService'; + import TrackInfoModal from './TrackInfoModal.svelte'; interface Props { playlistId: number; artists: PlaylistArtist[]; excludeTrackIds: number[]; onAddTrack?: (trackId: number) => Promise; + onGoToAlbum?: (albumId: string) => void; + onGoToArtist?: (artistId: number) => void; showReasons?: boolean; } @@ -24,9 +27,37 @@ artists, excludeTrackIds, onAddTrack, + onGoToAlbum, + onGoToArtist, showReasons = false }: Props = $props(); + // TrackInfo modal state + let trackInfoOpen = $state(false); + let trackInfoId = $state(null); + + function handleShowTrackInfo(trackId: number) { + trackInfoId = trackId; + trackInfoOpen = true; + } + + function handleCloseTrackInfo() { + trackInfoOpen = false; + trackInfoId = null; + } + + function handleArtistClick(artistId: number | undefined) { + if (artistId && onGoToArtist) { + onGoToArtist(artistId); + } + } + + function handleAlbumClick(albumId: string | undefined) { + if (albumId && onGoToAlbum) { + onGoToAlbum(albumId); + } + } + // State let loading = $state(false); let loadingMore = $state(false); @@ -40,6 +71,11 @@ const VISIBLE_COUNT = 6; const INITIAL_POOL = 18; // 3 pages worth const EXPANDED_POOL = 60; // Full pool on demand + const MAX_POOL = 120; // Maximum pool for "load more variety" + + // Track user interaction to detect when they want more variety + let completedCycles = $state(0); // How many times user cycled through all pages + let lastAddedAt = $state(0); // Track count when last track was added // Derived state const dismissedIds = $derived(getDismissedTrackIds(playlistId)); @@ -52,7 +88,9 @@ ); const hasMorePages = $derived(currentPage < totalPages - 1); const canLoadMore = $derived(hasLoadedOnce && pool.length < EXPANDED_POOL && !loadingMore); + const canLoadMoreVariety = $derived(hasLoadedOnce && pool.length >= EXPANDED_POOL && pool.length < MAX_POOL && !loadingMore); const isEmpty = $derived(filteredPool.length === 0 && !loading && hasLoadedOnce); + const showVarietyPrompt = $derived(completedCycles >= 1 && canLoadMoreVariety); // Track the last playlist we loaded for let lastLoadedPlaylistId = $state(null); @@ -74,6 +112,8 @@ lastLoadedPlaylistId = currentPlaylistId; hasLoadedOnce = false; pool = []; + completedCycles = 0; + lastAddedAt = 0; void loadSuggestions(false); } }); @@ -144,15 +184,46 @@ function handleRefresh() { if (hasMorePages) { - // Rotate to next page - currentPage = (currentPage + 1) % totalPages; - } else if (canLoadMore) { - // Load more tracks - void handleLoadMore(); - } else { - // Reload from scratch - hasLoadedOnce = false; - void loadSuggestions(false); + // Go to next page + currentPage = currentPage + 1; + } else if (totalPages > 0) { + // Completed a cycle - wrap to first page + currentPage = 0; + completedCycles++; + log(`Completed cycle ${completedCycles}, pool size: ${pool.length}`); + + // If we can load more, do it automatically after first cycle + if (completedCycles === 1 && canLoadMore) { + void handleLoadMore(); + } + } + } + + async function handleLoadMoreVariety() { + if (loadingMore || pool.length >= MAX_POOL) return; + + loadingMore = true; + log('Loading more variety...'); + + try { + const moreResult = await getPlaylistSuggestionsV2( + artists, + excludeTrackIds, + showReasons, + { max_pool_size: MAX_POOL } + ); + + // Merge new tracks, avoiding duplicates + const existingIds = new Set(pool.map(t => t.track_id)); + const newTracks = moreResult.tracks.filter(t => !existingIds.has(t.track_id)); + pool = [...pool, ...newTracks]; + result = moreResult; + completedCycles = 0; // Reset cycle counter + log(`Added ${newTracks.length} new tracks, total pool: ${pool.length}`); + } catch (err) { + console.error('Failed to load more variety:', err); + } finally { + loadingMore = false; } } @@ -163,6 +234,9 @@ await onAddTrack(track.track_id); // Remove from pool locally (will be excluded on next load anyway) pool = pool.filter(t => t.track_id !== track.track_id); + // Reset cycle counter - user found something they liked + completedCycles = 0; + lastAddedAt = pool.length; } catch (err) { console.error('Failed to add track:', err); } @@ -174,13 +248,16 @@ pool = [...pool]; } - function getAlbumArt(albumId: string): string { - if (!albumId) return ''; - // Qobuz album art URL pattern - return `https://static.qobuz.com/images/covers/${albumId}_50.jpg`; - } + + + {#if artists.length > 0 && !isEmpty}
@@ -219,9 +296,9 @@ {#each visibleTracks as track (track.track_id)}
- {#if track.album_id} + {#if track.album_image_url} { @@ -235,10 +312,28 @@
{track.title}
- {track.artist_name} + {#if track.artist_id && onGoToArtist} + + {:else} + {track.artist_name} + {/if} {#if track.album_title} ยท - {track.album_title} + {#if track.album_id && onGoToAlbum} + + {:else} + {track.album_title} + {/if} {/if}
@@ -247,14 +342,13 @@ {formatDuration(track.duration)}
- {#if showReasons && track.reason} - - {/if} +
+ {:else if showVarietyPrompt} + {/if} {/if}
@@ -467,7 +563,7 @@ flex-shrink: 0; } - .reason-btn { + .info-btn { display: flex; align-items: center; justify-content: center; @@ -476,14 +572,35 @@ background: transparent; border: none; color: var(--text-muted); - cursor: help; + cursor: pointer; border-radius: 4px; - opacity: 0.5; - transition: opacity 150ms ease; + opacity: 0; + transition: opacity 150ms ease, background-color 150ms ease; } - .suggestion-row:hover .reason-btn { - opacity: 1; + .suggestion-row:hover .info-btn { + opacity: 0.6; + } + + .info-btn:hover { + opacity: 1 !important; + background: var(--bg-tertiary); + color: var(--text-primary); + } + + .meta-link { + background: none; + border: none; + padding: 0; + font-size: inherit; + color: inherit; + cursor: pointer; + transition: color 150ms ease; + } + + .meta-link:hover { + color: var(--accent-primary); + text-decoration: underline; } .actions { diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index 7cff4e4b1..0fd063b2b 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -1682,6 +1682,8 @@ artists={playlistArtists} excludeTrackIds={excludeTrackIds} onAddTrack={handleAddSuggestedTrack} + onGoToAlbum={onTrackGoToAlbum} + onGoToArtist={onTrackGoToArtist} showReasons={false} /> {/if} diff --git a/src/lib/services/playlistSuggestionsService.ts b/src/lib/services/playlistSuggestionsService.ts index 159ab821e..dadf99415 100644 --- a/src/lib/services/playlistSuggestionsService.ts +++ b/src/lib/services/playlistSuggestionsService.ts @@ -23,9 +23,13 @@ export interface SuggestedTrack { track_id: number; title: string; artist_name: string; + /** Qobuz artist ID for navigation */ + artist_id?: number; artist_mbid?: string; album_title: string; album_id: string; + /** Direct URL to album cover image */ + album_image_url?: string; duration: number; similarity_score: number; reason?: string; @@ -158,7 +162,7 @@ export async function cleanupVectorStore(maxAgeDays?: number): Promise { // ============ Helpers ============ /** Default number of top artists to use for suggestions */ -const DEFAULT_TOP_ARTISTS = 5; +const DEFAULT_TOP_ARTISTS = 30; /** * Extract top artists from playlist tracks, sorted by frequency (track count)