feat(qobuz): track lyrics endpoint with live-verified wire DTOs
Add /track/lyricsUrl (signed RPC, method name "tracklyricsUrl") plus
the CDN document fetch through the offline choke point. Wire shapes
were validated against live captures (2026-06-10) and diverge from the
inferred yaml: numeric track_id in the step-1 envelope, CDN document
wrapped in {translation_langs, publishers, writers, original}, "wsync"
word-synced discriminator instead of "lsync" (per-word timestamps),
plain lines as objects, and miss = HTTP 404 error body mapped to a
typed Ok(None) instead of an error. Unit tests deserialize fixture
excerpts of the captures; examples/lyrics_probe.rs is the read-only
live probe used for the capture.
Also expose qbz_credentials::load_oauth_token_from_file so a stale
keyring entry cannot shadow the authoritative encrypted token file.
cc
This commit is contained in:
12 files changed
+831
No files matched your search
Generated
+1
@@ -5426,6 +5426,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"qbz-cmaf",
|
||||
"qbz-credentials",
|
||||
"qbz-models",
|
||||
"regex",
|
||||
"reqwest",
|
||||
|
||||
@@ -733,6 +733,18 @@ pub fn load_oauth_token() -> Result<Option<String>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
load_oauth_token_from_file()
|
||||
}
|
||||
|
||||
/// Load the OAuth token from the encrypted file ONLY, skipping the keyring.
|
||||
///
|
||||
/// The file is the authoritative store (`save_oauth_token` writes it first);
|
||||
/// the keyring is a best-effort cache that can go stale — e.g. an entry
|
||||
/// encrypted under an older key shadows a perfectly fresh file because
|
||||
/// `load_oauth_token` prefers the keyring. Callers that just watched a
|
||||
/// keyring-sourced token fail authentication (and diagnostic tooling) can
|
||||
/// use this to read the authoritative copy directly.
|
||||
pub fn load_oauth_token_from_file() -> Result<Option<String>, String> {
|
||||
let path = match get_oauth_token_path() {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
|
||||
@@ -39,3 +39,8 @@ log = { workspace = true }
|
||||
# Error handling
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used only by examples/lyrics_probe.rs (read-only live probe of the lyrics
|
||||
# endpoint): loads the saved OAuth token / credentials for login.
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Read-only live probe for the Qobuz lyrics endpoint (Lyrics epic, slice S1).
|
||||
//!
|
||||
//! Validates the inferred contract (`/track/lyricsUrl` + CDN document) against
|
||||
//! the LIVE API using the owner's saved credentials, and prints the RAW JSON
|
||||
//! of every response so the shapes can be captured into
|
||||
//! `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
|
||||
//!
|
||||
//! Strictly read-only: login + GETs, no mutations.
|
||||
|
||||
use qbz_qobuz::QobuzClient;
|
||||
|
||||
/// Minimal stderr logger so the credential/auth log lines are visible
|
||||
/// without pulling a logger crate into dev-dependencies.
|
||||
struct StderrLogger;
|
||||
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
metadata.level() <= log::Level::Debug
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
eprintln!("[{}] {}", record.level(), record.args());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static LOGGER: StderrLogger = StderrLogger;
|
||||
|
||||
// Defaults probed 2026-06-10 — one of each observed shape:
|
||||
// 266725027 Billie Eilish - LUNCH -> wsync (word-synced)
|
||||
// 29006863 Devin Townsend - Vampira (Retinal Circus) -> plain
|
||||
// 34 Leehom Wang - Yao Gun Zen Mo Liao! reprise -> miss (HTTP 404)
|
||||
const DEFAULT_TRACK_IDS: &[u64] = &[266725027, 29006863, 34];
|
||||
|
||||
#[tokio::main]
|
||||
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() {
|
||||
DEFAULT_TRACK_IDS.to_vec()
|
||||
} else {
|
||||
args
|
||||
};
|
||||
|
||||
let client = QobuzClient::new()?;
|
||||
let warm = client.init().await?;
|
||||
eprintln!("[probe] init done (warm bundle cache: {})", warm);
|
||||
|
||||
// Load saved credentials on a PLAIN OS thread: qbz-credentials derives
|
||||
// its AES key with the XDG portal secret only when a tokio runtime is
|
||||
// ambient (`Handle::try_current()`); the app encrypts from non-async
|
||||
// contexts, so decrypting here inside #[tokio::main] would derive a
|
||||
// DIFFERENT key and fail. The plain thread reproduces the app's
|
||||
// no-portal derivation. (The portal lookup result is cached per process,
|
||||
// so this must happen before any other credential call.)
|
||||
let (keyring_token, file_token, creds) = std::thread::spawn(|| {
|
||||
let keyring_token = qbz_credentials::load_oauth_token().ok().flatten();
|
||||
let file_token = qbz_credentials::load_oauth_token_from_file().ok().flatten();
|
||||
let creds = qbz_credentials::load_qobuz_credentials().ok().flatten();
|
||||
(keyring_token, file_token, creds)
|
||||
})
|
||||
.join()
|
||||
.expect("credential loader thread panicked");
|
||||
|
||||
// Login: saved OAuth token (keyring-first, then the authoritative
|
||||
// encrypted file — a stale keyring entry can shadow a fresh file), then
|
||||
// saved email+password as the last resort.
|
||||
let mut logged_in = false;
|
||||
let mut tried: Vec<String> = Vec::new();
|
||||
if let Some(token) = keyring_token {
|
||||
tried.push(token.clone());
|
||||
match client.login_with_token(&token).await {
|
||||
Ok(session) => {
|
||||
eprintln!(
|
||||
"[probe] logged in via OAuth token (user_id {})",
|
||||
session.user_id
|
||||
);
|
||||
logged_in = true;
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"[probe] OAuth token login failed ({}), trying the token file directly",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
if !logged_in {
|
||||
if let Some(token) = file_token {
|
||||
if !tried.contains(&token) {
|
||||
match client.login_with_token(&token).await {
|
||||
Ok(session) => {
|
||||
eprintln!(
|
||||
"[probe] logged in via OAuth token file (user_id {})",
|
||||
session.user_id
|
||||
);
|
||||
logged_in = true;
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"[probe] OAuth token-file login failed ({}), trying credentials",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !logged_in {
|
||||
let creds = creds.ok_or("no usable saved OAuth token and no saved credentials")?;
|
||||
let session = client.login(&creds.email, &creds.password).await?;
|
||||
eprintln!(
|
||||
"[probe] logged in via saved credentials (user_id {})",
|
||||
session.user_id
|
||||
);
|
||||
}
|
||||
|
||||
for track_id in track_ids {
|
||||
println!("\n=== track {} ===", track_id);
|
||||
|
||||
// Identify the track so the report can name what was probed.
|
||||
match client.get_track(track_id).await {
|
||||
Ok(track) => println!(
|
||||
"[meta] \"{}\" - {} (album: {})",
|
||||
track.title,
|
||||
track.performer.as_ref().map(|p| p.name.as_str()).unwrap_or("?"),
|
||||
track.album.as_ref().map(|a| a.title.as_str()).unwrap_or("?"),
|
||||
),
|
||||
Err(e) => println!("[meta] get_track failed: {}", e),
|
||||
}
|
||||
|
||||
// Step 1 raw: signed GET /track/lyricsUrl
|
||||
let (status, body) = match client.get_lyrics_url_raw(track_id).await {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
println!("[lyricsUrl] transport error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
println!("[lyricsUrl] HTTP {}", status);
|
||||
println!("[lyricsUrl] raw body:\n{}", body);
|
||||
|
||||
// Typed step 1
|
||||
let urls = match client.get_lyrics_url(track_id).await {
|
||||
Ok(Some(urls)) => urls,
|
||||
Ok(None) => {
|
||||
println!("[lyricsUrl] typed result: MISS (no lyrics)");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[lyricsUrl] typed error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"[lyricsUrl] typed: track_id={:?} album_id={:?} translation_requested={}",
|
||||
urls.track_id,
|
||||
urls.album_id,
|
||||
urls.translation_requested.is_some()
|
||||
);
|
||||
|
||||
// Step 2 raw: plain GET of the CDN document (public http handle).
|
||||
if let Some(lyrics_url) = &urls.lyrics_url {
|
||||
println!("[doc] GET {}", lyrics_url);
|
||||
match client.get_http().get(lyrics_url).send().await {
|
||||
Ok(resp) => {
|
||||
let doc_status = resp.status().as_u16();
|
||||
let doc_body = resp.text().await.unwrap_or_default();
|
||||
println!("[doc] HTTP {}", doc_status);
|
||||
println!("[doc] raw body:\n{}", doc_body);
|
||||
}
|
||||
Err(e) => println!("[doc] transport error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Typed full chain
|
||||
match client.get_lyrics(track_id).await {
|
||||
Ok(Some(doc)) => {
|
||||
let content = doc.original.as_ref().expect("get_lyrics guarantees original");
|
||||
println!(
|
||||
"[get_lyrics] typed: {} with {} lines (translations: {:?}, writers: {:?})",
|
||||
if content.is_synced() { "SYNCED (wsync)" } else { "PLAIN" },
|
||||
content.line_count(),
|
||||
doc.translation_langs,
|
||||
doc.writers
|
||||
);
|
||||
}
|
||||
Ok(None) => println!("[get_lyrics] typed result: MISS"),
|
||||
Err(e) => println!("[get_lyrics] typed error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use super::auth::{
|
||||
use super::bundle::{self, BundleTokens};
|
||||
use super::endpoints::{self, paths};
|
||||
use super::error::{ApiError, Result};
|
||||
use super::lyrics::{QobuzLyricsDocument, QobuzLyricsUrls};
|
||||
use qbz_models::*;
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
|
||||
@@ -1308,6 +1309,117 @@ impl QobuzClient {
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
// === Lyrics (v9.9.0.0-beta delta) ===
|
||||
|
||||
/// Raw step-1 lyrics call: signed `GET /track/lyricsUrl`, returning
|
||||
/// `(HTTP status, raw body)`. The signed-GET helpers are private to the
|
||||
/// 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)> {
|
||||
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 response = self
|
||||
.signed_get_auth(&url, "tracklyricsUrl", &[("track_id", track_id.to_string())])
|
||||
.await?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await?;
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
/// Step 1: fetch the lyrics-URL envelope for a track.
|
||||
///
|
||||
/// `Ok(None)` is the typed miss: no lyrics for this track (non-200, or a
|
||||
/// 200 envelope without a usable `lyrics_url`, or an unparsable body).
|
||||
/// `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?;
|
||||
if status != 200 {
|
||||
// Live-observed miss: HTTP 404 with body
|
||||
// {"status":"error","code":404,"message":"No lyrics found"}
|
||||
// (sample: qbz-nix-docs/qobuz-api/lyrics-miss-response.json).
|
||||
log::debug!(
|
||||
"[API] get_lyrics_url({}) status={} -> no lyrics",
|
||||
track_id,
|
||||
status
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
match serde_json::from_str::<QobuzLyricsUrls>(&body) {
|
||||
Ok(urls) if urls.lyrics_url.is_some() => Ok(Some(urls)),
|
||||
Ok(_) => {
|
||||
log::debug!(
|
||||
"[API] get_lyrics_url({}) 200 without lyrics_url -> no lyrics",
|
||||
track_id
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[API] get_lyrics_url({}) unparsable 200 body ({}) -> no lyrics",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps 1 + 2: fetch the full lyrics document for a track.
|
||||
///
|
||||
/// The CDN GET goes through [`Self::http`] (the offline choke point) so
|
||||
/// offline mode fails fast. The returned `lyrics_url` is self-authorizing
|
||||
/// — a pre-signed CloudFront URL (`Expires`/`Signature`/`Key-Pair-Id`);
|
||||
/// no `X-App-Id`/auth headers are sent (verified live 2026-06-10, HTTP
|
||||
/// 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? {
|
||||
Some(urls) => urls,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let lyrics_url = match urls.lyrics_url {
|
||||
Some(url) => url,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let response = self.http()?.get(&lyrics_url).send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
log::debug!(
|
||||
"[API] get_lyrics({}) CDN doc status={} -> no lyrics",
|
||||
track_id,
|
||||
status
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let body = response.text().await?;
|
||||
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
||||
Ok(doc) if doc.original.is_some() => Ok(Some(doc)),
|
||||
Ok(_) => {
|
||||
log::debug!(
|
||||
"[API] get_lyrics({}) document without original content -> no lyrics",
|
||||
track_id
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[API] get_lyrics({}) unparsable CDN doc ({}) -> no lyrics",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
Ok(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);
|
||||
|
||||
@@ -15,6 +15,10 @@ pub mod paths {
|
||||
pub const TRACK_GET_LIST: &str = "/track/getList";
|
||||
pub const TRACK_SEARCH: &str = "/track/search";
|
||||
pub const TRACK_GET_FILE_URL: &str = "/track/getFileUrl";
|
||||
// Lyrics (v9.9.0.0-beta delta — qobuz-api-inferred-openapi-v9.9.0.0-beta.yaml:759-806).
|
||||
// Returns URL(s) to the lyrics document; the document itself is a second
|
||||
// GET against the returned `lyrics_url` (Qobuz CDN host).
|
||||
pub const TRACK_LYRICS_URL: &str = "/track/lyricsUrl";
|
||||
|
||||
// Album
|
||||
pub const ALBUM_GET: &str = "/album/get";
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod cmaf;
|
||||
pub mod endpoints;
|
||||
pub mod error;
|
||||
pub mod link_resolver;
|
||||
pub mod lyrics;
|
||||
pub mod offline_gate;
|
||||
pub mod performers;
|
||||
pub mod retry;
|
||||
@@ -54,3 +55,7 @@ pub use cmaf::{
|
||||
};
|
||||
pub use error::{ApiError, Result};
|
||||
pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
|
||||
pub use lyrics::{
|
||||
QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine, QobuzLyricsPlainLine,
|
||||
QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls, QobuzLyricsWord,
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Qobuz lyrics wire DTOs.
|
||||
//!
|
||||
//! Two-step contract (inferred spec `qobuz-api-inferred-openapi-v9.9.0.0-beta.yaml:759-806`,
|
||||
//! reconciled against LIVE captures 2026-06-10 — samples in
|
||||
//! `qbz-nix-docs/qobuz-api/lyrics-*.json`):
|
||||
//! 1. `GET /track/lyricsUrl` (signed RPC) -> [`QobuzLyricsUrls`] envelope with the CDN URL.
|
||||
//! 2. `GET <lyrics_url>` (pre-signed CloudFront URL) -> [`QobuzLyricsDocument`].
|
||||
//!
|
||||
//! Divergences found live vs the yaml (each noted on the affected field):
|
||||
//! - Step-1 `track_id` is a JSON NUMBER (yaml said string); `album_id` IS a string.
|
||||
//! - The CDN payload is NOT the bare content union the yaml describes
|
||||
//! (`LyricsContentDto`): it is a wrapper `{album_id, track_id,
|
||||
//! translation_langs, publishers, writers, original}` with the content
|
||||
//! union under `original`.
|
||||
//! - The synced discriminator is `"wsync"` (word-synced), not `"lsync"`:
|
||||
//! each line carries per-WORD timestamps in addition to line start/end.
|
||||
//! - Plain lines are objects `{"line": "..."}`, not bare strings.
|
||||
//! - Miss = HTTP 404 with `{"status":"error","code":404,"message":"Lyrics
|
||||
//! are not available for this track."}` (sample: lyrics-miss-response.json).
|
||||
//!
|
||||
//! All fields stay serde-tolerant (`Option` / `#[serde(default)]`) — the
|
||||
//! endpoint is a beta-era delta and may still move.
|
||||
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
/// Accept either a JSON string or a JSON number and normalize to `String`.
|
||||
///
|
||||
/// The yaml declares `track_id` as string ("string form, even though the query
|
||||
/// param is numeric", yaml:205) but the live capture (lyrics-url-response.json)
|
||||
/// shows `track_id` as a JSON NUMBER while `album_id` is a string (album slug
|
||||
/// or barcode, e.g. `"gvcirtodd95kc"` / `"5052205066164"`). Tolerate both.
|
||||
fn de_opt_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrNumber {
|
||||
String(String),
|
||||
Number(serde_json::Number),
|
||||
}
|
||||
|
||||
Ok(Option::<StringOrNumber>::deserialize(deserializer)?.map(|v| match v {
|
||||
StringOrNumber::String(s) => s,
|
||||
StringOrNumber::Number(n) => n.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Step-1 envelope returned by `GET /track/lyricsUrl`.
|
||||
///
|
||||
/// Yaml `LyricsUrlsDto` (`:194-219`). Live shape (lyrics-url-response.json):
|
||||
/// `{"track_id": 266725027, "album_id": "gvcirtodd95kc", "lyrics_url": "https://…cloudfront.net/…"}`
|
||||
/// — `track_id` is a number (yaml said string), `translation_requested` is
|
||||
/// simply ABSENT when no translation was asked, and the URL is a pre-signed
|
||||
/// CloudFront URL (self-authorizing: `Expires`/`Signature`/`Key-Pair-Id`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QobuzLyricsUrls {
|
||||
#[serde(default, deserialize_with = "de_opt_string_or_number")]
|
||||
pub track_id: Option<String>,
|
||||
#[serde(default, deserialize_with = "de_opt_string_or_number")]
|
||||
pub album_id: Option<String>,
|
||||
/// URL to the original-language lyrics document (Qobuz CDN host).
|
||||
#[serde(default)]
|
||||
pub lyrics_url: Option<String>,
|
||||
/// Present only when the `translation` query param was sent AND a matching
|
||||
/// translation exists (yaml:208-213). We never send `translation` in v1,
|
||||
/// so this stays `None` (confirmed absent in the live capture).
|
||||
#[serde(default)]
|
||||
pub translation_requested: Option<QobuzLyricsTranslation>,
|
||||
}
|
||||
|
||||
/// Translation pointer inside [`QobuzLyricsUrls`] (yaml `LyricsTranslationDto`, `:221-231`).
|
||||
/// Unverified live (v1 never requests translations); kept yaml-shaped + tolerant.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QobuzLyricsTranslation {
|
||||
#[serde(default)]
|
||||
pub lyrics_url: Option<String>,
|
||||
/// ISO 639-1 code of the returned translation.
|
||||
#[serde(default)]
|
||||
pub lang: Option<String>,
|
||||
}
|
||||
|
||||
/// Step-2 CDN lyrics document — the REAL wire shape (live divergence: the
|
||||
/// yaml's `LyricsContentDto` union is nested under `original`, wrapped with
|
||||
/// catalog metadata; samples lyrics-doc-wsync.json / lyrics-doc-plain.json).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QobuzLyricsDocument {
|
||||
#[serde(default, deserialize_with = "de_opt_string_or_number")]
|
||||
pub track_id: Option<String>,
|
||||
#[serde(default, deserialize_with = "de_opt_string_or_number")]
|
||||
pub album_id: Option<String>,
|
||||
/// ISO 639-1 codes a translation exists for (e.g. `["pt","de","fr","es","it"]`).
|
||||
#[serde(default)]
|
||||
pub translation_langs: Vec<String>,
|
||||
/// Publisher copyright entries with applicability zones.
|
||||
#[serde(default)]
|
||||
pub publishers: Vec<QobuzLyricsPublisher>,
|
||||
/// Songwriter credits as a single display string.
|
||||
#[serde(default)]
|
||||
pub writers: Option<String>,
|
||||
/// The actual lyrics content (the yaml's "LyricsContentDto" union).
|
||||
#[serde(default)]
|
||||
pub original: Option<QobuzLyricsContent>,
|
||||
}
|
||||
|
||||
/// One publisher credit inside [`QobuzLyricsDocument`] (live-only; not in the yaml).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QobuzLyricsPublisher {
|
||||
#[serde(default)]
|
||||
pub copyright: Option<String>,
|
||||
/// Zone codes the copyright applies to (e.g. `["WW"]`, `["WW","FR"]`).
|
||||
#[serde(default)]
|
||||
pub zones: Vec<String>,
|
||||
}
|
||||
|
||||
/// Lyrics content union, discriminated on `type`.
|
||||
///
|
||||
/// Live discriminator is `"wsync"` (word-synced — richer than the yaml's
|
||||
/// `"lsync"` guess: per-word timestamps inside each line). `"lsync"` is kept
|
||||
/// as an alias in case line-only documents exist in the catalog (same shape
|
||||
/// minus `words`, which defaults to empty).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum QobuzLyricsContent {
|
||||
/// Time-synced lyrics; line + per-word timestamps, ms from track start.
|
||||
#[serde(rename = "wsync", alias = "lsync")]
|
||||
Synced {
|
||||
/// ISO 639-1 of the document language (present in live captures).
|
||||
#[serde(default)]
|
||||
lang: Option<String>,
|
||||
#[serde(default)]
|
||||
lines: Vec<QobuzLyricsLine>,
|
||||
},
|
||||
/// Plain (non-synced) lyrics: text-only lines, no timestamps.
|
||||
#[serde(rename = "plain")]
|
||||
Plain {
|
||||
#[serde(default)]
|
||||
lang: Option<String>,
|
||||
#[serde(default)]
|
||||
lines: Vec<QobuzLyricsPlainLine>,
|
||||
},
|
||||
}
|
||||
|
||||
impl QobuzLyricsContent {
|
||||
/// True when this document carries per-line timestamps.
|
||||
pub fn is_synced(&self) -> bool {
|
||||
matches!(self, QobuzLyricsContent::Synced { .. })
|
||||
}
|
||||
|
||||
/// Number of lines in the document.
|
||||
pub fn line_count(&self) -> usize {
|
||||
match self {
|
||||
QobuzLyricsContent::Synced { lines, .. } => lines.len(),
|
||||
QobuzLyricsContent::Plain { lines, .. } => lines.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One synced line.
|
||||
///
|
||||
/// Live divergence from the yaml's `LyricsLineDto` (`{line,start,end}` all
|
||||
/// required): instrumental-gap separator lines come as `{"line":"","words":[]}`
|
||||
/// with NO `start`/`end` at all — both must be `Option`. Regular lines carry
|
||||
/// line-level `start`/`end` plus per-word stamps (lyrics-doc-wsync.json).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QobuzLyricsLine {
|
||||
#[serde(default)]
|
||||
pub line: String,
|
||||
/// Per-word timestamps ("wsync"); empty for gap lines and "lsync" docs.
|
||||
#[serde(default)]
|
||||
pub words: Vec<QobuzLyricsWord>,
|
||||
/// Line start, ms from track start. Absent on gap lines.
|
||||
#[serde(default)]
|
||||
pub start: Option<i64>,
|
||||
/// Line end, ms from track start. Absent on gap lines.
|
||||
#[serde(default)]
|
||||
pub end: Option<i64>,
|
||||
}
|
||||
|
||||
/// One word inside a synced line (live-only; not in the yaml).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QobuzLyricsWord {
|
||||
#[serde(default)]
|
||||
pub word: String,
|
||||
/// Word start, ms from track start.
|
||||
#[serde(default)]
|
||||
pub start: i64,
|
||||
/// Word end, ms from track start.
|
||||
#[serde(default)]
|
||||
pub end: i64,
|
||||
}
|
||||
|
||||
/// One plain line. Live wire form is an object `{"line": "..."}`
|
||||
/// (lyrics-doc-plain.json); the yaml guessed bare strings (`:287-303`) —
|
||||
/// accept both.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QobuzLyricsPlainLine {
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for QobuzLyricsPlainLine {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Repr {
|
||||
Text(String),
|
||||
Object {
|
||||
#[serde(default)]
|
||||
line: String,
|
||||
},
|
||||
}
|
||||
|
||||
Ok(match Repr::deserialize(deserializer)? {
|
||||
Repr::Text(line) => QobuzLyricsPlainLine { line },
|
||||
Repr::Object { line } => QobuzLyricsPlainLine { line },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Fixtures are excerpts of the live captures of 2026-06-10 (full
|
||||
// sanitized copies live in qbz-nix-docs/qobuz-api/lyrics-*.json; the
|
||||
// synced/plain docs are truncated to a few lines here to avoid vendoring
|
||||
// full copyrighted lyrics in the repo — the STRUCTURE is verbatim).
|
||||
const URL_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-url-response.json");
|
||||
const DOC_WSYNC: &str = include_str!("../tests/fixtures/lyrics-doc-wsync.json");
|
||||
const DOC_PLAIN: &str = include_str!("../tests/fixtures/lyrics-doc-plain.json");
|
||||
const MISS_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-miss-response.json");
|
||||
|
||||
#[test]
|
||||
fn deserialize_live_lyrics_url_response() {
|
||||
let urls: QobuzLyricsUrls =
|
||||
serde_json::from_str(URL_RESPONSE).expect("parse step-1 envelope");
|
||||
// Live shape: numeric track_id normalized to string; album_id is a slug.
|
||||
assert_eq!(urls.track_id.as_deref(), Some("266725027"));
|
||||
assert_eq!(urls.album_id.as_deref(), Some("gvcirtodd95kc"));
|
||||
let url = urls.lyrics_url.expect("lyrics_url present");
|
||||
assert!(url.starts_with("https://"));
|
||||
assert!(urls.translation_requested.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_live_wsync_document() {
|
||||
let doc: QobuzLyricsDocument =
|
||||
serde_json::from_str(DOC_WSYNC).expect("parse wsync document");
|
||||
assert_eq!(doc.track_id.as_deref(), Some("266725027"));
|
||||
assert_eq!(doc.album_id.as_deref(), Some("gvcirtodd95kc"));
|
||||
assert!(!doc.translation_langs.is_empty());
|
||||
assert!(!doc.publishers.is_empty());
|
||||
assert!(doc.writers.is_some());
|
||||
|
||||
let content = doc.original.expect("original content present");
|
||||
assert!(content.is_synced());
|
||||
match &content {
|
||||
QobuzLyricsContent::Synced { lang, lines } => {
|
||||
assert_eq!(lang.as_deref(), Some("en"));
|
||||
assert_eq!(lines.len(), 3);
|
||||
// Regular line: text + line stamps + word stamps.
|
||||
let first = &lines[0];
|
||||
assert_eq!(first.line, "Oh, mm-mm");
|
||||
assert_eq!(first.start, Some(1750));
|
||||
assert_eq!(first.end, Some(4770));
|
||||
assert_eq!(first.words.len(), 2);
|
||||
assert_eq!(first.words[0].word, "Oh,");
|
||||
assert_eq!(first.words[0].start, 1750);
|
||||
assert_eq!(first.words[0].end, 2480);
|
||||
// Gap line: empty text, no words, NO start/end on the wire.
|
||||
let gap = &lines[1];
|
||||
assert!(gap.line.is_empty());
|
||||
assert!(gap.words.is_empty());
|
||||
assert!(gap.start.is_none());
|
||||
assert!(gap.end.is_none());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_live_plain_document() {
|
||||
let doc: QobuzLyricsDocument =
|
||||
serde_json::from_str(DOC_PLAIN).expect("parse plain document");
|
||||
assert_eq!(doc.track_id.as_deref(), Some("29006863"));
|
||||
let content = doc.original.expect("original content present");
|
||||
assert!(!content.is_synced());
|
||||
match &content {
|
||||
QobuzLyricsContent::Plain { lang, lines } => {
|
||||
assert_eq!(lang.as_deref(), Some("en"));
|
||||
assert_eq!(lines.len(), 4);
|
||||
// Live plain lines are OBJECTS {"line": "..."}.
|
||||
assert_eq!(lines[0].line, "Night follows me when you're gone");
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miss_response_is_not_a_lyrics_envelope() {
|
||||
// Live miss = HTTP 404 with Qobuz's standard error body. The client
|
||||
// maps non-200 to Ok(None) before parsing, but make sure the error
|
||||
// body could never masquerade as a usable envelope.
|
||||
let parsed: Result<QobuzLyricsUrls, _> = serde_json::from_str(MISS_RESPONSE);
|
||||
if let Ok(urls) = parsed {
|
||||
assert!(urls.lyrics_url.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lyrics_url_response_tolerates_string_ids() {
|
||||
// Yaml-shaped variant (track_id as string) must also parse — the wire
|
||||
// showed a number, the spec says string; accept both.
|
||||
let json = r#"{"track_id":"1","album_id":"2","lyrics_url":"https://example.com/l.json"}"#;
|
||||
let urls: QobuzLyricsUrls = serde_json::from_str(json).expect("parse string-id variant");
|
||||
assert_eq!(urls.track_id.as_deref(), Some("1"));
|
||||
assert_eq!(urls.album_id.as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsync_alias_parses_yaml_shape() {
|
||||
// The yaml's "lsync" guess ({line,start,end}, no words) must still
|
||||
// parse via the alias in case line-only docs exist in the catalog.
|
||||
let json = r#"{"type":"lsync","lang":"fr","lines":[{"line":"hi","start":100,"end":200}]}"#;
|
||||
let content: QobuzLyricsContent =
|
||||
serde_json::from_str(json).expect("parse lsync alias");
|
||||
match content {
|
||||
QobuzLyricsContent::Synced { lang, lines } => {
|
||||
assert_eq!(lang.as_deref(), Some("fr"));
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].start, Some(100));
|
||||
assert!(lines[0].words.is_empty());
|
||||
}
|
||||
_ => panic!("expected synced"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_lines_tolerate_bare_strings() {
|
||||
// Yaml-shaped plain variant (lines as bare strings) must also parse.
|
||||
let json = r#"{"type":"plain","lines":["first line","second line"]}"#;
|
||||
let content: QobuzLyricsContent =
|
||||
serde_json::from_str(json).expect("parse string-lines plain");
|
||||
assert_eq!(content.line_count(), 2);
|
||||
match content {
|
||||
QobuzLyricsContent::Plain { lines, .. } => {
|
||||
assert_eq!(lines[0].line, "first line");
|
||||
}
|
||||
_ => panic!("expected plain"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"album_id": "5052205066164",
|
||||
"track_id": 29006863,
|
||||
"translation_langs": [],
|
||||
"publishers": [
|
||||
{
|
||||
"copyright": "Reservoir Media Management, Inc.",
|
||||
"zones": [
|
||||
"WW",
|
||||
"FR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"copyright": "Reservoir Media Management, Inc., Sony/ATV Music Publishing LLC",
|
||||
"zones": [
|
||||
"FR"
|
||||
]
|
||||
}
|
||||
],
|
||||
"writers": "DEVIN GARRETT TOWNSEND",
|
||||
"original": {
|
||||
"type": "plain",
|
||||
"lang": "en",
|
||||
"lines": [
|
||||
{
|
||||
"line": "Night follows me when you're gone"
|
||||
},
|
||||
{
|
||||
"line": "And now how am I to carry on?"
|
||||
},
|
||||
{
|
||||
"line": "But into the night into the darkness"
|
||||
},
|
||||
{
|
||||
"line": "I must be strong, carry on"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"album_id": "gvcirtodd95kc",
|
||||
"track_id": 266725027,
|
||||
"translation_langs": [
|
||||
"pt",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"it"
|
||||
],
|
||||
"publishers": [
|
||||
{
|
||||
"copyright": "Kobalt Music Publishing Ltd., Universal Music Publishing Group",
|
||||
"zones": [
|
||||
"WW"
|
||||
]
|
||||
}
|
||||
],
|
||||
"writers": "Billie EIlish O'Connell, Finneas O'Connell",
|
||||
"original": {
|
||||
"type": "wsync",
|
||||
"lang": "en",
|
||||
"lines": [
|
||||
{
|
||||
"line": "Oh, mm-mm",
|
||||
"words": [
|
||||
{
|
||||
"word": "Oh,",
|
||||
"start": 1750,
|
||||
"end": 2480
|
||||
},
|
||||
{
|
||||
"word": "mm-mm",
|
||||
"start": 2480,
|
||||
"end": 4770
|
||||
}
|
||||
],
|
||||
"start": 1750,
|
||||
"end": 4770
|
||||
},
|
||||
{
|
||||
"line": "",
|
||||
"words": []
|
||||
},
|
||||
{
|
||||
"line": "I could eat that girl for lunch",
|
||||
"words": [
|
||||
{
|
||||
"word": "I",
|
||||
"start": 5320,
|
||||
"end": 5610
|
||||
},
|
||||
{
|
||||
"word": "could",
|
||||
"start": 5610,
|
||||
"end": 5950
|
||||
},
|
||||
{
|
||||
"word": "eat",
|
||||
"start": 5950,
|
||||
"end": 6170
|
||||
},
|
||||
{
|
||||
"word": "that",
|
||||
"start": 6170,
|
||||
"end": 6400
|
||||
},
|
||||
{
|
||||
"word": "girl",
|
||||
"start": 6400,
|
||||
"end": 6530
|
||||
},
|
||||
{
|
||||
"word": "for",
|
||||
"start": 6530,
|
||||
"end": 6700
|
||||
},
|
||||
{
|
||||
"word": "lunch",
|
||||
"start": 6700,
|
||||
"end": 6990
|
||||
}
|
||||
],
|
||||
"start": 5320,
|
||||
"end": 6990
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": "error",
|
||||
"code": 404,
|
||||
"message": "Lyrics are not available for this track."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"track_id": 266725027,
|
||||
"album_id": "gvcirtodd95kc",
|
||||
"lyrics_url": "https://d12eggcejhfkay.cloudfront.net/v1.3/kc/95/gvcirtodd95kc/266725027.json?user_id=REDACTED&app_id=798273057&Expires=1781132109&Signature=REDACTED&Key-Pair-Id=KGUJDFL0NX9O"
|
||||
}
|
||||
Reference in new issue
Block a user