fix(api): Release Watch lives at /favorite/getNewReleases?type=artists

The inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml, source:
x20/b.java) documents the endpoint as /favorite/getNewReleases with
three required query params:

  @GET favorite/getNewReleases
  - type: String   (artists | labels | awards — matches the three
                    tabs in the mobile UI)
  - limit: Integer
  - offset: Integer

My earlier attempts at /albums/releaseWatch were chasing the Android
route-table entry for the Release Watch *screen*, not the HTTP call
that screen makes. Different layer.

Also removes my made-up speculation about mobile app_id gating — the
/favorite/* endpoints are standard web-API calls the existing client
already knows how to sign (via authenticated_headers + /0.2/ base URL).

v2_get_release_watch now takes an optional releaseType arg defaulting
to 'artists' so callers can switch tabs later without another command.

cc
This commit is contained in:
vicrodh committed 2026-04-14 07:47:42 -06:00
1 parent 541c3613c9
commit ce375008ab
5 files changed
+39 -31

No files matched your search

+4 -4
View File
@@ -760,11 +760,11 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.map_err(CoreError::Api)
}
/// Get Release Watch — new releases from followed artists, labels and
/// awards (Qobuz mobile "Radar de Novedades"). REST endpoint called with
/// authenticated headers; no RPC signing.
/// Get Release Watch — new releases from followed artists/labels/awards.
/// `release_type` must be one of "artists" | "labels" | "awards".
pub async fn get_release_watch(
&self,
release_type: &str,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Album>, CoreError> {
@@ -772,7 +772,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client
.get_release_watch(limit, offset)
.get_release_watch(release_type, limit, offset)
.await
.map_err(CoreError::Api)
}
+14 -14
View File
@@ -767,21 +767,20 @@ impl QobuzClient {
Ok(serde_json::from_value(albums.clone())?)
}
/// Get Release Watch — new releases from artists, labels and awards the
/// user follows. The Qobuz mobile client surfaces this as "Radar de
/// Novedades" / "Release Watch".
/// Get Release Watch — new releases from artists, labels or awards the
/// user follows. Qobuz mobile surfaces this as "Radar de Novedades" /
/// "Release Watch". Endpoint and signature confirmed in
/// `qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml`.
///
/// This is a REST endpoint in the `/albums/*` plural namespace — called
/// with authenticated headers (X-App-Id + X-User-Auth-Token) and NO
/// request_sig/request_ts, same shape as the `/radio/*` endpoints. The
/// RPC signing protocol does NOT apply here and using it yields the
/// "no Service found" error.
/// `release_type` is required and must be one of `artists`, `labels`,
/// `awards` (matches the three tabs in the mobile UI).
pub async fn get_release_watch(
&self,
release_type: &str,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::ALBUMS_RELEASE_WATCH);
let url = endpoints::build_url(paths::FAVORITE_GET_NEW_RELEASES);
let limit_str = limit.to_string();
let offset_str = offset.to_string();
@@ -790,6 +789,7 @@ impl QobuzClient {
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[
("type", release_type),
("limit", limit_str.as_str()),
("offset", offset_str.as_str()),
])
@@ -798,14 +798,13 @@ impl QobuzClient {
let status = http_response.status();
log::info!(
"[API] get_release_watch(limit={}, offset={}) status={}",
"[API] get_release_watch(type={}, limit={}, offset={}) status={}",
release_type,
limit,
offset,
status
);
// Capture body as text first so we can log it on unexpected shape
// regardless of JSON parse success.
let body_text = http_response.text().await?;
if !status.is_success() {
log::warn!(
@@ -827,8 +826,9 @@ impl QobuzClient {
);
}
// Typical Qobuz paginated album wrapper: {albums: {items, total, ...}}.
// Fall back to root-level items if the server flattened it.
// Response is V2GenericListDto<AlbumDto> per x20/b.java. Qobuz
// wraps paginated album lists under `albums` by convention; try that
// first, fall back to root.
let page_value = response.get("albums").cloned().unwrap_or(response);
match serde_json::from_value::<SearchResultsPage<Album>>(page_value) {
Ok(page) => Ok(page),
+5 -5
View File
@@ -20,11 +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";
// Mobile/plural namespace. Unlike /album/* (singular), these endpoints
// are REST-style — called with X-App-Id + X-User-Auth-Token headers and
// NO request_sig/request_ts. See ALBUMS_RELEASE_WATCH (issue: mobile
// "Radar de Novedades" feed of releases from followed artists/labels/awards).
pub const ALBUMS_RELEASE_WATCH: &str = "/albums/releaseWatch";
// Artist
pub const ARTIST_GET: &str = "/artist/get";
@@ -51,6 +46,11 @@ pub mod paths {
pub const FAVORITE_GET_USER_FAVORITES: &str = "/favorite/getUserFavorites";
pub const FAVORITE_CREATE: &str = "/favorite/create";
pub const FAVORITE_DELETE: &str = "/favorite/delete";
// New releases from followed artists / labels / awards — the "Radar de
// Novedades" feed in the mobile Qobuz client. Path confirmed in
// qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml
// (source: x20/b.java Retrofit interface).
pub const FAVORITE_GET_NEW_RELEASES: &str = "/favorite/getNewReleases";
// Label
pub const LABEL_GET: &str = "/label/get";
+13 -6
View File
@@ -258,14 +258,15 @@ pub async fn v2_get_featured_albums(
Ok(results)
}
/// Get Release Watch (V2 — releases from followed artists, labels and awards).
/// Get Release Watch (V2 — releases from followed artists/labels/awards).
///
/// Calls the mobile "Radar de Novedades" endpoint (`/albums/releaseWatch`)
/// with authenticated headers (no RPC signing). Runs the result through the
/// artist blacklist filter.
/// Calls `/favorite/getNewReleases?type=...` — the same endpoint the mobile
/// client uses for its "Radar de Novedades" feed. `release_type` defaults to
/// `"artists"` (the mobile default tab) and accepts `"labels"` or `"awards"`.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_release_watch(
releaseType: Option<String>,
limit: Option<u32>,
offset: Option<u32>,
bridge: State<'_, CoreBridgeState>,
@@ -277,13 +278,19 @@ pub async fn v2_get_release_watch(
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
let release_type = releaseType.unwrap_or_else(|| "artists".to_string());
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
log::info!("[V2] get_release_watch: limit={}, offset={}", limit, offset);
log::info!(
"[V2] get_release_watch: type={}, limit={}, offset={}",
release_type,
limit,
offset
);
let bridge = bridge.get().await;
let mut results = bridge
.get_release_watch(limit, offset)
.get_release_watch(&release_type, limit, offset)
.await
.map_err(RuntimeError::Internal)?;
+3 -2
View File
@@ -498,14 +498,15 @@ impl CoreBridge {
.map_err(|e| e.to_string())
}
/// Get Release Watch — releases from followed artists, labels and awards.
/// Get Release Watch — releases from followed artists/labels/awards.
pub async fn get_release_watch(
&self,
release_type: &str,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Album>, String> {
self.core
.get_release_watch(limit, offset)
.get_release_watch(release_type, limit, offset)
.await
.map_err(|e| e.to_string())
}