feat: add Essentials [Genre] section to For You tab
Add v2_reco_get_top_genres command that queries the user's most-played genres from reco_events. Frontend loads top genre and fetches ideal-discography featured albums filtered by that genre.
This commit is contained in:
4 files changed
+149
-1
No files matched your search
@@ -4437,6 +4437,28 @@ pub async fn v2_reco_get_forgotten_favorites(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user's top genres by play count
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn v2_reco_get_top_genres(
|
||||||
|
limit: Option<u32>,
|
||||||
|
reco_state: State<'_, RecoState>,
|
||||||
|
) -> Result<Vec<TopGenre>, 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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct V2LibraryCacheStats {
|
pub struct V2LibraryCacheStats {
|
||||||
|
|||||||
@@ -1387,6 +1387,7 @@ pub fn run() {
|
|||||||
commands_v2::v2_reco_get_home_resolved,
|
commands_v2::v2_reco_get_home_resolved,
|
||||||
commands_v2::v2_get_album_suggestions,
|
commands_v2::v2_get_album_suggestions,
|
||||||
commands_v2::v2_reco_get_forgotten_favorites,
|
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_cache_stats,
|
||||||
commands_v2::v2_library_get_stats,
|
commands_v2::v2_library_get_stats,
|
||||||
commands_v2::v2_library_get_albums,
|
commands_v2::v2_library_get_albums,
|
||||||
|
|||||||
@@ -425,6 +425,36 @@ impl RecoStoreDb {
|
|||||||
Ok(albums)
|
Ok(albums)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the user's most-played genres by event count
|
||||||
|
pub fn get_top_genre_ids(&self, limit: u32) -> Result<Vec<(u64, String)>, 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<Vec<u64>, String> {
|
pub fn get_favorite_track_ids(&self, limit: u32) -> Result<Vec<u64>, String> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
|
|||||||
@@ -175,6 +175,11 @@
|
|||||||
let forgottenAlbums = $state<AlbumCardData[]>([]);
|
let forgottenAlbums = $state<AlbumCardData[]>([]);
|
||||||
let loadingForgottenAlbums = $state(false);
|
let loadingForgottenAlbums = $state(false);
|
||||||
|
|
||||||
|
// Phase 3: Essentials [Genre]
|
||||||
|
let essentialsGenreName = $state('');
|
||||||
|
let essentialsAlbums = $state<AlbumCardData[]>([]);
|
||||||
|
let loadingEssentials = $state(false);
|
||||||
|
|
||||||
// Radio Stations: use recent albums as radio seeds
|
// Radio Stations: use recent albums as radio seeds
|
||||||
// Take first 8 recent albums as potential radio stations
|
// Take first 8 recent albums as potential radio stations
|
||||||
const radioAlbums = $derived(recentAlbums.slice(0, 8));
|
const radioAlbums = $derived(recentAlbums.slice(0, 8));
|
||||||
@@ -187,6 +192,7 @@
|
|||||||
loadSpotlight();
|
loadSpotlight();
|
||||||
loadSimilarAlbums();
|
loadSimilarAlbums();
|
||||||
loadForgottenFavorites();
|
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<TopGenre[]>('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) {
|
async function handleFollowArtist(artistId: number) {
|
||||||
const idx = suggestedArtists.findIndex(a => a.id === artistId);
|
const idx = suggestedArtists.findIndex(a => a.id === artistId);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@@ -472,7 +524,8 @@
|
|||||||
topArtists.length > 0 ||
|
topArtists.length > 0 ||
|
||||||
favoriteAlbums.length > 0 ||
|
favoriteAlbums.length > 0 ||
|
||||||
similarAlbums.length > 0 ||
|
similarAlbums.length > 0 ||
|
||||||
forgottenAlbums.length > 0
|
forgottenAlbums.length > 0 ||
|
||||||
|
essentialsAlbums.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const anyLoading = $derived(
|
const anyLoading = $derived(
|
||||||
@@ -843,6 +896,48 @@
|
|||||||
</HorizontalScrollRow>
|
</HorizontalScrollRow>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Essentials [Genre] -->
|
||||||
|
{#if loadingEssentials}
|
||||||
|
<div class="skeleton-section">
|
||||||
|
<div class="skeleton-title"></div>
|
||||||
|
<div class="skeleton-row">
|
||||||
|
{#each { length: 6 } as _}<div class="skeleton-card"></div>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if essentialsAlbums.length > 0}
|
||||||
|
<HorizontalScrollRow title={$t('home.essentials', { values: { genre: essentialsGenreName } })}>
|
||||||
|
{#snippet children()}
|
||||||
|
{#each essentialsAlbums as album}
|
||||||
|
<AlbumCard
|
||||||
|
albumId={album.id}
|
||||||
|
artwork={album.artwork}
|
||||||
|
title={album.title}
|
||||||
|
artist={album.artist}
|
||||||
|
artistId={album.artistId}
|
||||||
|
onArtistClick={onArtistClick}
|
||||||
|
genre={album.genre}
|
||||||
|
releaseDate={album.releaseDate}
|
||||||
|
size="large"
|
||||||
|
quality={album.quality}
|
||||||
|
onPlay={onAlbumPlay ? () => 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}
|
||||||
|
<div class="spacer"></div>
|
||||||
|
{/snippet}
|
||||||
|
</HorizontalScrollRow>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Artists to Follow -->
|
<!-- Artists to Follow -->
|
||||||
{#if loadingSuggestedArtists}
|
{#if loadingSuggestedArtists}
|
||||||
<div class="skeleton-section">
|
<div class="skeleton-section">
|
||||||
|
|||||||
Reference in new issue
Block a user