feat: add Qobuz featured albums (New Releases, Press Awards)

- Add album/getFeatured endpoint to API
- Create get_featured_albums Tauri command
- Add New Releases and Press Awards sections to HomeView
- Featured albums load first for faster perceived performance
This commit is contained in:
vicrodh committed 2026-01-11 00:25:57 -06:00
1 parent bb996a4002
commit 6f8dc41a98
5 files changed
+121 -2

No files matched your search

+24
View File
@@ -246,6 +246,30 @@ impl QobuzClient {
Ok(serde_json::from_value(response)?)
}
/// Get featured albums by type (new-releases, press-awards, most-streamed)
pub async fn get_featured_albums(&self, featured_type: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::ALBUM_GET_FEATURED);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[
("type", featured_type),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
])
.send()
.await?
.json()
.await?;
let albums = response
.get("albums")
.ok_or_else(|| ApiError::ApiResponse("No albums in response".to_string()))?;
Ok(serde_json::from_value(albums.clone())?)
}
/// Get track by ID
pub async fn get_track(&self, track_id: u64) -> Result<Track> {
let url = endpoints::build_url(paths::TRACK_GET);
+1
View File
@@ -15,6 +15,7 @@ pub mod paths {
// Album
pub const ALBUM_GET: &str = "/album/get";
pub const ALBUM_SEARCH: &str = "/album/search";
pub const ALBUM_GET_FEATURED: &str = "/album/getFeatured";
// Artist
pub const ARTIST_GET: &str = "/artist/get";
+15
View File
@@ -53,6 +53,21 @@ pub async fn get_album(album_id: String, state: State<'_, AppState>) -> Result<A
client.get_album(&album_id).await.map_err(|e| e.to_string())
}
/// Get featured albums by type (new-releases, press-awards)
#[tauri::command]
pub async fn get_featured_albums(
featured_type: String,
limit: Option<u32>,
offset: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Album>, String> {
let client = state.client.lock().await;
client
.get_featured_albums(&featured_type, limit.unwrap_or(12), offset.unwrap_or(0))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_track(track_id: u64, state: State<'_, AppState>) -> Result<Track, String> {
let client = state.client.lock().await;
+1
View File
@@ -191,6 +191,7 @@ pub fn run() {
commands::search_tracks,
commands::search_artists,
commands::get_album,
commands::get_featured_albums,
commands::get_track,
commands::get_artist,
// Playback commands
+80 -2
View File
@@ -49,17 +49,25 @@
continueTracks: 8,
topArtists: 10,
favoriteAlbums: 12,
favoriteTracks: 10
favoriteTracks: 10,
featuredAlbums: 12
};
// Loading states for progressive render
let isInitializing = $state(true);
let error = $state<string | null>(null);
let loadingNewReleases = $state(true);
let loadingPressAwards = $state(true);
let loadingRecentAlbums = $state(true);
let loadingContinueTracks = $state(true);
let loadingTopArtists = $state(true);
let loadingFavoriteAlbums = $state(true);
// Featured albums (from Qobuz editorial)
let newReleases = $state<AlbumCardData[]>([]);
let pressAwards = $state<AlbumCardData[]>([]);
// User-specific content
let recentAlbums = $state<AlbumCardData[]>([]);
let continueTracks = $state<DisplayTrack[]>([]);
let topArtists = $state<ArtistCardData[]>([]);
@@ -68,7 +76,9 @@
let failedArtistImages = $state<Set<number>>(new Set());
const hasContent = $derived(
recentAlbums.length > 0
newReleases.length > 0
|| pressAwards.length > 0
|| recentAlbums.length > 0
|| continueTracks.length > 0
|| topArtists.length > 0
|| favoriteAlbums.length > 0
@@ -127,6 +137,24 @@
return artists;
}
interface FeaturedAlbumsResponse {
items: QobuzAlbum[];
total: number;
}
async function fetchFeaturedAlbums(featuredType: string, limit: number): Promise<AlbumCardData[]> {
try {
const response = await invoke<FeaturedAlbumsResponse>('get_featured_albums', {
featuredType,
limit
});
return response.items.map(toAlbumCard);
} catch (err) {
console.error(`Failed to fetch ${featuredType}:`, err);
return [];
}
}
function toAlbumCard(album: QobuzAlbum): AlbumCardData {
return {
id: album.id,
@@ -184,11 +212,27 @@
async function loadHome() {
isInitializing = true;
error = null;
loadingNewReleases = true;
loadingPressAwards = true;
loadingRecentAlbums = true;
loadingContinueTracks = true;
loadingTopArtists = true;
loadingFavoriteAlbums = true;
// Start loading featured albums immediately (no seeds needed)
// These show first since they're fast API calls
fetchFeaturedAlbums('new-releases', LIMITS.featuredAlbums).then(albums => {
newReleases = albums;
loadingNewReleases = false;
// Hide initializing as soon as we have some content
if (albums.length > 0) isInitializing = false;
});
fetchFeaturedAlbums('press-awards', LIMITS.featuredAlbums).then(albums => {
pressAwards = albums;
loadingPressAwards = false;
});
try {
// Fast query for seeds (uses simple DB queries, falls back if no ML scores)
const seeds = await invoke<HomeSeeds>('reco_get_home', {
@@ -273,6 +317,40 @@
<p>{error}</p>
</div>
{:else if hasContent}
{#if newReleases.length > 0}
<HorizontalScrollRow title="New Releases">
{#snippet children()}
{#each newReleases as album}
<AlbumCard
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
<div class="spacer"></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{#if pressAwards.length > 0}
<HorizontalScrollRow title="Press Awards">
{#snippet children()}
{#each pressAwards as album}
<AlbumCard
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
<div class="spacer"></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{#if recentAlbums.length > 0}
<HorizontalScrollRow title="Recently Played">
{#snippet children()}