diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 120aafa44..165f3b7ec 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -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 (`-.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> { + 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> { 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::(&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) diff --git a/crates/qbz-qobuz/src/lib.rs b/crates/qbz-qobuz/src/lib.rs index c2ac08ea6..6f1b69dfc 100644 --- a/crates/qbz-qobuz/src/lib.rs +++ b/crates/qbz-qobuz/src/lib.rs @@ -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, }; diff --git a/crates/qbz-qobuz/src/lyrics.rs b/crates/qbz-qobuz/src/lyrics.rs index 2d16cc3e5..5376b939b 100644 --- a/crates/qbz-qobuz/src/lyrics.rs +++ b/crates/qbz-qobuz/src/lyrics.rs @@ -104,14 +104,32 @@ pub struct QobuzLyricsDocument { #[serde(default)] pub original: Option, /// 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 (`-.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, } +/// 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 diff --git a/crates/qbz-qobuz/tests/fixtures/lyrics-doc-translation-only.json b/crates/qbz-qobuz/tests/fixtures/lyrics-doc-translation-only.json new file mode 100644 index 000000000..f674f48eb --- /dev/null +++ b/crates/qbz-qobuz/tests/fixtures/lyrics-doc-translation-only.json @@ -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 } + ] + } +}