fix(award): normalize award.id as string end-to-end so /award/* calls stop sending 'null'

The log showed 'award=null' reaching /award/getAlbums and Qobuz
replying '400 Bad Request: Invalid argument: award_id (accepted type
are number)'. Root cause: AlbumAward.id was typed Option<u64> and
/album/get returns award ids as strings; serde dropped them to None,
frontend stringified null → 'null', we sent that as the query param.

Fix:
- AlbumAward.id becomes Option<String> with a custom deserializer
  that accepts both string and integer JSON values.
- TS types + AlbumDetailView Award interface + pickAlbumRibbon all
  use string id (compared as '88'/'151' now).
- Sidebar button is disabled when id is falsy; handleAwardClick
  early-returns on empty id so we never navigate to award 'null'.
- Better body/status logging in get_award_albums stays so future
  4xx responses are visible in the log.

Also switches /award/page and /award/getAlbums to signed_get_auth
(app_id + user token). Mobile does the same — catalog visibility is
region-scoped and the unsigned variant returns 400.

cc
This commit is contained in:
vicrodh committed 2026-04-14 14:39:20 -06:00
1 parent a273174716
commit 037e18423a
7 files changed
+117 -33

No files matched your search

+17 -5
View File
@@ -851,19 +851,31 @@ pub struct PageArtistAward {
/// Award attached to an album. Shape is intentionally lenient because
/// Qobuz returns different field types across endpoints:
/// - `/discover/index` — id=int, name=string, awarded_at="YYYY-MM-DD"
/// - `/album/get` — id may be missing, awarded_at may be Unix epoch int
/// Keeps the parent Album struct from blowing up on any of those
/// variations while still giving the UI enough to render a ribbon.
/// - `/album/get` — id=string, awarded_at may be Unix epoch int
/// id is emitted as String downstream so the frontend has a single
/// type to carry into /award/page and /award/getAlbums.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AlbumAward {
#[serde(default)]
pub id: Option<u64>,
#[serde(default, deserialize_with = "deserialize_award_id")]
pub id: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default, deserialize_with = "deserialize_award_awarded_at")]
pub awarded_at: Option<String>,
}
fn deserialize_award_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(match value {
Some(serde_json::Value::String(s)) if !s.is_empty() => Some(s),
Some(serde_json::Value::Number(n)) => Some(n.to_string()),
_ => None,
})
}
fn deserialize_award_awarded_at<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
+67 -11
View File
@@ -1478,12 +1478,13 @@ impl QobuzClient {
}
/// Get award page — hero info + categorized award-winning releases.
/// Mirrors Android's AwardDto shape from /award/page.
/// Mirrors Android's AwardDto shape from /award/page. Uses auth
/// headers (app_id + user token) — region-gated catalog.
pub async fn get_award_page(&self, award_id: &str) -> Result<qbz_models::AwardPageData> {
let url = endpoints::build_url(paths::AWARD_PAGE);
log::debug!("[API] get_award_page({})", award_id);
let response: serde_json::Value = self
.signed_get(&url, "awardpage", &[("award_id", award_id.to_string())])
.signed_get_auth(&url, "awardpage", &[("award_id", award_id.to_string())])
.await?
.json()
.await?;
@@ -1491,6 +1492,10 @@ impl QobuzClient {
}
/// Get paginated albums for a single award (/award/getAlbums).
/// Response is V2AlbumGenericListDto = {has_more, items:[DiscographyAlbumDto]}
/// per k20/b.java. DiscographyAlbumDto carries artists[] plural and
/// artist singular as an optional DiscographyArtistDto — we backfill
/// artist from artists[0] when missing (same trick as release watch).
pub async fn get_award_albums(
&self,
award_id: &str,
@@ -1498,9 +1503,14 @@ impl QobuzClient {
offset: u32,
) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::AWARD_GET_ALBUMS);
log::debug!("[API] get_award_albums({}, limit={}, offset={})", award_id, limit, offset);
let response: serde_json::Value = self
.signed_get(
log::debug!(
"[API] get_award_albums({}, limit={}, offset={})",
award_id,
limit,
offset
);
let http_response = self
.signed_get_auth(
&url,
"awardgetAlbums",
&[
@@ -1509,17 +1519,57 @@ impl QobuzClient {
("offset", offset.to_string()),
],
)
.await?
.json()
.await?;
let status = http_response.status();
let body_text = http_response.text().await?;
if !status.is_success() {
log::warn!(
"[API] get_award_albums non-success body (first 400 chars): {}",
body_text.chars().take(400).collect::<String>()
);
return Err(ApiError::ApiResponse(format!(
"get_award_albums status {}",
status
)));
}
let response: serde_json::Value = serde_json::from_str(&body_text)?;
// Response envelope may be V2GenericListDto {has_more,items} or the
// search-style {albums: {items,total,offset,limit}}. Try both.
// Legacy shape some endpoints use: {albums: {items, total, offset, limit}}
if let Some(albums_obj) = response.get("albums") {
return Ok(serde_json::from_value(albums_obj.clone())?);
}
let items_value = response.get("items").cloned().unwrap_or(serde_json::Value::Null);
let items: Vec<Album> = serde_json::from_value(items_value).unwrap_or_default();
// Current shape: V2AlbumGenericListDto {has_more, items}.
let mut items_value = response.get("items").cloned().unwrap_or(serde_json::Value::Null);
if let Some(items_arr) = items_value.as_array_mut() {
for item in items_arr {
if let Some(obj) = item.as_object_mut() {
let needs_backfill = obj
.get("artist")
.map(|v| v.is_null())
.unwrap_or(true);
if needs_backfill {
if let Some(first_artist) = obj
.get("artists")
.and_then(|a| a.as_array())
.and_then(|arr| arr.first())
.cloned()
{
obj.insert("artist".to_string(), first_artist);
}
}
}
}
}
let items: Vec<Album> = serde_json::from_value(items_value).map_err(|e| {
log::warn!(
"[API] get_award_albums items parse error: {}. Body (first 600 chars): {}",
e,
body_text.chars().take(600).collect::<String>()
);
ApiError::ApiResponse(format!("get_award_albums items parse: {}", e))
})?;
let has_more = response
.get("has_more")
.and_then(|v| v.as_bool())
@@ -1529,6 +1579,12 @@ impl QobuzClient {
} else {
offset + items.len() as u32
};
log::info!(
"[API] get_award_albums({}) parsed {} items (has_more={})",
award_id,
items.len(),
has_more
);
Ok(SearchResultsPage::<Album> {
items,
total: total_hint,