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:
4 files changed
+557
-30
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user