fix(qobuz): merge the translation-only v10 lyrics document with the original
Live wire (verified 2026-07-22, track 266725027 with language=es): when a
translation is requested and available, lyrics_url points at the TRANSLATED
document (<id>-<lang>.json) carrying ONLY 'translation' — no 'original' —
and 'translation_requested' is absent. get_lyrics rejected such documents
('without original content -> no lyrics'), so every translation toggle
ended in the unavailable toast. Now get_lyrics fetches the original-only
document in a second round trip and merges both into one bilingual doc
(merge_translation_into); requested-but-unavailable still 403s the CDN
URL -> typed miss (fail soft).
km
This commit is contained in:
4 files changed
+156
-14
No files matched your search
@@ -13,7 +13,9 @@ use super::bundle::{self, BundleTokens};
|
|||||||
use super::endpoints::{self, paths};
|
use super::endpoints::{self, paths};
|
||||||
use super::error::{ApiError, Result};
|
use super::error::{ApiError, Result};
|
||||||
use super::forbidden_breaker::ForbiddenBreaker;
|
use super::forbidden_breaker::ForbiddenBreaker;
|
||||||
use super::lyrics::{QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls};
|
use super::lyrics::{
|
||||||
|
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls,
|
||||||
|
};
|
||||||
use qbz_models::*;
|
use qbz_models::*;
|
||||||
|
|
||||||
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
|
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
|
||||||
@@ -1486,12 +1488,18 @@ impl QobuzClient {
|
|||||||
/// — a pre-signed CloudFront URL (`Expires`/`Signature`/`Key-Pair-Id`);
|
/// — a pre-signed CloudFront URL (`Expires`/`Signature`/`Key-Pair-Id`);
|
||||||
/// no `X-App-Id`/auth headers are sent (verified live 2026-06-10, HTTP
|
/// 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
|
/// 200 on a bare GET). `Ok(None)` = typed miss, same contract as
|
||||||
/// [`Self::get_lyrics_url`]; a document without `original` content also
|
/// [`Self::get_lyrics_url`]; a document without usable content counts as
|
||||||
/// counts as a miss.
|
/// a miss.
|
||||||
///
|
///
|
||||||
/// `language` (ISO 639-1, API v10) requests a translation; `None` fetches
|
/// `language` (ISO 639-1, API v10) requests a translation; `None` fetches
|
||||||
/// original-only (the default flow). When a translation was requested but
|
/// original-only (the default flow). LIVE WIRE (verified 2026-07-22):
|
||||||
/// the document does not EMBED a `translation` block, the envelope's
|
/// when a translation is requested AND available, `lyrics_url` points at
|
||||||
|
/// the TRANSLATED document (`<id>-<lang>.json`), which carries ONLY
|
||||||
|
/// `translation` and no `original` — so this fn then fetches the
|
||||||
|
/// original-only document too and merges both into one bilingual
|
||||||
|
/// document. When requested but NOT available the signed CDN URL 403s →
|
||||||
|
/// typed miss (fail soft). When the document has an `original` but does
|
||||||
|
/// not EMBED a `translation` block, the envelope's
|
||||||
/// `translation_requested.lyrics_url` is fetched as a fallback and its
|
/// `translation_requested.lyrics_url` is fetched as a fallback and its
|
||||||
/// content becomes the translation (fail soft: any gap in that fallback
|
/// content becomes the translation (fail soft: any gap in that fallback
|
||||||
/// just leaves the document original-only).
|
/// just leaves the document original-only).
|
||||||
@@ -1499,6 +1507,49 @@ impl QobuzClient {
|
|||||||
&self,
|
&self,
|
||||||
track_id: u64,
|
track_id: u64,
|
||||||
language: Option<&str>,
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsDocument>> {
|
||||||
|
let doc = match self.fetch_lyrics_doc(track_id, language).await? {
|
||||||
|
Some(doc) => doc,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if language.is_some() && doc.original.is_none() && doc.translation.is_some() {
|
||||||
|
// Translation-only document (the common v10 live shape): fetch the
|
||||||
|
// original document separately and merge into one bilingual doc.
|
||||||
|
return match self.fetch_lyrics_doc(track_id, None).await? {
|
||||||
|
Some(mut original_doc) if original_doc.original.is_some() => {
|
||||||
|
merge_translation_into(&mut original_doc, doc);
|
||||||
|
Ok(Some(original_doc))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::debug!(
|
||||||
|
"[API] get_lyrics({}) translation found but original doc missing -> no lyrics",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the original-only miss semantics: a document with neither
|
||||||
|
// `original` nor a mergeable `translation` is unusable.
|
||||||
|
if doc.original.is_none() {
|
||||||
|
log::debug!(
|
||||||
|
"[API] get_lyrics({}) document without original content -> no lyrics",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lyrics round trip (signed lyricsUrl + bare CDN GET). Accepts any
|
||||||
|
/// document carrying `original` OR `translation` content; the caller
|
||||||
|
/// decides which shapes are usable (see [`Self::get_lyrics`]).
|
||||||
|
async fn fetch_lyrics_doc(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
) -> Result<Option<QobuzLyricsDocument>> {
|
) -> Result<Option<QobuzLyricsDocument>> {
|
||||||
let urls = match self.get_lyrics_url(track_id, language).await? {
|
let urls = match self.get_lyrics_url(track_id, language).await? {
|
||||||
Some(urls) => urls,
|
Some(urls) => urls,
|
||||||
@@ -1521,8 +1572,8 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
let body = response.text().await?;
|
let body = response.text().await?;
|
||||||
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
||||||
Ok(mut doc) if doc.original.is_some() => {
|
Ok(mut doc) if doc.original.is_some() || doc.translation.is_some() => {
|
||||||
if language.is_some() && doc.translation.is_none() {
|
if language.is_some() && doc.original.is_some() && doc.translation.is_none() {
|
||||||
doc.translation = self
|
doc.translation = self
|
||||||
.fetch_translation_fallback(track_id, &urls)
|
.fetch_translation_fallback(track_id, &urls)
|
||||||
.await;
|
.await;
|
||||||
@@ -1531,7 +1582,7 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[API] get_lyrics({}) document without original content -> no lyrics",
|
"[API] get_lyrics({}) document without any lyrics content -> no lyrics",
|
||||||
track_id
|
track_id
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ pub use cmaf::{
|
|||||||
pub use error::{ApiError, Result};
|
pub use error::{ApiError, Result};
|
||||||
pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
|
pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
|
||||||
pub use lyrics::{
|
pub use lyrics::{
|
||||||
QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine, QobuzLyricsPlainLine,
|
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine,
|
||||||
QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls, QobuzLyricsWord,
|
QobuzLyricsPlainLine, QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls,
|
||||||
|
QobuzLyricsWord,
|
||||||
};
|
};
|
||||||
@@ -104,14 +104,32 @@ pub struct QobuzLyricsDocument {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub original: Option<QobuzLyricsContent>,
|
pub original: Option<QobuzLyricsContent>,
|
||||||
/// Translated lyrics content (NEW in API v10) — same union shape as
|
/// Translated lyrics content (NEW in API v10) — same union shape as
|
||||||
/// `original`. Present only when a translation was requested via the
|
/// `original`. LIVE WIRE (verified 2026-07-22): when `language` is
|
||||||
/// `language` query param AND the document embeds it; otherwise the
|
/// requested and available, the step-1 `lyrics_url` itself points at the
|
||||||
/// step-1 envelope's `translation_requested.lyrics_url` points at a
|
/// TRANSLATED document (`<id>-<lang>.json`), which carries ONLY this
|
||||||
/// separate document. Absent on all pre-v10 captures.
|
/// `translation` block and no `original` — the client must fetch the
|
||||||
|
/// original document separately and merge. `translation_requested` was
|
||||||
|
/// not observed in practice (kept as a fallback source only).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub translation: Option<QobuzLyricsContent>,
|
pub translation: Option<QobuzLyricsContent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge a translation-only document into its original document (v10 live
|
||||||
|
/// wire: a `language` request returns a document carrying ONLY `translation`).
|
||||||
|
/// Moves the `translation` block over and unions `translation_langs`.
|
||||||
|
/// `original` keeps its own content/writers/publishers.
|
||||||
|
pub fn merge_translation_into(
|
||||||
|
original: &mut QobuzLyricsDocument,
|
||||||
|
mut translated: QobuzLyricsDocument,
|
||||||
|
) {
|
||||||
|
original.translation = translated.translation.take();
|
||||||
|
for lang in translated.translation_langs.drain(..) {
|
||||||
|
if !original.translation_langs.contains(&lang) {
|
||||||
|
original.translation_langs.push(lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One publisher credit inside [`QobuzLyricsDocument`] (live-only; not in the yaml).
|
/// One publisher credit inside [`QobuzLyricsDocument`] (live-only; not in the yaml).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct QobuzLyricsPublisher {
|
pub struct QobuzLyricsPublisher {
|
||||||
@@ -257,6 +275,8 @@ mod tests {
|
|||||||
include_str!("../tests/fixtures/lyrics-doc-lsync-translation.json");
|
include_str!("../tests/fixtures/lyrics-doc-lsync-translation.json");
|
||||||
const DOC_PLAIN_TRANSLATION: &str =
|
const DOC_PLAIN_TRANSLATION: &str =
|
||||||
include_str!("../tests/fixtures/lyrics-doc-plain-translation.json");
|
include_str!("../tests/fixtures/lyrics-doc-plain-translation.json");
|
||||||
|
const DOC_TRANSLATION_ONLY: &str =
|
||||||
|
include_str!("../tests/fixtures/lyrics-doc-translation-only.json");
|
||||||
const MISS_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-miss-response.json");
|
const MISS_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-miss-response.json");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -453,6 +473,54 @@ mod tests {
|
|||||||
assert!(doc.translation_langs.is_empty());
|
assert!(doc.translation_langs.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_translation_only_document() {
|
||||||
|
// v10 LIVE WIRE (verified 2026-07-22, track 266725027 with
|
||||||
|
// language=es): a translation request returns a document carrying
|
||||||
|
// ONLY `translation` — no `original` at all.
|
||||||
|
let doc: QobuzLyricsDocument = serde_json::from_str(DOC_TRANSLATION_ONLY)
|
||||||
|
.expect("parse translation-only document");
|
||||||
|
assert!(doc.original.is_none(), "no original on the translated doc");
|
||||||
|
assert_eq!(doc.translation_langs.len(), 5);
|
||||||
|
let translation = doc.translation.expect("translation present");
|
||||||
|
assert!(translation.is_synced(), "lsync alias maps to Synced");
|
||||||
|
assert_eq!(translation.lang(), Some("es"));
|
||||||
|
match &translation {
|
||||||
|
QobuzLyricsContent::Synced { lines, .. } => {
|
||||||
|
assert_eq!(lines.len(), 4);
|
||||||
|
assert!(lines[1].start.is_none(), "gap line without stamps");
|
||||||
|
assert_eq!(lines[2].line, "Podría comerme a esa chica para el almuerzo");
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_translation_into_original_document() {
|
||||||
|
// The get_lyrics merge path: original doc (9.x shape) + translation-
|
||||||
|
// only doc combine into one bilingual document.
|
||||||
|
let mut original: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_WSYNC).expect("parse original document");
|
||||||
|
assert!(original.translation.is_none());
|
||||||
|
let translated: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_TRANSLATION_ONLY).expect("parse translation-only document");
|
||||||
|
|
||||||
|
merge_translation_into(&mut original, translated);
|
||||||
|
|
||||||
|
assert!(original.original.is_some(), "original content kept");
|
||||||
|
let translation = original.translation.expect("merged translation");
|
||||||
|
assert_eq!(translation.lang(), Some("es"));
|
||||||
|
// translation_langs unioned WITHOUT duplicates (both fixtures carry
|
||||||
|
// the same 5 codes).
|
||||||
|
assert_eq!(original.translation_langs.len(), 5);
|
||||||
|
let mut sorted = original.translation_langs.clone();
|
||||||
|
sorted.sort();
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sorted.len(), original.translation_langs.len());
|
||||||
|
// Writers/publishers stay the original document's.
|
||||||
|
assert!(original.writers.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn content_serializes_back_to_wire_shape() {
|
fn content_serializes_back_to_wire_shape() {
|
||||||
// The lyrics cache persists an embedded translation verbatim, so the
|
// The lyrics cache persists an embedded translation verbatim, so the
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"translation": {
|
||||||
|
"type": "lsync",
|
||||||
|
"lang": "es",
|
||||||
|
"lines": [
|
||||||
|
{ "line": "Oh, mm-mm", "start": 1750, "end": 4770 },
|
||||||
|
{ "line": "" },
|
||||||
|
{ "line": "Podría comerme a esa chica para el almuerzo", "start": 5320, "end": 6990 },
|
||||||
|
{ "line": "Sí, ella baila en mi lengua", "start": 7230, "end": 8840 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user