feat(discover): add Release Watch section to Home and For You

The Qobuz mobile client (v9.7) surfaces a 'Release Watch' /
'Radar de Novedades' feed in its Discover screens — new releases
from the artists, labels and award series the user follows. We
didn't have parity for it. This adds it as a module in both
Discover tabs.

Backend:
- qbz-qobuz: new ALBUMS_RELEASE_WATCH endpoint constant
  (/albums/releaseWatch, plural as in mobile) and signed_get_auth
  client method returning a SearchResultsPage<Album>. Signing
  method is 'albumsreleaseWatch' per the Qobuz secret protocol.
- qbz-core: passthrough get_release_watch on QbzCore.
- src-tauri: core_bridge wrapper, new v2_get_release_watch Tauri
  command registered in lib.rs. Filters through the artist
  blacklist like other album endpoints.

Frontend:
- HomeView: new renderable section 'releaseWatch' in the home tab
  (default visible, placed right after New Releases). Parallel
  fetch alongside the existing discover_index call; mapped via
  the existing toAlbumCard helper.
- ForYouTab: section appears ahead of 'Essentials' since followed
  artists/labels are typically more personal.
- homeSettingsStore: new HomeSectionId 'releaseWatch' in the type
  union and DEFAULT_SECTIONS so the user can toggle/reorder it
  from home customization.
- i18n: discover.releaseWatch.title + subtitle added to all five
  locales, using Qobuz's own translations where they exist
  ('Radar de Novedades' for Spanish, 'Release-Radar' for German)
  with the 'followed artists/labels/awards' subtitle copy adapted
  from the mobile app's strings.xml resources.

cc
This commit is contained in:
vicrodh committed 2026-04-13 22:33:59 -06:00
1 parent 808b9db0db
commit ea216adf0f
14 files changed
+307 -10

No files matched your search

