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:
vicrodh committed 2026-07-22 21:34:39 -06:00
1 parent 9cc7307616
commit accdef5a02
4 files changed
+156 -14

No files matched your search

+59 -8
View File
@@ -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)
+3 -2
View File
@@ -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,
}; };
+72 -4
View File
@@ -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 }
]
}
}