feat(qobuz): parse the v10 embedded lyrics translation block

QobuzLyricsDocument gains the optional translation member (same content
union as original, new in API 10.0), the content DTOs become
Serialize+PartialEq so the lyrics cache can persist a translation
verbatim, and a lang() accessor exposes the content language. New
fixtures cover embedded translations of each type (wsync/lsync/plain),
the lyricsUrl envelope with translation_requested, empty
translation_langs (feature off), and 9.x documents (no translation).

km
This commit is contained in:
vicrodh committed 2026-07-22 19:11:28 -06:00
1 parent 26baeb0ffc
commit 9d7f9c6c17
5 files changed
+279 -11

No files matched your search

+161 -11
View File
@@ -21,7 +21,7 @@
//! All fields stay serde-tolerant (`Option` / `#[serde(default)]`) — the
//! endpoint is a beta-era delta and may still move.
use serde::{Deserialize, Deserializer};
use serde::{Deserialize, Deserializer, Serialize};
/// Accept either a JSON string or a JSON number and normalize to `String`.
///
@@ -62,15 +62,16 @@ pub struct QobuzLyricsUrls {
/// 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).
/// Present only when the `language` query param was sent AND a matching
/// translation exists (yaml:208-213). Absent when no translation was
/// requested (confirmed absent in the live capture) or none is available
/// for the requested language.
#[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.
/// Kept yaml-shaped + tolerant.
#[derive(Debug, Clone, Deserialize)]
pub struct QobuzLyricsTranslation {
#[serde(default)]
@@ -92,7 +93,8 @@ pub struct QobuzLyricsDocument {
/// 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.
/// Publisher copyright entries with applicability zones. Parsed but never
/// enforced (owner decision 2026-07-22: no zone/copyright gating).
#[serde(default)]
pub publishers: Vec<QobuzLyricsPublisher>,
/// Songwriter credits as a single display string.
@@ -101,6 +103,13 @@ pub struct QobuzLyricsDocument {
/// The actual lyrics content (the yaml's "LyricsContentDto" union).
#[serde(default)]
pub original: Option<QobuzLyricsContent>,
/// Translated lyrics content (NEW in API v10) — same union shape as
/// `original`. Present only when a translation was requested via the
/// `language` query param AND the document embeds it; otherwise the
/// step-1 envelope's `translation_requested.lyrics_url` points at a
/// separate document. Absent on all pre-v10 captures.
#[serde(default)]
pub translation: Option<QobuzLyricsContent>,
}
/// One publisher credit inside [`QobuzLyricsDocument`] (live-only; not in the yaml).
@@ -118,8 +127,9 @@ pub struct QobuzLyricsPublisher {
/// 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)]
/// minus `words`, which defaults to empty). `Serialize` exists so the lyrics
/// cache can persist a document's embedded translation verbatim.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum QobuzLyricsContent {
/// Time-synced lyrics; line + per-word timestamps, ms from track start.
@@ -154,6 +164,14 @@ impl QobuzLyricsContent {
QobuzLyricsContent::Plain { lines, .. } => lines.len(),
}
}
/// ISO 639-1 of the content's language, when the document carries it.
pub fn lang(&self) -> Option<&str> {
match self {
QobuzLyricsContent::Synced { lang, .. } => lang.as_deref(),
QobuzLyricsContent::Plain { lang, .. } => lang.as_deref(),
}
}
}
/// One synced line.
@@ -162,7 +180,7 @@ impl QobuzLyricsContent {
/// 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)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QobuzLyricsLine {
#[serde(default)]
pub line: String,
@@ -178,7 +196,7 @@ pub struct QobuzLyricsLine {
}
/// One word inside a synced line (live-only; not in the yaml).
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QobuzLyricsWord {
#[serde(default)]
pub word: String,
@@ -193,7 +211,7 @@ pub struct QobuzLyricsWord {
/// 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)]
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct QobuzLyricsPlainLine {
pub line: String,
}
@@ -229,8 +247,16 @@ mod tests {
// 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 URL_RESPONSE_TRANSLATION: &str =
include_str!("../tests/fixtures/lyrics-url-response-translation.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 DOC_WSYNC_TRANSLATION: &str =
include_str!("../tests/fixtures/lyrics-doc-wsync-translation.json");
const DOC_LSYNC_TRANSLATION: &str =
include_str!("../tests/fixtures/lyrics-doc-lsync-translation.json");
const DOC_PLAIN_TRANSLATION: &str =
include_str!("../tests/fixtures/lyrics-doc-plain-translation.json");
const MISS_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-miss-response.json");
#[test]
@@ -320,6 +346,130 @@ mod tests {
assert_eq!(urls.album_id.as_deref(), Some("2"));
}
#[test]
fn deserialize_lyrics_url_response_with_translation_requested() {
// v10: when a `language` was requested and a translation exists, the
// envelope carries the `translation_requested` pointer (yaml
// `LyricsTranslationDto`).
let urls: QobuzLyricsUrls =
serde_json::from_str(URL_RESPONSE_TRANSLATION).expect("parse translation envelope");
assert_eq!(urls.track_id.as_deref(), Some("266725027"));
let requested = urls
.translation_requested
.expect("translation_requested present");
assert_eq!(requested.lang.as_deref(), Some("es"));
assert!(requested
.lyrics_url
.as_deref()
.unwrap_or("")
.starts_with("https://"));
}
#[test]
fn deserialize_document_with_wsync_translation() {
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_WSYNC_TRANSLATION).expect("parse wsync+translation document");
assert_eq!(doc.translation_langs.len(), 5);
let original = doc.original.expect("original present");
assert!(original.is_synced());
assert_eq!(original.lang(), Some("en"));
let translation = doc.translation.expect("embedded translation present");
assert!(translation.is_synced());
assert_eq!(translation.lang(), Some("es"));
match &translation {
QobuzLyricsContent::Synced { lines, .. } => {
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].line, "Primera linea traducida");
assert_eq!(lines[0].words.len(), 3);
assert_eq!(lines[0].words[2].word, "traducida");
assert!(lines[1].start.is_none(), "gap line without stamps");
}
_ => unreachable!(),
}
}
#[test]
fn deserialize_document_with_lsync_translation() {
// Line-synced variant: lsync alias on BOTH original and translation,
// no per-word stamps.
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_LSYNC_TRANSLATION).expect("parse lsync+translation document");
let original = doc.original.expect("original present");
assert!(original.is_synced());
let translation = doc.translation.expect("translation present");
match &translation {
QobuzLyricsContent::Synced { lang, lines } => {
assert_eq!(lang.as_deref(), Some("fr"));
assert_eq!(lines.len(), 2);
assert!(lines[0].words.is_empty());
assert_eq!(lines[1].start, Some(5000));
}
_ => panic!("expected synced translation"),
}
}
#[test]
fn deserialize_document_with_plain_translation() {
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_PLAIN_TRANSLATION).expect("parse plain+translation document");
let original = doc.original.expect("original present");
assert!(!original.is_synced());
let translation = doc.translation.expect("translation present");
match &translation {
QobuzLyricsContent::Plain { lang, lines } => {
assert_eq!(lang.as_deref(), Some("de"));
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].line, "Einfache Uebersetzung eins");
}
_ => panic!("expected plain translation"),
}
}
#[test]
fn document_without_translation_stays_none() {
// 9.x back-compat: existing captures have no `translation` member.
for fixture in [DOC_WSYNC, DOC_PLAIN] {
let doc: QobuzLyricsDocument =
serde_json::from_str(fixture).expect("parse 9.x document");
assert!(doc.translation.is_none());
}
}
#[test]
fn empty_translation_langs_means_feature_off() {
// Feature-off document: no translations offered at all.
let json = r#"{"track_id": 1, "translation_langs": [],
"original": {"type": "plain", "lang": "en", "lines": [{"line": "hi"}]}}"#;
let doc: QobuzLyricsDocument = serde_json::from_str(json).expect("parse");
assert!(doc.translation_langs.is_empty());
assert!(doc.translation.is_none());
// Missing member entirely behaves the same.
let json = r#"{"track_id": 1,
"original": {"type": "plain", "lang": "en", "lines": [{"line": "hi"}]}}"#;
let doc: QobuzLyricsDocument = serde_json::from_str(json).expect("parse");
assert!(doc.translation_langs.is_empty());
}
#[test]
fn content_serializes_back_to_wire_shape() {
// The lyrics cache persists an embedded translation verbatim, so the
// content union must round-trip through serde.
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_WSYNC_TRANSLATION).expect("parse");
let translation = doc.translation.expect("translation present");
let json = serde_json::to_string(&translation).expect("serialize");
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["type"], "wsync");
assert_eq!(value["lang"], "es");
assert_eq!(value["lines"][0]["words"][0]["word"], "Primera");
let back: QobuzLyricsContent = serde_json::from_str(&json).expect("re-parse");
assert_eq!(back.lang(), Some("es"));
assert_eq!(back.line_count(), 3);
}
#[test]
fn lsync_alias_parses_yaml_shape() {
// The yaml's "lsync" guess ({line,start,end}, no words) must still
@@ -0,0 +1,23 @@
{
"album_id": "abc123",
"track_id": 111111111,
"translation_langs": ["fr"],
"publishers": [],
"writers": "Writer Three",
"original": {
"type": "lsync",
"lang": "en",
"lines": [
{"line": "Line-synced original one", "start": 1000, "end": 4000},
{"line": "Line-synced original two", "start": 5000, "end": 9000}
]
},
"translation": {
"type": "lsync",
"lang": "fr",
"lines": [
{"line": "Traduction synchronisee un", "start": 1000, "end": 4000},
{"line": "Traduction synchronisee deux", "start": 5000, "end": 9000}
]
}
}
@@ -0,0 +1,23 @@
{
"album_id": "def456",
"track_id": 222222222,
"translation_langs": ["de"],
"publishers": [],
"writers": null,
"original": {
"type": "plain",
"lang": "en",
"lines": [
{"line": "Plain original one"},
{"line": "Plain original two"}
]
},
"translation": {
"type": "plain",
"lang": "de",
"lines": [
{"line": "Einfache Uebersetzung eins"},
{"line": "Einfache Uebersetzung zwei"}
]
}
}
@@ -0,0 +1,63 @@
{
"album_id": "gvcirtodd95kc",
"track_id": 266725027,
"translation_langs": ["pt", "de", "fr", "es", "it"],
"publishers": [
{"copyright": "(c) Example Publishing", "zones": ["WW"]}
],
"writers": "Writer One, Writer Two",
"original": {
"type": "wsync",
"lang": "en",
"lines": [
{
"line": "First original line",
"start": 1750,
"end": 4770,
"words": [
{"word": "First", "start": 1750, "end": 2480},
{"word": "original", "start": 2480, "end": 3600},
{"word": "line", "start": 3600, "end": 4770}
]
},
{"line": "", "words": []},
{
"line": "Second original line",
"start": 19690,
"end": 22180,
"words": [
{"word": "Second", "start": 19690, "end": 20560},
{"word": "original", "start": 20560, "end": 21310},
{"word": "line", "start": 21310, "end": 22180}
]
}
]
},
"translation": {
"type": "wsync",
"lang": "es",
"lines": [
{
"line": "Primera linea traducida",
"start": 1750,
"end": 4770,
"words": [
{"word": "Primera", "start": 1750, "end": 2900},
{"word": "linea", "start": 2900, "end": 3800},
{"word": "traducida", "start": 3800, "end": 4770}
]
},
{"line": "", "words": []},
{
"line": "Segunda linea traducida",
"start": 19690,
"end": 22180,
"words": [
{"word": "Segunda", "start": 19690, "end": 20800},
{"word": "linea", "start": 20800, "end": 21500},
{"word": "traducida", "start": 21500, "end": 22180}
]
}
]
}
}
@@ -0,0 +1,9 @@
{
"track_id": 266725027,
"album_id": "gvcirtodd95kc",
"lyrics_url": "https://ga-cdn.qobuz.com/lyrics/266725027/original.json?Expires=2000000000&Signature=example&Key-Pair-Id=EXAMPLE",
"translation_requested": {
"lyrics_url": "https://ga-cdn.qobuz.com/lyrics/266725027/es.json?Expires=2000000000&Signature=example&Key-Pair-Id=EXAMPLE",
"lang": "es"
}
}