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:
vicrodh committed 2026-06-10 16:03:44 -06:00
1 parent 70fe3effb7
commit 46c15f507b
12 files changed
+831

No files matched your search

+1
View File
@@ -5426,6 +5426,7 @@ dependencies = [
"log",
"md-5",
"qbz-cmaf",
"qbz-credentials",
"qbz-models",
"regex",
"reqwest",
+12
View File
@@ -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),
+5
View File
@@ -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" }
+199
View File
@@ -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(())
}
+112
View File
@@ -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);
+4
View File
@@ -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";
+5
View File
@@ -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,
};
+355
View File
@@ -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"),
}
}
}
+39
View File
@@ -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"
}
]
}
}
+89
View File
@@ -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"
}