feat(suggestions): add interactive navigation and track info modal

- Add artist_id field to SuggestedTrack for navigation
- Make artist names clickable to navigate to artist view
- Make album names clickable to navigate to album view
- Add TrackInfo button (info icon) to view track credits
- Integrate TrackInfoModal component with suggestions
- Update PlaylistDetailView to pass navigation callbacks
This commit is contained in:
vicrodh committed 2026-01-27 16:53:56 -06:00
1 parent 54315c2e4e
commit 9a89074b5f
4 files changed
+311 -74

No files matched your search

+154 -40
View File
@@ -60,12 +60,16 @@ pub struct SuggestedTrack {
pub title: String, pub title: String,
/// Artist name /// Artist name
pub artist_name: String, pub artist_name: String,
/// Artist Qobuz ID (for navigation)
pub artist_id: Option<u64>,
/// Artist MBID (if known) /// Artist MBID (if known)
pub artist_mbid: Option<String>, pub artist_mbid: Option<String>,
/// Album title /// Album title
pub album_title: String, pub album_title: String,
/// Album ID for cover art /// Album ID for cover art
pub album_id: String, pub album_id: String,
/// Direct URL to album cover image
pub album_image_url: Option<String>,
/// Duration in seconds /// Duration in seconds
pub duration: u32, pub duration: u32,
/// Similarity score (higher = more similar to playlist) /// Similarity score (higher = more similar to playlist)
@@ -118,18 +122,18 @@ impl SuggestionsEngine {
/// Generate suggestions for a playlist /// Generate suggestions for a playlist
/// ///
/// # Arguments /// # 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) /// * `exclude_track_ids` - Track IDs to exclude (already in playlist)
/// * `include_reasons` - Whether to include reason strings (dev mode) /// * `include_reasons` - Whether to include reason strings (dev mode)
pub async fn generate_suggestions( pub async fn generate_suggestions(
&self, &self,
playlist_artist_mbids: &[String], playlist_artists: &[(String, String)], // (mbid, name)
exclude_track_ids: &HashSet<u64>, exclude_track_ids: &HashSet<u64>,
include_reasons: bool, include_reasons: bool,
) -> Result<SuggestionResult, String> { ) -> Result<SuggestionResult, String> {
use std::time::Instant; use std::time::Instant;
if playlist_artist_mbids.is_empty() { if playlist_artists.is_empty() {
log::debug!("[SuggestionsEngine] Empty playlist, returning empty"); log::debug!("[SuggestionsEngine] Empty playlist, returning empty");
return Ok(SuggestionResult { return Ok(SuggestionResult {
tracks: Vec::new(), tracks: Vec::new(),
@@ -139,19 +143,22 @@ impl SuggestionsEngine {
}); });
} }
// Extract MBIDs for vector operations
let playlist_artist_mbids: Vec<String> = playlist_artists.iter().map(|(mbid, _)| mbid.clone()).collect();
// 1. Ensure vectors exist for playlist artists (skip if configured) // 1. Ensure vectors exist for playlist artists (skip if configured)
let step1_start = Instant::now(); let step1_start = Instant::now();
if self.config.skip_vector_build { if self.config.skip_vector_build {
log::info!("[SuggestionsEngine] Step 1: SKIPPED (skip_vector_build=true), using only cached vectors"); log::info!("[SuggestionsEngine] Step 1: SKIPPED (skip_vector_build=true), using only cached vectors");
} else { } else {
log::info!("[SuggestionsEngine] Step 1: Ensuring vectors for {} artists", playlist_artist_mbids.len()); log::info!("[SuggestionsEngine] Step 1: Ensuring vectors for {} artists", playlist_artists.len());
for (i, mbid) in playlist_artist_mbids.iter().enumerate() { for (i, (mbid, name)) in playlist_artists.iter().enumerate() {
let artist_start = Instant::now(); let artist_start = Instant::now();
let _ = self let _ = self
.builder .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; .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()); log::info!("[SuggestionsEngine] Step 1 completed in {:?}", step1_start.elapsed());
} }
@@ -159,7 +166,7 @@ impl SuggestionsEngine {
// 2. Compute combined playlist vector // 2. Compute combined playlist vector
log::info!("[SuggestionsEngine] Step 2: Computing playlist vector"); log::info!("[SuggestionsEngine] Step 2: Computing playlist vector");
let step2_start = Instant::now(); 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()); log::info!("[SuggestionsEngine] Step 2 completed in {:?}, vector empty={}", step2_start.elapsed(), playlist_vector.is_empty());
if playlist_vector.is_empty() { if playlist_vector.is_empty() {
@@ -172,37 +179,68 @@ impl SuggestionsEngine {
}); });
} }
// 3. Find nearest artists (excluding playlist artists) // 3. Find related artists (using direct relationships, not vector similarity)
log::info!("[SuggestionsEngine] Step 3: Finding nearest artists"); log::info!("[SuggestionsEngine] Step 3: Finding related artists");
let step3_start = Instant::now(); let step3_start = Instant::now();
let exclude_vec: Vec<String> = playlist_artist_mbids.to_vec(); let exclude_vec: Vec<String> = playlist_artist_mbids.to_vec();
let similar_artists = { let similar_artists = {
let store = self.store.lock().await; 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 similar_artists_count = similar_artists.len();
let mut source_artists = Vec::new(); let mut source_artists = Vec::new();
let mut all_tracks = Vec::new(); let mut all_tracks = Vec::new();
// 4. Search for tracks by similar artists // 4a. First, search for tracks by playlist artists themselves (highest relevance)
log::info!("[SuggestionsEngine] Step 4: Searching tracks for similar artists"); log::info!("[SuggestionsEngine] Step 4a: Searching tracks for {} playlist artists", playlist_artists.len());
let step4_start = Instant::now(); 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() { for (i, artist) in similar_artists.iter().enumerate() {
if artist.similarity < self.config.min_similarity { if artist.similarity < self.config.min_similarity {
continue; 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 // Search Qobuz for tracks by this related artist
let search_start = Instant::now();
let tracks = self let tracks = self
.search_artist_tracks(&artist.mbid, artist.name.as_deref(), artist.similarity) .search_artist_tracks(&artist.mbid, artist.name.as_deref(), artist.similarity)
.await; .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 { for mut track in tracks {
// Skip if already in playlist // Skip if already in playlist
@@ -216,7 +254,7 @@ impl SuggestionsEngine {
&artist.mbid, &artist.mbid,
artist.name.as_deref(), artist.name.as_deref(),
artist.similarity, artist.similarity,
playlist_artist_mbids, &playlist_artist_mbids,
)); ));
} }
@@ -224,19 +262,26 @@ impl SuggestionsEngine {
} }
// Stop if we have enough tracks // Stop if we have enough tracks
if all_tracks.len() >= self.config.max_pool_size { if all_tracks.len() >= self.config.max_pool_size * 2 {
log::info!("[SuggestionsEngine] Reached pool size {} after {} artists", all_tracks.len(), i + 1); log::info!("[SuggestionsEngine] Reached extended pool size {} after {} related artists", all_tracks.len(), i + 1);
break; 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 // 5. Deduplicate by title+artist (keeps highest similarity version)
all_tracks.sort_by(|a, b| { let mut seen_titles: HashSet<String> = HashSet::new();
b.similarity_score all_tracks.retain(|track| {
.partial_cmp(&a.similarity_score) let key = format!("{}|{}", track.title.to_lowercase(), track.artist_name.to_lowercase());
.unwrap_or(std::cmp::Ordering::Equal) 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); all_tracks.truncate(self.config.max_pool_size);
Ok(SuggestionResult { Ok(SuggestionResult {
@@ -265,12 +310,23 @@ impl SuggestionsEngine {
Ok(combined.normalize()) 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( async fn search_artist_tracks(
&self, &self,
artist_mbid: &str, artist_mbid: &str,
artist_name: Option<&str>, artist_name: Option<&str>,
similarity: f32, similarity: f32,
) -> Vec<SuggestedTrack> {
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<SuggestedTrack> { ) -> Vec<SuggestedTrack> {
let search_query = match artist_name { let search_query = match artist_name {
Some(name) => name.to_string(), Some(name) => name.to_string(),
@@ -285,16 +341,34 @@ impl SuggestionsEngine {
let client = self.qobuz_client.lock().await; 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 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 .await
{ {
Ok(results) => { Ok(results) => {
let mut tracks = Vec::new(); let mut tracks = Vec::new();
let search_name_lower = search_query.to_lowercase();
for item in results.items { 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 tracks
@@ -308,26 +382,35 @@ impl SuggestionsEngine {
/// Convert a Track to a SuggestedTrack /// Convert a Track to a SuggestedTrack
fn track_to_suggested(&self, track: &Track, artist_mbid: &str, similarity: f32) -> SuggestedTrack { fn track_to_suggested(&self, track: &Track, artist_mbid: &str, similarity: f32) -> SuggestedTrack {
// Extract album info // Extract album info including image URL
let (album_title, album_id) = match &track.album { let (album_title, album_id, album_image_url) = match &track.album {
Some(album) => (album.title.clone(), album.id.clone()), Some(album) => {
None => (String::new(), String::new()), 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 // Extract artist name and ID from track performer
let track_artist = track let (track_artist, artist_id) = track
.performer .performer
.as_ref() .as_ref()
.map(|p| p.name.clone()) .map(|p| (p.name.clone(), p.id))
.unwrap_or_default(); .unwrap_or_default();
SuggestedTrack { SuggestedTrack {
track_id: track.id, track_id: track.id,
title: track.title.clone(), title: track.title.clone(),
artist_name: track_artist, artist_name: track_artist,
artist_id,
artist_mbid: Some(artist_mbid.to_string()), artist_mbid: Some(artist_mbid.to_string()),
album_title, album_title,
album_id, album_id,
album_image_url,
duration: track.duration, duration: track.duration,
similarity_score: similarity, similarity_score: similarity,
reason: None, 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 /// Extract unique artist MBIDs from playlist tracks
/// ///
/// This is a helper function that should be called from the Tauri command /// This is a helper function that should be called from the Tauri command
+150 -33
View File
@@ -10,12 +10,15 @@
dismissTrack, dismissTrack,
formatDuration formatDuration
} from '$lib/services/playlistSuggestionsService'; } from '$lib/services/playlistSuggestionsService';
import TrackInfoModal from './TrackInfoModal.svelte';
interface Props { interface Props {
playlistId: number; playlistId: number;
artists: PlaylistArtist[]; artists: PlaylistArtist[];
excludeTrackIds: number[]; excludeTrackIds: number[];
onAddTrack?: (trackId: number) => Promise<void>; onAddTrack?: (trackId: number) => Promise<void>;
onGoToAlbum?: (albumId: string) => void;
onGoToArtist?: (artistId: number) => void;
showReasons?: boolean; showReasons?: boolean;
} }
@@ -24,9 +27,37 @@
artists, artists,
excludeTrackIds, excludeTrackIds,
onAddTrack, onAddTrack,
onGoToAlbum,
onGoToArtist,
showReasons = false showReasons = false
}: Props = $props(); }: Props = $props();
// TrackInfo modal state
let trackInfoOpen = $state(false);
let trackInfoId = $state<number | null>(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 // State
let loading = $state(false); let loading = $state(false);
let loadingMore = $state(false); let loadingMore = $state(false);
@@ -40,6 +71,11 @@
const VISIBLE_COUNT = 6; const VISIBLE_COUNT = 6;
const INITIAL_POOL = 18; // 3 pages worth const INITIAL_POOL = 18; // 3 pages worth
const EXPANDED_POOL = 60; // Full pool on demand 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 // Derived state
const dismissedIds = $derived(getDismissedTrackIds(playlistId)); const dismissedIds = $derived(getDismissedTrackIds(playlistId));
@@ -52,7 +88,9 @@
); );
const hasMorePages = $derived(currentPage < totalPages - 1); const hasMorePages = $derived(currentPage < totalPages - 1);
const canLoadMore = $derived(hasLoadedOnce && pool.length < EXPANDED_POOL && !loadingMore); 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 isEmpty = $derived(filteredPool.length === 0 && !loading && hasLoadedOnce);
const showVarietyPrompt = $derived(completedCycles >= 1 && canLoadMoreVariety);
// Track the last playlist we loaded for // Track the last playlist we loaded for
let lastLoadedPlaylistId = $state<number | null>(null); let lastLoadedPlaylistId = $state<number | null>(null);
@@ -74,6 +112,8 @@
lastLoadedPlaylistId = currentPlaylistId; lastLoadedPlaylistId = currentPlaylistId;
hasLoadedOnce = false; hasLoadedOnce = false;
pool = []; pool = [];
completedCycles = 0;
lastAddedAt = 0;
void loadSuggestions(false); void loadSuggestions(false);
} }
}); });
@@ -144,15 +184,46 @@
function handleRefresh() { function handleRefresh() {
if (hasMorePages) { if (hasMorePages) {
// Rotate to next page // Go to next page
currentPage = (currentPage + 1) % totalPages; currentPage = currentPage + 1;
} else if (canLoadMore) { } else if (totalPages > 0) {
// Load more tracks // Completed a cycle - wrap to first page
void handleLoadMore(); currentPage = 0;
} else { completedCycles++;
// Reload from scratch log(`Completed cycle ${completedCycles}, pool size: ${pool.length}`);
hasLoadedOnce = false;
void loadSuggestions(false); // 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); await onAddTrack(track.track_id);
// Remove from pool locally (will be excluded on next load anyway) // Remove from pool locally (will be excluded on next load anyway)
pool = pool.filter(t => t.track_id !== track.track_id); 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) { } catch (err) {
console.error('Failed to add track:', err); console.error('Failed to add track:', err);
} }
@@ -174,13 +248,16 @@
pool = [...pool]; 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`;
}
</script> </script>
<!-- Track Info Modal -->
<TrackInfoModal
isOpen={trackInfoOpen}
trackId={trackInfoId}
onClose={handleCloseTrackInfo}
onArtistClick={onGoToArtist}
/>
{#if artists.length > 0 && !isEmpty} {#if artists.length > 0 && !isEmpty}
<div class="suggestions-section" id="suggestions-anchor"> <div class="suggestions-section" id="suggestions-anchor">
<div class="suggestions-header"> <div class="suggestions-header">
@@ -219,9 +296,9 @@
{#each visibleTracks as track (track.track_id)} {#each visibleTracks as track (track.track_id)}
<div class="suggestion-row"> <div class="suggestion-row">
<div class="album-art"> <div class="album-art">
{#if track.album_id} {#if track.album_image_url}
<img <img
src={getAlbumArt(track.album_id)} src={track.album_image_url}
alt="" alt=""
loading="lazy" loading="lazy"
onerror={(e) => { onerror={(e) => {
@@ -235,10 +312,28 @@
<div class="track-info"> <div class="track-info">
<div class="track-title">{track.title}</div> <div class="track-title">{track.title}</div>
<div class="track-meta"> <div class="track-meta">
<span class="artist">{track.artist_name}</span> {#if track.artist_id && onGoToArtist}
<button
class="meta-link artist"
onclick={(e) => { e.stopPropagation(); handleArtistClick(track.artist_id); }}
>
{track.artist_name}
</button>
{:else}
<span class="artist">{track.artist_name}</span>
{/if}
{#if track.album_title} {#if track.album_title}
<span class="separator">·</span> <span class="separator">·</span>
<span class="album">{track.album_title}</span> {#if track.album_id && onGoToAlbum}
<button
class="meta-link album"
onclick={(e) => { e.stopPropagation(); handleAlbumClick(track.album_id); }}
>
{track.album_title}
</button>
{:else}
<span class="album">{track.album_title}</span>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@@ -247,14 +342,13 @@
{formatDuration(track.duration)} {formatDuration(track.duration)}
</div> </div>
{#if showReasons && track.reason} <button
<button class="info-btn"
class="reason-btn" onclick={(e) => { e.stopPropagation(); handleShowTrackInfo(track.track_id); }}
title={track.reason} title={showReasons && track.reason ? track.reason : 'Track info'}
> >
<Info size={14} /> <Info size={14} />
</button> </button>
{/if}
<div class="actions"> <div class="actions">
<button <button
@@ -276,7 +370,7 @@
{/each} {/each}
</div> </div>
{#if filteredPool.length > VISIBLE_COUNT || canLoadMore} {#if filteredPool.length > VISIBLE_COUNT || canLoadMore || canLoadMoreVariety}
<div class="pagination-info"> <div class="pagination-info">
{#if loadingMore} {#if loadingMore}
Loading more... Loading more...
@@ -284,6 +378,8 @@
Showing {currentPage * VISIBLE_COUNT + 1}-{Math.min((currentPage + 1) * VISIBLE_COUNT, filteredPool.length)} of {filteredPool.length} Showing {currentPage * VISIBLE_COUNT + 1}-{Math.min((currentPage + 1) * VISIBLE_COUNT, filteredPool.length)} of {filteredPool.length}
{#if canLoadMore} {#if canLoadMore}
<button class="load-more-link" onclick={handleLoadMore}>Load more</button> <button class="load-more-link" onclick={handleLoadMore}>Load more</button>
{:else if showVarietyPrompt}
<button class="load-more-link" onclick={handleLoadMoreVariety}>Load more variety</button>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -467,7 +563,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.reason-btn { .info-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -476,14 +572,35 @@
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-muted); color: var(--text-muted);
cursor: help; cursor: pointer;
border-radius: 4px; border-radius: 4px;
opacity: 0.5; opacity: 0;
transition: opacity 150ms ease; transition: opacity 150ms ease, background-color 150ms ease;
} }
.suggestion-row:hover .reason-btn { .suggestion-row:hover .info-btn {
opacity: 1; 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 { .actions {
@@ -1682,6 +1682,8 @@
artists={playlistArtists} artists={playlistArtists}
excludeTrackIds={excludeTrackIds} excludeTrackIds={excludeTrackIds}
onAddTrack={handleAddSuggestedTrack} onAddTrack={handleAddSuggestedTrack}
onGoToAlbum={onTrackGoToAlbum}
onGoToArtist={onTrackGoToArtist}
showReasons={false} showReasons={false}
/> />
{/if} {/if}
@@ -23,9 +23,13 @@ export interface SuggestedTrack {
track_id: number; track_id: number;
title: string; title: string;
artist_name: string; artist_name: string;
/** Qobuz artist ID for navigation */
artist_id?: number;
artist_mbid?: string; artist_mbid?: string;
album_title: string; album_title: string;
album_id: string; album_id: string;
/** Direct URL to album cover image */
album_image_url?: string;
duration: number; duration: number;
similarity_score: number; similarity_score: number;
reason?: string; reason?: string;
@@ -158,7 +162,7 @@ export async function cleanupVectorStore(maxAgeDays?: number): Promise<number> {
// ============ Helpers ============ // ============ Helpers ============
/** Default number of top artists to use for suggestions */ /** 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) * Extract top artists from playlist tracks, sorted by frequency (track count)