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
/// 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(
&self,
track_id: u64,
@@ -1000,7 +1001,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = self.client.read().await;
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
+4 -1
View File
@@ -40,7 +40,10 @@ pub mod wsync;
pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
pub use lrc::{emit_lrc, parse_lrc, parse_plain, LrcSpan, MAX_SUNG_MS};
pub use model::{build_cache_key, LyricsDoc, LyricsLine, LyricsPayload, LyricsProvider, Word};
pub use model::{
build_cache_key, derive_has_translation, LyricsDoc, LyricsKind, LyricsLine, LyricsPayload,
LyricsProvider, TranslatedLyrics, Word,
};
pub use providers::LyricsData;
pub use service::{
HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse,
+71 -6
View File
@@ -81,6 +81,38 @@ pub struct LyricsLine {
pub words: Option<Vec<Word>>,
}
/// Synchronization kind of a lyrics content block — mirrors the Android v10
/// mapper's dispatch on the wire `type` (wsync -> word-synced, lsync ->
/// line-synced, plain -> plain).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum LyricsKind {
LineSynced,
Plain,
WordSynced,
}
/// A translated lyrics block (Qobuz API v10). Reuses [`LyricsLine`]/[`Word`]:
/// synced translations carry the SAME timestamps as the original, so sync and
/// karaoke stay keyed on the original timings while the translation renders
/// as static text under each line.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TranslatedLyrics {
pub kind: LyricsKind,
/// ISO 639-1 of the translation (the language it was fetched with).
pub lang: Option<String>,
pub lines: Vec<LyricsLine>,
}
/// Derive `has_translation` (client-derived, NEVER a wire field): the
/// requested language is listed in the document's `translation_langs`.
/// False when no language was requested.
pub fn derive_has_translation(translation_langs: &[String], requested_lang: Option<&str>) -> bool {
match requested_lang {
Some(lang) => translation_langs.iter().any(|candidate| candidate == lang),
None => false,
}
}
/// Parsed, render-ready lyrics document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LyricsDoc {
@@ -92,6 +124,14 @@ pub struct LyricsDoc {
pub translation_langs: Vec<String>,
/// Songwriter credits display string (Qobuz metadata passthrough).
pub writers: Option<String>,
/// Embedded translation (Qobuz v10 only; `None` for other providers and
/// for original-only fetches). Boxed: rare + large, keep the doc small.
#[serde(default)]
pub translation: Option<Box<TranslatedLyrics>>,
/// Client-derived (see [`derive_has_translation`]): a translation for the
/// requested language is available. False when none was requested.
#[serde(default)]
pub has_translation: bool,
}
impl LyricsDoc {
@@ -103,6 +143,8 @@ impl LyricsDoc {
provider,
translation_langs: Vec::new(),
writers: None,
translation: None,
has_translation: false,
}
}
@@ -116,6 +158,8 @@ impl LyricsDoc {
provider,
translation_langs: Vec::new(),
writers: None,
translation: None,
has_translation: false,
}
}
@@ -127,6 +171,8 @@ impl LyricsDoc {
provider,
translation_langs: Vec::new(),
writers: None,
translation: None,
has_translation: false,
}
}
@@ -152,13 +198,19 @@ impl LyricsDoc {
/// Build from a cached row. Readers PREFER the native wsync document when
/// present (amended Q5): word timestamps survive the cache; the LRC column
/// stays the cross-frontend lingua franca.
pub fn from_cached(payload: &LyricsPayload, qobuz_wsync_json: Option<&str>) -> Self {
/// stays the cross-frontend lingua franca. `requested_lang` (the active
/// translation target, if any) drives the client-derived `has_translation`
/// on the served document.
pub fn from_cached(
payload: &LyricsPayload,
qobuz_wsync_json: Option<&str>,
requested_lang: Option<&str>,
) -> Self {
if payload.provider == LyricsProvider::Qobuz {
if let Some(json) = qobuz_wsync_json {
match serde_json::from_str::<crate::wsync::QobuzWsync>(json) {
Ok(wsync) => {
let doc = wsync.to_doc();
let doc = wsync.to_doc(requested_lang);
if !doc.lines.is_empty() {
return doc;
}
@@ -297,22 +349,35 @@ mod tests {
{"line":"hello","start":1000,"end":2000,
"words":[{"word":"hello","start":1000,"end":2000}]}
]}"#;
let doc = LyricsDoc::from_cached(&payload, Some(wsync_json));
let doc = LyricsDoc::from_cached(&payload, Some(wsync_json), None);
assert!(doc.synced);
let words = doc.lines[0].words.as_ref().expect("native words preserved");
assert_eq!(words[0].text, "hello");
// Without the column, the LRC path applies (no words).
let doc = LyricsDoc::from_cached(&payload, None);
let doc = LyricsDoc::from_cached(&payload, None, None);
assert!(doc.synced);
assert!(doc.lines[0].words.is_none());
// Corrupt wsync json degrades to the LRC path instead of erroring.
let doc = LyricsDoc::from_cached(&payload, Some("{not json"));
let doc = LyricsDoc::from_cached(&payload, Some("{not json"), None);
assert!(doc.synced);
assert!(doc.lines[0].words.is_none());
}
#[test]
fn has_translation_derivation() {
let langs = vec!["pt".to_string(), "de".to_string(), "es".to_string()];
// Requested lang listed -> true.
assert!(derive_has_translation(&langs, Some("es")));
// Requested lang NOT listed -> false (track lacks that language).
assert!(!derive_has_translation(&langs, Some("ja")));
// No language requested -> false, even when translations exist.
assert!(!derive_has_translation(&langs, None));
// Empty list (feature off) -> false.
assert!(!derive_has_translation(&[], Some("es")));
}
#[test]
fn plain_text_joins_lines() {
let doc = LyricsDoc::from_plain_text("a\n\n b \nc", LyricsProvider::Ovh);
+274 -18
View File
@@ -38,9 +38,11 @@ use tokio::sync::Mutex;
use qbz_qobuz::{QobuzClient, QobuzLyricsContent, QobuzLyricsDocument};
use crate::cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
use crate::model::{build_cache_key, LyricsDoc, LyricsPayload, LyricsProvider};
use crate::model::{
build_cache_key, derive_has_translation, LyricsDoc, LyricsPayload, LyricsProvider,
};
use crate::providers::{fetch_lrclib, fetch_lyrics_ovh, LyricsData};
use crate::wsync::QobuzWsync;
use crate::wsync::{translated_from_content, QobuzWsync};
/// What kind of source the playing track comes from. The Qobuz primary step
/// only applies to Qobuz catalog tracks with a real track_id; local-library /
@@ -63,6 +65,12 @@ pub struct LyricsRequest {
pub album: Option<String>,
pub duration_secs: Option<u64>,
pub offline: bool,
/// Active translation target (ISO 639-1, Qobuz API v10). `None` = the
/// default original-only fetch; `Some(lang)` asks the Qobuz primary for
/// a translation and drives the client-derived `has_translation` on the
/// served document. Only the Qobuz provider is affected — the external
/// fallback chain never sees it.
pub language: Option<String>,
}
/// Served lyrics: the wire-compatible payload plus the parsed document
@@ -96,10 +104,15 @@ pub struct LyricsResponse {
/// fakes and the chain logic stays headless-testable.
#[async_trait]
pub trait LyricsProviders: Send + Sync {
/// Qobuz primary (two-step: signed lyricsUrl + CDN doc).
/// `Ok(None)` = typed miss; `Err` = transport/auth/offline failure —
/// both degrade silently to the fallback chain.
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String>;
/// Qobuz primary (two-step: signed lyricsUrl + CDN doc). `language` is the
/// optional translation target (ISO 639-1, v10); `None` fetches
/// original-only. `Ok(None)` = typed miss; `Err` = transport/auth/offline
/// failure — both degrade silently to the fallback chain.
async fn qobuz(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsDocument>, String>;
/// LRCLIB. `Ok(None)` = no match; `Err` = transport error (the chain
/// retries exactly once).
@@ -128,9 +141,13 @@ impl HttpLyricsProviders {
#[async_trait]
impl LyricsProviders for HttpLyricsProviders {
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
async fn qobuz(
&self,
track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsDocument>, String> {
self.client
.get_lyrics(track_id)
.get_lyrics(track_id, language)
.await
.map_err(|e| e.to_string())
}
@@ -263,11 +280,40 @@ fn dedupe_key(request: &LyricsRequest) -> String {
)
),
};
format!("{}|offline:{}", identity, request.offline)
// The translation target is part of the identity: a refetch WITH a
// language must never join an in-flight original-only resolution.
format!(
"{}|offline:{}|lang:{}",
identity,
request.offline,
request.language.as_deref().unwrap_or("")
)
}
fn cached_result(cached: CachedLyrics) -> LyricsResult {
let doc = LyricsDoc::from_cached(&cached.payload, cached.qobuz_wsync_json.as_deref());
/// A cached entry satisfies a language request only when its stored native
/// wsync document carries a translation FOR THAT language (the content's own
/// `lang` records it, spec §A.5); anything else falls through to a refetch.
/// Original-only requests (`None`) are served by any synced hit, as before.
fn cached_serves_language(cached: &CachedLyrics, language: Option<&str>) -> bool {
match language {
None => true,
Some(lang) => cached
.qobuz_wsync_json
.as_deref()
.and_then(|json| serde_json::from_str::<QobuzWsync>(json).ok())
.and_then(|wsync| wsync.translation)
.and_then(|content| content.lang().map(str::to_owned))
.map(|cached_lang| cached_lang == lang)
.unwrap_or(false),
}
}
fn cached_result(cached: CachedLyrics, requested_lang: Option<&str>) -> LyricsResult {
let doc = LyricsDoc::from_cached(
&cached.payload,
cached.qobuz_wsync_json.as_deref(),
requested_lang,
);
LyricsResult {
payload: cached.payload,
doc,
@@ -306,7 +352,10 @@ async fn run_chain(
.or_else(|| db.get_by_cache_key(&cache_key).ok().flatten());
return Ok(match cached {
Some(cached) => respond(LyricsOutcome::Found(cached_result(cached))),
Some(cached) => respond(LyricsOutcome::Found(cached_result(
cached,
request.language.as_deref(),
))),
None => respond(LyricsOutcome::NotAvailableOffline),
});
}
@@ -330,10 +379,14 @@ async fn run_chain(
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_synced {
return Ok(respond(LyricsOutcome::Found(cached_result(cached))));
if has_synced && cached_serves_language(&cached, request.language.as_deref()) {
return Ok(respond(LyricsOutcome::Found(cached_result(
cached,
request.language.as_deref(),
))));
}
// plain-only cache: fall through to re-fetch for synced
// plain-only cache — or a synced entry without a translation for
// the requested language: fall through to re-fetch (spec §A.5).
}
} // lock released before any network round-trip
@@ -360,7 +413,11 @@ async fn run_chain(
let qobuz_fut = async {
if request.source == LyricsSourceKind::Qobuz {
if let Some(track_id) = request.track_id {
match inner.providers.qobuz(track_id).await {
match inner
.providers
.qobuz(track_id, request.language.as_deref())
.await
{
Ok(Some(document)) => return Some(document),
Ok(None) => log::debug!("[Lyrics] Qobuz miss for track {}", track_id),
Err(e) => {
@@ -397,7 +454,7 @@ async fn run_chain(
let mut qobuz_plain_candidate: Option<LyricsResult> = None;
if let Some(document) = qobuz_doc {
if let Some(wsync) = QobuzWsync::from_document(&document) {
let doc = wsync.to_doc();
let doc = wsync.to_doc(request.language.as_deref());
if !doc.lines.is_empty() {
let mut payload = base_payload(LyricsProvider::Qobuz);
payload.plain = Some(doc.plain_text());
@@ -421,6 +478,14 @@ async fn run_chain(
let mut doc = LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz);
doc.translation_langs = document.translation_langs.clone();
doc.writers = document.writers.clone();
doc.translation = document
.translation
.as_ref()
.map(|content| Box::new(translated_from_content(content)));
doc.has_translation = derive_has_translation(
&document.translation_langs,
request.language.as_deref(),
);
let mut payload = base_payload(LyricsProvider::Qobuz);
payload.plain = Some(blob.trim().to_string());
qobuz_plain_candidate = Some(LyricsResult { payload, doc });
@@ -493,13 +558,23 @@ mod tests {
qobuz_calls: AtomicUsize,
lrclib_calls: AtomicUsize,
ovh_calls: AtomicUsize,
/// Language argument observed on each qobuz call (threading proof).
qobuz_languages: StdMutex<Vec<Option<String>>>,
delay_ms: Option<u64>,
}
#[async_trait]
impl LyricsProviders for FakeProviders {
async fn qobuz(&self, _track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
async fn qobuz(
&self,
_track_id: u64,
language: Option<&str>,
) -> Result<Option<QobuzLyricsDocument>, String> {
self.qobuz_calls.fetch_add(1, Ordering::SeqCst);
self.qobuz_languages
.lock()
.unwrap()
.push(language.map(str::to_owned));
if let Some(ms) = self.delay_ms {
tokio::time::sleep(Duration::from_millis(ms)).await;
}
@@ -549,6 +624,32 @@ mod tests {
.unwrap()
}
fn wsync_document_with_translation() -> QobuzLyricsDocument {
serde_json::from_str(
r#"{
"track_id": 100, "album_id": "alb",
"translation_langs": ["es", "fr"], "writers": "W. Riter",
"original": {"type": "wsync", "lang": "en", "lines": [
{"line": "first line", "start": 1000, "end": 3000,
"words": [{"word": "first", "start": 1000, "end": 1800},
{"word": "line", "start": 1800, "end": 3000}]},
{"line": "second line", "start": 9000, "end": 11000,
"words": [{"word": "second", "start": 9000, "end": 9900},
{"word": "line", "start": 9900, "end": 11000}]}
]},
"translation": {"type": "wsync", "lang": "es", "lines": [
{"line": "primera linea", "start": 1000, "end": 3000,
"words": [{"word": "primera", "start": 1000, "end": 1800},
{"word": "linea", "start": 1800, "end": 3000}]},
{"line": "segunda linea", "start": 9000, "end": 11000,
"words": [{"word": "segunda", "start": 9000, "end": 9900},
{"word": "linea", "start": 9900, "end": 11000}]}
]}
}"#,
)
.unwrap()
}
fn plain_document() -> QobuzLyricsDocument {
serde_json::from_str(
r#"{
@@ -593,6 +694,7 @@ mod tests {
album: Some("Album".into()),
duration_secs: Some(200),
offline: false,
language: None,
}
}
@@ -797,6 +899,160 @@ mod tests {
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 2);
}
// ---------- translation (v10) ----------
#[tokio::test]
async fn language_threads_to_qobuz_and_translation_is_mapped() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(wsync_document_with_translation())));
let (service, _dir) = service_with(providers.clone()).await;
let mut translated = request();
translated.language = Some("es".into());
let response = service.get(translated).await.unwrap();
let result = found(&response);
// The language reached the provider; the doc carries the mapped
// translation (wsync -> WordSynced, native word stamps, same timings).
assert_eq!(
providers.qobuz_languages.lock().unwrap().as_slice(),
&[Some("es".to_string())]
);
assert!(result.doc.has_translation);
let translation = result.doc.translation.as_ref().expect("translation mapped");
assert_eq!(translation.kind, crate::LyricsKind::WordSynced);
assert_eq!(translation.lang.as_deref(), Some("es"));
assert_eq!(translation.lines.len(), 2);
assert_eq!(translation.lines[0].text, "primera linea");
assert_eq!(translation.lines[0].time_ms, Some(1000));
let words = translation.lines[0].words.as_ref().expect("words survive");
assert_eq!(words[0].text, "primera");
// Sync stays keyed on the ORIGINAL lines/timings.
assert_eq!(result.doc.lines[0].text, "first line");
}
#[tokio::test]
async fn translation_survives_the_cache_for_the_same_language() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(wsync_document_with_translation())));
let (service, _dir) = service_with(providers.clone()).await;
let mut translated = request();
translated.language = Some("es".into());
service.get(translated.clone()).await.unwrap();
// Same language again -> synced cache hit WITH the stored
// translation; no second provider call.
let response = service.get(translated).await.unwrap();
let result = found(&response);
assert!(result.payload.cached);
assert!(result.doc.has_translation);
let translation = result.doc.translation.as_ref().expect("cached translation");
assert_eq!(translation.lang.as_deref(), Some("es"));
assert!(translation.lines[0].words.is_some());
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn different_language_refetches_original_only_uses_the_cache() {
let providers = Arc::new(FakeProviders::default());
{
let mut q = providers.qobuz_queue.lock().unwrap();
q.push_back(Ok(Some(wsync_document_with_translation())));
q.push_back(Ok(Some(wsync_document_with_translation())));
}
let (service, _dir) = service_with(providers.clone()).await;
// Seed the cache with the es translation.
let mut es = request();
es.language = Some("es".into());
service.get(es).await.unwrap();
// A request for a DIFFERENT language misses the stored translation
// and refetches (spec §A.5: the stored translation records its lang).
let mut fr = request();
fr.language = Some("fr".into());
let response = service.get(fr).await.unwrap();
assert!(!found(&response).payload.cached);
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 2);
// Original-only requests are still served by the synced cache hit.
let response = service.get(request()).await.unwrap();
let result = found(&response);
assert!(result.payload.cached);
assert!(!result.doc.has_translation);
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 2);
// The default fetch sent NO language.
assert_eq!(
providers.qobuz_languages.lock().unwrap().as_slice(),
&[Some("es".to_string()), Some("fr".to_string())]
);
}
#[tokio::test]
async fn default_fetch_maps_original_only_even_when_langs_are_listed() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(wsync_document())));
let (service, _dir) = service_with(providers.clone()).await;
let response = service.get(request()).await.unwrap();
let result = found(&response);
// translation_langs passthrough present, but nothing was requested:
// no translation, has_translation false.
assert_eq!(result.doc.translation_langs, vec!["es"]);
assert!(result.doc.translation.is_none());
assert!(!result.doc.has_translation);
assert_eq!(
providers.qobuz_languages.lock().unwrap().as_slice(),
&[None]
);
}
#[tokio::test]
async fn plain_original_with_plain_translation_is_mapped() {
let providers = Arc::new(FakeProviders::default());
providers
.qobuz_queue
.lock()
.unwrap()
.push_back(Ok(Some(serde_json::from_str(
r#"{
"track_id": 100, "album_id": "alb",
"translation_langs": ["de"],
"original": {"type": "plain", "lang": "en",
"lines": [{"line": "plain one"}, {"line": "plain two"}]},
"translation": {"type": "plain", "lang": "de",
"lines": [{"line": "einfach eins"}, {"line": "einfach zwei"}]}
}"#,
)
.unwrap())));
let (service, _dir) = service_with(providers.clone()).await;
let mut translated = request();
translated.language = Some("de".into());
let response = service.get(translated).await.unwrap();
let result = found(&response);
assert_eq!(result.payload.provider, LyricsProvider::Qobuz);
assert!(!result.doc.synced);
assert!(result.doc.has_translation);
let translation = result.doc.translation.as_ref().expect("translation mapped");
assert_eq!(translation.kind, crate::LyricsKind::Plain);
assert_eq!(translation.lines.len(), 2);
assert_eq!(translation.lines[0].text, "einfach eins");
}
// ---------- cache policy ----------
#[tokio::test]
+208 -5
View File
@@ -10,14 +10,18 @@
//! live captures `qbz-nix-docs/qobuz-api/lyrics-doc-wsync.json`), so a wire
//! content union parses as a [`QobuzWsync`] and a stored [`QobuzWsync`] is
//! wire-shaped. The Qobuz wrapper metadata (`translation_langs`, `writers`)
//! is carried as additive optional fields so it survives the cache.
//! and the v10 `translation` block are carried as additive optional fields
//! so they survive the cache.
use serde::{Deserialize, Serialize};
use qbz_qobuz::{QobuzLyricsContent, QobuzLyricsDocument};
use crate::lrc::{emit_lrc, LrcSpan};
use crate::model::{LyricsDoc, LyricsLine, LyricsProvider, Word};
use crate::model::{
derive_has_translation, LyricsDoc, LyricsKind, LyricsLine, LyricsProvider, TranslatedLyrics,
Word,
};
/// Discriminator tag — always `"wsync"` on output; accepts `"lsync"` on input
/// (the yaml's guess, kept as alias like the wire DTO does) and defaults when
@@ -43,6 +47,13 @@ pub struct QobuzWsync {
pub translation_langs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub writers: Option<String>,
/// Embedded translation (API v10), kept in the wire content-union shape
/// so it persists verbatim inside the cache JSON; the content's own
/// `lang` records which language the translation is for (a request for a
/// DIFFERENT language refetches). `serde(default)` — no DB migration:
/// rows written before this member existed still deserialize.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation: Option<QobuzLyricsContent>,
}
/// One synced line, wire-shaped. Gap separator lines come as
@@ -99,6 +110,7 @@ impl QobuzWsync {
.collect(),
translation_langs: doc.translation_langs.clone(),
writers: doc.writers.clone(),
translation: doc.translation.clone(),
}),
QobuzLyricsContent::Plain { .. } => None,
}
@@ -107,8 +119,10 @@ impl QobuzWsync {
/// Convert to the domain model. Gap separator lines (empty text) and
/// unplaceable lines (text without a `start` stamp) are skipped — in
/// wsync the previous line already carries its explicit `end`, so nothing
/// is lost. Native word stamps are preserved.
pub fn to_doc(&self) -> LyricsDoc {
/// is lost. Native word stamps are preserved. `requested_lang` (the
/// active translation target, if any) drives the client-derived
/// `has_translation`; the embedded translation maps like the original.
pub fn to_doc(&self, requested_lang: Option<&str>) -> LyricsDoc {
let lines: Vec<LyricsLine> = self
.lines
.iter()
@@ -142,6 +156,11 @@ impl QobuzWsync {
provider: LyricsProvider::Qobuz,
translation_langs: self.translation_langs.clone(),
writers: self.writers.clone(),
translation: self
.translation
.as_ref()
.map(|content| Box::new(translated_from_content(content))),
has_translation: derive_has_translation(&self.translation_langs, requested_lang),
}
}
@@ -165,6 +184,69 @@ impl QobuzWsync {
}
}
/// Map a wire content union to the domain [`TranslatedLyrics`] — used for a
/// document's embedded `translation` block. Kind dispatch mirrors the
/// Android v10 mapper (`qt6.java`): wsync (any word stamps) -> WordSynced,
/// lsync (synced, no words) -> LineSynced, plain -> Plain. Synced lines keep
/// their stamps (the SAME timings as the original) and are filtered exactly
/// like `to_doc`, so translation lines stay 1:1 aligned with the rendered
/// original lines.
pub(crate) fn translated_from_content(content: &QobuzLyricsContent) -> TranslatedLyrics {
match content {
QobuzLyricsContent::Synced { lang, lines } => {
let mapped: Vec<LyricsLine> = lines
.iter()
.filter(|line| !line.line.trim().is_empty())
.filter_map(|line| {
let start = line.start?;
Some(LyricsLine {
time_ms: Some(start),
end_ms: line.end,
text: line.line.clone(),
words: if line.words.is_empty() {
None
} else {
Some(
line.words
.iter()
.map(|word| Word {
start: word.start,
end: word.end,
text: word.word.clone(),
})
.collect(),
)
},
})
})
.collect();
let kind = if mapped.iter().any(|line| line.words.is_some()) {
LyricsKind::WordSynced
} else {
LyricsKind::LineSynced
};
TranslatedLyrics {
kind,
lang: lang.clone(),
lines: mapped,
}
}
QobuzLyricsContent::Plain { lang, lines } => TranslatedLyrics {
kind: LyricsKind::Plain,
lang: lang.clone(),
lines: lines
.iter()
.map(|line| LyricsLine {
time_ms: None,
end_ms: None,
text: line.line.clone(),
words: None,
})
.collect(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -228,7 +310,7 @@ mod tests {
#[test]
fn to_doc_skips_gaps_and_preserves_words() {
let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
let doc = wsync.to_doc();
let doc = wsync.to_doc(None);
assert!(doc.synced);
assert_eq!(doc.provider, LyricsProvider::Qobuz);
assert_eq!(doc.lines.len(), 2); // gap separator dropped from render model
@@ -282,4 +364,125 @@ mod tests {
let parsed: QobuzWsync = serde_json::from_str(lsync).unwrap();
assert_eq!(parsed.lines[0].start, Some(1));
}
fn sample_document_with_translation() -> QobuzLyricsDocument {
// v10 shape: the document embeds a `translation` block (same content
// union as `original`) when a `language` was requested.
let json = r#"{
"album_id": "gvcirtodd95kc",
"track_id": 266725027,
"translation_langs": ["pt", "de", "fr", "es", "it"],
"writers": "Billie Eilish O'Connell",
"original": {
"type": "wsync",
"lang": "en",
"lines": [
{"line": "Oh, mm-mm", "start": 1750, "end": 4770,
"words": [
{"word": "Oh,", "start": 1750, "end": 2480},
{"word": "mm-mm", "start": 2480, "end": 4770}
]}
]
},
"translation": {
"type": "wsync",
"lang": "es",
"lines": [
{"line": "Oh, mm-mm (es)", "start": 1750, "end": 4770,
"words": [
{"word": "Oh,", "start": 1750, "end": 2480},
{"word": "mm-mm", "start": 2480, "end": 4770}
]}
]
}
}"#;
serde_json::from_str(json).expect("valid translation fixture")
}
#[test]
fn from_document_carries_the_embedded_translation() {
let wsync =
QobuzWsync::from_document(&sample_document_with_translation()).expect("synced doc");
let translation = wsync.translation.expect("translation preserved");
assert_eq!(translation.lang(), Some("es"));
assert_eq!(translation.line_count(), 1);
// 9.x documents (no `translation` member) keep `translation: None`.
let wsync = QobuzWsync::from_document(&sample_document()).expect("synced doc");
assert!(wsync.translation.is_none());
}
#[test]
fn to_doc_maps_translation_and_derives_has_translation() {
let wsync = QobuzWsync::from_document(&sample_document_with_translation()).unwrap();
// Requested lang listed -> has_translation; translation mapped with
// kind dispatch (wsync -> WordSynced) and native word stamps.
let doc = wsync.to_doc(Some("es"));
assert!(doc.has_translation);
let translation = doc.translation.expect("translation mapped");
assert_eq!(translation.kind, LyricsKind::WordSynced);
assert_eq!(translation.lang.as_deref(), Some("es"));
assert_eq!(translation.lines.len(), 1);
assert_eq!(translation.lines[0].time_ms, Some(1750));
let words = translation.lines[0].words.as_ref().expect("words survive");
assert_eq!(words[1].text, "mm-mm");
assert_eq!(words[1].start, 2480);
// Requested lang NOT in translation_langs -> no has_translation (the
// mapped block may still be present; the caller renders original-only).
let doc = wsync.to_doc(Some("ja"));
assert!(!doc.has_translation);
// No language requested -> has_translation false by construction.
let doc = wsync.to_doc(None);
assert!(!doc.has_translation);
assert!(doc.translation.is_some());
}
#[test]
fn translation_kind_dispatch_covers_lsync_and_plain() {
// lsync: synced without words -> LineSynced.
let content: QobuzLyricsContent = serde_json::from_str(
r#"{"type":"lsync","lang":"fr","lines":[{"line":"salut","start":100,"end":200}]}"#,
)
.unwrap();
let translated = translated_from_content(&content);
assert_eq!(translated.kind, LyricsKind::LineSynced);
assert_eq!(translated.lines[0].time_ms, Some(100));
assert!(translated.lines[0].words.is_none());
// plain: text-only lines -> Plain, no stamps.
let content: QobuzLyricsContent = serde_json::from_str(
r#"{"type":"plain","lang":"de","lines":[{"line":"hallo"},{"line":"welt"}]}"#,
)
.unwrap();
let translated = translated_from_content(&content);
assert_eq!(translated.kind, LyricsKind::Plain);
assert_eq!(translated.lines.len(), 2);
assert_eq!(translated.lines[0].time_ms, None);
}
#[test]
fn stored_json_with_translation_round_trips_and_old_rows_still_load() {
let wsync = QobuzWsync::from_document(&sample_document_with_translation()).unwrap();
let json = serde_json::to_string(&wsync).unwrap();
let back: QobuzWsync = serde_json::from_str(&json).unwrap();
assert_eq!(back, wsync);
assert_eq!(
back.translation.as_ref().and_then(|t| t.lang().map(str::to_owned)),
Some("es".to_string())
);
// NO DB migration: a row written BEFORE the translation member
// existed must still deserialize (serde default -> None).
let old_format = r#"{"type":"wsync","lang":"en","translation_langs":["es","fr"],
"lines":[{"line":"hi","start":1,"end":2,
"words":[{"word":"hi","start":1,"end":2}]}]}"#;
let parsed: QobuzWsync = serde_json::from_str(old_format).unwrap();
assert!(parsed.translation.is_none());
let doc = parsed.to_doc(Some("es"));
assert!(doc.translation.is_none());
assert!(doc.has_translation, "lang listed -> has_translation");
}
}
+51
View File
@@ -127,6 +127,14 @@ pub struct UserSession {
pub subscription_label: String,
#[serde(default)]
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 ============
@@ -1475,3 +1483,46 @@ pub struct ArtistStoryAuthor {
#[serde(default)]
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.
//!
//! Usage:
//! 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 # default track 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.
@@ -41,15 +45,23 @@ const DEFAULT_TRACK_IDS: &[u64] = &[266725027, 29006863, 34];
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug));
let args: Vec<u64> = std::env::args()
.skip(1)
.filter_map(|a| a.parse().ok())
.collect();
let track_ids: Vec<u64> = if args.is_empty() {
let mut language: Option<String> = None;
let mut ids: Vec<u64> = Vec::new();
for arg in std::env::args().skip(1) {
if let Some(lang) = arg.strip_prefix("--lang=") {
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()
} else {
args
ids
};
if let Some(lang) = &language {
eprintln!("[probe] translation target language: {}", lang);
}
let client = QobuzClient::new()?;
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
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,
Err(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);
// 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(None) => {
println!("[lyricsUrl] typed result: MISS (no lyrics)");
@@ -179,7 +191,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// Typed full chain
match client.get_lyrics(track_id).await {
match client.get_lyrics(track_id, language.as_deref()).await {
Ok(Some(doc)) => {
let content = doc.original.as_ref().expect("get_lyrics guarantees original");
println!(
@@ -189,6 +201,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
doc.translation_langs,
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"),
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(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
let has_subscription = credential
.and_then(|c| c.get("parameters"))
@@ -163,6 +176,8 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
display_name,
subscription_label,
subscription_valid_until,
country_code,
language_code,
})
}
@@ -201,4 +216,41 @@ mod tests {
let sig = sign_get_file_url(123456, 27, 1234567890, "testsecret");
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::error::{ApiError, Result};
use super::forbidden_breaker::ForbiddenBreaker;
use super::lyrics::{QobuzLyricsDocument, QobuzLyricsUrls};
use super::lyrics::{
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls,
};
use qbz_models::*;
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
@@ -324,6 +326,17 @@ impl QobuzClient {
*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.
///
/// This implements the new Qobuz OAuth flow:
@@ -1397,14 +1410,27 @@ impl QobuzClient {
/// client, so this is the only way diagnostic tooling (e.g.
/// `examples/lyrics_probe.rs`) can observe the exact wire shape; the
/// 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);
// Signing method name per the concatenation convention ("trackget" at
// get_track, "trackgetFileUrl" in auth.rs): path with slashes removed,
// case preserved -> "tracklyricsUrl". Verified against the live API
// 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
.signed_get_auth(&url, "tracklyricsUrl", &[("track_id", track_id.to_string())])
.signed_get_auth(&url, "tracklyricsUrl", &params)
.await?;
let status = response.status().as_u16();
let body = response.text().await?;
@@ -1418,8 +1444,12 @@ impl QobuzClient {
/// `Err` is reserved for transport/auth/offline failures — the lyrics
/// orchestrator degrades those to a miss as well (spec §1.1); nothing in
/// this path is ever a user-facing error.
pub async fn get_lyrics_url(&self, track_id: u64) -> Result<Option<QobuzLyricsUrls>> {
let (status, body) = self.get_lyrics_url_raw(track_id).await?;
pub async fn get_lyrics_url(
&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 {
// Live-observed miss: HTTP 404 with body
// {"status":"error","code":404,"message":"No lyrics found"}
@@ -1458,14 +1488,74 @@ impl QobuzClient {
/// — a pre-signed CloudFront URL (`Expires`/`Signature`/`Key-Pair-Id`);
/// no `X-App-Id`/auth headers are sent (verified live 2026-06-10, HTTP
/// 200 on a bare GET). `Ok(None)` = typed miss, same contract as
/// [`Self::get_lyrics_url`]; a document without `original` content also
/// counts as a miss.
pub async fn get_lyrics(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>> {
let urls = match self.get_lyrics_url(track_id).await? {
/// [`Self::get_lyrics_url`]; a document without usable content counts as
/// a miss.
///
/// `language` (ISO 639-1, API v10) requests a translation; `None` fetches
/// original-only (the default flow). 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,
None => return Ok(None),
};
let lyrics_url = match urls.lyrics_url {
let lyrics_url = match urls.lyrics_url.clone() {
Some(url) => url,
None => return Ok(None),
};
@@ -1482,10 +1572,17 @@ impl QobuzClient {
}
let body = response.text().await?;
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(_) => {
log::debug!(
"[API] get_lyrics({}) document without original content -> no lyrics",
"[API] get_lyrics({}) document without any lyrics content -> no lyrics",
track_id
);
Ok(None)
@@ -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)
pub async fn get_artist_basic(&self, artist_id: u64) -> Result<Artist> {
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 link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
pub use lyrics::{
QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine, QobuzLyricsPlainLine,
QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls, QobuzLyricsWord,
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine,
QobuzLyricsPlainLine, QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls,
QobuzLyricsWord,
};
+229 -11
View File
@@ -21,7 +21,7 @@
//! All fields stay serde-tolerant (`Option` / `#[serde(default)]`) — the
//! 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`.
///
@@ -62,15 +62,16 @@ pub struct QobuzLyricsUrls {
/// URL to the original-language lyrics document (Qobuz CDN host).
#[serde(default)]
pub lyrics_url: Option<String>,
/// Present only when the `translation` query param was sent AND a matching
/// translation exists (yaml:208-213). We never send `translation` in v1,
/// so this stays `None` (confirmed absent in the live capture).
/// Present only when the `language` query param was sent AND a matching
/// translation exists (yaml:208-213). Absent when no translation was
/// requested (confirmed absent in the live capture) or none is available
/// for the requested language.
#[serde(default)]
pub translation_requested: Option<QobuzLyricsTranslation>,
}
/// 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)]
pub struct QobuzLyricsTranslation {
#[serde(default)]
@@ -92,7 +93,8 @@ pub struct QobuzLyricsDocument {
/// ISO 639-1 codes a translation exists for (e.g. `["pt","de","fr","es","it"]`).
#[serde(default)]
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)]
pub publishers: Vec<QobuzLyricsPublisher>,
/// Songwriter credits as a single display string.
@@ -101,6 +103,31 @@ pub struct QobuzLyricsDocument {
/// The actual lyrics content (the yaml's "LyricsContentDto" union).
#[serde(default)]
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).
@@ -118,8 +145,9 @@ pub struct QobuzLyricsPublisher {
/// Live discriminator is `"wsync"` (word-synced — richer than the yaml's
/// `"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
/// minus `words`, which defaults to empty).
#[derive(Debug, Clone, Deserialize)]
/// minus `words`, which defaults to empty). `Serialize` exists so the lyrics
/// cache can persist a document's embedded translation verbatim.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum QobuzLyricsContent {
/// Time-synced lyrics; line + per-word timestamps, ms from track start.
@@ -154,6 +182,14 @@ impl QobuzLyricsContent {
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.
@@ -162,7 +198,7 @@ impl QobuzLyricsContent {
/// required): instrumental-gap separator lines come as `{"line":"","words":[]}`
/// 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).
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QobuzLyricsLine {
#[serde(default)]
pub line: String,
@@ -178,7 +214,7 @@ pub struct QobuzLyricsLine {
}
/// 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 {
#[serde(default)]
pub word: String,
@@ -193,7 +229,7 @@ pub struct QobuzLyricsWord {
/// One plain line. Live wire form is an object `{"line": "..."}`
/// (lyrics-doc-plain.json); the yaml guessed bare strings (`:287-303`) —
/// accept both.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct QobuzLyricsPlainLine {
pub line: String,
}
@@ -229,8 +265,18 @@ mod tests {
// synced/plain docs are truncated to a few lines here to avoid vendoring
// 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_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_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");
#[test]
@@ -320,6 +366,178 @@ mod tests {
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]
fn lsync_alias_parses_yaml_shape() {
// 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
msgid "{} plays"
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
msgid "{} 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
msgid "{} plays"
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
msgid "{} plays"
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
msgid "{} plays"
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
msgid "{} plays"
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
msgid "{} plays"
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
msgid "{} plays"
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).
//
// 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
// 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
// underneath, right-aligned on the control grid (D6).
VerticalLayout {
@@ -70,6 +70,9 @@ import "../../../../static/fonts/SourceSans3-VariableFont_wght.ttf";
component LyricsLineRow inherits VerticalLayout {
in property <int> index;
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-active;
// 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.
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
// string (display only — the engine still matches on the raw 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 {
@@ -386,6 +413,7 @@ export component LyricsLinesView inherits Rectangle {
for line[idx] in LyricsState.lines: LyricsLineRow {
index: idx;
line-text: line.text;
translation-text: line.translation-text;
size-inactive: root.size-inactive;
size-active: root.size-active;
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 { LyricsLinesView } from "LyricsLinesView.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 {
callback clicked();
@@ -128,9 +176,10 @@ export component LyricsSidebar inherits Rectangle {
x: 32px + 8px + 28px - 260px;
y: 32px + 8px;
width: 260px;
// padding 28 + rows (22+30+30+44+22+30+34 = 212) +
// spacing 12 x 6 = 72.
height: 312px;
// padding 28 + rows (22+30+30+30+44+22+22+30+34 =
// 264, incl. lite-fill + translation language) +
// spacing 12 x 8 = 96.
height: 388px;
close-policy: PopupClosePolicy.close-on-click-outside;
LyricsControlsPanel { }
}
@@ -278,6 +327,14 @@ export component LyricsSidebar inherits Rectangle {
? LyricsState.custom-color
: 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
+36
View File
@@ -4454,6 +4454,11 @@ export struct LyricsLineItem {
time-ms: int,
end-ms: int,
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
@@ -4488,6 +4493,18 @@ export global LyricsState {
// Playing-track meta for the header ("{title} - {artist}").
in property <string> track-title;
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.
in property <string> error;
// 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
// tiers force it on via ShellState.reduce-motion regardless of this pref.
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
// accent (Tauri activeColor: ''). The palette swatches set both (deviation
// 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
/// modal's order, then the Slint-era external-PR contributors: `hoyon`
/// (classical "work" grouping, PR #536) and `mxnix` (Russian translation,
/// PR #517).
/// (classical "work" grouping, PR #536), `mxnix` (Russian translation,
/// PR #517) and `TerminalTilt`.
const CONTRIBUTORS: &[&str] = &[
"vorce",
"boxdot",
@@ -66,12 +66,13 @@ const CONTRIBUTORS: &[&str] = &[
"DoubleGate",
"hoyon",
"mxnix",
"TerminalTilt",
];
/// 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
/// the widened ~840px panel (10 contributors → 2 even rows) instead of the old
/// 4-per-row / 3-row layout that left the wider modal half-empty.
/// the widened ~840px panel instead of the old 4-per-row / 3-row layout that
/// left the wider modal half-empty.
const CONTRIBUTORS_PER_ROW: usize = 5;
/// 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 +
//! a `has-words` flag. The S4 sync engine consumes the doc for the
//! 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};
@@ -58,6 +68,11 @@ const STATUS_OFFLINE: i32 = 5;
/// the per-user cache handle re-binds via `init_at` on every activation.
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
/// committed for it. `key` doubles as the stale guard (F2) and the
/// duplicate-fetch guard (`loaded` mirrors Tauri's `status === 'loaded'`).
@@ -71,6 +86,20 @@ static CURRENT: Mutex<CurrentLyrics> = Mutex::new(CurrentLyrics {
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
/// Qobuz word timestamps survive for the S4 sync engine (the UI model only
/// carries text + line bounds + a has-words flag).
@@ -96,12 +125,19 @@ struct SharedClientProviders {
#[async_trait::async_trait]
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 client = guard
.as_ref()
.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(
@@ -126,6 +162,10 @@ impl LyricsProviders for SharedClientProviders {
/// Best-effort: failures are logged, never block entry. Must run within the
/// tokio runtime context (the bind is spawned).
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
.get_or_init(move || {
Arc::new(LyricsService::new(Arc::new(SharedClientProviders {
@@ -161,6 +201,7 @@ pub fn teardown() {
if let Ok(mut doc) = CURRENT_DOC.lock() {
*doc = None;
}
reset_translation_session();
if let Some(service) = SERVICE.get().cloned() {
tokio::spawn(async move {
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`
/// rows carry the REAL Qobuz catalog id (`local_queue_track`), so the Qobuz
/// 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()),
duration_secs: (track.duration_secs > 0).then_some(track.duration_secs),
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 {
// 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
// read here and travels with the request.
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(
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_label("".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() {
*doc = None;
}
reset_translation_session();
let _ = weak.upgrade_in_event_loop(|w| {
let state = w.global::<LyricsState>();
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_label("".into());
state.set_error("".into());
state.set_translation_available(false);
state.set_show_translation(false);
});
}
/// Map the engine response into `LyricsState` (UI thread push).
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 {
LyricsOutcome::Found(found) => {
let translation = found.doc.translation.as_deref();
let items: Vec<LyricsLineItem> = found
.doc
.lines
.iter()
.map(|line| LyricsLineItem {
.enumerate()
.map(|(i, line)| LyricsLineItem {
text: line.text.clone().into(),
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),
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();
let synced = found.doc.synced;
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() {
*doc = Some(found.doc);
}
@@ -455,13 +703,14 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
provider.as_str(),
provider_label(provider),
String::new(),
translation_available,
)
}
LyricsOutcome::NotFound => {
if let Ok(mut doc) = CURRENT_DOC.lock() {
*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
// 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() {
*doc = None;
}
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new())
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new(), false)
}
},
Err(e) => {
@@ -477,7 +726,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
*doc = None;
}
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| {
@@ -487,6 +736,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
state.set_provider(provider.into());
state.set_provider_label(label.into());
state.set_error(error.into());
state.set_translation_available(translation_available);
state.set_active_index(-1);
state.set_line_progress(0.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.
#[serde(default)]
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 {
@@ -70,6 +74,9 @@ fn d_size() -> String {
fn d_dimming() -> String {
"strong".to_string()
}
fn d_translation_lang() -> String {
"auto".to_string()
}
impl Default for LyricsPrefs {
/// Tauri defaults (`lyricsDisplayStore.ts:37-44`).
@@ -82,6 +89,7 @@ impl Default for LyricsPrefs {
active_color: String::new(),
uppercase: 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 SIZES: [&str; 4] = ["small", "medium", "large", "xl"];
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
/// (`lyricsDisplayStore.ts:46-52`).
@@ -115,6 +128,9 @@ impl LyricsPrefs {
if !valid_color(&self.active_color) {
self.active_color.clear();
}
if !TRANSLATION_LANGS.contains(&self.translation_language.as_str()) {
self.translation_language = d_translation_lang();
}
self
}
}
@@ -188,6 +204,13 @@ fn dimming_index(dimming: &str) -> 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> {
if hex.len() != 7 || !hex.starts_with('#') {
return None;
@@ -218,6 +241,7 @@ pub fn apply_to_ui(window: &AppWindow, prefs: &LyricsPrefs) {
None => state.set_use_custom_color(false),
}
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
@@ -248,6 +272,11 @@ pub fn persist_from_ui(window: &AppWindow) {
},
uppercase: state.get_uppercase(),
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);
}
@@ -273,6 +302,7 @@ mod tests {
assert_eq!(p.dimming, "strong");
assert_eq!(p.active_color, "");
assert!(!p.uppercase);
assert_eq!(p.translation_language, "auto");
}
#[test]
@@ -291,6 +321,7 @@ mod tests {
active_color: "purple".into(),
uppercase: true,
lite_fill: false,
translation_language: "klingon".into(),
}
.sanitized();
assert!(!p.auto_follow); // bools pass through
@@ -299,6 +330,8 @@ mod tests {
assert_eq!(p.font_size, "medium");
assert_eq!(p.dimming, "strong");
assert_eq!(p.active_color, "");
// Unknown translation language falls back to Auto.
assert_eq!(p.translation_language, "auto");
}
#[test]
@@ -311,12 +344,14 @@ mod tests {
active_color: "#8b5cf6".into(),
uppercase: false,
lite_fill: true,
translation_language: "it".into(),
}
.sanitized();
assert_eq!(p.font, "line-seed-jp");
assert_eq!(p.font_size, "xl");
assert_eq!(p.dimming, "off");
assert_eq!(p.active_color, "#8b5cf6");
assert_eq!(p.translation_language, "it");
}
#[test]
@@ -340,10 +375,14 @@ mod tests {
for (i, d) in DIMMINGS.iter().enumerate() {
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.
assert_eq!(font_index("nope"), 0);
assert_eq!(size_index("nope"), 1);
assert_eq!(dimming_index("nope"), 2);
assert_eq!(translation_lang_index("nope"), 0);
}
#[test]
+41
View File
@@ -4962,6 +4962,23 @@ fn reseed_i18n_labels(window: &AppWindow) {
t("Lite"),
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
// the live global streaming-quality label, so this also re-reads
// 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);
});
}
{
// 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
// 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_line_progress(s.get_line_progress());
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);
// 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(),
subscription_label: "studio".into(),
subscription_valid_until: None,
country_code: None,
language_code: None,
};
set_logged_in(&shared, &session);
let s = shared.lock().unwrap();