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>,