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:
1 parent
54315c2e4e
commit
9a89074b5f
4 files changed
+311
-74
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user