feat(qobuz): send the language param on track/lyricsUrl

get_lyrics_url_raw/get_lyrics_url/get_lyrics take an optional ISO 639-1
language, appended to the signed param list only when Some (the existing
sign_request sort covers it). None keeps the original-only default flow.
When a translation was requested but the document does not embed one,
the envelope's translation_requested.lyrics_url is fetched as a fallback
translation source (spec section B.3); every gap degrades to
original-only. lyrics_probe gains --lang=<iso639-1> for parity.

km
This commit is contained in:
vicrodh committed 2026-07-22 19:11:41 -06:00
1 parent 9d7f9c6c17
commit 072223078e
2 files changed
+115 -20

No files matched your search

+32 -11
View File
@@ -6,8 +6,12 @@
//! `qbz-nix-docs/qobuz-api/` before freezing the serde structs.
//!
//! Usage:
//! cargo run -p qbz-qobuz --example lyrics_probe # default track ids
//! cargo run -p qbz-qobuz --example lyrics_probe 123 456 # explicit ids
//! cargo run -p qbz-qobuz --example lyrics_probe # default track ids
//! cargo run -p qbz-qobuz --example lyrics_probe 123 456 # explicit ids
//! cargo run -p qbz-qobuz --example lyrics_probe --lang=es 123 # + translation target
//!
//! `--lang=<iso639-1>` exercises the v10 `language` query param (translation
//! request); omitted = original-only fetch.
//!
//! Strictly read-only: login + GETs, no mutations.
@@ -41,15 +45,23 @@ const DEFAULT_TRACK_IDS: &[u64] = &[266725027, 29006863, 34];
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug));
let args: Vec<u64> = std::env::args()
.skip(1)
.filter_map(|a| a.parse().ok())
.collect();
let track_ids: Vec<u64> = if args.is_empty() {
let mut language: Option<String> = None;
let mut ids: Vec<u64> = Vec::new();
for arg in std::env::args().skip(1) {
if let Some(lang) = arg.strip_prefix("--lang=") {
language = Some(lang.to_string());
} else if let Ok(id) = arg.parse() {
ids.push(id);
}
}
let track_ids: Vec<u64> = if ids.is_empty() {
DEFAULT_TRACK_IDS.to_vec()
} else {
args
ids
};
if let Some(lang) = &language {
eprintln!("[probe] translation target language: {}", lang);
}
let client = QobuzClient::new()?;
let warm = client.init().await?;
@@ -135,7 +147,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// Step 1 raw: signed GET /track/lyricsUrl
let (status, body) = match client.get_lyrics_url_raw(track_id).await {
let (status, body) = match client.get_lyrics_url_raw(track_id, language.as_deref()).await {
Ok(pair) => pair,
Err(e) => {
println!("[lyricsUrl] transport error: {}", e);
@@ -146,7 +158,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("[lyricsUrl] raw body:\n{}", body);
// Typed step 1
let urls = match client.get_lyrics_url(track_id).await {
let urls = match client.get_lyrics_url(track_id, language.as_deref()).await {
Ok(Some(urls)) => urls,
Ok(None) => {
println!("[lyricsUrl] typed result: MISS (no lyrics)");
@@ -179,7 +191,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// Typed full chain
match client.get_lyrics(track_id).await {
match client.get_lyrics(track_id, language.as_deref()).await {
Ok(Some(doc)) => {
let content = doc.original.as_ref().expect("get_lyrics guarantees original");
println!(
@@ -189,6 +201,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
doc.translation_langs,
doc.writers
);
match doc.translation.as_ref() {
Some(translation) => println!(
"[get_lyrics] embedded translation: {} with {} lines (lang: {:?})",
if translation.is_synced() { "SYNCED" } else { "PLAIN" },
translation.line_count(),
translation.lang()
),
None => println!("[get_lyrics] no embedded translation"),
}
}
Ok(None) => println!("[get_lyrics] typed result: MISS"),
Err(e) => println!("[get_lyrics] typed error: {}", e),
+83 -9
View File
@@ -13,7 +13,7 @@ use super::bundle::{self, BundleTokens};
use super::endpoints::{self, paths};
use super::error::{ApiError, Result};
use super::forbidden_breaker::ForbiddenBreaker;
use super::lyrics::{QobuzLyricsDocument, QobuzLyricsUrls};
use super::lyrics::{QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls};
use qbz_models::*;
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
@@ -1397,14 +1397,27 @@ impl QobuzClient {
/// client, so this is the only way diagnostic tooling (e.g.
/// `examples/lyrics_probe.rs`) can observe the exact wire shape; the
/// typed [`Self::get_lyrics_url`] builds on it.
pub async fn get_lyrics_url_raw(&self, track_id: u64) -> Result<(u16, String)> {
///
/// `language` (ISO 639-1, API v10) requests a translation; it is appended
/// to the signed param list ONLY when `Some` (`sign_request` sorts the
/// kv pairs alphabetically, so signing needs no change). Pass `None` for
/// the original-only fetch.
pub async fn get_lyrics_url_raw(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<(u16, String)> {
let url = endpoints::build_url(paths::TRACK_LYRICS_URL);
// Signing method name per the concatenation convention ("trackget" at
// get_track, "trackgetFileUrl" in auth.rs): path with slashes removed,
// case preserved -> "tracklyricsUrl". Verified against the live API
// 2026-06-10 (HTTP 200 with a valid envelope).
let mut params = vec![("track_id", track_id.to_string())];
if let Some(language) = language {
params.push(("language", language.to_string()));
}
let response = self
.signed_get_auth(&url, "tracklyricsUrl", &[("track_id", track_id.to_string())])
.signed_get_auth(&url, "tracklyricsUrl", &params)
.await?;
let status = response.status().as_u16();
let body = response.text().await?;
@@ -1418,8 +1431,12 @@ impl QobuzClient {
/// `Err` is reserved for transport/auth/offline failures — the lyrics
/// orchestrator degrades those to a miss as well (spec §1.1); nothing in
/// this path is ever a user-facing error.
pub async fn get_lyrics_url(&self, track_id: u64) -> Result<Option<QobuzLyricsUrls>> {
let (status, body) = self.get_lyrics_url_raw(track_id).await?;
pub async fn get_lyrics_url(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsUrls>> {
let (status, body) = self.get_lyrics_url_raw(track_id, language).await?;
if status != 200 {
// Live-observed miss: HTTP 404 with body
// {"status":"error","code":404,"message":"No lyrics found"}
@@ -1460,12 +1477,23 @@ impl QobuzClient {
/// 200 on a bare GET). `Ok(None)` = typed miss, same contract as
/// [`Self::get_lyrics_url`]; a document without `original` content also
/// counts as a miss.
pub async fn get_lyrics(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>> {
let urls = match self.get_lyrics_url(track_id).await? {
///
/// `language` (ISO 639-1, API v10) requests a translation; `None` fetches
/// original-only (the default flow). When a translation was requested but
/// the document does not EMBED a `translation` block, the envelope's
/// `translation_requested.lyrics_url` is fetched as a fallback and its
/// content becomes the translation (fail soft: any gap in that fallback
/// just leaves the document original-only).
pub async fn get_lyrics(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsDocument>> {
let urls = match self.get_lyrics_url(track_id, language).await? {
Some(urls) => urls,
None => return Ok(None),
};
let lyrics_url = match urls.lyrics_url {
let lyrics_url = match urls.lyrics_url.clone() {
Some(url) => url,
None => return Ok(None),
};
@@ -1482,7 +1510,14 @@ impl QobuzClient {
}
let body = response.text().await?;
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
Ok(doc) if doc.original.is_some() => Ok(Some(doc)),
Ok(mut doc) if doc.original.is_some() => {
if language.is_some() && doc.translation.is_none() {
doc.translation = self
.fetch_translation_fallback(track_id, &urls)
.await;
}
Ok(Some(doc))
}
Ok(_) => {
log::debug!(
"[API] get_lyrics({}) document without original content -> no lyrics",
@@ -1501,6 +1536,45 @@ impl QobuzClient {
}
}
/// Fallback translation source (spec §B.3): when the main document did
/// not embed a `translation` block, fetch the document the envelope's
/// `translation_requested.lyrics_url` points at (same bare CDN GET) and
/// take its content as the translation. Every failure mode degrades to
/// `None` — translation is best-effort, never an error.
async fn fetch_translation_fallback(
&self,
track_id: u64,
urls: &QobuzLyricsUrls,
) -> Option<QobuzLyricsContent> {
let translation_url = urls
.translation_requested
.as_ref()
.and_then(|t| t.lyrics_url.as_deref())?;
let response = self.http().ok()?.get(translation_url).send().await.ok()?;
if !response.status().is_success() {
log::debug!(
"[API] get_lyrics({}) translation doc status={} -> original only",
track_id,
response.status()
);
return None;
}
let body = response.text().await.ok()?;
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
// The translation document carries its content under `original`
// (same wrapper shape as the main document).
Ok(doc) => doc.original,
Err(e) => {
log::debug!(
"[API] get_lyrics({}) unparsable translation doc ({}) -> original only",
track_id,
e
);
None
}
}
}
/// Get artist by ID (basic info only - no albums, faster response)
pub async fn get_artist_basic(&self, artist_id: u64) -> Result<Artist> {
let url = endpoints::build_url(paths::ARTIST_GET);