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:
2 files changed
+115
-20
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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", ¶ms)
|
||||
.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);
|
||||
|
||||
Reference in new issue
Block a user