diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 241fd1d04..796e8f14b 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -4437,6 +4437,28 @@ pub async fn v2_reco_get_forgotten_favorites( .await } +/// Get user's top genres by play count +#[tauri::command] +pub async fn v2_reco_get_top_genres( + limit: Option, + reco_state: State<'_, RecoState>, +) -> Result, String> { + let guard = reco_state.db.lock().await; + let db = guard.as_ref().ok_or("No active session")?; + let genres = db.get_top_genre_ids(limit.unwrap_or(5))?; + Ok(genres + .into_iter() + .map(|(id, name)| TopGenre { id, name }) + .collect()) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TopGenre { + pub id: u64, + pub name: String, +} + #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct V2LibraryCacheStats { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd353ffce..631f70495 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1387,6 +1387,7 @@ pub fn run() { commands_v2::v2_reco_get_home_resolved, commands_v2::v2_get_album_suggestions, commands_v2::v2_reco_get_forgotten_favorites, + commands_v2::v2_reco_get_top_genres, commands_v2::v2_library_get_cache_stats, commands_v2::v2_library_get_stats, commands_v2::v2_library_get_albums, diff --git a/src-tauri/src/reco_store/db.rs b/src-tauri/src/reco_store/db.rs index 4df5669ad..9fa54263c 100644 --- a/src-tauri/src/reco_store/db.rs +++ b/src-tauri/src/reco_store/db.rs @@ -425,6 +425,36 @@ impl RecoStoreDb { Ok(albums) } + /// Get the user's most-played genres by event count + pub fn get_top_genre_ids(&self, limit: u32) -> Result, String> { + let mut stmt = self + .conn + .prepare( + r#" + SELECT e.genre_id, COALESCE(m.genre_name, ''), COUNT(*) AS play_count + FROM reco_events e + LEFT JOIN reco_album_meta m ON e.album_id = m.album_id + WHERE e.genre_id IS NOT NULL AND e.genre_id > 0 + GROUP BY e.genre_id + ORDER BY play_count DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare top genres query: {}", e))?; + + let rows = stmt + .query_map(params![limit], |row| { + Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("Failed to query top genres: {}", e))?; + + let mut genres = Vec::new(); + for row in rows { + genres.push(row.map_err(|e| format!("Failed to read genre row: {}", e))?); + } + Ok(genres) + } + pub fn get_favorite_track_ids(&self, limit: u32) -> Result, String> { let mut stmt = self .conn diff --git a/src/lib/components/views/ForYouTab.svelte b/src/lib/components/views/ForYouTab.svelte index be2f8cf6b..3c147b195 100644 --- a/src/lib/components/views/ForYouTab.svelte +++ b/src/lib/components/views/ForYouTab.svelte @@ -175,6 +175,11 @@ let forgottenAlbums = $state([]); let loadingForgottenAlbums = $state(false); + // Phase 3: Essentials [Genre] + let essentialsGenreName = $state(''); + let essentialsAlbums = $state([]); + let loadingEssentials = $state(false); + // Radio Stations: use recent albums as radio seeds // Take first 8 recent albums as potential radio stations const radioAlbums = $derived(recentAlbums.slice(0, 8)); @@ -187,6 +192,7 @@ loadSpotlight(); loadSimilarAlbums(); loadForgottenFavorites(); + loadEssentials(); } }); @@ -330,6 +336,52 @@ } } + interface TopGenre { + id: number; + name: string; + } + + async function loadEssentials() { + loadingEssentials = true; + + try { + // Get user's top genres + const genres = await invoke('v2_reco_get_top_genres', { limit: 3 }); + if (genres.length === 0) return; + + // Use the top genre for essentials + const topGenre = genres[0]; + essentialsGenreName = topGenre.name; + + // Fetch essential/ideal discography albums for that genre + const result = await invoke<{ items: AlbumSuggestResult[]; total: number }>('v2_get_featured_albums', { + featuredType: 'ideal-discography', + limit: 12, + offset: 0, + genreId: topGenre.id + }); + + essentialsAlbums = (result.items || []).map(album => ({ + id: album.id, + artwork: album.image?.large || album.image?.small || '', + title: album.title, + artist: album.artist?.name || '', + artistId: album.artist?.id, + genre: album.genre?.name || '', + quality: formatQuality( + album.hires, + album.maximum_bit_depth, + album.maximum_sampling_rate + ), + releaseDate: album.release_date_original + })); + } catch (err) { + console.error('Failed to load essentials:', err); + } finally { + loadingEssentials = false; + } + } + async function handleFollowArtist(artistId: number) { const idx = suggestedArtists.findIndex(a => a.id === artistId); if (idx === -1) return; @@ -472,7 +524,8 @@ topArtists.length > 0 || favoriteAlbums.length > 0 || similarAlbums.length > 0 || - forgottenAlbums.length > 0 + forgottenAlbums.length > 0 || + essentialsAlbums.length > 0 ); const anyLoading = $derived( @@ -843,6 +896,48 @@ {/if} + +{#if loadingEssentials} +
+
+
+ {#each { length: 6 } as _}
{/each} +
+
+{:else if essentialsAlbums.length > 0} + + {#snippet children()} + {#each essentialsAlbums as album} + onAlbumPlay(album.id) : undefined} + onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} + onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} + onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} + onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} + onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} + onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} + isAlbumFullyDownloaded={isAlbumDownloaded(album.id)} + onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} + onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} + {downloadStateVersion} + onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }} + /> + {/each} +
+ {/snippet} +
+{/if} + {#if loadingSuggestedArtists}