Merge branch 'feature/internal/lyrics-translation' into chore/internal/smoke-lyrics-discover

This commit is contained in:
vicrodh committed 2026-07-22 23:03:15 -06:00
commit 3a6e5960ac
35 files changed
+1857 -84

No files matched your search

+3 -2
View File
@@ -992,7 +992,8 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// Get a track's lyrics document (synced/unsynced), or `None` if the track /// Get a track's lyrics document (synced/unsynced), or `None` if the track
/// has none. Thin wrapper over `QobuzClient::get_lyrics` so headless callers /// has none. Thin wrapper over `QobuzClient::get_lyrics` so headless callers
/// (qbzd) avoid the `client()` escape hatch. /// (qbzd) avoid the `client()` escape hatch. Original-only fetch (no
/// translation target — the headless surface has no translation toggle).
pub async fn get_lyrics( pub async fn get_lyrics(
&self, &self,
track_id: u64, track_id: u64,
@@ -1000,7 +1001,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = self.client.read().await; let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client.get_lyrics(track_id).await.map_err(CoreError::Api) client.get_lyrics(track_id, None).await.map_err(CoreError::Api)
} }
/// Get artist by ID /// Get artist by ID
+4 -1
View File
@@ -40,7 +40,10 @@ pub mod wsync;
pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats}; pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
pub use lrc::{emit_lrc, parse_lrc, parse_plain, LrcSpan, MAX_SUNG_MS}; 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 providers::LyricsData;
pub use service::{ pub use service::{
HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse, HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse,
+71 -6
View File
@@ -81,6 +81,38 @@ pub struct LyricsLine {
pub words: Option<Vec<Word>>, 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. /// Parsed, render-ready lyrics document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LyricsDoc { pub struct LyricsDoc {
@@ -92,6 +124,14 @@ pub struct LyricsDoc {
pub translation_langs: Vec<String>, pub translation_langs: Vec<String>,
/// Songwriter credits display string (Qobuz metadata passthrough). /// Songwriter credits display string (Qobuz metadata passthrough).
pub writers: Option<String>, 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 { impl LyricsDoc {
@@ -103,6 +143,8 @@ impl LyricsDoc {
provider, provider,
translation_langs: Vec::new(), translation_langs: Vec::new(),
writers: None, writers: None,
translation: None,
has_translation: false,
} }
} }
@@ -116,6 +158,8 @@ impl LyricsDoc {
provider, provider,
translation_langs: Vec::new(), translation_langs: Vec::new(),
writers: None, writers: None,
translation: None,
has_translation: false,
} }
} }
@@ -127,6 +171,8 @@ impl LyricsDoc {
provider, provider,
translation_langs: Vec::new(), translation_langs: Vec::new(),
writers: None, 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 /// Build from a cached row. Readers PREFER the native wsync document when
/// present (amended Q5): word timestamps survive the cache; the LRC column /// present (amended Q5): word timestamps survive the cache; the LRC column
/// stays the cross-frontend lingua franca. /// stays the cross-frontend lingua franca. `requested_lang` (the active
pub fn from_cached(payload: &LyricsPayload, qobuz_wsync_json: Option<&str>) -> Self { /// 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 payload.provider == LyricsProvider::Qobuz {
if let Some(json) = qobuz_wsync_json { if let Some(json) = qobuz_wsync_json {
match serde_json::from_str::<crate::wsync::QobuzWsync>(json) { match serde_json::from_str::<crate::wsync::QobuzWsync>(json) {
Ok(wsync) => { Ok(wsync) => {
let doc = wsync.to_doc(); let doc = wsync.to_doc(requested_lang);
if !doc.lines.is_empty() { if !doc.lines.is_empty() {
return doc; return doc;
} }
@@ -297,22 +349,35 @@ mod tests {
{"line":"hello","start":1000,"end":2000, {"line":"hello","start":1000,"end":2000,
"words":[{"word":"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); assert!(doc.synced);
let words = doc.lines[0].words.as_ref().expect("native words preserved"); let words = doc.lines[0].words.as_ref().expect("native words preserved");
assert_eq!(words[0].text, "hello"); assert_eq!(words[0].text, "hello");
// Without the column, the LRC path applies (no words). // 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.synced);
assert!(doc.lines[0].words.is_none()); assert!(doc.lines[0].words.is_none());
// Corrupt wsync json degrades to the LRC path instead of erroring. // 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.synced);
assert!(doc.lines[0].words.is_none()); 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] #[test]
fn plain_text_joins_lines() { fn plain_text_joins_lines() {
let doc = LyricsDoc::from_plain_text("a\n\n b \nc", LyricsProvider::Ovh); 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 qbz_qobuz::{QobuzClient, QobuzLyricsContent, QobuzLyricsDocument};
use crate::cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats}; 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::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 /// 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 / /// 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 album: Option<String>,
pub duration_secs: Option<u64>, pub duration_secs: Option<u64>,
pub offline: bool, 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 /// 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. /// fakes and the chain logic stays headless-testable.
#[async_trait] #[async_trait]
pub trait LyricsProviders: Send + Sync { pub trait LyricsProviders: Send + Sync {
/// Qobuz primary (two-step: signed lyricsUrl + CDN doc). /// Qobuz primary (two-step: signed lyricsUrl + CDN doc). `language` is the
/// `Ok(None)` = typed miss; `Err` = transport/auth/offline failure — /// optional translation target (ISO 639-1, v10); `None` fetches
/// both degrade silently to the fallback chain. /// original-only. `Ok(None)` = typed miss; `Err` = transport/auth/offline
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String>; /// 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 /// LRCLIB. `Ok(None)` = no match; `Err` = transport error (the chain
/// retries exactly once). /// retries exactly once).
@@ -128,9 +141,13 @@ impl HttpLyricsProviders {
#[async_trait] #[async_trait]
impl LyricsProviders for HttpLyricsProviders { 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 self.client
.get_lyrics(track_id) .get_lyrics(track_id, language)
.await .await
.map_err(|e| e.to_string()) .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 { /// A cached entry satisfies a language request only when its stored native
let doc = LyricsDoc::from_cached(&cached.payload, cached.qobuz_wsync_json.as_deref()); /// 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 { LyricsResult {
payload: cached.payload, payload: cached.payload,
doc, doc,
@@ -306,7 +352,10 @@ async fn run_chain(
.or_else(|| db.get_by_cache_key(&cache_key).ok().flatten()); .or_else(|| db.get_by_cache_key(&cache_key).ok().flatten());
return Ok(match cached { 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), None => respond(LyricsOutcome::NotAvailableOffline),
}); });
} }
@@ -330,10 +379,14 @@ async fn run_chain(
.as_ref() .as_ref()
.map(|s| !s.trim().is_empty()) .map(|s| !s.trim().is_empty())
.unwrap_or(false); .unwrap_or(false);
if has_synced { if has_synced && cached_serves_language(&cached, request.language.as_deref()) {
return Ok(respond(LyricsOutcome::Found(cached_result(cached)))); 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 } // lock released before any network round-trip
@@ -360,7 +413,11 @@ async fn run_chain(
let qobuz_fut = async { let qobuz_fut = async {
if request.source == LyricsSourceKind::Qobuz { if request.source == LyricsSourceKind::Qobuz {
if let Some(track_id) = request.track_id { 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(Some(document)) => return Some(document),
Ok(None) => log::debug!("[Lyrics] Qobuz miss for track {}", track_id), Ok(None) => log::debug!("[Lyrics] Qobuz miss for track {}", track_id),
Err(e) => { Err(e) => {
@@ -397,7 +454,7 @@ async fn run_chain(
let mut qobuz_plain_candidate: Option<LyricsResult> = None; let mut qobuz_plain_candidate: Option<LyricsResult> = None;
if let Some(document) = qobuz_doc { if let Some(document) = qobuz_doc {
if let Some(wsync) = QobuzWsync::from_document(&document) { 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() { if !doc.lines.is_empty() {
let mut payload = base_payload(LyricsProvider::Qobuz); let mut payload = base_payload(LyricsProvider::Qobuz);
payload.plain = Some(doc.plain_text()); payload.plain = Some(doc.plain_text());
@@ -421,6 +478,14 @@ async fn run_chain(
let mut doc = LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz); let mut doc = LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz);
doc.translation_langs = document.translation_langs.clone(); doc.translation_langs = document.translation_langs.clone();
doc.writers = document.writers.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); let mut payload = base_payload(LyricsProvider::Qobuz);
payload.plain = Some(blob.trim().to_string()); payload.plain = Some(blob.trim().to_string());
qobuz_plain_candidate = Some(LyricsResult { payload, doc }); qobuz_plain_candidate = Some(LyricsResult { payload, doc });
@@ -493,13 +558,23 @@ mod tests {
qobuz_calls: AtomicUsize, qobuz_calls: AtomicUsize,
lrclib_calls: AtomicUsize, lrclib_calls: AtomicUsize,
ovh_calls: AtomicUsize, ovh_calls: AtomicUsize,
/// Language argument observed on each qobuz call (threading proof).
qobuz_languages: StdMutex<Vec<Option<String>>>,
delay_ms: Option<u64>, delay_ms: Option<u64>,
} }
#[async_trait] #[async_trait]
impl LyricsProviders for FakeProviders { 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_calls.fetch_add(1, Ordering::SeqCst);
self.qobuz_languages
.lock()
.unwrap()
.push(language.map(str::to_owned));
if let Some(ms) = self.delay_ms { if let Some(ms) = self.delay_ms {
tokio::time::sleep(Duration::from_millis(ms)).await; tokio::time::sleep(Duration::from_millis(ms)).await;
} }
@@ -549,6 +624,32 @@ mod tests {
.unwrap() .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 { fn plain_document() -> QobuzLyricsDocument {
serde_json::from_str( serde_json::from_str(
r#"{ r#"{
@@ -593,6 +694,7 @@ mod tests {
album: Some("Album".into()), album: Some("Album".into()),
duration_secs: Some(200), duration_secs: Some(200),
offline: false, offline: false,
language: None,
} }
} }
@@ -797,6 +899,160 @@ mod tests {
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 2); 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 ---------- // ---------- cache policy ----------
#[tokio::test] #[tokio::test]
+208 -5
View File
@@ -10,14 +10,18 @@
//! live captures `qbz-nix-docs/qobuz-api/lyrics-doc-wsync.json`), so a wire //! 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 //! content union parses as a [`QobuzWsync`] and a stored [`QobuzWsync`] is
//! wire-shaped. The Qobuz wrapper metadata (`translation_langs`, `writers`) //! 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 serde::{Deserialize, Serialize};
use qbz_qobuz::{QobuzLyricsContent, QobuzLyricsDocument}; use qbz_qobuz::{QobuzLyricsContent, QobuzLyricsDocument};
use crate::lrc::{emit_lrc, LrcSpan}; 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 /// 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 /// (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>, pub translation_langs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub writers: Option<String>, 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 /// One synced line, wire-shaped. Gap separator lines come as
@@ -99,6 +110,7 @@ impl QobuzWsync {
.collect(), .collect(),
translation_langs: doc.translation_langs.clone(), translation_langs: doc.translation_langs.clone(),
writers: doc.writers.clone(), writers: doc.writers.clone(),
translation: doc.translation.clone(),
}), }),
QobuzLyricsContent::Plain { .. } => None, QobuzLyricsContent::Plain { .. } => None,
} }
@@ -107,8 +119,10 @@ impl QobuzWsync {
/// Convert to the domain model. Gap separator lines (empty text) and /// Convert to the domain model. Gap separator lines (empty text) and
/// unplaceable lines (text without a `start` stamp) are skipped — in /// unplaceable lines (text without a `start` stamp) are skipped — in
/// wsync the previous line already carries its explicit `end`, so nothing /// wsync the previous line already carries its explicit `end`, so nothing
/// is lost. Native word stamps are preserved. /// is lost. Native word stamps are preserved. `requested_lang` (the
pub fn to_doc(&self) -> LyricsDoc { /// 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 let lines: Vec<LyricsLine> = self
.lines .lines
.iter() .iter()
@@ -142,6 +156,11 @@ impl QobuzWsync {
provider: LyricsProvider::Qobuz, provider: LyricsProvider::Qobuz,
translation_langs: self.translation_langs.clone(), translation_langs: self.translation_langs.clone(),
writers: self.writers.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -228,7 +310,7 @@ mod tests {
#[test] #[test]
fn to_doc_skips_gaps_and_preserves_words() { fn to_doc_skips_gaps_and_preserves_words() {
let wsync = QobuzWsync::from_document(&sample_document()).unwrap(); let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
let doc = wsync.to_doc(); let doc = wsync.to_doc(None);
assert!(doc.synced); assert!(doc.synced);
assert_eq!(doc.provider, LyricsProvider::Qobuz); assert_eq!(doc.provider, LyricsProvider::Qobuz);
assert_eq!(doc.lines.len(), 2); // gap separator dropped from render model 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(); let parsed: QobuzWsync = serde_json::from_str(lsync).unwrap();
assert_eq!(parsed.lines[0].start, Some(1)); 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");
}
} }
+51
View File
@@ -127,6 +127,14 @@ pub struct UserSession {
pub subscription_label: String, pub subscription_label: String,
#[serde(default)] #[serde(default)]
pub subscription_valid_until: Option<String>, pub subscription_valid_until: Option<String>,
/// Account territory (ISO 3166-1 alpha-2, e.g. "FR") from the login
/// response. `serde(default)` keeps pre-v10 persisted sessions loadable.
#[serde(default)]
pub country_code: Option<String>,
/// Account language (ISO 639-1, e.g. "fr") from the login response —
/// the default target for lyrics translation ("Auto").
#[serde(default)]
pub language_code: Option<String>,
} }
// ============ Stream Types ============ // ============ Stream Types ============
@@ -1475,3 +1483,46 @@ pub struct ArtistStoryAuthor {
#[serde(default)] #[serde(default)]
pub slug: Option<String>, pub slug: Option<String>,
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_session_deserializes_pre_v10_json() {
// Sessions persisted before the country/language capture must still
// load: both new fields default to None (feature stays Auto-off).
let json = r#"{
"user_auth_token": "token",
"user_id": 1705826,
"email": "a@b.c",
"display_name": "Tester",
"subscription_label": "Studio",
"subscription_valid_until": null
}"#;
let session: UserSession = serde_json::from_str(json).expect("old session json loads");
assert_eq!(session.country_code, None);
assert_eq!(session.language_code, None);
assert_eq!(session.user_id, 1705826);
}
#[test]
fn user_session_round_trips_country_and_language() {
let json = r#"{
"user_auth_token": "token",
"user_id": 1705826,
"email": "a@b.c",
"display_name": "Tester",
"subscription_label": "Studio",
"subscription_valid_until": null,
"country_code": "FR",
"language_code": "fr"
}"#;
let session: UserSession = serde_json::from_str(json).expect("v10 session json loads");
assert_eq!(session.country_code.as_deref(), Some("FR"));
assert_eq!(session.language_code.as_deref(), Some("fr"));
let back: UserSession =
serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap();
assert_eq!(back.language_code.as_deref(), Some("fr"));
}
}
+32 -11
View File
@@ -6,8 +6,12 @@
//! `qbz-nix-docs/qobuz-api/` before freezing the serde structs. //! `qbz-nix-docs/qobuz-api/` before freezing the serde structs.
//! //!
//! Usage: //! Usage:
//! cargo run -p qbz-qobuz --example lyrics_probe # default track ids //! cargo run -p qbz-qobuz --example lyrics_probe # default track ids
//! cargo run -p qbz-qobuz --example lyrics_probe 123 456 # explicit ids //! cargo run -p qbz-qobuz --example lyrics_probe 123 456 # explicit ids
//! cargo run -p qbz-qobuz --example lyrics_probe --lang=es 123 # + translation target
//!
//! `--lang=<iso639-1>` exercises the v10 `language` query param (translation
//! request); omitted = original-only fetch.
//! //!
//! Strictly read-only: login + GETs, no mutations. //! Strictly read-only: login + GETs, no mutations.
@@ -41,15 +45,23 @@ const DEFAULT_TRACK_IDS: &[u64] = &[266725027, 29006863, 34];
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug)); let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug));
let args: Vec<u64> = std::env::args() let mut language: Option<String> = None;
.skip(1) let mut ids: Vec<u64> = Vec::new();
.filter_map(|a| a.parse().ok()) for arg in std::env::args().skip(1) {
.collect(); if let Some(lang) = arg.strip_prefix("--lang=") {
let track_ids: Vec<u64> = if args.is_empty() { language = Some(lang.to_string());
} else if let Ok(id) = arg.parse() {
ids.push(id);
}
}
let track_ids: Vec<u64> = if ids.is_empty() {
DEFAULT_TRACK_IDS.to_vec() DEFAULT_TRACK_IDS.to_vec()
} else { } else {
args ids
}; };
if let Some(lang) = &language {
eprintln!("[probe] translation target language: {}", lang);
}
let client = QobuzClient::new()?; let client = QobuzClient::new()?;
let warm = client.init().await?; let warm = client.init().await?;
@@ -135,7 +147,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
// Step 1 raw: signed GET /track/lyricsUrl // Step 1 raw: signed GET /track/lyricsUrl
let (status, body) = match client.get_lyrics_url_raw(track_id).await { let (status, body) = match client.get_lyrics_url_raw(track_id, language.as_deref()).await {
Ok(pair) => pair, Ok(pair) => pair,
Err(e) => { Err(e) => {
println!("[lyricsUrl] transport error: {}", e); println!("[lyricsUrl] transport error: {}", e);
@@ -146,7 +158,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("[lyricsUrl] raw body:\n{}", body); println!("[lyricsUrl] raw body:\n{}", body);
// Typed step 1 // Typed step 1
let urls = match client.get_lyrics_url(track_id).await { let urls = match client.get_lyrics_url(track_id, language.as_deref()).await {
Ok(Some(urls)) => urls, Ok(Some(urls)) => urls,
Ok(None) => { Ok(None) => {
println!("[lyricsUrl] typed result: MISS (no lyrics)"); println!("[lyricsUrl] typed result: MISS (no lyrics)");
@@ -179,7 +191,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
// Typed full chain // Typed full chain
match client.get_lyrics(track_id).await { match client.get_lyrics(track_id, language.as_deref()).await {
Ok(Some(doc)) => { Ok(Some(doc)) => {
let content = doc.original.as_ref().expect("get_lyrics guarantees original"); let content = doc.original.as_ref().expect("get_lyrics guarantees original");
println!( println!(
@@ -189,6 +201,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
doc.translation_langs, doc.translation_langs,
doc.writers doc.writers
); );
match doc.translation.as_ref() {
Some(translation) => println!(
"[get_lyrics] embedded translation: {} with {} lines (lang: {:?})",
if translation.is_synced() { "SYNCED" } else { "PLAIN" },
translation.line_count(),
translation.lang()
),
None => println!("[get_lyrics] no embedded translation"),
}
} }
Ok(None) => println!("[get_lyrics] typed result: MISS"), Ok(None) => println!("[get_lyrics] typed result: MISS"),
Err(e) => println!("[get_lyrics] typed error: {}", e), Err(e) => println!("[get_lyrics] typed error: {}", e),
+52
View File
@@ -146,6 +146,19 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
.and_then(|c| c.get("parameters")) .and_then(|c| c.get("parameters"))
.and_then(parse_subscription_valid_until); .and_then(parse_subscription_valid_until);
// Account territory + language (snake_case wire names, verbatim in
// Qobuz's own embedded /user/login fixture — see qbz-nix-docs
// offline-mode/tauri-review-2026-06-09/10-subscription-trial-offline-
// gating.md §1.2). Absent on older captures -> None (feature stays off).
let country_code = user
.get("country_code")
.and_then(|v| v.as_str())
.map(str::to_string);
let language_code = user
.get("language_code")
.and_then(|v| v.as_str())
.map(str::to_string);
// Check if user has valid subscription // Check if user has valid subscription
let has_subscription = credential let has_subscription = credential
.and_then(|c| c.get("parameters")) .and_then(|c| c.get("parameters"))
@@ -163,6 +176,8 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
display_name, display_name,
subscription_label, subscription_label,
subscription_valid_until, subscription_valid_until,
country_code,
language_code,
}) })
} }
@@ -201,4 +216,41 @@ mod tests {
let sig = sign_get_file_url(123456, 27, 1234567890, "testsecret"); let sig = sign_get_file_url(123456, 27, 1234567890, "testsecret");
assert_eq!(sig.len(), 32); assert_eq!(sig.len(), 32);
} }
fn login_response(user_extra: serde_json::Value) -> serde_json::Value {
let mut user = serde_json::json!({
"id": 1705826,
"email": "a@b.c",
"display_name": "Tester",
"credential": {"parameters": {"short_label": "Studio"}}
});
user.as_object_mut()
.unwrap()
.extend(user_extra.as_object().unwrap().clone());
serde_json::json!({
"user_auth_token": "token",
"user": user,
})
}
#[test]
fn parse_login_response_captures_country_and_language() {
let response = login_response(serde_json::json!({
"country_code": "FR",
"language_code": "fr",
}));
let session = parse_login_response(&response).expect("valid login response");
assert_eq!(session.country_code.as_deref(), Some("FR"));
assert_eq!(session.language_code.as_deref(), Some("fr"));
}
#[test]
fn parse_login_response_tolerates_missing_country_and_language() {
// Older captures / partial payloads: both stay None (feature off),
// the rest of the session parses as before.
let response = login_response(serde_json::json!({}));
let session = parse_login_response(&response).expect("valid login response");
assert_eq!(session.country_code, None);
assert_eq!(session.language_code, None);
}
} }
+148 -12
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::{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";
@@ -324,6 +326,17 @@ impl QobuzClient {
*self.session.write().await = Some(session); *self.session.write().await = Some(session);
} }
/// Account language (ISO 639-1) of the active session, if the login
/// response carried one — the "Auto" target for lyrics translation.
/// `None` pre-login and for pre-v10 persisted sessions.
pub async fn session_language_code(&self) -> Option<String> {
self.session
.read()
.await
.as_ref()
.and_then(|s| s.language_code.clone())
}
/// Exchange an OAuth code for a full user session. /// Exchange an OAuth code for a full user session.
/// ///
/// This implements the new Qobuz OAuth flow: /// This implements the new Qobuz OAuth flow:
@@ -1397,14 +1410,27 @@ impl QobuzClient {
/// client, so this is the only way diagnostic tooling (e.g. /// client, so this is the only way diagnostic tooling (e.g.
/// `examples/lyrics_probe.rs`) can observe the exact wire shape; the /// `examples/lyrics_probe.rs`) can observe the exact wire shape; the
/// typed [`Self::get_lyrics_url`] builds on it. /// typed [`Self::get_lyrics_url`] builds on it.
pub async fn get_lyrics_url_raw(&self, track_id: u64) -> Result<(u16, String)> { ///
/// `language` (ISO 639-1, API v10) requests a translation; it is appended
/// to the signed param list ONLY when `Some` (`sign_request` sorts the
/// kv pairs alphabetically, so signing needs no change). Pass `None` for
/// the original-only fetch.
pub async fn get_lyrics_url_raw(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<(u16, String)> {
let url = endpoints::build_url(paths::TRACK_LYRICS_URL); let url = endpoints::build_url(paths::TRACK_LYRICS_URL);
// Signing method name per the concatenation convention ("trackget" at // Signing method name per the concatenation convention ("trackget" at
// get_track, "trackgetFileUrl" in auth.rs): path with slashes removed, // get_track, "trackgetFileUrl" in auth.rs): path with slashes removed,
// case preserved -> "tracklyricsUrl". Verified against the live API // case preserved -> "tracklyricsUrl". Verified against the live API
// 2026-06-10 (HTTP 200 with a valid envelope). // 2026-06-10 (HTTP 200 with a valid envelope).
let mut params = vec![("track_id", track_id.to_string())];
if let Some(language) = language {
params.push(("language", language.to_string()));
}
let response = self let response = self
.signed_get_auth(&url, "tracklyricsUrl", &[("track_id", track_id.to_string())]) .signed_get_auth(&url, "tracklyricsUrl", &params)
.await?; .await?;
let status = response.status().as_u16(); let status = response.status().as_u16();
let body = response.text().await?; let body = response.text().await?;
@@ -1418,8 +1444,12 @@ impl QobuzClient {
/// `Err` is reserved for transport/auth/offline failures — the lyrics /// `Err` is reserved for transport/auth/offline failures — the lyrics
/// orchestrator degrades those to a miss as well (spec §1.1); nothing in /// orchestrator degrades those to a miss as well (spec §1.1); nothing in
/// this path is ever a user-facing error. /// this path is ever a user-facing error.
pub async fn get_lyrics_url(&self, track_id: u64) -> Result<Option<QobuzLyricsUrls>> { pub async fn get_lyrics_url(
let (status, body) = self.get_lyrics_url_raw(track_id).await?; &self,
track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsUrls>> {
let (status, body) = self.get_lyrics_url_raw(track_id, language).await?;
if status != 200 { if status != 200 {
// Live-observed miss: HTTP 404 with body // Live-observed miss: HTTP 404 with body
// {"status":"error","code":404,"message":"No lyrics found"} // {"status":"error","code":404,"message":"No lyrics found"}
@@ -1458,14 +1488,74 @@ 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.
pub async fn get_lyrics(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>> { ///
let urls = match self.get_lyrics_url(track_id).await? { /// `language` (ISO 639-1, API v10) requests a translation; `None` fetches
/// 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).
pub async fn get_lyrics(
&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, Some(urls) => urls,
None => return Ok(None), None => return Ok(None),
}; };
let lyrics_url = match urls.lyrics_url { let lyrics_url = match urls.lyrics_url.clone() {
Some(url) => url, Some(url) => url,
None => return Ok(None), None => return Ok(None),
}; };
@@ -1482,10 +1572,17 @@ 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(doc) if doc.original.is_some() => Ok(Some(doc)), 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;
}
Ok(Some(doc))
}
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)
@@ -1501,6 +1598,45 @@ impl QobuzClient {
} }
} }
/// Fallback translation source (spec §B.3): when the main document did
/// not embed a `translation` block, fetch the document the envelope's
/// `translation_requested.lyrics_url` points at (same bare CDN GET) and
/// take its content as the translation. Every failure mode degrades to
/// `None` — translation is best-effort, never an error.
async fn fetch_translation_fallback(
&self,
track_id: u64,
urls: &QobuzLyricsUrls,
) -> Option<QobuzLyricsContent> {
let translation_url = urls
.translation_requested
.as_ref()
.and_then(|t| t.lyrics_url.as_deref())?;
let response = self.http().ok()?.get(translation_url).send().await.ok()?;
if !response.status().is_success() {
log::debug!(
"[API] get_lyrics({}) translation doc status={} -> original only",
track_id,
response.status()
);
return None;
}
let body = response.text().await.ok()?;
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
// The translation document carries its content under `original`
// (same wrapper shape as the main document).
Ok(doc) => doc.original,
Err(e) => {
log::debug!(
"[API] get_lyrics({}) unparsable translation doc ({}) -> original only",
track_id,
e
);
None
}
}
}
/// Get artist by ID (basic info only - no albums, faster response) /// Get artist by ID (basic info only - no albums, faster response)
pub async fn get_artist_basic(&self, artist_id: u64) -> Result<Artist> { pub async fn get_artist_basic(&self, artist_id: u64) -> Result<Artist> {
let url = endpoints::build_url(paths::ARTIST_GET); let url = endpoints::build_url(paths::ARTIST_GET);
+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,
}; };
+229 -11
View File
@@ -21,7 +21,7 @@
//! All fields stay serde-tolerant (`Option` / `#[serde(default)]`) — the //! All fields stay serde-tolerant (`Option` / `#[serde(default)]`) — the
//! endpoint is a beta-era delta and may still move. //! endpoint is a beta-era delta and may still move.
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer, Serialize};
/// Accept either a JSON string or a JSON number and normalize to `String`. /// Accept either a JSON string or a JSON number and normalize to `String`.
/// ///
@@ -62,15 +62,16 @@ pub struct QobuzLyricsUrls {
/// URL to the original-language lyrics document (Qobuz CDN host). /// URL to the original-language lyrics document (Qobuz CDN host).
#[serde(default)] #[serde(default)]
pub lyrics_url: Option<String>, pub lyrics_url: Option<String>,
/// Present only when the `translation` query param was sent AND a matching /// Present only when the `language` query param was sent AND a matching
/// translation exists (yaml:208-213). We never send `translation` in v1, /// translation exists (yaml:208-213). Absent when no translation was
/// so this stays `None` (confirmed absent in the live capture). /// requested (confirmed absent in the live capture) or none is available
/// for the requested language.
#[serde(default)] #[serde(default)]
pub translation_requested: Option<QobuzLyricsTranslation>, pub translation_requested: Option<QobuzLyricsTranslation>,
} }
/// Translation pointer inside [`QobuzLyricsUrls`] (yaml `LyricsTranslationDto`, `:221-231`). /// Translation pointer inside [`QobuzLyricsUrls`] (yaml `LyricsTranslationDto`, `:221-231`).
/// Unverified live (v1 never requests translations); kept yaml-shaped + tolerant. /// Kept yaml-shaped + tolerant.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct QobuzLyricsTranslation { pub struct QobuzLyricsTranslation {
#[serde(default)] #[serde(default)]
@@ -92,7 +93,8 @@ pub struct QobuzLyricsDocument {
/// ISO 639-1 codes a translation exists for (e.g. `["pt","de","fr","es","it"]`). /// ISO 639-1 codes a translation exists for (e.g. `["pt","de","fr","es","it"]`).
#[serde(default)] #[serde(default)]
pub translation_langs: Vec<String>, pub translation_langs: Vec<String>,
/// Publisher copyright entries with applicability zones. /// Publisher copyright entries with applicability zones. Parsed but never
/// enforced (owner decision 2026-07-22: no zone/copyright gating).
#[serde(default)] #[serde(default)]
pub publishers: Vec<QobuzLyricsPublisher>, pub publishers: Vec<QobuzLyricsPublisher>,
/// Songwriter credits as a single display string. /// Songwriter credits as a single display string.
@@ -101,6 +103,31 @@ pub struct QobuzLyricsDocument {
/// The actual lyrics content (the yaml's "LyricsContentDto" union). /// The actual lyrics content (the yaml's "LyricsContentDto" union).
#[serde(default)] #[serde(default)]
pub original: Option<QobuzLyricsContent>, pub original: Option<QobuzLyricsContent>,
/// Translated lyrics content (NEW in API v10) — same union shape as
/// `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). /// One publisher credit inside [`QobuzLyricsDocument`] (live-only; not in the yaml).
@@ -118,8 +145,9 @@ pub struct QobuzLyricsPublisher {
/// Live discriminator is `"wsync"` (word-synced — richer than the yaml's /// Live discriminator is `"wsync"` (word-synced — richer than the yaml's
/// `"lsync"` guess: per-word timestamps inside each line). `"lsync"` is kept /// `"lsync"` guess: per-word timestamps inside each line). `"lsync"` is kept
/// as an alias in case line-only documents exist in the catalog (same shape /// as an alias in case line-only documents exist in the catalog (same shape
/// minus `words`, which defaults to empty). /// minus `words`, which defaults to empty). `Serialize` exists so the lyrics
#[derive(Debug, Clone, Deserialize)] /// cache can persist a document's embedded translation verbatim.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum QobuzLyricsContent { pub enum QobuzLyricsContent {
/// Time-synced lyrics; line + per-word timestamps, ms from track start. /// Time-synced lyrics; line + per-word timestamps, ms from track start.
@@ -154,6 +182,14 @@ impl QobuzLyricsContent {
QobuzLyricsContent::Plain { lines, .. } => lines.len(), QobuzLyricsContent::Plain { lines, .. } => lines.len(),
} }
} }
/// ISO 639-1 of the content's language, when the document carries it.
pub fn lang(&self) -> Option<&str> {
match self {
QobuzLyricsContent::Synced { lang, .. } => lang.as_deref(),
QobuzLyricsContent::Plain { lang, .. } => lang.as_deref(),
}
}
} }
/// One synced line. /// One synced line.
@@ -162,7 +198,7 @@ impl QobuzLyricsContent {
/// required): instrumental-gap separator lines come as `{"line":"","words":[]}` /// required): instrumental-gap separator lines come as `{"line":"","words":[]}`
/// with NO `start`/`end` at all — both must be `Option`. Regular lines carry /// with NO `start`/`end` at all — both must be `Option`. Regular lines carry
/// line-level `start`/`end` plus per-word stamps (lyrics-doc-wsync.json). /// line-level `start`/`end` plus per-word stamps (lyrics-doc-wsync.json).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QobuzLyricsLine { pub struct QobuzLyricsLine {
#[serde(default)] #[serde(default)]
pub line: String, pub line: String,
@@ -178,7 +214,7 @@ pub struct QobuzLyricsLine {
} }
/// One word inside a synced line (live-only; not in the yaml). /// One word inside a synced line (live-only; not in the yaml).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QobuzLyricsWord { pub struct QobuzLyricsWord {
#[serde(default)] #[serde(default)]
pub word: String, pub word: String,
@@ -193,7 +229,7 @@ pub struct QobuzLyricsWord {
/// One plain line. Live wire form is an object `{"line": "..."}` /// One plain line. Live wire form is an object `{"line": "..."}`
/// (lyrics-doc-plain.json); the yaml guessed bare strings (`:287-303`) — /// (lyrics-doc-plain.json); the yaml guessed bare strings (`:287-303`) —
/// accept both. /// accept both.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, PartialEq)]
pub struct QobuzLyricsPlainLine { pub struct QobuzLyricsPlainLine {
pub line: String, pub line: String,
} }
@@ -229,8 +265,18 @@ mod tests {
// synced/plain docs are truncated to a few lines here to avoid vendoring // synced/plain docs are truncated to a few lines here to avoid vendoring
// full copyrighted lyrics in the repo — the STRUCTURE is verbatim). // full copyrighted lyrics in the repo — the STRUCTURE is verbatim).
const URL_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-url-response.json"); const URL_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-url-response.json");
const URL_RESPONSE_TRANSLATION: &str =
include_str!("../tests/fixtures/lyrics-url-response-translation.json");
const DOC_WSYNC: &str = include_str!("../tests/fixtures/lyrics-doc-wsync.json"); const DOC_WSYNC: &str = include_str!("../tests/fixtures/lyrics-doc-wsync.json");
const DOC_PLAIN: &str = include_str!("../tests/fixtures/lyrics-doc-plain.json"); const DOC_PLAIN: &str = include_str!("../tests/fixtures/lyrics-doc-plain.json");
const DOC_WSYNC_TRANSLATION: &str =
include_str!("../tests/fixtures/lyrics-doc-wsync-translation.json");
const DOC_LSYNC_TRANSLATION: &str =
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"); const MISS_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-miss-response.json");
#[test] #[test]
@@ -320,6 +366,178 @@ mod tests {
assert_eq!(urls.album_id.as_deref(), Some("2")); assert_eq!(urls.album_id.as_deref(), Some("2"));
} }
#[test]
fn deserialize_lyrics_url_response_with_translation_requested() {
// v10: when a `language` was requested and a translation exists, the
// envelope carries the `translation_requested` pointer (yaml
// `LyricsTranslationDto`).
let urls: QobuzLyricsUrls =
serde_json::from_str(URL_RESPONSE_TRANSLATION).expect("parse translation envelope");
assert_eq!(urls.track_id.as_deref(), Some("266725027"));
let requested = urls
.translation_requested
.expect("translation_requested present");
assert_eq!(requested.lang.as_deref(), Some("es"));
assert!(requested
.lyrics_url
.as_deref()
.unwrap_or("")
.starts_with("https://"));
}
#[test]
fn deserialize_document_with_wsync_translation() {
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_WSYNC_TRANSLATION).expect("parse wsync+translation document");
assert_eq!(doc.translation_langs.len(), 5);
let original = doc.original.expect("original present");
assert!(original.is_synced());
assert_eq!(original.lang(), Some("en"));
let translation = doc.translation.expect("embedded translation present");
assert!(translation.is_synced());
assert_eq!(translation.lang(), Some("es"));
match &translation {
QobuzLyricsContent::Synced { lines, .. } => {
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].line, "Primera linea traducida");
assert_eq!(lines[0].words.len(), 3);
assert_eq!(lines[0].words[2].word, "traducida");
assert!(lines[1].start.is_none(), "gap line without stamps");
}
_ => unreachable!(),
}
}
#[test]
fn deserialize_document_with_lsync_translation() {
// Line-synced variant: lsync alias on BOTH original and translation,
// no per-word stamps.
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_LSYNC_TRANSLATION).expect("parse lsync+translation document");
let original = doc.original.expect("original present");
assert!(original.is_synced());
let translation = doc.translation.expect("translation present");
match &translation {
QobuzLyricsContent::Synced { lang, lines } => {
assert_eq!(lang.as_deref(), Some("fr"));
assert_eq!(lines.len(), 2);
assert!(lines[0].words.is_empty());
assert_eq!(lines[1].start, Some(5000));
}
_ => panic!("expected synced translation"),
}
}
#[test]
fn deserialize_document_with_plain_translation() {
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_PLAIN_TRANSLATION).expect("parse plain+translation document");
let original = doc.original.expect("original present");
assert!(!original.is_synced());
let translation = doc.translation.expect("translation present");
match &translation {
QobuzLyricsContent::Plain { lang, lines } => {
assert_eq!(lang.as_deref(), Some("de"));
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].line, "Einfache Uebersetzung eins");
}
_ => panic!("expected plain translation"),
}
}
#[test]
fn document_without_translation_stays_none() {
// 9.x back-compat: existing captures have no `translation` member.
for fixture in [DOC_WSYNC, DOC_PLAIN] {
let doc: QobuzLyricsDocument =
serde_json::from_str(fixture).expect("parse 9.x document");
assert!(doc.translation.is_none());
}
}
#[test]
fn empty_translation_langs_means_feature_off() {
// Feature-off document: no translations offered at all.
let json = r#"{"track_id": 1, "translation_langs": [],
"original": {"type": "plain", "lang": "en", "lines": [{"line": "hi"}]}}"#;
let doc: QobuzLyricsDocument = serde_json::from_str(json).expect("parse");
assert!(doc.translation_langs.is_empty());
assert!(doc.translation.is_none());
// Missing member entirely behaves the same.
let json = r#"{"track_id": 1,
"original": {"type": "plain", "lang": "en", "lines": [{"line": "hi"}]}}"#;
let doc: QobuzLyricsDocument = serde_json::from_str(json).expect("parse");
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
// content union must round-trip through serde.
let doc: QobuzLyricsDocument =
serde_json::from_str(DOC_WSYNC_TRANSLATION).expect("parse");
let translation = doc.translation.expect("translation present");
let json = serde_json::to_string(&translation).expect("serialize");
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["type"], "wsync");
assert_eq!(value["lang"], "es");
assert_eq!(value["lines"][0]["words"][0]["word"], "Primera");
let back: QobuzLyricsContent = serde_json::from_str(&json).expect("re-parse");
assert_eq!(back.lang(), Some("es"));
assert_eq!(back.line_count(), 3);
}
#[test] #[test]
fn lsync_alias_parses_yaml_shape() { fn lsync_alias_parses_yaml_shape() {
// The yaml's "lsync" guess ({line,start,end}, no words) must still // The yaml's "lsync" guess ({line,start,end}, no words) must still
@@ -0,0 +1,23 @@
{
"album_id": "abc123",
"track_id": 111111111,
"translation_langs": ["fr"],
"publishers": [],
"writers": "Writer Three",
"original": {
"type": "lsync",
"lang": "en",
"lines": [
{"line": "Line-synced original one", "start": 1000, "end": 4000},
{"line": "Line-synced original two", "start": 5000, "end": 9000}
]
},
"translation": {
"type": "lsync",
"lang": "fr",
"lines": [
{"line": "Traduction synchronisee un", "start": 1000, "end": 4000},
{"line": "Traduction synchronisee deux", "start": 5000, "end": 9000}
]
}
}
@@ -0,0 +1,23 @@
{
"album_id": "def456",
"track_id": 222222222,
"translation_langs": ["de"],
"publishers": [],
"writers": null,
"original": {
"type": "plain",
"lang": "en",
"lines": [
{"line": "Plain original one"},
{"line": "Plain original two"}
]
},
"translation": {
"type": "plain",
"lang": "de",
"lines": [
{"line": "Einfache Uebersetzung eins"},
{"line": "Einfache Uebersetzung zwei"}
]
}
}
@@ -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 }
]
}
}
@@ -0,0 +1,63 @@
{
"album_id": "gvcirtodd95kc",
"track_id": 266725027,
"translation_langs": ["pt", "de", "fr", "es", "it"],
"publishers": [
{"copyright": "(c) Example Publishing", "zones": ["WW"]}
],
"writers": "Writer One, Writer Two",
"original": {
"type": "wsync",
"lang": "en",
"lines": [
{
"line": "First original line",
"start": 1750,
"end": 4770,
"words": [
{"word": "First", "start": 1750, "end": 2480},
{"word": "original", "start": 2480, "end": 3600},
{"word": "line", "start": 3600, "end": 4770}
]
},
{"line": "", "words": []},
{
"line": "Second original line",
"start": 19690,
"end": 22180,
"words": [
{"word": "Second", "start": 19690, "end": 20560},
{"word": "original", "start": 20560, "end": 21310},
{"word": "line", "start": 21310, "end": 22180}
]
}
]
},
"translation": {
"type": "wsync",
"lang": "es",
"lines": [
{
"line": "Primera linea traducida",
"start": 1750,
"end": 4770,
"words": [
{"word": "Primera", "start": 1750, "end": 2900},
{"word": "linea", "start": 2900, "end": 3800},
{"word": "traducida", "start": 3800, "end": 4770}
]
},
{"line": "", "words": []},
{
"line": "Segunda linea traducida",
"start": 19690,
"end": 22180,
"words": [
{"word": "Segunda", "start": 19690, "end": 20800},
{"word": "linea", "start": 20800, "end": 21500},
{"word": "traducida", "start": 21500, "end": 22180}
]
}
]
}
}
@@ -0,0 +1,9 @@
{
"track_id": 266725027,
"album_id": "gvcirtodd95kc",
"lyrics_url": "https://ga-cdn.qobuz.com/lyrics/266725027/original.json?Expires=2000000000&Signature=example&Key-Pair-Id=EXAMPLE",
"translation_requested": {
"lyrics_url": "https://ga-cdn.qobuz.com/lyrics/266725027/es.json?Expires=2000000000&Signature=example&Key-Pair-Id=EXAMPLE",
"lang": "es"
}
}
@@ -4778,3 +4778,19 @@ msgstr "Meistgespielte Alben"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} Wiedergaben" msgstr "{} Wiedergaben"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "Songtext-Übersetzung anzeigen"
msgid "Hide lyrics translation"
msgstr "Songtext-Übersetzung ausblenden"
msgid "Translation language"
msgstr "Übersetzungssprache"
msgid "Auto (account language)"
msgstr "Automatisch (Kontosprache)"
msgid "Translation is not available right now. Please try again later."
msgstr "Die Übersetzung ist gerade nicht verfügbar. Bitte versuche es später erneut."
@@ -571,3 +571,19 @@ msgstr "Most Played Albums"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} plays" msgstr "{} plays"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "Show lyrics translation"
msgid "Hide lyrics translation"
msgstr "Hide lyrics translation"
msgid "Translation language"
msgstr "Translation language"
msgid "Auto (account language)"
msgstr "Auto (account language)"
msgid "Translation is not available right now. Please try again later."
msgstr "Translation is not available right now. Please try again later."
@@ -4808,3 +4808,19 @@ msgstr "Álbumes más escuchados"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} reproducciones" msgstr "{} reproducciones"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "Mostrar traducción de la letra"
msgid "Hide lyrics translation"
msgstr "Ocultar traducción de la letra"
msgid "Translation language"
msgstr "Idioma de traducción"
msgid "Auto (account language)"
msgstr "Automático (idioma de la cuenta)"
msgid "Translation is not available right now. Please try again later."
msgstr "La traducción no está disponible ahora mismo. Inténtalo de nuevo más tarde."
@@ -4778,3 +4778,19 @@ msgstr "Albums les plus écoutés"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} lectures" msgstr "{} lectures"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "Afficher la traduction des paroles"
msgid "Hide lyrics translation"
msgstr "Masquer la traduction des paroles"
msgid "Translation language"
msgstr "Langue de traduction"
msgid "Auto (account language)"
msgstr "Automatique (langue du compte)"
msgid "Translation is not available right now. Please try again later."
msgstr "La traduction n'est pas disponible pour le moment. Veuillez réessayer plus tard."
@@ -4766,3 +4766,19 @@ msgstr "最も再生したアルバム"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} 回再生" msgstr "{} 回再生"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "歌詞の翻訳を表示"
msgid "Hide lyrics translation"
msgstr "歌詞の翻訳を隠す"
msgid "Translation language"
msgstr "翻訳言語"
msgid "Auto (account language)"
msgstr "自動(アカウントの言語)"
msgid "Translation is not available right now. Please try again later."
msgstr "翻訳は現在利用できません。後でもう一度お試しください。"
@@ -4790,3 +4790,19 @@ msgstr "Meest afgespeelde albums"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} keer afgespeeld" msgstr "{} keer afgespeeld"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "Songtekstvertaling tonen"
msgid "Hide lyrics translation"
msgstr "Songtekstvertaling verbergen"
msgid "Translation language"
msgstr "Vertaaltaal"
msgid "Auto (account language)"
msgstr "Automatisch (accounttaal)"
msgid "Translation is not available right now. Please try again later."
msgstr "Vertaling is nu niet beschikbaar. Probeer het later opnieuw."
@@ -4778,3 +4778,19 @@ msgstr "Álbuns mais tocados"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} reproduções" msgstr "{} reproduções"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "Mostrar tradução da letra"
msgid "Hide lyrics translation"
msgstr "Ocultar tradução da letra"
msgid "Translation language"
msgstr "Idioma da tradução"
msgid "Auto (account language)"
msgstr "Automático (idioma da conta)"
msgid "Translation is not available right now. Please try again later."
msgstr "A tradução não está disponível de momento. Tente novamente mais tarde."
@@ -4792,3 +4792,19 @@ msgstr "Самые прослушиваемые альбомы"
# Most Played Albums View-all — per-card play count # Most Played Albums View-all — per-card play count
msgid "{} plays" msgid "{} plays"
msgstr "{} воспроизведений" msgstr "{} воспроизведений"
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
msgid "Show lyrics translation"
msgstr "Показать перевод текста"
msgid "Hide lyrics translation"
msgstr "Скрыть перевод текста"
msgid "Translation language"
msgstr "Язык перевода"
msgid "Auto (account language)"
msgstr "Автоматически (язык аккаунта)"
msgid "Translation is not available right now. Please try again later."
msgstr "Перевод сейчас недоступен. Попробуйте ещё раз позже."
@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-languages"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m5 8 6 6" />
<path d="m4 14 6-6 2-3" />
<path d="M2 5h12" />
<path d="M7 2h1" />
<path d="m22 22-5-10-5 10" />
<path d="M14 18h6" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -8,7 +8,9 @@
// so Tauri's segmented S/M/L/XL and Off/Soft/Strong rows become selects). // so Tauri's segmented S/M/L/XL and Off/Soft/Strong rows become selects).
// //
// Row order is Tauri's exactly: Auto-follow / Font / Size / Active color / // Row order is Tauri's exactly: Auto-follow / Font / Size / Active color /
// Uppercase / Dimming / footer (Copy lyrics + Reset). // Uppercase / Dimming / footer (Copy lyrics + Reset) — plus two Slint-side
// additions in order: Translation language (v10, after Size) and Lite fill
// (perf, after Uppercase).
// //
// Deviations (spec §6): D6 — active color is a fixed swatch palette (Slint // Deviations (spec §6): D6 — active color is a fixed swatch palette (Slint
// has no native color-picker input); the "Theme" reset link shows only while // has no native color-picker input); the "Theme" reset link shows only while
@@ -132,6 +134,25 @@ export component LyricsControlsPanel inherits Rectangle {
} }
} }
// Translation language (Qobuz v10) — Auto (account language) + the
// 9 approved ISO 639-1 targets; options reseeded from Rust on
// language change (the array carries one @tr'd label).
HorizontalLayout {
height: 30px;
spacing: 8px;
RowLabel { text: @tr("Translation language"); }
QbzSelect {
sm: true;
menu-width: 178px;
options: LyricsState.translation-language-options;
current-index: LyricsState.translation-language-index;
selected(i) => {
LyricsState.translation-language-index = i;
LyricsState.prefs-changed();
}
}
}
// Active color — label (+ "Theme" reset while custom), swatch palette // Active color — label (+ "Theme" reset while custom), swatch palette
// underneath, right-aligned on the control grid (D6). // underneath, right-aligned on the control grid (D6).
VerticalLayout { VerticalLayout {
@@ -70,6 +70,9 @@ import "../../../../static/fonts/SourceSans3-VariableFont_wght.ttf";
component LyricsLineRow inherits VerticalLayout { component LyricsLineRow inherits VerticalLayout {
in property <int> index; in property <int> index;
in property <string> line-text; in property <string> line-text;
// Translation of this line (Qobuz v10); empty when the doc has none.
// Rendered under the original line while LyricsState.show-translation.
in property <string> translation-text;
in property <length> size-inactive; in property <length> size-inactive;
in property <length> size-active; in property <length> size-active;
// 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5). // 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5).
@@ -86,6 +89,10 @@ component LyricsLineRow inherits VerticalLayout {
// eager init already saw it active. // eager init already saw it active.
in property <int> recenter-epoch; in property <int> recenter-epoch;
// Gap between the original line and its translation (no effect while
// the translation is hidden — a single child has no spacing).
spacing: 3px;
// Tauri renders uppercase via CSS text-transform; Slint transforms the // Tauri renders uppercase via CSS text-transform; Slint transforms the
// string (display only — the engine still matches on the raw text). // string (display only — the engine still matches on the raw text).
property <string> display-text: property <string> display-text:
@@ -255,6 +262,26 @@ component LyricsLineRow inherits VerticalLayout {
} }
} }
} }
// Translation (Qobuz v10): static text UNDER the original line — smaller
// (~72%, mobile reference) and dimmer. The karaoke fill above stays keyed
// on the ORIGINAL timings/text; this follows its line statically and
// joins the same dimming ladder (never uppercased — it's a translation,
// not the lyric). No-op while the toggle is off or the doc has none.
if LyricsState.show-translation && root.translation-text != "": Text {
text: root.translation-text;
wrap: word-wrap;
opacity: root.line-opacity();
color: root.is-past ? Theme.text-muted : Theme.text-secondary.with-alpha(0.7);
font-family: root.font-name;
font-size: (root.is-active ? root.size-active : root.size-inactive) * 0.72;
font-weight: Typography.regular;
letter-spacing: 0.2px;
animate opacity {
duration: 220ms;
easing: ease-out;
}
}
} }
export component LyricsLinesView inherits Rectangle { export component LyricsLinesView inherits Rectangle {
@@ -386,6 +413,7 @@ export component LyricsLinesView inherits Rectangle {
for line[idx] in LyricsState.lines: LyricsLineRow { for line[idx] in LyricsState.lines: LyricsLineRow {
index: idx; index: idx;
line-text: line.text; line-text: line.text;
translation-text: line.translation-text;
size-inactive: root.size-inactive; size-inactive: root.size-inactive;
size-active: root.size-active; size-active: root.size-active;
dimming-mode: root.dimming-mode; dimming-mode: root.dimming-mode;
+61 -4
View File
@@ -23,7 +23,55 @@ import { IconButton } from "../primitives/BarControls.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { LyricsLinesView } from "LyricsLinesView.slint"; import { LyricsLinesView } from "LyricsLinesView.slint";
import { LyricsControlsPanel } from "LyricsControlsFlyout.slint"; import { LyricsControlsPanel } from "LyricsControlsFlyout.slint";
import { ShellState, LyricsState, ArtworkActions, AppearanceState } from "../state.slint"; import { ShellState, LyricsState, ArtworkActions, AppearanceState, TooltipState } from "../state.slint";
// Floating translate toggle (Qobuz v10, owner decision 2026-07-22): circular
// button anchored bottom-right of the lyrics body, overlaying it — mobile
// parity (the 文A button on iOS/Android). Off = dim, on = accent. Tooltip
// rides the shared TooltipState channel like the bar's IconButton.
component TranslateButton inherits Rectangle {
width: 36px;
height: 36px;
border-radius: 18px;
background: LyricsState.show-translation
? Theme.accent.with-alpha(0.15)
: Theme.surface-card;
border-width: 1px;
border-color: LyricsState.show-translation ? Theme.accent : Theme.border-muted;
drop-shadow-blur: 12px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 3px;
QbzIcon {
source: @image-url("../assets/icons/languages.svg");
width: 18px;
height: 18px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: LyricsState.show-translation
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
LyricsState.toggle-translation();
}
changed has-hover => {
if (self.has-hover) {
TooltipState.text = LyricsState.show-translation
? @tr("Hide lyrics translation")
: @tr("Show lyrics translation");
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
component CloseButton inherits Rectangle { component CloseButton inherits Rectangle {
callback clicked(); callback clicked();
@@ -128,9 +176,10 @@ export component LyricsSidebar inherits Rectangle {
x: 32px + 8px + 28px - 260px; x: 32px + 8px + 28px - 260px;
y: 32px + 8px; y: 32px + 8px;
width: 260px; width: 260px;
// padding 28 + rows (22+30+30+44+22+30+34 = 212) + // padding 28 + rows (22+30+30+30+44+22+22+30+34 =
// spacing 12 x 6 = 72. // 264, incl. lite-fill + translation language) +
height: 312px; // spacing 12 x 8 = 96.
height: 388px;
close-policy: PopupClosePolicy.close-on-click-outside; close-policy: PopupClosePolicy.close-on-click-outside;
LyricsControlsPanel { } LyricsControlsPanel { }
} }
@@ -278,6 +327,14 @@ export component LyricsSidebar inherits Rectangle {
? LyricsState.custom-color ? LyricsState.custom-color
: Theme.accent; : Theme.accent;
} }
// Floating translate toggle — bottom-right overlay of the lyrics
// body (mobile parity; NOT in the header, NOT in the flyout).
// Mounted only while the current doc advertises translations;
// otherwise the sidebar renders exactly as before.
if LyricsState.translation-available: TranslateButton {
x: parent.width - self.width - 12px;
y: parent.height - self.height - 12px;
}
} }
// Source attribution — a tiny line pinned at the very bottom, always // Source attribution — a tiny line pinned at the very bottom, always
+36
View File
@@ -4454,6 +4454,11 @@ export struct LyricsLineItem {
time-ms: int, time-ms: int,
end-ms: int, end-ms: int,
has-words: bool, has-words: bool,
// Translation of this line (Qobuz v10), aligned 1:1 with `text` by the
// Rust glue; empty when the doc has no translation (or it wasn't
// requested). Rendered under the original line only while
// `LyricsState.show-translation` is on.
translation-text: string,
} }
// One VISUAL line of the ACTIVE logical line, for the per-visual-line karaoke // One VISUAL line of the ACTIVE logical line, for the per-visual-line karaoke
@@ -4488,6 +4493,18 @@ export global LyricsState {
// Playing-track meta for the header ("{title} - {artist}"). // Playing-track meta for the header ("{title} - {artist}").
in property <string> track-title; in property <string> track-title;
in property <string> track-artist; in property <string> track-artist;
// ---- Translation (Qobuz API v10) ---------------------------------------
// The current doc advertises at least one translation language
// (`translation_langs` non-empty) — gates the sidebar's floating toggle.
// False on every other state, so the sidebar renders exactly as before.
in property <bool> translation-available: false;
// User intent: render the translation under each original line. Driven by
// Rust (`toggle-translation`): ON refetches with the target language and
// flips this only when the translation actually arrived; OFF is pure UI.
in-out property <bool> show-translation: false;
// Sidebar floating-button action (wired in main.rs).
callback toggle-translation();
// Surfaced backend error text (status == 4) — dynamic content, not chrome. // Surfaced backend error text (status == 4) — dynamic content, not chrome.
in property <string> error; in property <string> error;
// Active line index; -1 = none. The S4 sync engine drives it. // Active line index; -1 = none. The S4 sync engine drives it.
@@ -4538,6 +4555,25 @@ export global LyricsState {
// between line changes). Default false (smooth karaoke). Kiosk / weak-GPU // between line changes). Default false (smooth karaoke). Kiosk / weak-GPU
// tiers force it on via ShellState.reduce-motion regardless of this pref. // tiers force it on via ShellState.reduce-motion regardless of this pref.
in-out property <bool> lite-fill: false; in-out property <bool> lite-fill: false;
// Translation language (Qobuz v10, persisted like the other prefs):
// 0 Auto (account language) · 1..9 = en es fr de it pt nl ja ru. Option
// labels: the Auto entry is @tr'd, the language names are native
// literals — the same convention as AppearanceState.languages. The @tr
// DEFAULT is not reactive, so `reseed_i18n_labels` re-pushes the array
// on language change.
in-out property <int> translation-language-index: 0;
in-out property <[string]> translation-language-options: [
@tr("Auto (account language)"),
"English",
"Español",
"Français",
"Deutsch",
"Italiano",
"Português",
"Nederlands",
"日本語",
"Русский",
];
// Active (karaoke fill) color. `use-custom-color: false` = follow the theme // Active (karaoke fill) color. `use-custom-color: false` = follow the theme
// accent (Tauri activeColor: ''). The palette swatches set both (deviation // accent (Tauri activeColor: ''). The palette swatches set both (deviation
// D6: swatches instead of a native color input — Slint has none). // D6: swatches instead of a native color input — Slint has none).
+5 -4
View File
@@ -53,8 +53,8 @@ const AUTHOR_HANDLE: &str = "vicrodh";
/// The contributor handles + their GitHub profile URLs. First the Tauri About /// The contributor handles + their GitHub profile URLs. First the Tauri About
/// modal's order, then the Slint-era external-PR contributors: `hoyon` /// modal's order, then the Slint-era external-PR contributors: `hoyon`
/// (classical "work" grouping, PR #536) and `mxnix` (Russian translation, /// (classical "work" grouping, PR #536), `mxnix` (Russian translation,
/// PR #517). /// PR #517) and `TerminalTilt`.
const CONTRIBUTORS: &[&str] = &[ const CONTRIBUTORS: &[&str] = &[
"vorce", "vorce",
"boxdot", "boxdot",
@@ -66,12 +66,13 @@ const CONTRIBUTORS: &[&str] = &[
"DoubleGate", "DoubleGate",
"hoyon", "hoyon",
"mxnix", "mxnix",
"TerminalTilt",
]; ];
/// How many contributor chips per wrap row. Slint has no flex-wrap, so the flat /// How many contributor chips per wrap row. Slint has no flex-wrap, so the flat
/// list is pre-grouped into fixed rows (see `AboutContributorGroup`). 5 fills /// list is pre-grouped into fixed rows (see `AboutContributorGroup`). 5 fills
/// the widened ~840px panel (10 contributors → 2 even rows) instead of the old /// the widened ~840px panel instead of the old 4-per-row / 3-row layout that
/// 4-per-row / 3-row layout that left the wider modal half-empty. /// left the wider modal half-empty.
const CONTRIBUTORS_PER_ROW: usize = 5; const CONTRIBUTORS_PER_ROW: usize = 5;
/// Build the row-grouped contributor model. Avatars start blank (default image) /// Build the row-grouped contributor model. Avatars start blank (default image)
+257 -7
View File
@@ -31,6 +31,16 @@
//! Rust-side in [`CURRENT_DOC`]; the UI model only carries the line list + //! Rust-side in [`CURRENT_DOC`]; the UI model only carries the line list +
//! a `has-words` flag. The S4 sync engine consumes the doc for the //! a `has-words` flag. The S4 sync engine consumes the doc for the
//! word-anchored karaoke fill. //! word-anchored karaoke fill.
//!
//! Translation (Qobuz API v10): the default flow fetches original-only and
//! pushes `translation-available` (doc `translation_langs` non-empty), which
//! gates the sidebar's floating toggle. Toggling ON refetches the current
//! track WITH the resolved target (`LyricsRequest.language`) via
//! [`enable_translation`] and flips `show-translation` only when a
//! translation actually arrived — any gap toasts and reverts (fail soft,
//! never an error state). Toggling OFF ([`disable_translation`]) is pure
//! UI. While ON, track-change requests ride the resolved target so the
//! translation follows across tracks.
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
@@ -58,6 +68,11 @@ const STATUS_OFFLINE: i32 = 5;
/// the per-user cache handle re-binds via `init_at` on every activation. /// the per-user cache handle re-binds via `init_at` on every activation.
static SERVICE: OnceLock<Arc<LyricsService>> = OnceLock::new(); static SERVICE: OnceLock<Arc<LyricsService>> = OnceLock::new();
/// The shared core client lock, kept for the translation "Auto" resolution
/// (the account `language_code` lives on the client's session). Installed
/// alongside [`SERVICE`] on the first session activation.
static CLIENT: OnceLock<Arc<tokio::sync::RwLock<Option<QobuzClient>>>> = OnceLock::new();
/// Identity of the latest requested track + whether a Found result has been /// Identity of the latest requested track + whether a Found result has been
/// committed for it. `key` doubles as the stale guard (F2) and the /// committed for it. `key` doubles as the stale guard (F2) and the
/// duplicate-fetch guard (`loaded` mirrors Tauri's `status === 'loaded'`). /// duplicate-fetch guard (`loaded` mirrors Tauri's `status === 'loaded'`).
@@ -71,6 +86,20 @@ static CURRENT: Mutex<CurrentLyrics> = Mutex::new(CurrentLyrics {
loaded: false, loaded: false,
}); });
/// Active translation session (Qobuz v10), the Rust-side source of truth
/// behind `LyricsState.show-translation`: `enabled` = the toggle is ON,
/// `language` = the target it was resolved with (kept so track changes
/// re-request the same target — spec §B: translation follows across tracks).
struct TranslationSession {
enabled: bool,
language: Option<String>,
}
static TRANSLATION: Mutex<TranslationSession> = Mutex::new(TranslationSession {
enabled: false,
language: None,
});
/// The parsed document for the current track, held Rust-side so the native /// The parsed document for the current track, held Rust-side so the native
/// Qobuz word timestamps survive for the S4 sync engine (the UI model only /// Qobuz word timestamps survive for the S4 sync engine (the UI model only
/// carries text + line bounds + a has-words flag). /// carries text + line bounds + a has-words flag).
@@ -96,12 +125,19 @@ struct SharedClientProviders {
#[async_trait::async_trait] #[async_trait::async_trait]
impl LyricsProviders for SharedClientProviders { impl LyricsProviders for SharedClientProviders {
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> {
let guard = self.client.read().await; let guard = self.client.read().await;
let client = guard let client = guard
.as_ref() .as_ref()
.ok_or_else(|| "Qobuz client not initialized".to_string())?; .ok_or_else(|| "Qobuz client not initialized".to_string())?;
client.get_lyrics(track_id).await.map_err(|e| e.to_string()) client
.get_lyrics(track_id, language)
.await
.map_err(|e| e.to_string())
} }
async fn lrclib( async fn lrclib(
@@ -126,6 +162,10 @@ impl LyricsProviders for SharedClientProviders {
/// Best-effort: failures are logged, never block entry. Must run within the /// Best-effort: failures are logged, never block entry. Must run within the
/// tokio runtime context (the bind is spawned). /// tokio runtime context (the bind is spawned).
pub fn init_for_user(client: Arc<tokio::sync::RwLock<Option<QobuzClient>>>, user_id: u64) { pub fn init_for_user(client: Arc<tokio::sync::RwLock<Option<QobuzClient>>>, user_id: u64) {
// Keep a handle for the translation "Auto" resolution (the account
// language lives on the client's session). Same lock per process, so a
// later activation's re-set is a no-op.
let _ = CLIENT.set(client.clone());
let service = SERVICE let service = SERVICE
.get_or_init(move || { .get_or_init(move || {
Arc::new(LyricsService::new(Arc::new(SharedClientProviders { Arc::new(LyricsService::new(Arc::new(SharedClientProviders {
@@ -161,6 +201,7 @@ pub fn teardown() {
if let Ok(mut doc) = CURRENT_DOC.lock() { if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = None; *doc = None;
} }
reset_translation_session();
if let Some(service) = SERVICE.get().cloned() { if let Some(service) = SERVICE.get().cloned() {
tokio::spawn(async move { tokio::spawn(async move {
service.teardown().await; service.teardown().await;
@@ -242,6 +283,187 @@ pub fn clear_cache(handle: &tokio::runtime::Handle, weak: slint::Weak<AppWindow>
}); });
} }
// ---- Translation (Qobuz API v10) -------------------------------------------
/// Whether the translation toggle is currently ON (Rust-side source of truth
/// behind `LyricsState.show-translation`). main.rs reads it to route the
/// toggle callback; the fetch path reads [`active_translation_language`] to
/// keep requesting the target across track changes.
pub fn translation_enabled() -> bool {
TRANSLATION.lock().map(|t| t.enabled).unwrap_or(false)
}
fn reset_translation_session() {
if let Ok(mut t) = TRANSLATION.lock() {
t.enabled = false;
t.language = None;
}
}
/// The target the toggle was enabled with, while ON — the track-change fetch
/// rides it so the translation follows across tracks (spec §B).
fn active_translation_language() -> Option<String> {
TRANSLATION
.lock()
.ok()
.and_then(|t| t.enabled.then(|| t.language.clone()).flatten())
}
/// Account language (ISO 639-1) from the shared client's session — the
/// "Auto" resolution's first hop. None pre-login / pre-v10 sessions.
async fn account_language() -> Option<String> {
let client = CLIENT.get()?.clone();
let guard = client.read().await;
guard.as_ref()?.session_language_code().await
}
/// Resolve the effective translation target (spec §B.3): the explicit
/// setting when set; "Auto" = account `language_code`, falling back to the
/// UI locale. `None` when nothing resolves — the caller fails soft (toast +
/// revert); the lyrics view never enters an error state over translation.
async fn resolve_target_language() -> Option<String> {
let pref = crate::lyrics_prefs::load().translation_language;
if pref != "auto" {
return Some(pref);
}
if let Some(lang) = account_language().await {
return Some(lang);
}
let ui_locale = qbz_i18n::current_language();
(!ui_locale.is_empty()).then(|| ui_locale.to_string())
}
/// Toggle OFF: drop the session and re-render original-only. Pure UI — the
/// committed line model keeps its (now hidden) translation texts; NO
/// network (spec §B.3).
pub fn disable_translation(weak: &slint::Weak<AppWindow>) {
reset_translation_session();
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LyricsState>().set_show_translation(false);
});
}
/// Toast + revert, for every translation failure path (toggle ON with no
/// current track, no resolvable target, fetch error, or a response without
/// a translation). Fail soft: the current lyrics view is left untouched.
pub fn translation_unavailable(weak: &slint::Weak<AppWindow>) {
reset_translation_session();
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LyricsState>().set_show_translation(false);
});
crate::toast::error_weak(
weak,
qbz_i18n::t("Translation is not available right now. Please try again later."),
);
}
/// Toggle ON: refetch the current track's lyrics WITH the resolved target
/// language (spec §B.3). The committed doc then carries the embedded
/// translation and `show-translation` flips on — but ONLY when the response
/// actually holds one; any gap toasts and reverts (Android
/// `player_lyrics_translation_error` parity). When the current doc already
/// holds the translation for the target (toggle off -> on on the same
/// track), the flip is local — no refetch.
pub fn enable_translation(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
let Some(service) = SERVICE.get().cloned() else {
translation_unavailable(&weak);
return;
};
let source = source_kind(track);
let track_id = (source == LyricsSourceKind::Qobuz).then_some(track.id);
let duration_secs = (track.duration_secs > 0).then_some(track.duration_secs);
let key = request_identity(
track_id,
&build_cache_key(track.title.trim(), track.artist.trim(), duration_secs),
);
let request = LyricsRequest {
track_id,
source,
title: track.title.clone(),
artist: track.artist.clone(),
album: (!track.album.is_empty()).then(|| track.album.clone()),
duration_secs,
offline: crate::offline_mode::engine().is_offline(),
// Resolved inside the task (the session read is async).
language: None,
};
tokio::spawn(async move {
let Some(target) = resolve_target_language().await else {
translation_unavailable(&weak);
return;
};
// Fast path: the current doc already carries this translation.
let already = with_current_doc(|doc| {
doc.and_then(|d| d.translation.as_ref())
.and_then(|t| t.lang.as_deref())
== Some(target.as_str())
});
if already {
if let Ok(mut t) = TRANSLATION.lock() {
t.enabled = true;
t.language = Some(target);
}
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LyricsState>().set_show_translation(true);
});
return;
}
let mut request = request;
request.language = Some(target.clone());
let result = service.get(request).await;
// Same stale guard (F2) as the track-change path: a superseded
// response is dropped whole.
let response_key = match &result {
Ok(response) => {
request_identity(response.request_track_id, &response.request_key)
}
Err(_) => key.clone(),
};
{
let mut current = CURRENT.lock().expect("lyrics CURRENT lock poisoned");
if current.key != response_key {
return;
}
if matches!(
result.as_ref().map(|r| &r.outcome),
Ok(LyricsOutcome::Found(_))
) {
current.loaded = true;
}
}
match result {
Ok(response) if matches!(response.outcome, LyricsOutcome::Found(_)) => {
let has_translation = match &response.outcome {
LyricsOutcome::Found(found) => found.doc.translation.is_some(),
_ => false,
};
if has_translation {
if let Ok(mut t) = TRANSLATION.lock() {
t.enabled = true;
t.language = Some(target);
}
}
apply_result(weak.clone(), Ok(response));
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<LyricsState>().set_show_translation(has_translation);
});
if !has_translation {
// The track lacks the target language — the original
// committed above stays; toast + revert (spec §B.4).
translation_unavailable(&weak);
}
}
// NotFound / offline-miss: keep the current view untouched.
Ok(_) => translation_unavailable(&weak),
// Fetch error: NEVER an error state because of translation.
Err(e) => {
log::warn!("[qbz-slint] translation refetch failed: {e}");
translation_unavailable(&weak);
}
}
});
}
/// Qobuz vs non-Qobuz from the queue track's source tag. `qobuz_download` /// Qobuz vs non-Qobuz from the queue track's source tag. `qobuz_download`
/// rows carry the REAL Qobuz catalog id (`local_queue_track`), so the Qobuz /// rows carry the REAL Qobuz catalog id (`local_queue_track`), so the Qobuz
/// primary applies to them too; local user files / ephemeral folders / Plex /// primary applies to them too; local user files / ephemeral folders / Plex
@@ -299,6 +521,9 @@ pub fn prefetch_lyrics(track: &QueueTrack) {
album: (!track.album.is_empty()).then(|| track.album.clone()), album: (!track.album.is_empty()).then(|| track.album.clone()),
duration_secs: (track.duration_secs > 0).then_some(track.duration_secs), duration_secs: (track.duration_secs > 0).then_some(track.duration_secs),
offline: false, offline: false,
// Prefetch warms the original-only entry; the translation toggle
// (UI task) triggers the refetch-with-language when needed.
language: None,
}; };
tokio::spawn(async move { tokio::spawn(async move {
// Resolution upserts into lyrics.db; the result is discarded (no UI). // Resolution upserts into lyrics.db; the result is discarded (no UI).
@@ -332,6 +557,10 @@ pub fn on_track_changed(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
// Offline as data, not lookup (spec §2.2.4): the engine verdict is // Offline as data, not lookup (spec §2.2.4): the engine verdict is
// read here and travels with the request. // read here and travels with the request.
offline: crate::offline_mode::engine().is_offline(), offline: crate::offline_mode::engine().is_offline(),
// Default flow fetches original-only (spec §B.1). While the
// translation toggle is ON the request rides the resolved target so
// the translation follows across tracks.
language: active_translation_language(),
}; };
let key = request_identity( let key = request_identity(
request.track_id, request.track_id,
@@ -372,6 +601,9 @@ pub fn on_track_changed(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
state.set_provider("".into()); state.set_provider("".into());
state.set_provider_label("".into()); state.set_provider_label("".into());
state.set_error("".into()); state.set_error("".into());
// Hide the toggle while loading — the availability of the NEXT
// track's doc is unknown until its commit.
state.set_translation_available(false);
}); });
} }
@@ -411,6 +643,7 @@ pub fn on_track_cleared(weak: slint::Weak<AppWindow>) {
if let Ok(mut doc) = CURRENT_DOC.lock() { if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = None; *doc = None;
} }
reset_translation_session();
let _ = weak.upgrade_in_event_loop(|w| { let _ = weak.upgrade_in_event_loop(|w| {
let state = w.global::<LyricsState>(); let state = w.global::<LyricsState>();
state.set_status(STATUS_IDLE); state.set_status(STATUS_IDLE);
@@ -424,27 +657,42 @@ pub fn on_track_cleared(weak: slint::Weak<AppWindow>) {
state.set_provider("".into()); state.set_provider("".into());
state.set_provider_label("".into()); state.set_provider_label("".into());
state.set_error("".into()); state.set_error("".into());
state.set_translation_available(false);
state.set_show_translation(false);
}); });
} }
/// Map the engine response into `LyricsState` (UI thread push). /// Map the engine response into `LyricsState` (UI thread push).
fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, String>) { fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, String>) {
let (status, items, synced, provider, label, error) = match result { let (status, items, synced, provider, label, error, translation_available) = match result {
Ok(response) => match response.outcome { Ok(response) => match response.outcome {
LyricsOutcome::Found(found) => { LyricsOutcome::Found(found) => {
let translation = found.doc.translation.as_deref();
let items: Vec<LyricsLineItem> = found let items: Vec<LyricsLineItem> = found
.doc .doc
.lines .lines
.iter() .iter()
.map(|line| LyricsLineItem { .enumerate()
.map(|(i, line)| LyricsLineItem {
text: line.text.clone().into(), text: line.text.clone().into(),
time_ms: line.time_ms.map(|v| v as i32).unwrap_or(-1), time_ms: line.time_ms.map(|v| v as i32).unwrap_or(-1),
end_ms: line.end_ms.map(|v| v as i32).unwrap_or(-1), end_ms: line.end_ms.map(|v| v as i32).unwrap_or(-1),
has_words: line.words.is_some(), has_words: line.words.is_some(),
// 1:1 with the rendered lines — the core filters the
// translation lines the same way as the originals;
// a missing entry degrades to "" (fail soft).
translation_text: translation
.and_then(|t| t.lines.get(i))
.map(|l| l.text.clone())
.unwrap_or_default()
.into(),
}) })
.collect(); .collect();
let synced = found.doc.synced; let synced = found.doc.synced;
let provider = found.doc.provider; let provider = found.doc.provider;
// The toggle lights only when Qobuz advertises translations
// for this track (spec §B.2).
let translation_available = !found.doc.translation_langs.is_empty();
if let Ok(mut doc) = CURRENT_DOC.lock() { if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = Some(found.doc); *doc = Some(found.doc);
} }
@@ -455,13 +703,14 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
provider.as_str(), provider.as_str(),
provider_label(provider), provider_label(provider),
String::new(), String::new(),
translation_available,
) )
} }
LyricsOutcome::NotFound => { LyricsOutcome::NotFound => {
if let Ok(mut doc) = CURRENT_DOC.lock() { if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = None; *doc = None;
} }
(STATUS_NOT_FOUND, Vec::new(), false, "", "", String::new()) (STATUS_NOT_FOUND, Vec::new(), false, "", "", String::new(), false)
} }
// Typed offline miss (F3) — the view maps it to a translated // Typed offline miss (F3) — the view maps it to a translated
// string, never a hardcoded message. // string, never a hardcoded message.
@@ -469,7 +718,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
if let Ok(mut doc) = CURRENT_DOC.lock() { if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = None; *doc = None;
} }
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new()) (STATUS_OFFLINE, Vec::new(), false, "", "", String::new(), false)
} }
}, },
Err(e) => { Err(e) => {
@@ -477,7 +726,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
*doc = None; *doc = None;
} }
log::warn!("[qbz-slint] lyrics fetch failed: {e}"); log::warn!("[qbz-slint] lyrics fetch failed: {e}");
(STATUS_ERROR, Vec::new(), false, "", "", e) (STATUS_ERROR, Vec::new(), false, "", "", e, false)
} }
}; };
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
@@ -487,6 +736,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
state.set_provider(provider.into()); state.set_provider(provider.into());
state.set_provider_label(label.into()); state.set_provider_label(label.into());
state.set_error(error.into()); state.set_error(error.into());
state.set_translation_available(translation_available);
state.set_active_index(-1); state.set_active_index(-1);
state.set_line_progress(0.0); state.set_line_progress(0.0);
state.set_fill_anim_ms(0); state.set_fill_anim_ms(0);
+39
View File
@@ -56,6 +56,10 @@ pub struct LyricsPrefs {
/// karaoke sweep, so the lyrics surface stops driving continuous repaints. /// karaoke sweep, so the lyrics surface stops driving continuous repaints.
#[serde(default)] #[serde(default)]
pub lite_fill: bool, pub lite_fill: bool,
/// Translation target (Qobuz v10): "auto" (account language, UI-locale
/// fallback) or an explicit ISO 639-1 code from [`TRANSLATION_LANGS`].
#[serde(default = "d_translation_lang")]
pub translation_language: String,
} }
fn d_true() -> bool { fn d_true() -> bool {
@@ -70,6 +74,9 @@ fn d_size() -> String {
fn d_dimming() -> String { fn d_dimming() -> String {
"strong".to_string() "strong".to_string()
} }
fn d_translation_lang() -> String {
"auto".to_string()
}
impl Default for LyricsPrefs { impl Default for LyricsPrefs {
/// Tauri defaults (`lyricsDisplayStore.ts:37-44`). /// Tauri defaults (`lyricsDisplayStore.ts:37-44`).
@@ -82,6 +89,7 @@ impl Default for LyricsPrefs {
active_color: String::new(), active_color: String::new(),
uppercase: false, uppercase: false,
lite_fill: false, lite_fill: false,
translation_language: d_translation_lang(),
} }
} }
} }
@@ -89,6 +97,11 @@ impl Default for LyricsPrefs {
const FONTS: [&str; 5] = ["system", "line-seed-jp", "montserrat", "noto-sans", "source-sans-3"]; const FONTS: [&str; 5] = ["system", "line-seed-jp", "montserrat", "noto-sans", "source-sans-3"];
const SIZES: [&str; 4] = ["small", "medium", "large", "xl"]; const SIZES: [&str; 4] = ["small", "medium", "large", "xl"];
const DIMMINGS: [&str; 3] = ["off", "soft", "strong"]; const DIMMINGS: [&str; 3] = ["off", "soft", "strong"];
/// Translation targets (owner-approved list, spec §Decisions.2): "auto" +
/// the observed `translation_langs` ∪ app UI locales. Order matches the
/// flyout's `translation-language-options` (Auto, en es fr de it pt nl ja ru).
const TRANSLATION_LANGS: [&str; 10] =
["auto", "en", "es", "fr", "de", "it", "pt", "nl", "ja", "ru"];
/// `#RRGGBB` (or empty = theme) — the Tauri validator /// `#RRGGBB` (or empty = theme) — the Tauri validator
/// (`lyricsDisplayStore.ts:46-52`). /// (`lyricsDisplayStore.ts:46-52`).
@@ -115,6 +128,9 @@ impl LyricsPrefs {
if !valid_color(&self.active_color) { if !valid_color(&self.active_color) {
self.active_color.clear(); self.active_color.clear();
} }
if !TRANSLATION_LANGS.contains(&self.translation_language.as_str()) {
self.translation_language = d_translation_lang();
}
self self
} }
} }
@@ -188,6 +204,13 @@ fn dimming_index(dimming: &str) -> i32 {
DIMMINGS.iter().position(|d| *d == dimming).unwrap_or(2) as i32 DIMMINGS.iter().position(|d| *d == dimming).unwrap_or(2) as i32
} }
fn translation_lang_index(lang: &str) -> i32 {
TRANSLATION_LANGS
.iter()
.position(|l| *l == lang)
.unwrap_or(0) as i32
}
fn parse_color(hex: &str) -> Option<slint::Color> { fn parse_color(hex: &str) -> Option<slint::Color> {
if hex.len() != 7 || !hex.starts_with('#') { if hex.len() != 7 || !hex.starts_with('#') {
return None; return None;
@@ -218,6 +241,7 @@ pub fn apply_to_ui(window: &AppWindow, prefs: &LyricsPrefs) {
None => state.set_use_custom_color(false), None => state.set_use_custom_color(false),
} }
state.set_lite_fill(prefs.lite_fill); state.set_lite_fill(prefs.lite_fill);
state.set_translation_language_index(translation_lang_index(&prefs.translation_language));
} }
/// Read the in-out props back into a pref set and persist — the /// Read the in-out props back into a pref set and persist — the
@@ -248,6 +272,11 @@ pub fn persist_from_ui(window: &AppWindow) {
}, },
uppercase: state.get_uppercase(), uppercase: state.get_uppercase(),
lite_fill: state.get_lite_fill(), lite_fill: state.get_lite_fill(),
translation_language: TRANSLATION_LANGS
.get(state.get_translation_language_index().max(0) as usize)
.copied()
.unwrap_or("auto")
.to_string(),
}; };
save(&prefs); save(&prefs);
} }
@@ -273,6 +302,7 @@ mod tests {
assert_eq!(p.dimming, "strong"); assert_eq!(p.dimming, "strong");
assert_eq!(p.active_color, ""); assert_eq!(p.active_color, "");
assert!(!p.uppercase); assert!(!p.uppercase);
assert_eq!(p.translation_language, "auto");
} }
#[test] #[test]
@@ -291,6 +321,7 @@ mod tests {
active_color: "purple".into(), active_color: "purple".into(),
uppercase: true, uppercase: true,
lite_fill: false, lite_fill: false,
translation_language: "klingon".into(),
} }
.sanitized(); .sanitized();
assert!(!p.auto_follow); // bools pass through assert!(!p.auto_follow); // bools pass through
@@ -299,6 +330,8 @@ mod tests {
assert_eq!(p.font_size, "medium"); assert_eq!(p.font_size, "medium");
assert_eq!(p.dimming, "strong"); assert_eq!(p.dimming, "strong");
assert_eq!(p.active_color, ""); assert_eq!(p.active_color, "");
// Unknown translation language falls back to Auto.
assert_eq!(p.translation_language, "auto");
} }
#[test] #[test]
@@ -311,12 +344,14 @@ mod tests {
active_color: "#8b5cf6".into(), active_color: "#8b5cf6".into(),
uppercase: false, uppercase: false,
lite_fill: true, lite_fill: true,
translation_language: "it".into(),
} }
.sanitized(); .sanitized();
assert_eq!(p.font, "line-seed-jp"); assert_eq!(p.font, "line-seed-jp");
assert_eq!(p.font_size, "xl"); assert_eq!(p.font_size, "xl");
assert_eq!(p.dimming, "off"); assert_eq!(p.dimming, "off");
assert_eq!(p.active_color, "#8b5cf6"); assert_eq!(p.active_color, "#8b5cf6");
assert_eq!(p.translation_language, "it");
} }
#[test] #[test]
@@ -340,10 +375,14 @@ mod tests {
for (i, d) in DIMMINGS.iter().enumerate() { for (i, d) in DIMMINGS.iter().enumerate() {
assert_eq!(dimming_index(d), i as i32); assert_eq!(dimming_index(d), i as i32);
} }
for (i, l) in TRANSLATION_LANGS.iter().enumerate() {
assert_eq!(translation_lang_index(l), i as i32);
}
// Unknowns fall back to the defaults' indices. // Unknowns fall back to the defaults' indices.
assert_eq!(font_index("nope"), 0); assert_eq!(font_index("nope"), 0);
assert_eq!(size_index("nope"), 1); assert_eq!(size_index("nope"), 1);
assert_eq!(dimming_index("nope"), 2); assert_eq!(dimming_index("nope"), 2);
assert_eq!(translation_lang_index("nope"), 0);
} }
#[test] #[test]
+41
View File
@@ -4962,6 +4962,23 @@ fn reseed_i18n_labels(window: &AppWindow) {
t("Lite"), t("Lite"),
t("Off"), t("Off"),
]))); ])));
// Lyrics translation-language options (v10): the Auto label is @tr'd,
// the language names are native literals — the same convention as
// `languages` above.
window.global::<LyricsState>().set_translation_language_options(ModelRc::new(
VecModel::from(vec![
t("Auto (account language)"),
"English".into(),
"Español".into(),
"Français".into(),
"Deutsch".into(),
"Italiano".into(),
"Português".into(),
"Nederlands".into(),
"日本語".into(),
"Русский".into(),
]),
));
// Cast picker: per-renderer cap options (#638 fix 4) — option 0 embeds // Cast picker: per-renderer cap options (#638 fix 4) — option 0 embeds
// the live global streaming-quality label, so this also re-reads // the live global streaming-quality label, so this also re-reads
// ui_prefs (the Settings streaming-quality arm re-pushes on change). // ui_prefs (the Settings streaming-quality arm re-pushes on change).
@@ -14130,6 +14147,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
lyrics::copy_current_lyrics(&weak); lyrics::copy_current_lyrics(&weak);
}); });
} }
{
// Translation toggle (Qobuz v10, sidebar floating button). OFF is
// pure UI (no network); ON resolves the target language and refetches
// the current track's lyrics with it — any gap toasts and reverts
// inside lyrics::enable_translation / translation_unavailable.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LyricsState>().on_toggle_translation(move || {
if lyrics::translation_enabled() {
lyrics::disable_translation(&weak);
return;
}
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
let state = runtime.core().get_queue_state().await;
match state.current_track {
Some(track) => lyrics::enable_translation(weak, &track),
None => lyrics::translation_unavailable(&weak),
}
});
});
}
{ {
// Settings > Offline lyrics-cache row: stats refresh on section // Settings > Offline lyrics-cache row: stats refresh on section
// mount + clear action (F1: stats from the real per-user DB). // mount + clear action (F1: stats from the real per-user DB).
+3
View File
@@ -607,6 +607,9 @@ fn mirror_lyrics_gated(main: &AppWindow, mini: &MiniPlayerWindow) {
d.set_active_index(s.get_active_index()); d.set_active_index(s.get_active_index());
d.set_line_progress(s.get_line_progress()); d.set_line_progress(s.get_line_progress());
d.set_fill_anim_ms(s.get_fill_anim_ms()); d.set_fill_anim_ms(s.get_fill_anim_ms());
// Translation toggle (v10): the mini surface has no button of its own —
// it follows the sidebar's bilingual state through the shared model.
d.set_show_translation(s.get_show_translation());
refresh_mini_segments(&d); refresh_mini_segments(&d);
// Gate the lines-model copy on (status, line-count) so we don't thrash the // Gate the lines-model copy on (status, line-count) so we don't thrash the
+2
View File
@@ -988,6 +988,8 @@ mod tests {
display_name: "Tester".into(), display_name: "Tester".into(),
subscription_label: "studio".into(), subscription_label: "studio".into(),
subscription_valid_until: None, subscription_valid_until: None,
country_code: None,
language_code: None,
}; };
set_logged_in(&shared, &session); set_logged_in(&shared, &session);
let s = shared.lock().unwrap(); let s = shared.lock().unwrap();