feat(lyrics): map and cache embedded translations

LyricsRequest carries an optional translation target that threads
through the LyricsProviders::qobuz boundary to the client; the external
fallback chain is untouched. LyricsDoc gains translation
(Option<Box<TranslatedLyrics>> with a LyricsKind dispatch mirroring the
Android v10 mapper: wsync->WordSynced, lsync->LineSynced, plain->Plain)
plus a client-derived has_translation (requested lang in
translation_langs, false when none requested). QobuzWsync stores the
translation verbatim in the existing cache JSON (serde default, no DB
migration) with its lang recorded: a synced cache hit only serves a
language request when the stored translation matches it, otherwise the
chain refetches; original-only requests keep today's behavior.

km
This commit is contained in:
vicrodh committed 2026-07-22 19:12:07 -06:00
1 parent 8c2dfd5727
commit 378afb9c9a
4 files changed
+557 -30

No files matched your search

+4 -1
View File
@@ -40,7 +40,10 @@ pub mod wsync;
pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
pub use lrc::{emit_lrc, parse_lrc, parse_plain, LrcSpan, MAX_SUNG_MS};
pub use model::{build_cache_key, LyricsDoc, LyricsLine, LyricsPayload, LyricsProvider, Word};
pub use model::{
build_cache_key, derive_has_translation, LyricsDoc, LyricsKind, LyricsLine, LyricsPayload,
LyricsProvider, TranslatedLyrics, Word,
};
pub use providers::LyricsData;
pub use service::{
HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse,
+71 -6
View File
@@ -81,6 +81,38 @@ pub struct LyricsLine {
pub words: Option<Vec<Word>>,
}
/// Synchronization kind of a lyrics content block — mirrors the Android v10
/// mapper's dispatch on the wire `type` (wsync -> word-synced, lsync ->
/// line-synced, plain -> plain).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum LyricsKind {
LineSynced,
Plain,
WordSynced,
}
/// A translated lyrics block (Qobuz API v10). Reuses [`LyricsLine`]/[`Word`]:
/// synced translations carry the SAME timestamps as the original, so sync and
/// karaoke stay keyed on the original timings while the translation renders
/// as static text under each line.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TranslatedLyrics {
pub kind: LyricsKind,
/// ISO 639-1 of the translation (the language it was fetched with).
pub lang: Option<String>,
pub lines: Vec<LyricsLine>,
}
/// Derive `has_translation` (client-derived, NEVER a wire field): the
/// requested language is listed in the document's `translation_langs`.
/// False when no language was requested.
pub fn derive_has_translation(translation_langs: &[String], requested_lang: Option<&str>) -> bool {
match requested_lang {
Some(lang) => translation_langs.iter().any(|candidate| candidate == lang),
None => false,
}
}
/// Parsed, render-ready lyrics document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LyricsDoc {
@@ -92,6 +124,14 @@ pub struct LyricsDoc {
pub translation_langs: Vec<String>,
/// Songwriter credits display string (Qobuz metadata passthrough).
pub writers: Option<String>,
/// Embedded translation (Qobuz v10 only; `None` for other providers and
/// for original-only fetches). Boxed: rare + large, keep the doc small.
#[serde(default)]
pub translation: Option<Box<TranslatedLyrics>>,
/// Client-derived (see [`derive_has_translation`]): a translation for the
/// requested language is available. False when none was requested.
#[serde(default)]
pub has_translation: bool,
}
impl LyricsDoc {
@@ -103,6 +143,8 @@ impl LyricsDoc {
provider,
translation_langs: Vec::new(),
writers: None,
translation: None,
has_translation: false,
}
}
@@ -116,6 +158,8 @@ impl LyricsDoc {
provider,
translation_langs: Vec::new(),
writers: None,
translation: None,
has_translation: false,
}
}
@@ -127,6 +171,8 @@ impl LyricsDoc {
provider,
translation_langs: Vec::new(),
writers: None,
translation: None,
has_translation: false,
}
}
@@ -152,13 +198,19 @@ impl LyricsDoc {
/// Build from a cached row. Readers PREFER the native wsync document when
/// present (amended Q5): word timestamps survive the cache; the LRC column
/// stays the cross-frontend lingua franca.
pub fn from_cached(payload: &LyricsPayload, qobuz_wsync_json: Option<&str>) -> Self {
/// stays the cross-frontend lingua franca. `requested_lang` (the active
/// translation target, if any) drives the client-derived `has_translation`
/// on the served document.
pub fn from_cached(
payload: &LyricsPayload,
qobuz_wsync_json: Option<&str>,
requested_lang: Option<&str>,
) -> Self {
if payload.provider == LyricsProvider::Qobuz {
if let Some(json) = qobuz_wsync_json {
match serde_json::from_str::<crate::wsync::QobuzWsync>(json) {
Ok(wsync) => {
let doc = wsync.to_doc();
let doc = wsync.to_doc(requested_lang);
if !doc.lines.is_empty() {
return doc;
}
@@ -297,22 +349,35 @@ mod tests {
{"line":"hello","start":1000,"end":2000,
"words":[{"word":"hello","start":1000,"end":2000}]}
]}"#;
let doc = LyricsDoc::from_cached(&payload, Some(wsync_json));
let doc = LyricsDoc::from_cached(&payload, Some(wsync_json), None);
assert!(doc.synced);
let words = doc.lines[0].words.as_ref().expect("native words preserved");
assert_eq!(words[0].text, "hello");
// Without the column, the LRC path applies (no words).
let doc = LyricsDoc::from_cached(&payload, None);
let doc = LyricsDoc::from_cached(&payload, None, None);
assert!(doc.synced);
assert!(doc.lines[0].words.is_none());
// Corrupt wsync json degrades to the LRC path instead of erroring.
let doc = LyricsDoc::from_cached(&payload, Some("{not json"));
let doc = LyricsDoc::from_cached(&payload, Some("{not json"), None);
assert!(doc.synced);
assert!(doc.lines[0].words.is_none());
}
#[test]
fn has_translation_derivation() {
let langs = vec!["pt".to_string(), "de".to_string(), "es".to_string()];
// Requested lang listed -> true.
assert!(derive_has_translation(&langs, Some("es")));
// Requested lang NOT listed -> false (track lacks that language).
assert!(!derive_has_translation(&langs, Some("ja")));
// No language requested -> false, even when translations exist.
assert!(!derive_has_translation(&langs, None));
// Empty list (feature off) -> false.
assert!(!derive_has_translation(&[], Some("es")));
}
#[test]
fn plain_text_joins_lines() {
let doc = LyricsDoc::from_plain_text("a\n\n b \nc", LyricsProvider::Ovh);
+274 -18
View File
@@ -38,9 +38,11 @@ use tokio::sync::Mutex;
use qbz_qobuz::{QobuzClient, QobuzLyricsContent, QobuzLyricsDocument};
use crate::cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
use crate::model::{build_cache_key, LyricsDoc, LyricsPayload, LyricsProvider};
use crate::model::{
build_cache_key, derive_has_translation, LyricsDoc, LyricsPayload, LyricsProvider,
};
use crate::providers::{fetch_lrclib, fetch_lyrics_ovh, LyricsData};
use crate::wsync::QobuzWsync;
use crate::wsync::{translated_from_content, QobuzWsync};
/// What kind of source the playing track comes from. The Qobuz primary step
/// only applies to Qobuz catalog tracks with a real track_id; local-library /
@@ -63,6 +65,12 @@ pub struct LyricsRequest {
pub album: Option<String>,
pub duration_secs: Option<u64>,
pub offline: bool,
/// Active translation target (ISO 639-1, Qobuz API v10). `None` = the
/// default original-only fetch; `Some(lang)` asks the Qobuz primary for
/// a translation and drives the client-derived `has_translation` on the
/// served document. Only the Qobuz provider is affected — the external
/// fallback chain never sees it.
pub language: Option<String>,
}
/// Served lyrics: the wire-compatible payload plus the parsed document
@@ -96,10 +104,15 @@ pub struct LyricsResponse {
/// fakes and the chain logic stays headless-testable.
#[async_trait]
pub trait LyricsProviders: Send + Sync {
/// Qobuz primary (two-step: signed lyricsUrl + CDN doc).
/// `Ok(None)` = typed miss; `Err` = transport/auth/offline failure —
/// both degrade silently to the fallback chain.
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String>;
/// Qobuz primary (two-step: signed lyricsUrl + CDN doc). `language` is the
/// optional translation target (ISO 639-1, v10); `None` fetches
/// original-only. `Ok(None)` = typed miss; `Err` = transport/auth/offline
/// failure — both degrade silently to the fallback chain.
async fn qobuz(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsDocument>, String>;
/// LRCLIB. `Ok(None)` = no match; `Err` = transport error (the chain
/// retries exactly once).
@@ -128,9 +141,13 @@ impl HttpLyricsProviders {
#[async_trait]
impl LyricsProviders for HttpLyricsProviders {
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
async fn qobuz(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsDocument>, String> {
self.client
.get_lyrics(track_id)
.get_lyrics(track_id, language)
.await
.map_err(|e| e.to_string())
}
@@ -263,11 +280,40 @@ fn dedupe_key(request: &LyricsRequest) -> String {
)
),
};
format!("{}|offline:{}", identity, request.offline)
// The translation target is part of the identity: a refetch WITH a
// language must never join an in-flight original-only resolution.
format!(
"{}|offline:{}|lang:{}",
identity,
request.offline,
request.language.as_deref().unwrap_or("")
)
}
fn cached_result(cached: CachedLyrics) -> LyricsResult {
let doc = LyricsDoc::from_cached(&cached.payload, cached.qobuz_wsync_json.as_deref());
/// A cached entry satisfies a language request only when its stored native
/// wsync document carries a translation FOR THAT language (the content's own
/// `lang` records it, spec §A.5); anything else falls through to a refetch.
/// Original-only requests (`None`) are served by any synced hit, as before.
fn cached_serves_language(cached: &CachedLyrics, language: Option<&str>) -> bool {
match language {
None => true,
Some(lang) => cached
.qobuz_wsync_json
.as_deref()
.and_then(|json| serde_json::from_str::<QobuzWsync>(json).ok())
.and_then(|wsync| wsync.translation)
.and_then(|content| content.lang().map(str::to_owned))
.map(|cached_lang| cached_lang == lang)
.unwrap_or(false),
}
}
fn cached_result(cached: CachedLyrics, requested_lang: Option<&str>) -> LyricsResult {
let doc = LyricsDoc::from_cached(
&cached.payload,
cached.qobuz_wsync_json.as_deref(),
requested_lang,
);
LyricsResult {
payload: cached.payload,
doc,
@@ -306,7 +352,10 @@ async fn run_chain(
.or_else(|| db.get_by_cache_key(&cache_key).ok().flatten());
return Ok(match cached {
Some(cached) => respond(LyricsOutcome::Found(cached_result(cached))),
Some(cached) => respond(LyricsOutcome::Found(cached_result(
cached,
request.language.as_deref(),
))),
None => respond(LyricsOutcome::NotAvailableOffline),
});
}
@@ -330,10 +379,14 @@ async fn run_chain(
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_synced {
return Ok(respond(LyricsOutcome::Found(cached_result(cached))));
if has_synced && cached_serves_language(&cached, request.language.as_deref()) {
return Ok(respond(LyricsOutcome::Found(cached_result(
cached,
request.language.as_deref(),
))));
}
// plain-only cache: fall through to re-fetch for synced
// plain-only cache — or a synced entry without a translation for
// the requested language: fall through to re-fetch (spec §A.5).
}
} // lock released before any network round-trip
@@ -360,7 +413,11 @@ async fn run_chain(
let qobuz_fut = async {
if request.source == LyricsSourceKind::Qobuz {
if let Some(track_id) = request.track_id {
match inner.providers.qobuz(track_id).await {
match inner
.providers
.qobuz(track_id, request.language.as_deref())
.await
{
Ok(Some(document)) => return Some(document),
Ok(None) => log::debug!("[Lyrics] Qobuz miss for track {}", track_id),
Err(e) => {
@@ -397,7 +454,7 @@ async fn run_chain(
let mut qobuz_plain_candidate: Option<LyricsResult> = None;
if let Some(document) = qobuz_doc {
if let Some(wsync) = QobuzWsync::from_document(&document) {
let doc = wsync.to_doc();
let doc = wsync.to_doc(request.language.as_deref());
if !doc.lines.is_empty() {
let mut payload = base_payload(LyricsProvider::Qobuz);
payload.plain = Some(doc.plain_text());
@@ -421,6 +478,14 @@ async fn run_chain(
let mut doc = LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz);
doc.translation_langs = document.translation_langs.clone();
doc.writers = document.writers.clone();
doc.translation = document
.translation
.as_ref()
.map(|content| Box::new(translated_from_content(content)));
doc.has_translation = derive_has_translation(
&document.translation_langs,
request.language.as_deref(),
);
let mut payload = base_payload(LyricsProvider::Qobuz);
payload.plain = Some(blob.trim().to_string());
qobuz_plain_candidate = Some(LyricsResult { payload, doc });
@@ -493,13 +558,23 @@ mod tests {
qobuz_calls: AtomicUsize,
lrclib_calls: AtomicUsize,
ovh_calls: AtomicUsize,
/// Language argument observed on each qobuz call (threading proof).
qobuz_languages: StdMutex<Vec<Option<String>>>,
delay_ms: Option<u64>,
}
#[async_trait]
impl LyricsProviders for FakeProviders {
async fn qobuz(&self, _track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
async fn qobuz(
&self,
_track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsDocument>, String> {
self.qobuz_calls.fetch_add(1, Ordering::SeqCst);
self.qobuz_languages
.lock()
.unwrap()
.push(language.map(str::to_owned));
if let Some(ms) = self.delay_ms {
tokio::time::sleep(Duration::from_millis(ms)).await;
}
@@ -549,6 +624,32 @@ mod tests {
.unwrap()
}
fn wsync_document_with_translation() -> QobuzLyricsDocument {
serde_json::from_str(
r#"{
"track_id": 100, "album_id": "alb",
"translation_langs": ["es", "fr"], "writers": "W. Riter",
"original": {"type": "wsync", "lang": "en", "lines": [
{"line": "first line", "start": 1000, "end": 3000,
"words": [{"word": "first", "start": 1000, "end": 1800},
{"word": "line", "start": 1800, "end": 3000}]},
{"line": "second line", "start": 9000, "end": 11000,
"words": [{"word": "second", "start": 9000, "end": 9900},
{"word": "line", "start": 9900, "end": 11000}]}
]},
"translation": {"type": "wsync", "lang": "es", "lines": [
{"line": "primera linea", "start": 1000, "end": 3000,
"words": [{"word": "primera", "start": 1000, "end": 1800},
{"word": "linea", "start": 1800, "end": 3000}]},
{"line": "segunda linea", "start": 9000, "end": 11000,
"words": [{"word": "segunda", "start": 9000, "end": 9900},
{"word": "linea", "start": 9900, "end": 11000}]}
]}
}"#,
)
.unwrap()
}
fn plain_document() -> QobuzLyricsDocument {
serde_json::from_str(
r#"{
@@ -593,6 +694,7 @@ mod tests {
album: Some("Album".into()),
duration_secs: Some(200),
offline: false,
language: None,
}
}
@@ -797,6 +899,160 @@ mod tests {
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 2);
}
// ---------- translation (v10) ----------
#[tokio::test]
async fn language_threads_to_qobuz_and_translation_is_mapped() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(wsync_document_with_translation())));
let (service, _dir) = service_with(providers.clone()).await;
let mut translated = request();
translated.language = Some("es".into());
let response = service.get(translated).await.unwrap();
let result = found(&response);
// The language reached the provider; the doc carries the mapped
// translation (wsync -> WordSynced, native word stamps, same timings).
assert_eq!(
providers.qobuz_languages.lock().unwrap().as_slice(),
&[Some("es".to_string())]
);
assert!(result.doc.has_translation);
let translation = result.doc.translation.as_ref().expect("translation mapped");
assert_eq!(translation.kind, crate::LyricsKind::WordSynced);
assert_eq!(translation.lang.as_deref(), Some("es"));
assert_eq!(translation.lines.len(), 2);
assert_eq!(translation.lines[0].text, "primera linea");
assert_eq!(translation.lines[0].time_ms, Some(1000));
let words = translation.lines[0].words.as_ref().expect("words survive");
assert_eq!(words[0].text, "primera");
// Sync stays keyed on the ORIGINAL lines/timings.
assert_eq!(result.doc.lines[0].text, "first line");
}
#[tokio::test]
async fn translation_survives_the_cache_for_the_same_language() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(wsync_document_with_translation())));
let (service, _dir) = service_with(providers.clone()).await;
let mut translated = request();
translated.language = Some("es".into());
service.get(translated.clone()).await.unwrap();
// Same language again -> synced cache hit WITH the stored
// translation; no second provider call.
let response = service.get(translated).await.unwrap();
let result = found(&response);
assert!(result.payload.cached);
assert!(result.doc.has_translation);
let translation = result.doc.translation.as_ref().expect("cached translation");
assert_eq!(translation.lang.as_deref(), Some("es"));
assert!(translation.lines[0].words.is_some());
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn different_language_refetches_original_only_uses_the_cache() {
let providers = Arc::new(FakeProviders::default());
{
let mut q = providers.qobuz_queue.lock().unwrap();
q.push_back(Ok(Some(wsync_document_with_translation())));
q.push_back(Ok(Some(wsync_document_with_translation())));
}
let (service, _dir) = service_with(providers.clone()).await;
// Seed the cache with the es translation.
let mut es = request();
es.language = Some("es".into());
service.get(es).await.unwrap();
// A request for a DIFFERENT language misses the stored translation
// and refetches (spec §A.5: the stored translation records its lang).
let mut fr = request();
fr.language = Some("fr".into());
let response = service.get(fr).await.unwrap();
assert!(!found(&response).payload.cached);
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 2);
// Original-only requests are still served by the synced cache hit.
let response = service.get(request()).await.unwrap();
let result = found(&response);
assert!(result.payload.cached);
assert!(!result.doc.has_translation);
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 2);
// The default fetch sent NO language.
assert_eq!(
providers.qobuz_languages.lock().unwrap().as_slice(),
&[Some("es".to_string()), Some("fr".to_string())]
);
}
#[tokio::test]
async fn default_fetch_maps_original_only_even_when_langs_are_listed() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(wsync_document())));
let (service, _dir) = service_with(providers.clone()).await;
let response = service.get(request()).await.unwrap();
let result = found(&response);
// translation_langs passthrough present, but nothing was requested:
// no translation, has_translation false.
assert_eq!(result.doc.translation_langs, vec!["es"]);
assert!(result.doc.translation.is_none());
assert!(!result.doc.has_translation);
assert_eq!(
providers.qobuz_languages.lock().unwrap().as_slice(),
&[None]
);
}
#[tokio::test]
async fn plain_original_with_plain_translation_is_mapped() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(serde_json::from_str(
r#"{
"track_id": 100, "album_id": "alb",
"translation_langs": ["de"],
"original": {"type": "plain", "lang": "en",
"lines": [{"line": "plain one"}, {"line": "plain two"}]},
"translation": {"type": "plain", "lang": "de",
"lines": [{"line": "einfach eins"}, {"line": "einfach zwei"}]}
}"#,
)
.unwrap())));
let (service, _dir) = service_with(providers.clone()).await;
let mut translated = request();
translated.language = Some("de".into());
let response = service.get(translated).await.unwrap();
let result = found(&response);
assert_eq!(result.payload.provider, LyricsProvider::Qobuz);
assert!(!result.doc.synced);
assert!(result.doc.has_translation);
let translation = result.doc.translation.as_ref().expect("translation mapped");
assert_eq!(translation.kind, crate::LyricsKind::Plain);
assert_eq!(translation.lines.len(), 2);
assert_eq!(translation.lines[0].text, "einfach eins");
}
// ---------- cache policy ----------
#[tokio::test]
+208 -5
View File
@@ -10,14 +10,18 @@
//! live captures `qbz-nix-docs/qobuz-api/lyrics-doc-wsync.json`), so a wire
//! content union parses as a [`QobuzWsync`] and a stored [`QobuzWsync`] is
//! wire-shaped. The Qobuz wrapper metadata (`translation_langs`, `writers`)
//! is carried as additive optional fields so it survives the cache.
//! and the v10 `translation` block are carried as additive optional fields
//! so they survive the cache.
use serde::{Deserialize, Serialize};
use qbz_qobuz::{QobuzLyricsContent, QobuzLyricsDocument};
use crate::lrc::{emit_lrc, LrcSpan};
use crate::model::{LyricsDoc, LyricsLine, LyricsProvider, Word};
use crate::model::{
derive_has_translation, LyricsDoc, LyricsKind, LyricsLine, LyricsProvider, TranslatedLyrics,
Word,
};
/// Discriminator tag — always `"wsync"` on output; accepts `"lsync"` on input
/// (the yaml's guess, kept as alias like the wire DTO does) and defaults when
@@ -43,6 +47,13 @@ pub struct QobuzWsync {
pub translation_langs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub writers: Option<String>,
/// Embedded translation (API v10), kept in the wire content-union shape
/// so it persists verbatim inside the cache JSON; the content's own
/// `lang` records which language the translation is for (a request for a
/// DIFFERENT language refetches). `serde(default)` — no DB migration:
/// rows written before this member existed still deserialize.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation: Option<QobuzLyricsContent>,
}
/// One synced line, wire-shaped. Gap separator lines come as
@@ -99,6 +110,7 @@ impl QobuzWsync {
.collect(),
translation_langs: doc.translation_langs.clone(),
writers: doc.writers.clone(),
translation: doc.translation.clone(),
}),
QobuzLyricsContent::Plain { .. } => None,
}
@@ -107,8 +119,10 @@ impl QobuzWsync {
/// Convert to the domain model. Gap separator lines (empty text) and
/// unplaceable lines (text without a `start` stamp) are skipped — in
/// wsync the previous line already carries its explicit `end`, so nothing
/// is lost. Native word stamps are preserved.
pub fn to_doc(&self) -> LyricsDoc {
/// is lost. Native word stamps are preserved. `requested_lang` (the
/// active translation target, if any) drives the client-derived
/// `has_translation`; the embedded translation maps like the original.
pub fn to_doc(&self, requested_lang: Option<&str>) -> LyricsDoc {
let lines: Vec<LyricsLine> = self
.lines
.iter()
@@ -142,6 +156,11 @@ impl QobuzWsync {
provider: LyricsProvider::Qobuz,
translation_langs: self.translation_langs.clone(),
writers: self.writers.clone(),
translation: self
.translation
.as_ref()
.map(|content| Box::new(translated_from_content(content))),
has_translation: derive_has_translation(&self.translation_langs, requested_lang),
}
}
@@ -165,6 +184,69 @@ impl QobuzWsync {
}
}
/// Map a wire content union to the domain [`TranslatedLyrics`] — used for a
/// document's embedded `translation` block. Kind dispatch mirrors the
/// Android v10 mapper (`qt6.java`): wsync (any word stamps) -> WordSynced,
/// lsync (synced, no words) -> LineSynced, plain -> Plain. Synced lines keep
/// their stamps (the SAME timings as the original) and are filtered exactly
/// like `to_doc`, so translation lines stay 1:1 aligned with the rendered
/// original lines.
pub(crate) fn translated_from_content(content: &QobuzLyricsContent) -> TranslatedLyrics {
match content {
QobuzLyricsContent::Synced { lang, lines } => {
let mapped: Vec<LyricsLine> = lines
.iter()
.filter(|line| !line.line.trim().is_empty())
.filter_map(|line| {
let start = line.start?;
Some(LyricsLine {
time_ms: Some(start),
end_ms: line.end,
text: line.line.clone(),
words: if line.words.is_empty() {
None
} else {
Some(
line.words
.iter()
.map(|word| Word {
start: word.start,
end: word.end,
text: word.word.clone(),
})
.collect(),
)
},
})
})
.collect();
let kind = if mapped.iter().any(|line| line.words.is_some()) {
LyricsKind::WordSynced
} else {
LyricsKind::LineSynced
};
TranslatedLyrics {
kind,
lang: lang.clone(),
lines: mapped,
}
}
QobuzLyricsContent::Plain { lang, lines } => TranslatedLyrics {
kind: LyricsKind::Plain,
lang: lang.clone(),
lines: lines
.iter()
.map(|line| LyricsLine {
time_ms: None,
end_ms: None,
text: line.line.clone(),
words: None,
})
.collect(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -228,7 +310,7 @@ mod tests {
#[test]
fn to_doc_skips_gaps_and_preserves_words() {
let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
let doc = wsync.to_doc();
let doc = wsync.to_doc(None);
assert!(doc.synced);
assert_eq!(doc.provider, LyricsProvider::Qobuz);
assert_eq!(doc.lines.len(), 2); // gap separator dropped from render model
@@ -282,4 +364,125 @@ mod tests {
let parsed: QobuzWsync = serde_json::from_str(lsync).unwrap();
assert_eq!(parsed.lines[0].start, Some(1));
}
fn sample_document_with_translation() -> QobuzLyricsDocument {
// v10 shape: the document embeds a `translation` block (same content
// union as `original`) when a `language` was requested.
let json = r#"{
"album_id": "gvcirtodd95kc",
"track_id": 266725027,
"translation_langs": ["pt", "de", "fr", "es", "it"],
"writers": "Billie Eilish O'Connell",
"original": {
"type": "wsync",
"lang": "en",
"lines": [
{"line": "Oh, mm-mm", "start": 1750, "end": 4770,
"words": [
{"word": "Oh,", "start": 1750, "end": 2480},
{"word": "mm-mm", "start": 2480, "end": 4770}
]}
]
},
"translation": {
"type": "wsync",
"lang": "es",
"lines": [
{"line": "Oh, mm-mm (es)", "start": 1750, "end": 4770,
"words": [
{"word": "Oh,", "start": 1750, "end": 2480},
{"word": "mm-mm", "start": 2480, "end": 4770}
]}
]
}
}"#;
serde_json::from_str(json).expect("valid translation fixture")
}
#[test]
fn from_document_carries_the_embedded_translation() {
let wsync =
QobuzWsync::from_document(&sample_document_with_translation()).expect("synced doc");
let translation = wsync.translation.expect("translation preserved");
assert_eq!(translation.lang(), Some("es"));
assert_eq!(translation.line_count(), 1);
// 9.x documents (no `translation` member) keep `translation: None`.
let wsync = QobuzWsync::from_document(&sample_document()).expect("synced doc");
assert!(wsync.translation.is_none());
}
#[test]
fn to_doc_maps_translation_and_derives_has_translation() {
let wsync = QobuzWsync::from_document(&sample_document_with_translation()).unwrap();
// Requested lang listed -> has_translation; translation mapped with
// kind dispatch (wsync -> WordSynced) and native word stamps.
let doc = wsync.to_doc(Some("es"));
assert!(doc.has_translation);
let translation = doc.translation.expect("translation mapped");
assert_eq!(translation.kind, LyricsKind::WordSynced);
assert_eq!(translation.lang.as_deref(), Some("es"));
assert_eq!(translation.lines.len(), 1);
assert_eq!(translation.lines[0].time_ms, Some(1750));
let words = translation.lines[0].words.as_ref().expect("words survive");
assert_eq!(words[1].text, "mm-mm");
assert_eq!(words[1].start, 2480);
// Requested lang NOT in translation_langs -> no has_translation (the
// mapped block may still be present; the caller renders original-only).
let doc = wsync.to_doc(Some("ja"));
assert!(!doc.has_translation);
// No language requested -> has_translation false by construction.
let doc = wsync.to_doc(None);
assert!(!doc.has_translation);
assert!(doc.translation.is_some());
}
#[test]
fn translation_kind_dispatch_covers_lsync_and_plain() {
// lsync: synced without words -> LineSynced.
let content: QobuzLyricsContent = serde_json::from_str(
r#"{"type":"lsync","lang":"fr","lines":[{"line":"salut","start":100,"end":200}]}"#,
)
.unwrap();
let translated = translated_from_content(&content);
assert_eq!(translated.kind, LyricsKind::LineSynced);
assert_eq!(translated.lines[0].time_ms, Some(100));
assert!(translated.lines[0].words.is_none());
// plain: text-only lines -> Plain, no stamps.
let content: QobuzLyricsContent = serde_json::from_str(
r#"{"type":"plain","lang":"de","lines":[{"line":"hallo"},{"line":"welt"}]}"#,
)
.unwrap();
let translated = translated_from_content(&content);
assert_eq!(translated.kind, LyricsKind::Plain);
assert_eq!(translated.lines.len(), 2);
assert_eq!(translated.lines[0].time_ms, None);
}
#[test]
fn stored_json_with_translation_round_trips_and_old_rows_still_load() {
let wsync = QobuzWsync::from_document(&sample_document_with_translation()).unwrap();
let json = serde_json::to_string(&wsync).unwrap();
let back: QobuzWsync = serde_json::from_str(&json).unwrap();
assert_eq!(back, wsync);
assert_eq!(
back.translation.as_ref().and_then(|t| t.lang().map(str::to_owned)),
Some("es".to_string())
);
// NO DB migration: a row written BEFORE the translation member
// existed must still deserialize (serde default -> None).
let old_format = r#"{"type":"wsync","lang":"en","translation_langs":["es","fr"],
"lines":[{"line":"hi","start":1,"end":2,
"words":[{"word":"hi","start":1,"end":2}]}]}"#;
let parsed: QobuzWsync = serde_json::from_str(old_format).unwrap();
assert!(parsed.translation.is_none());
let doc = parsed.to_doc(Some("es"));
assert!(doc.translation.is_none());
assert!(doc.has_translation, "lang listed -> has_translation");
}
}