+16
View File
@@ -760,6 +760,22 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.map_err(CoreError::Api)
}
/// Get Release Watch — new releases from followed artists, labels and
/// awards (the "Radar de Novedades" feed in the Qobuz mobile client).
pub async fn get_release_watch(
&self,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Album>, CoreError> {
let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client
.get_release_watch(limit, offset)
.await
.map_err(CoreError::Api)
}
/// Get artist page (full artist details with albums, tracks, similar)
pub async fn get_artist_page(
&self,
+41
View File
@@ -767,6 +767,47 @@ impl QobuzClient {
Ok(serde_json::from_value(albums.clone())?)
}
/// Get Release Watch — new and upcoming releases from artists, labels
/// and awards the user follows. Exposed on mobile/web as "Release Watch"
/// or "Radar de Novedades".
///
/// Returns the same `{items, offset, limit, total}` page structure as
/// `get_featured_albums`. Requires the user to be authenticated; the
/// endpoint uses the follows on the caller's account.
pub async fn get_release_watch(
&self,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::ALBUMS_RELEASE_WATCH);
let params: Vec<(&str, String)> = vec![
("limit", limit.to_string()),
("offset", offset.to_string()),
];
let http_response = self
.signed_get_auth(&url, "albumsreleaseWatch", &params)
.await?;
log::debug!(
"[API] get_release_watch(limit={}, offset={}) status={}",
limit,
offset,
http_response.status()
);
let response: Value = http_response.json().await?;
// The mobile client calls into /albums/releaseWatch and expects an
// `albums` key wrapping the page. If the server changes this someday
// (e.g. to `releaseWatch`), fall back transparently.
let page = response
.get("albums")
.or_else(|| response.get("releaseWatch"))
.cloned()
.unwrap_or(response);
Ok(serde_json::from_value(page)?)
}
/// Get list of genres
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
let url = endpoints::build_url(paths::GENRE_LIST);
+4
View File
@@ -20,6 +20,10 @@ pub mod paths {
pub const ALBUM_GET: &str = "/album/get";
pub const ALBUM_SEARCH: &str = "/album/search";
pub const ALBUM_GET_FEATURED: &str = "/album/getFeatured";
// Release Watch: releases from artists, labels, and awards the user
// follows. Mobile/web apps expose this in Discover as "Release Watch" /
// "Radar de Novedades". Endpoint path uses the `/albums` (plural) prefix.
pub const ALBUMS_RELEASE_WATCH: &str = "/albums/releaseWatch";
// Artist
pub const ARTIST_GET: &str = "/artist/get";
+44
View File
@@ -258,6 +258,50 @@ pub async fn v2_get_featured_albums(
Ok(results)
}
/// Get Release Watch (V2 — releases from followed artists, labels and awards)
///
/// Surfaces the mobile client's "Radar de Novedades" / "Release Watch" feed.
/// Filtered through the user's artist blacklist like other album results.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_release_watch(
limit: Option<u32>,
offset: Option<u32>,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<SearchResultsPage<Album>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
log::info!("[V2] get_release_watch: limit={}, offset={}", limit, offset);
let bridge = bridge.get().await;
let mut results = bridge
.get_release_watch(limit, offset)
.await
.map_err(RuntimeError::Internal)?;
let original_count = results.items.len();
results
.items
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
let filtered_count = original_count - results.items.len();
if filtered_count > 0 {
log::debug!(
"[V2/Blacklist] Filtered {} albums from release watch",
filtered_count
);
results.total = results.total.saturating_sub(filtered_count as u32);
}
Ok(results)
}
/// Get artist page (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
+14
View File
@@ -498,6 +498,20 @@ impl CoreBridge {
.map_err(|e| e.to_string())
}
/// Get Release Watch — releases from artists, labels, and awards the user
/// follows. Surfaces the Qobuz "Radar de Novedades" / "Release Watch"
/// feed used in the official mobile client's Discover screens.
pub async fn get_release_watch(
&self,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Album>, String> {
self.core
.get_release_watch(limit, offset)
.await
.map_err(|e| e.to_string())
}
/// Get artist page (full artist details with albums, tracks, similar)
pub async fn get_artist_page(
&self,
+1
View File
@@ -1594,6 +1594,7 @@ pub fn run() {
commands_v2::v2_get_playlist_tags,
commands_v2::v2_get_discover_albums,
commands_v2::v2_get_featured_albums,
commands_v2::v2_get_release_watch,
commands_v2::v2_get_artist_page,
commands_v2::v2_get_similar_artists,
commands_v2::v2_get_artist_with_albums,
+82
View File
@@ -210,6 +210,11 @@
let essentialsAlbums = $state<AlbumCardData[]>([]);
let loadingEssentials = $state(false);
// Release Watch — the Qobuz mobile "Radar de Novedades" feed: new releases
// from artists, labels, and awards the user follows.
let releaseWatchAlbums = $state<AlbumCardData[]>([]);
let loadingReleaseWatch = $state(false);
// Radio Stations: mix of 3 recent + 3 favorites + 3 top-artist albums, no dupes
let radioAlbums = $state<AlbumCardData[]>([]);
let radioBuilt = false;
@@ -273,9 +278,38 @@
loadSimilarAlbums();
loadForgottenFavorites();
loadEssentials();
loadReleaseWatch();
}
});
async function loadReleaseWatch() {
loadingReleaseWatch = true;
try {
const result = await invoke<{ items: AlbumSuggestResult[]; total: number }>(
'v2_get_release_watch',
{ limit: 20, offset: 0 }
);
releaseWatchAlbums = (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 release watch:', err);
} finally {
loadingReleaseWatch = false;
}
}
async function loadArtistsToFollow() {
if (topArtists.length === 0) return;
loadingSuggestedArtists = true;
@@ -1192,6 +1226,54 @@
</HorizontalScrollRow>
{/if}
<!-- Release Watch — followed artists/labels/awards, Qobuz mobile parity -->
{#if loadingReleaseWatch}
<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 releaseWatchAlbums.length > 0}
<HorizontalScrollRow>
{#snippet header()}
<div class="section-header-col">
<h2 class="section-title">{$t('discover.releaseWatch.title')}</h2>
<p class="section-subtitle">{$t('discover.releaseWatch.subtitle')}</p>
</div>
{/snippet}
{#snippet children()}
{#each releaseWatchAlbums 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}
<!-- Essentials [Genre] -->
{#if loadingEssentials}
<div class="skeleton-section">
+77 -4
View File
@@ -269,13 +269,14 @@
let loadingFavoriteAlbums = $state(true);
let loadingQobuzPlaylists = $state(true);
let loadingEssentialDiscography = $state(true);
let loadingReleaseWatch = $state(true);
// True when all sections have finished loading (for empty state detection)
const anyLoading = $derived(
loadingNewReleases || loadingPressAwards || loadingMostStreamed ||
loadingQobuzissimes || loadingEditorPicks || loadingRecentAlbums ||
loadingContinueTracks || loadingTopArtists || loadingFavoriteAlbums ||
loadingQobuzPlaylists || loadingEssentialDiscography
loadingQobuzPlaylists || loadingEssentialDiscography || loadingReleaseWatch
);
// Featured albums (from Qobuz editorial)
@@ -285,6 +286,10 @@
let qobuzissimes = $state<AlbumCardData[]>([]);
let editorPicks = $state<AlbumCardData[]>([]);
// Release Watch — followed-artists/labels/awards feed from the mobile
// client (Qobuz "Radar de Novedades").
let releaseWatchAlbums = $state<AlbumCardData[]>([]);
// User-specific content
let recentAlbums = $state<AlbumCardData[]>([]);
let continueTracks = $state<DisplayTrack[]>([]);
@@ -359,6 +364,7 @@
|| favoriteAlbums.length > 0
|| qobuzPlaylists.length > 0
|| essentialDiscography.length > 0
|| releaseWatchAlbums.length > 0
);
@@ -832,6 +838,7 @@
loadingFavoriteAlbums = true;
loadingQobuzPlaylists = true;
loadingEssentialDiscography = true;
loadingReleaseWatch = true;
}
// Get current genre filter (array of IDs for multi-select)
@@ -840,7 +847,9 @@
// Two parallel paths:
// 1. Single Qobuz discover API call (all editorial content)
// 2. ML seeds from local SQLite -> user-specific sections
// Plus release-watch which is its own endpoint.
const discoverPromise = fetchAllDiscoverData(genreIds);
const releaseWatchPromise = fetchReleaseWatch();
// Single IPC call returns fully-resolved card data (3-tier cache in Rust)
const mlPromise = invoke<HomeResolved>('v2_reco_get_home_resolved', {
@@ -878,17 +887,32 @@
loadingFavoriteAlbums = false;
}
// Ensure discover promise completes (errors already handled internally)
await discoverPromise;
// Ensure discover + release watch promises complete (errors already
// handled internally)
await Promise.all([discoverPromise, releaseWatchPromise]);
// Single batch download status check for ALL albums at once
const allAlbums = [
...newReleases, ...pressAwards, ...mostStreamed,
...qobuzissimes, ...editorPicks,
...recentAlbums, ...favoriteAlbums
...recentAlbums, ...favoriteAlbums, ...releaseWatchAlbums
];
loadAllAlbumDownloadStatusesBatch(allAlbums).catch(() => {});
}
async function fetchReleaseWatch() {
try {
const result = await invoke<{ items: QobuzAlbum[]; total: number }>(
'v2_get_release_watch',
{ limit: 20, offset: 0 }
);
releaseWatchAlbums = (result.items || []).map(toAlbumCard);
} catch (err) {
console.error('fetchReleaseWatch failed:', err);
} finally {
loadingReleaseWatch = false;
}
}
</script>
<div class="home-view" bind:this={homeViewEl} onscroll={handleHomeScroll}>
@@ -1307,6 +1331,55 @@
{/if}
{/if}
{#if sectionId === 'releaseWatch'}
{#if loadingReleaseWatch}
<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 releaseWatchAlbums.length > 0}
<HorizontalScrollRow>
{#snippet header()}
<div class="section-header-col">
<h2 class="section-title">{$t('discover.releaseWatch.title')}</h2>
<p class="section-subtitle">{$t('discover.releaseWatch.subtitle')}</p>
</div>
{/snippet}
{#snippet children()}
{#each releaseWatchAlbums as album (album.id)}
<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}
{/if}
{#if sectionId === 'essentialDiscography'}
{#if loadingEssentialDiscography}
<div class="skeleton-section">
+5 -1
View File
@@ -91,7 +91,11 @@
"pressAccolades": "Presseauszeichnungen",
"qobuzPlaylists": "Qobuz-Playlists",
"searchPlaylistsPlaceholder": "Playlists suchen...",
"noPlaylistResults": "Keine Playlists gefunden"
"noPlaylistResults": "Keine Playlists gefunden",
"releaseWatch": {
"title": "Release-Radar",
"subtitle": "Die neuesten Releases der Künstler, Labels und Auszeichnungen, denen Sie folgen."
}
},
"search": {
"title": "Suchen",
+5 -1
View File
@@ -91,7 +91,11 @@
"pressAccolades": "Press Accolades",
"qobuzPlaylists": "Qobuz Playlists",
"searchPlaylistsPlaceholder": "Search playlists...",
"noPlaylistResults": "No playlists found"
"noPlaylistResults": "No playlists found",
"releaseWatch": {
"title": "Release Watch",
"subtitle": "Releases from artists, labels and awards you follow."
}
},
"search": {
"title": "Search",
+5 -1
View File
@@ -91,7 +91,11 @@
"pressAccolades": "Reconocimientos de Prensa",
"qobuzPlaylists": "Playlists de Qobuz",
"searchPlaylistsPlaceholder": "Buscar playlists...",
"noPlaylistResults": "No se encontraron playlists"
"noPlaylistResults": "No se encontraron playlists",
"releaseWatch": {
"title": "Radar de Novedades",
"subtitle": "Novedades de los artistas, sellos y premios que sigues."
}
},
"search": {
"title": "Buscar",
+5 -1
View File
@@ -91,7 +91,11 @@
"pressAccolades": "Distinctions de la presse",
"qobuzPlaylists": "Playlists Qobuz",
"searchPlaylistsPlaceholder": "Rechercher des playlists...",
"noPlaylistResults": "Aucune playlist trouvée"
"noPlaylistResults": "Aucune playlist trouvée",
"releaseWatch": {
"title": "Radar des sorties",
"subtitle": "Sorties des artistes, labels et récompenses que vous suivez."
}
},
"search": {
"title": "Recherche",
+5 -1
View File
@@ -91,7 +91,11 @@
"pressAccolades": "Elogios da Crítica",
"qobuzPlaylists": "Playlists Qobuz",
"searchPlaylistsPlaceholder": "Buscar playlists...",
"noPlaylistResults": "Nenhuma playlist encontrada"
"noPlaylistResults": "Nenhuma playlist encontrada",
"releaseWatch": {
"title": "Radar de Novidades",
"subtitle": "Lançamentos dos artistas, selos e prêmios que você segue."
}
},
"search": {
"title": "Buscar",
+3 -1
View File
@@ -18,7 +18,8 @@ export type HomeSectionId =
| 'continueTracks'
| 'topArtists'
| 'favoriteAlbums'
| 'yourMixes';
| 'yourMixes'
| 'releaseWatch';
export interface HomeSection {
id: HomeSectionId;
@@ -49,6 +50,7 @@ const STORAGE_KEY = 'qbz-home-settings';
// Default section order and visibility
const DEFAULT_SECTIONS: HomeSection[] = [
{ id: 'newReleases', label: 'New Releases', visible: true, source: 'qobuz' },
{ id: 'releaseWatch', label: 'Release Watch', visible: true, source: 'qobuz' },
{ id: 'pressAwards', label: 'Press Awards', visible: true, source: 'qobuz' },
{ id: 'mostStreamed', label: 'Popular Albums', visible: false, source: 'qobuz' },
{ id: 'qobuzissimes', label: 'Qobuzissimes', visible: false, source: 'qobuz' },