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::error::{ApiError, Result};
|
||||
use super::forbidden_breaker::ForbiddenBreaker;
|
||||
use super::lyrics::{QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls};
|
||||
use super::lyrics::{
|
||||
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls,
|
||||
};
|
||||
use qbz_models::*;
|
||||
|
||||
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`);
|
||||
/// 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.
|
||||
/// [`Self::get_lyrics_url`]; a document without usable content counts as
|
||||
/// a miss.
|
||||
///
|
||||
/// `language` (ISO 639-1, API v10) requests a translation; `None` fetches
|
||||
/// original-only (the default flow). When a translation was requested but
|
||||
/// the document does not EMBED a `translation` block, the envelope's
|
||||
/// original-only (the default flow). LIVE WIRE (verified 2026-07-22):
|
||||
/// 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
|
||||
/// content becomes the translation (fail soft: any gap in that fallback
|
||||
/// just leaves the document original-only).
|
||||
@@ -1499,6 +1507,49 @@ impl QobuzClient {
|
||||
&self,
|
||||
track_id: u64,
|
||||
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>> {
|
||||
let urls = match self.get_lyrics_url(track_id, language).await? {
|
||||
Some(urls) => urls,
|
||||
@@ -1521,8 +1572,8 @@ impl QobuzClient {
|
||||
}
|
||||
let body = response.text().await?;
|
||||
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
||||
Ok(mut doc) if doc.original.is_some() => {
|
||||
if language.is_some() && doc.translation.is_none() {
|
||||
Ok(mut doc) if doc.original.is_some() || doc.translation.is_some() => {
|
||||
if language.is_some() && doc.original.is_some() && doc.translation.is_none() {
|
||||
doc.translation = self
|
||||
.fetch_translation_fallback(track_id, &urls)
|
||||
.await;
|
||||
@@ -1531,7 +1582,7 @@ impl QobuzClient {
|
||||
}
|
||||
Ok(_) => {
|
||||
log::debug!(
|
||||
"[API] get_lyrics({}) document without original content -> no lyrics",
|
||||
"[API] get_lyrics({}) document without any lyrics content -> no lyrics",
|
||||
track_id
|
||||
);
|
||||
Ok(None)
|
||||
|
||||
@@ -58,6 +58,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,
|
||||
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine,
|
||||
QobuzLyricsPlainLine, QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls,
|
||||
QobuzLyricsWord,
|
||||
};
|
||||
@@ -104,14 +104,32 @@ pub struct QobuzLyricsDocument {
|
||||
#[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.
|
||||
/// `original`. LIVE WIRE (verified 2026-07-22): when `language` is
|
||||
/// requested and available, the step-1 `lyrics_url` itself points at the
|
||||
/// TRANSLATED document (`<id>-<lang>.json`), which carries ONLY this
|
||||
/// `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)]
|
||||
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).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QobuzLyricsPublisher {
|
||||
@@ -257,6 +275,8 @@ mod tests {
|
||||
include_str!("../tests/fixtures/lyrics-doc-lsync-translation.json");
|
||||
const DOC_PLAIN_TRANSLATION: &str =
|
||||
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");
|
||||
|
||||
#[test]
|
||||
@@ -453,6 +473,54 @@ mod tests {
|
||||
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]
|
||||
fn content_serializes_back_to_wire_shape() {
|
||||
// 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