From 60e0861e1d64702f81735db3791cd1937300086f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:49:34 -0600 Subject: [PATCH] refactor(discover): read Release Watch from /discover/index response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial implementation added a dedicated get_release_watch client method calling /albums/releaseWatch, but that path is only routed on the mobile API; the web API (api.json/0.2) that qbz-qobuz speaks to returned 'no Service found with those values'. The correct source is already available: /discover/index — which we were already calling for newReleases/pressAwards/qobuzissimes/etc — also returns a release_watch container when the user has follows. We were silently dropping it because DiscoverContainers didn't declare the field. Now: - DiscoverContainers (Rust + TS mirror) declares the release_watch field with a serde alias that accepts both camelCase and snake_case spellings, so future naming churn upstream is tolerated - HomeView reads it in fetchAllDiscoverData next to the other editorial sections (one less network round-trip) - ForYouTab calls v2_get_discover_index and extracts only the release_watch container - Reverted the custom /albums/releaseWatch endpoint, client method, core passthrough, bridge wrapper and v2_get_release_watch command Section identity (homeSettingsStore id, i18n keys, render blocks) is unchanged. cc --- crates/qbz-core/src/core.rs | 16 ----- crates/qbz-models/src/types.rs | 8 +++ crates/qbz-qobuz/src/client.rs | 85 ----------------------- crates/qbz-qobuz/src/endpoints.rs | 4 -- src-tauri/src/commands_v2/catalog.rs | 44 ------------ src-tauri/src/core_bridge.rs | 14 ---- src-tauri/src/lib.rs | 1 - src/lib/components/views/ForYouTab.svelte | 38 ++++++---- src/lib/components/views/HomeView.svelte | 33 ++++----- src/lib/types/index.ts | 1 + 10 files changed, 48 insertions(+), 196 deletions(-) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index b4d6d4c7e..50cc9da70 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -760,22 +760,6 @@ impl QbzCore { .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, 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, diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs index c5b3079ff..12ada69e8 100644 --- a/crates/qbz-models/src/types.rs +++ b/crates/qbz-models/src/types.rs @@ -548,6 +548,14 @@ pub struct DiscoverContainers { pub most_streamed: Option>, pub press_awards: Option>, pub album_of_the_week: Option>, + /// Release Watch — new releases from artists, labels and awards the user + /// follows. The mobile client surfaces this as "Radar de Novedades" / + /// "Release Watch". Comes back from `/discover/index` alongside the + /// editorial sections; we consume it in the same response rather than + /// calling the separate `/albums/releaseWatch` endpoint used by the + /// Android app (which the web API doesn't route). + #[serde(default, alias = "releaseWatch", alias = "releasewatch")] + pub release_watch: Option>, } /// Generic discover container diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 9780eee11..22c5b9e2c 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -767,91 +767,6 @@ 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". - /// - /// 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> { - 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", ¶ms) - .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's /albums/releaseWatch response shape isn't - // documented publicly — it may wrap items in `albums`, `releaseWatch` - // or serve a flat page at the root. Try every known shape and log - // the top-level keys when we fall through so future breakage is - // diagnosable from logs. - if let Some(obj) = response.as_object() { - log::debug!( - "[API] get_release_watch response keys: {:?}", - obj.keys().collect::>() - ); - } - - // Candidate wrappers in order of plausibility. - let candidates: &[&str] = &["albums", "releaseWatch", "release_watch", "items_albums"]; - for key in candidates { - if let Some(inner) = response.get(*key) { - // The wrapper might be the page directly, or an object with - // a nested `items` array. - if inner.get("items").is_some() { - return Ok(serde_json::from_value(inner.clone())?); - } - // If it's an array, wrap it into a page manually - if let Some(arr) = inner.as_array() { - let page = SearchResultsPage:: { - items: serde_json::from_value(Value::Array(arr.clone()))?, - total: arr.len() as u32, - offset, - limit, - }; - return Ok(page); - } - } - } - - // Root-level `{items, total, ...}` shape - if response.get("items").is_some() { - return Ok(serde_json::from_value(response)?); - } - - // Nothing matched — log and return an empty page instead of erroring, - // so the UI section just hides rather than killing adjacent loaders. - log::warn!( - "[API] get_release_watch: unrecognised response shape; returning empty page. Raw body: {}", - serde_json::to_string(&response) - .unwrap_or_else(|_| "".to_string()) - .chars() - .take(400) - .collect::() - ); - Ok(SearchResultsPage:: { - items: vec![], - total: 0, - offset, - limit, - }) - } - /// Get list of genres pub async fn get_genres(&self, parent_id: Option) -> Result> { let url = endpoints::build_url(paths::GENRE_LIST); diff --git a/crates/qbz-qobuz/src/endpoints.rs b/crates/qbz-qobuz/src/endpoints.rs index 6fc5d5d32..1b19cb50b 100644 --- a/crates/qbz-qobuz/src/endpoints.rs +++ b/crates/qbz-qobuz/src/endpoints.rs @@ -20,10 +20,6 @@ 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"; diff --git a/src-tauri/src/commands_v2/catalog.rs b/src-tauri/src/commands_v2/catalog.rs index 576988765..65c0a5e37 100644 --- a/src-tauri/src/commands_v2/catalog.rs +++ b/src-tauri/src/commands_v2/catalog.rs @@ -258,50 +258,6 @@ 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, - offset: Option, - bridge: State<'_, CoreBridgeState>, - blacklist_state: State<'_, BlacklistState>, - runtime: State<'_, RuntimeManagerState>, -) -> Result, 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)] diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index e5a747c19..4e53c7daa 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -498,20 +498,6 @@ 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, 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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2ef17a677..e9b8ea922 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1594,7 +1594,6 @@ 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, diff --git a/src/lib/components/views/ForYouTab.svelte b/src/lib/components/views/ForYouTab.svelte index d44256d4b..a6c4ee58d 100644 --- a/src/lib/components/views/ForYouTab.svelte +++ b/src/lib/components/views/ForYouTab.svelte @@ -285,23 +285,37 @@ 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 => ({ + // Release Watch lives inside /discover/index (same call HomeView uses) + // rather than a dedicated /albums/releaseWatch endpoint, which the web + // API doesn't route — that path only exists on the mobile client. + type DiscoverAlbumItem = { + id: string; + title: string; + image?: { small?: string; large?: string }; + artists?: Array<{ id?: number; name: string }>; + genre?: { name?: string }; + audio_info?: { maximum_bit_depth?: number; maximum_sampling_rate?: number }; + dates?: { original?: string }; + }; + const response = await invoke<{ + containers?: { + release_watch?: { data?: { items?: DiscoverAlbumItem[] } }; + }; + }>('v2_get_discover_index', { genreIds: null }); + const items = response?.containers?.release_watch?.data?.items ?? []; + releaseWatchAlbums = items.map((album): AlbumCardData => ({ id: album.id, - artwork: album.image?.large || album.image?.small || '', + artwork: album.image?.small || album.image?.large || '', title: album.title, - artist: album.artist?.name || '', - artistId: album.artist?.id, + artist: album.artists?.[0]?.name || 'Unknown Artist', + artistId: album.artists?.[0]?.id, genre: album.genre?.name || '', quality: formatQuality( - album.hires, - album.maximum_bit_depth, - album.maximum_sampling_rate + (album.audio_info?.maximum_bit_depth ?? 16) > 16, + album.audio_info?.maximum_bit_depth, + album.audio_info?.maximum_sampling_rate ), - releaseDate: album.release_date_original + releaseDate: album.dates?.original })); } catch (err) { console.error('Failed to load release watch:', err); diff --git a/src/lib/components/views/HomeView.svelte b/src/lib/components/views/HomeView.svelte index 440a80d00..616872edf 100644 --- a/src/lib/components/views/HomeView.svelte +++ b/src/lib/components/views/HomeView.svelte @@ -793,6 +793,14 @@ loadingEditorPicks = false; } + // Release Watch (mobile app's "Radar de Novedades") — /discover/index + // returns it alongside the editorial sections. Strictly followed-artist + // content, so we mirror the page-limit pattern used for new_releases. + if (c.release_watch?.data?.items) { + releaseWatchAlbums = c.release_watch.data.items.slice(0, homeLimits.featuredAlbums).map(discoverAlbumToCardData); + } + loadingReleaseWatch = false; + // Extract playlists (limited) - initial load without tag filter if (c.playlists?.data?.items) { qobuzPlaylists = c.playlists.data.items.slice(0, LIMITS.qobuzPlaylists); @@ -820,6 +828,7 @@ loadingEditorPicks = false; loadingQobuzPlaylists = false; loadingEssentialDiscography = false; + loadingReleaseWatch = false; } } @@ -845,11 +854,10 @@ const genreIds = Array.from(getSelectedGenreIds()); // Two parallel paths: - // 1. Single Qobuz discover API call (all editorial content) + // 1. Single Qobuz discover API call (all editorial content, including + // Release Watch from the user's follows) // 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('v2_reco_get_home_resolved', { @@ -887,9 +895,8 @@ loadingFavoriteAlbums = false; } - // Ensure discover + release watch promises complete (errors already - // handled internally) - await Promise.all([discoverPromise, releaseWatchPromise]); + // Ensure discover promise completes (errors already handled internally) + await discoverPromise; // Single batch download status check for ALL albums at once const allAlbums = [ @@ -899,20 +906,6 @@ ]; 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; - } - }
diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 521d26f88..24f706d0a 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -461,6 +461,7 @@ export interface DiscoverContainers { most_streamed?: DiscoverContainer; press_awards?: DiscoverContainer; album_of_the_week?: DiscoverContainer; + release_watch?: DiscoverContainer; } export interface DiscoverContainer {