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:
5 files changed
+279
-11
No files matched your search
+161
-11
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user