refactor(discover): read Release Watch from /discover/index response
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
This commit is contained in:
10 files changed
+48
-196
No files matched your search
@@ -760,22 +760,6 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
.map_err(CoreError::Api)
|
.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)
|
/// Get artist page (full artist details with albums, tracks, similar)
|
||||||
pub async fn get_artist_page(
|
pub async fn get_artist_page(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -548,6 +548,14 @@ pub struct DiscoverContainers {
|
|||||||
pub most_streamed: Option<DiscoverContainer<DiscoverAlbum>>,
|
pub most_streamed: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||||
pub press_awards: Option<DiscoverContainer<DiscoverAlbum>>,
|
pub press_awards: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||||
pub album_of_the_week: Option<DiscoverContainer<DiscoverAlbum>>,
|
pub album_of_the_week: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||||
|
/// 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<DiscoverContainer<DiscoverAlbum>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generic discover container
|
/// Generic discover container
|
||||||
|
|||||||
@@ -767,91 +767,6 @@ impl QobuzClient {
|
|||||||
Ok(serde_json::from_value(albums.clone())?)
|
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<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", ¶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::<Vec<_>>()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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::<Album> {
|
|
||||||
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(|_| "<unserializable>".to_string())
|
|
||||||
.chars()
|
|
||||||
.take(400)
|
|
||||||
.collect::<String>()
|
|
||||||
);
|
|
||||||
Ok(SearchResultsPage::<Album> {
|
|
||||||
items: vec![],
|
|
||||||
total: 0,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get list of genres
|
/// Get list of genres
|
||||||
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
|
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
|
||||||
let url = endpoints::build_url(paths::GENRE_LIST);
|
let url = endpoints::build_url(paths::GENRE_LIST);
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ pub mod paths {
|
|||||||
pub const ALBUM_GET: &str = "/album/get";
|
pub const ALBUM_GET: &str = "/album/get";
|
||||||
pub const ALBUM_SEARCH: &str = "/album/search";
|
pub const ALBUM_SEARCH: &str = "/album/search";
|
||||||
pub const ALBUM_GET_FEATURED: &str = "/album/getFeatured";
|
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
|
// Artist
|
||||||
pub const ARTIST_GET: &str = "/artist/get";
|
pub const ARTIST_GET: &str = "/artist/get";
|
||||||
|
|||||||
@@ -258,50 +258,6 @@ pub async fn v2_get_featured_albums(
|
|||||||
Ok(results)
|
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)
|
/// Get artist page (V2 - uses QbzCore)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
|
|||||||
@@ -498,20 +498,6 @@ impl CoreBridge {
|
|||||||
.map_err(|e| e.to_string())
|
.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)
|
/// Get artist page (full artist details with albums, tracks, similar)
|
||||||
pub async fn get_artist_page(
|
pub async fn get_artist_page(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1594,7 +1594,6 @@ pub fn run() {
|
|||||||
commands_v2::v2_get_playlist_tags,
|
commands_v2::v2_get_playlist_tags,
|
||||||
commands_v2::v2_get_discover_albums,
|
commands_v2::v2_get_discover_albums,
|
||||||
commands_v2::v2_get_featured_albums,
|
commands_v2::v2_get_featured_albums,
|
||||||
commands_v2::v2_get_release_watch,
|
|
||||||
commands_v2::v2_get_artist_page,
|
commands_v2::v2_get_artist_page,
|
||||||
commands_v2::v2_get_similar_artists,
|
commands_v2::v2_get_similar_artists,
|
||||||
commands_v2::v2_get_artist_with_albums,
|
commands_v2::v2_get_artist_with_albums,
|
||||||
|
|||||||
@@ -285,23 +285,37 @@
|
|||||||
async function loadReleaseWatch() {
|
async function loadReleaseWatch() {
|
||||||
loadingReleaseWatch = true;
|
loadingReleaseWatch = true;
|
||||||
try {
|
try {
|
||||||
const result = await invoke<{ items: AlbumSuggestResult[]; total: number }>(
|
// Release Watch lives inside /discover/index (same call HomeView uses)
|
||||||
'v2_get_release_watch',
|
// rather than a dedicated /albums/releaseWatch endpoint, which the web
|
||||||
{ limit: 20, offset: 0 }
|
// API doesn't route — that path only exists on the mobile client.
|
||||||
);
|
type DiscoverAlbumItem = {
|
||||||
releaseWatchAlbums = (result.items || []).map(album => ({
|
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,
|
id: album.id,
|
||||||
artwork: album.image?.large || album.image?.small || '',
|
artwork: album.image?.small || album.image?.large || '',
|
||||||
title: album.title,
|
title: album.title,
|
||||||
artist: album.artist?.name || '',
|
artist: album.artists?.[0]?.name || 'Unknown Artist',
|
||||||
artistId: album.artist?.id,
|
artistId: album.artists?.[0]?.id,
|
||||||
genre: album.genre?.name || '',
|
genre: album.genre?.name || '',
|
||||||
quality: formatQuality(
|
quality: formatQuality(
|
||||||
album.hires,
|
(album.audio_info?.maximum_bit_depth ?? 16) > 16,
|
||||||
album.maximum_bit_depth,
|
album.audio_info?.maximum_bit_depth,
|
||||||
album.maximum_sampling_rate
|
album.audio_info?.maximum_sampling_rate
|
||||||
),
|
),
|
||||||
releaseDate: album.release_date_original
|
releaseDate: album.dates?.original
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load release watch:', err);
|
console.error('Failed to load release watch:', err);
|
||||||
|
|||||||
@@ -793,6 +793,14 @@
|
|||||||
loadingEditorPicks = false;
|
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
|
// Extract playlists (limited) - initial load without tag filter
|
||||||
if (c.playlists?.data?.items) {
|
if (c.playlists?.data?.items) {
|
||||||
qobuzPlaylists = c.playlists.data.items.slice(0, LIMITS.qobuzPlaylists);
|
qobuzPlaylists = c.playlists.data.items.slice(0, LIMITS.qobuzPlaylists);
|
||||||
@@ -820,6 +828,7 @@
|
|||||||
loadingEditorPicks = false;
|
loadingEditorPicks = false;
|
||||||
loadingQobuzPlaylists = false;
|
loadingQobuzPlaylists = false;
|
||||||
loadingEssentialDiscography = false;
|
loadingEssentialDiscography = false;
|
||||||
|
loadingReleaseWatch = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,11 +854,10 @@
|
|||||||
const genreIds = Array.from(getSelectedGenreIds());
|
const genreIds = Array.from(getSelectedGenreIds());
|
||||||
|
|
||||||
// Two parallel paths:
|
// 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
|
// 2. ML seeds from local SQLite -> user-specific sections
|
||||||
// Plus release-watch which is its own endpoint.
|
|
||||||
const discoverPromise = fetchAllDiscoverData(genreIds);
|
const discoverPromise = fetchAllDiscoverData(genreIds);
|
||||||
const releaseWatchPromise = fetchReleaseWatch();
|
|
||||||
|
|
||||||
// Single IPC call returns fully-resolved card data (3-tier cache in Rust)
|
// Single IPC call returns fully-resolved card data (3-tier cache in Rust)
|
||||||
const mlPromise = invoke<HomeResolved>('v2_reco_get_home_resolved', {
|
const mlPromise = invoke<HomeResolved>('v2_reco_get_home_resolved', {
|
||||||
@@ -887,9 +895,8 @@
|
|||||||
loadingFavoriteAlbums = false;
|
loadingFavoriteAlbums = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure discover + release watch promises complete (errors already
|
// Ensure discover promise completes (errors already handled internally)
|
||||||
// handled internally)
|
await discoverPromise;
|
||||||
await Promise.all([discoverPromise, releaseWatchPromise]);
|
|
||||||
|
|
||||||
// Single batch download status check for ALL albums at once
|
// Single batch download status check for ALL albums at once
|
||||||
const allAlbums = [
|
const allAlbums = [
|
||||||
@@ -899,20 +906,6 @@
|
|||||||
];
|
];
|
||||||
loadAllAlbumDownloadStatusesBatch(allAlbums).catch(() => {});
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="home-view" bind:this={homeViewEl} onscroll={handleHomeScroll}>
|
<div class="home-view" bind:this={homeViewEl} onscroll={handleHomeScroll}>
|
||||||
|
|||||||
@@ -461,6 +461,7 @@ export interface DiscoverContainers {
|
|||||||
most_streamed?: DiscoverContainer<DiscoverAlbum>;
|
most_streamed?: DiscoverContainer<DiscoverAlbum>;
|
||||||
press_awards?: DiscoverContainer<DiscoverAlbum>;
|
press_awards?: DiscoverContainer<DiscoverAlbum>;
|
||||||
album_of_the_week?: DiscoverContainer<DiscoverAlbum>;
|
album_of_the_week?: DiscoverContainer<DiscoverAlbum>;
|
||||||
|
release_watch?: DiscoverContainer<DiscoverAlbum>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoverContainer<T> {
|
export interface DiscoverContainer<T> {
|
||||||
|
|||||||
Reference in new issue
Block a user