Merge branch 'feature/internal/lyrics-translation' into chore/internal/smoke-lyrics-discover
This commit is contained in:
35 files changed
+1857
-84
No files matched your search
@@ -992,7 +992,8 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
|
|
||||||
/// Get a track's lyrics document (synced/unsynced), or `None` if the track
|
/// Get a track's lyrics document (synced/unsynced), or `None` if the track
|
||||||
/// has none. Thin wrapper over `QobuzClient::get_lyrics` so headless callers
|
/// has none. Thin wrapper over `QobuzClient::get_lyrics` so headless callers
|
||||||
/// (qbzd) avoid the `client()` escape hatch.
|
/// (qbzd) avoid the `client()` escape hatch. Original-only fetch (no
|
||||||
|
/// translation target — the headless surface has no translation toggle).
|
||||||
pub async fn get_lyrics(
|
pub async fn get_lyrics(
|
||||||
&self,
|
&self,
|
||||||
track_id: u64,
|
track_id: u64,
|
||||||
@@ -1000,7 +1001,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
let client = self.client.read().await;
|
let client = self.client.read().await;
|
||||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||||
|
|
||||||
client.get_lyrics(track_id).await.map_err(CoreError::Api)
|
client.get_lyrics(track_id, None).await.map_err(CoreError::Api)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get artist by ID
|
/// Get artist by ID
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ pub mod wsync;
|
|||||||
|
|
||||||
pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
|
pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
|
||||||
pub use lrc::{emit_lrc, parse_lrc, parse_plain, LrcSpan, MAX_SUNG_MS};
|
pub use lrc::{emit_lrc, parse_lrc, parse_plain, LrcSpan, MAX_SUNG_MS};
|
||||||
pub use model::{build_cache_key, LyricsDoc, LyricsLine, LyricsPayload, LyricsProvider, Word};
|
pub use model::{
|
||||||
|
build_cache_key, derive_has_translation, LyricsDoc, LyricsKind, LyricsLine, LyricsPayload,
|
||||||
|
LyricsProvider, TranslatedLyrics, Word,
|
||||||
|
};
|
||||||
pub use providers::LyricsData;
|
pub use providers::LyricsData;
|
||||||
pub use service::{
|
pub use service::{
|
||||||
HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse,
|
HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse,
|
||||||
|
|||||||
@@ -81,6 +81,38 @@ pub struct LyricsLine {
|
|||||||
pub words: Option<Vec<Word>>,
|
pub words: Option<Vec<Word>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synchronization kind of a lyrics content block — mirrors the Android v10
|
||||||
|
/// mapper's dispatch on the wire `type` (wsync -> word-synced, lsync ->
|
||||||
|
/// line-synced, plain -> plain).
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum LyricsKind {
|
||||||
|
LineSynced,
|
||||||
|
Plain,
|
||||||
|
WordSynced,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A translated lyrics block (Qobuz API v10). Reuses [`LyricsLine`]/[`Word`]:
|
||||||
|
/// synced translations carry the SAME timestamps as the original, so sync and
|
||||||
|
/// karaoke stay keyed on the original timings while the translation renders
|
||||||
|
/// as static text under each line.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TranslatedLyrics {
|
||||||
|
pub kind: LyricsKind,
|
||||||
|
/// ISO 639-1 of the translation (the language it was fetched with).
|
||||||
|
pub lang: Option<String>,
|
||||||
|
pub lines: Vec<LyricsLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive `has_translation` (client-derived, NEVER a wire field): the
|
||||||
|
/// requested language is listed in the document's `translation_langs`.
|
||||||
|
/// False when no language was requested.
|
||||||
|
pub fn derive_has_translation(translation_langs: &[String], requested_lang: Option<&str>) -> bool {
|
||||||
|
match requested_lang {
|
||||||
|
Some(lang) => translation_langs.iter().any(|candidate| candidate == lang),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parsed, render-ready lyrics document.
|
/// Parsed, render-ready lyrics document.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct LyricsDoc {
|
pub struct LyricsDoc {
|
||||||
@@ -92,6 +124,14 @@ pub struct LyricsDoc {
|
|||||||
pub translation_langs: Vec<String>,
|
pub translation_langs: Vec<String>,
|
||||||
/// Songwriter credits display string (Qobuz metadata passthrough).
|
/// Songwriter credits display string (Qobuz metadata passthrough).
|
||||||
pub writers: Option<String>,
|
pub writers: Option<String>,
|
||||||
|
/// Embedded translation (Qobuz v10 only; `None` for other providers and
|
||||||
|
/// for original-only fetches). Boxed: rare + large, keep the doc small.
|
||||||
|
#[serde(default)]
|
||||||
|
pub translation: Option<Box<TranslatedLyrics>>,
|
||||||
|
/// Client-derived (see [`derive_has_translation`]): a translation for the
|
||||||
|
/// requested language is available. False when none was requested.
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_translation: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LyricsDoc {
|
impl LyricsDoc {
|
||||||
@@ -103,6 +143,8 @@ impl LyricsDoc {
|
|||||||
provider,
|
provider,
|
||||||
translation_langs: Vec::new(),
|
translation_langs: Vec::new(),
|
||||||
writers: None,
|
writers: None,
|
||||||
|
translation: None,
|
||||||
|
has_translation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +158,8 @@ impl LyricsDoc {
|
|||||||
provider,
|
provider,
|
||||||
translation_langs: Vec::new(),
|
translation_langs: Vec::new(),
|
||||||
writers: None,
|
writers: None,
|
||||||
|
translation: None,
|
||||||
|
has_translation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +171,8 @@ impl LyricsDoc {
|
|||||||
provider,
|
provider,
|
||||||
translation_langs: Vec::new(),
|
translation_langs: Vec::new(),
|
||||||
writers: None,
|
writers: None,
|
||||||
|
translation: None,
|
||||||
|
has_translation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,13 +198,19 @@ impl LyricsDoc {
|
|||||||
|
|
||||||
/// Build from a cached row. Readers PREFER the native wsync document when
|
/// Build from a cached row. Readers PREFER the native wsync document when
|
||||||
/// present (amended Q5): word timestamps survive the cache; the LRC column
|
/// present (amended Q5): word timestamps survive the cache; the LRC column
|
||||||
/// stays the cross-frontend lingua franca.
|
/// stays the cross-frontend lingua franca. `requested_lang` (the active
|
||||||
pub fn from_cached(payload: &LyricsPayload, qobuz_wsync_json: Option<&str>) -> Self {
|
/// translation target, if any) drives the client-derived `has_translation`
|
||||||
|
/// on the served document.
|
||||||
|
pub fn from_cached(
|
||||||
|
payload: &LyricsPayload,
|
||||||
|
qobuz_wsync_json: Option<&str>,
|
||||||
|
requested_lang: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
if payload.provider == LyricsProvider::Qobuz {
|
if payload.provider == LyricsProvider::Qobuz {
|
||||||
if let Some(json) = qobuz_wsync_json {
|
if let Some(json) = qobuz_wsync_json {
|
||||||
match serde_json::from_str::<crate::wsync::QobuzWsync>(json) {
|
match serde_json::from_str::<crate::wsync::QobuzWsync>(json) {
|
||||||
Ok(wsync) => {
|
Ok(wsync) => {
|
||||||
let doc = wsync.to_doc();
|
let doc = wsync.to_doc(requested_lang);
|
||||||
if !doc.lines.is_empty() {
|
if !doc.lines.is_empty() {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -297,22 +349,35 @@ mod tests {
|
|||||||
{"line":"hello","start":1000,"end":2000,
|
{"line":"hello","start":1000,"end":2000,
|
||||||
"words":[{"word":"hello","start":1000,"end":2000}]}
|
"words":[{"word":"hello","start":1000,"end":2000}]}
|
||||||
]}"#;
|
]}"#;
|
||||||
let doc = LyricsDoc::from_cached(&payload, Some(wsync_json));
|
let doc = LyricsDoc::from_cached(&payload, Some(wsync_json), None);
|
||||||
assert!(doc.synced);
|
assert!(doc.synced);
|
||||||
let words = doc.lines[0].words.as_ref().expect("native words preserved");
|
let words = doc.lines[0].words.as_ref().expect("native words preserved");
|
||||||
assert_eq!(words[0].text, "hello");
|
assert_eq!(words[0].text, "hello");
|
||||||
|
|
||||||
// Without the column, the LRC path applies (no words).
|
// Without the column, the LRC path applies (no words).
|
||||||
let doc = LyricsDoc::from_cached(&payload, None);
|
let doc = LyricsDoc::from_cached(&payload, None, None);
|
||||||
assert!(doc.synced);
|
assert!(doc.synced);
|
||||||
assert!(doc.lines[0].words.is_none());
|
assert!(doc.lines[0].words.is_none());
|
||||||
|
|
||||||
// Corrupt wsync json degrades to the LRC path instead of erroring.
|
// Corrupt wsync json degrades to the LRC path instead of erroring.
|
||||||
let doc = LyricsDoc::from_cached(&payload, Some("{not json"));
|
let doc = LyricsDoc::from_cached(&payload, Some("{not json"), None);
|
||||||
assert!(doc.synced);
|
assert!(doc.synced);
|
||||||
assert!(doc.lines[0].words.is_none());
|
assert!(doc.lines[0].words.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_translation_derivation() {
|
||||||
|
let langs = vec!["pt".to_string(), "de".to_string(), "es".to_string()];
|
||||||
|
// Requested lang listed -> true.
|
||||||
|
assert!(derive_has_translation(&langs, Some("es")));
|
||||||
|
// Requested lang NOT listed -> false (track lacks that language).
|
||||||
|
assert!(!derive_has_translation(&langs, Some("ja")));
|
||||||
|
// No language requested -> false, even when translations exist.
|
||||||
|
assert!(!derive_has_translation(&langs, None));
|
||||||
|
// Empty list (feature off) -> false.
|
||||||
|
assert!(!derive_has_translation(&[], Some("es")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plain_text_joins_lines() {
|
fn plain_text_joins_lines() {
|
||||||
let doc = LyricsDoc::from_plain_text("a\n\n b \nc", LyricsProvider::Ovh);
|
let doc = LyricsDoc::from_plain_text("a\n\n b \nc", LyricsProvider::Ovh);
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ use tokio::sync::Mutex;
|
|||||||
use qbz_qobuz::{QobuzClient, QobuzLyricsContent, QobuzLyricsDocument};
|
use qbz_qobuz::{QobuzClient, QobuzLyricsContent, QobuzLyricsDocument};
|
||||||
|
|
||||||
use crate::cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
|
use crate::cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
|
||||||
use crate::model::{build_cache_key, LyricsDoc, LyricsPayload, LyricsProvider};
|
use crate::model::{
|
||||||
|
build_cache_key, derive_has_translation, LyricsDoc, LyricsPayload, LyricsProvider,
|
||||||
|
};
|
||||||
use crate::providers::{fetch_lrclib, fetch_lyrics_ovh, LyricsData};
|
use crate::providers::{fetch_lrclib, fetch_lyrics_ovh, LyricsData};
|
||||||
use crate::wsync::QobuzWsync;
|
use crate::wsync::{translated_from_content, QobuzWsync};
|
||||||
|
|
||||||
/// What kind of source the playing track comes from. The Qobuz primary step
|
/// What kind of source the playing track comes from. The Qobuz primary step
|
||||||
/// only applies to Qobuz catalog tracks with a real track_id; local-library /
|
/// only applies to Qobuz catalog tracks with a real track_id; local-library /
|
||||||
@@ -63,6 +65,12 @@ pub struct LyricsRequest {
|
|||||||
pub album: Option<String>,
|
pub album: Option<String>,
|
||||||
pub duration_secs: Option<u64>,
|
pub duration_secs: Option<u64>,
|
||||||
pub offline: bool,
|
pub offline: bool,
|
||||||
|
/// Active translation target (ISO 639-1, Qobuz API v10). `None` = the
|
||||||
|
/// default original-only fetch; `Some(lang)` asks the Qobuz primary for
|
||||||
|
/// a translation and drives the client-derived `has_translation` on the
|
||||||
|
/// served document. Only the Qobuz provider is affected — the external
|
||||||
|
/// fallback chain never sees it.
|
||||||
|
pub language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Served lyrics: the wire-compatible payload plus the parsed document
|
/// Served lyrics: the wire-compatible payload plus the parsed document
|
||||||
@@ -96,10 +104,15 @@ pub struct LyricsResponse {
|
|||||||
/// fakes and the chain logic stays headless-testable.
|
/// fakes and the chain logic stays headless-testable.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LyricsProviders: Send + Sync {
|
pub trait LyricsProviders: Send + Sync {
|
||||||
/// Qobuz primary (two-step: signed lyricsUrl + CDN doc).
|
/// Qobuz primary (two-step: signed lyricsUrl + CDN doc). `language` is the
|
||||||
/// `Ok(None)` = typed miss; `Err` = transport/auth/offline failure —
|
/// optional translation target (ISO 639-1, v10); `None` fetches
|
||||||
/// both degrade silently to the fallback chain.
|
/// original-only. `Ok(None)` = typed miss; `Err` = transport/auth/offline
|
||||||
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String>;
|
/// failure — both degrade silently to the fallback chain.
|
||||||
|
async fn qobuz(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsDocument>, String>;
|
||||||
|
|
||||||
/// LRCLIB. `Ok(None)` = no match; `Err` = transport error (the chain
|
/// LRCLIB. `Ok(None)` = no match; `Err` = transport error (the chain
|
||||||
/// retries exactly once).
|
/// retries exactly once).
|
||||||
@@ -128,9 +141,13 @@ impl HttpLyricsProviders {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LyricsProviders for HttpLyricsProviders {
|
impl LyricsProviders for HttpLyricsProviders {
|
||||||
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
|
async fn qobuz(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsDocument>, String> {
|
||||||
self.client
|
self.client
|
||||||
.get_lyrics(track_id)
|
.get_lyrics(track_id, language)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -263,11 +280,40 @@ fn dedupe_key(request: &LyricsRequest) -> String {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
format!("{}|offline:{}", identity, request.offline)
|
// The translation target is part of the identity: a refetch WITH a
|
||||||
|
// language must never join an in-flight original-only resolution.
|
||||||
|
format!(
|
||||||
|
"{}|offline:{}|lang:{}",
|
||||||
|
identity,
|
||||||
|
request.offline,
|
||||||
|
request.language.as_deref().unwrap_or("")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cached_result(cached: CachedLyrics) -> LyricsResult {
|
/// A cached entry satisfies a language request only when its stored native
|
||||||
let doc = LyricsDoc::from_cached(&cached.payload, cached.qobuz_wsync_json.as_deref());
|
/// wsync document carries a translation FOR THAT language (the content's own
|
||||||
|
/// `lang` records it, spec §A.5); anything else falls through to a refetch.
|
||||||
|
/// Original-only requests (`None`) are served by any synced hit, as before.
|
||||||
|
fn cached_serves_language(cached: &CachedLyrics, language: Option<&str>) -> bool {
|
||||||
|
match language {
|
||||||
|
None => true,
|
||||||
|
Some(lang) => cached
|
||||||
|
.qobuz_wsync_json
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|json| serde_json::from_str::<QobuzWsync>(json).ok())
|
||||||
|
.and_then(|wsync| wsync.translation)
|
||||||
|
.and_then(|content| content.lang().map(str::to_owned))
|
||||||
|
.map(|cached_lang| cached_lang == lang)
|
||||||
|
.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_result(cached: CachedLyrics, requested_lang: Option<&str>) -> LyricsResult {
|
||||||
|
let doc = LyricsDoc::from_cached(
|
||||||
|
&cached.payload,
|
||||||
|
cached.qobuz_wsync_json.as_deref(),
|
||||||
|
requested_lang,
|
||||||
|
);
|
||||||
LyricsResult {
|
LyricsResult {
|
||||||
payload: cached.payload,
|
payload: cached.payload,
|
||||||
doc,
|
doc,
|
||||||
@@ -306,7 +352,10 @@ async fn run_chain(
|
|||||||
.or_else(|| db.get_by_cache_key(&cache_key).ok().flatten());
|
.or_else(|| db.get_by_cache_key(&cache_key).ok().flatten());
|
||||||
|
|
||||||
return Ok(match cached {
|
return Ok(match cached {
|
||||||
Some(cached) => respond(LyricsOutcome::Found(cached_result(cached))),
|
Some(cached) => respond(LyricsOutcome::Found(cached_result(
|
||||||
|
cached,
|
||||||
|
request.language.as_deref(),
|
||||||
|
))),
|
||||||
None => respond(LyricsOutcome::NotAvailableOffline),
|
None => respond(LyricsOutcome::NotAvailableOffline),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -330,10 +379,14 @@ async fn run_chain(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| !s.trim().is_empty())
|
.map(|s| !s.trim().is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if has_synced {
|
if has_synced && cached_serves_language(&cached, request.language.as_deref()) {
|
||||||
return Ok(respond(LyricsOutcome::Found(cached_result(cached))));
|
return Ok(respond(LyricsOutcome::Found(cached_result(
|
||||||
|
cached,
|
||||||
|
request.language.as_deref(),
|
||||||
|
))));
|
||||||
}
|
}
|
||||||
// plain-only cache: fall through to re-fetch for synced
|
// plain-only cache — or a synced entry without a translation for
|
||||||
|
// the requested language: fall through to re-fetch (spec §A.5).
|
||||||
}
|
}
|
||||||
} // lock released before any network round-trip
|
} // lock released before any network round-trip
|
||||||
|
|
||||||
@@ -360,7 +413,11 @@ async fn run_chain(
|
|||||||
let qobuz_fut = async {
|
let qobuz_fut = async {
|
||||||
if request.source == LyricsSourceKind::Qobuz {
|
if request.source == LyricsSourceKind::Qobuz {
|
||||||
if let Some(track_id) = request.track_id {
|
if let Some(track_id) = request.track_id {
|
||||||
match inner.providers.qobuz(track_id).await {
|
match inner
|
||||||
|
.providers
|
||||||
|
.qobuz(track_id, request.language.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Some(document)) => return Some(document),
|
Ok(Some(document)) => return Some(document),
|
||||||
Ok(None) => log::debug!("[Lyrics] Qobuz miss for track {}", track_id),
|
Ok(None) => log::debug!("[Lyrics] Qobuz miss for track {}", track_id),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -397,7 +454,7 @@ async fn run_chain(
|
|||||||
let mut qobuz_plain_candidate: Option<LyricsResult> = None;
|
let mut qobuz_plain_candidate: Option<LyricsResult> = None;
|
||||||
if let Some(document) = qobuz_doc {
|
if let Some(document) = qobuz_doc {
|
||||||
if let Some(wsync) = QobuzWsync::from_document(&document) {
|
if let Some(wsync) = QobuzWsync::from_document(&document) {
|
||||||
let doc = wsync.to_doc();
|
let doc = wsync.to_doc(request.language.as_deref());
|
||||||
if !doc.lines.is_empty() {
|
if !doc.lines.is_empty() {
|
||||||
let mut payload = base_payload(LyricsProvider::Qobuz);
|
let mut payload = base_payload(LyricsProvider::Qobuz);
|
||||||
payload.plain = Some(doc.plain_text());
|
payload.plain = Some(doc.plain_text());
|
||||||
@@ -421,6 +478,14 @@ async fn run_chain(
|
|||||||
let mut doc = LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz);
|
let mut doc = LyricsDoc::from_plain_text(&blob, LyricsProvider::Qobuz);
|
||||||
doc.translation_langs = document.translation_langs.clone();
|
doc.translation_langs = document.translation_langs.clone();
|
||||||
doc.writers = document.writers.clone();
|
doc.writers = document.writers.clone();
|
||||||
|
doc.translation = document
|
||||||
|
.translation
|
||||||
|
.as_ref()
|
||||||
|
.map(|content| Box::new(translated_from_content(content)));
|
||||||
|
doc.has_translation = derive_has_translation(
|
||||||
|
&document.translation_langs,
|
||||||
|
request.language.as_deref(),
|
||||||
|
);
|
||||||
let mut payload = base_payload(LyricsProvider::Qobuz);
|
let mut payload = base_payload(LyricsProvider::Qobuz);
|
||||||
payload.plain = Some(blob.trim().to_string());
|
payload.plain = Some(blob.trim().to_string());
|
||||||
qobuz_plain_candidate = Some(LyricsResult { payload, doc });
|
qobuz_plain_candidate = Some(LyricsResult { payload, doc });
|
||||||
@@ -493,13 +558,23 @@ mod tests {
|
|||||||
qobuz_calls: AtomicUsize,
|
qobuz_calls: AtomicUsize,
|
||||||
lrclib_calls: AtomicUsize,
|
lrclib_calls: AtomicUsize,
|
||||||
ovh_calls: AtomicUsize,
|
ovh_calls: AtomicUsize,
|
||||||
|
/// Language argument observed on each qobuz call (threading proof).
|
||||||
|
qobuz_languages: StdMutex<Vec<Option<String>>>,
|
||||||
delay_ms: Option<u64>,
|
delay_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LyricsProviders for FakeProviders {
|
impl LyricsProviders for FakeProviders {
|
||||||
async fn qobuz(&self, _track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
|
async fn qobuz(
|
||||||
|
&self,
|
||||||
|
_track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsDocument>, String> {
|
||||||
self.qobuz_calls.fetch_add(1, Ordering::SeqCst);
|
self.qobuz_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.qobuz_languages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(language.map(str::to_owned));
|
||||||
if let Some(ms) = self.delay_ms {
|
if let Some(ms) = self.delay_ms {
|
||||||
tokio::time::sleep(Duration::from_millis(ms)).await;
|
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||||
}
|
}
|
||||||
@@ -549,6 +624,32 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wsync_document_with_translation() -> QobuzLyricsDocument {
|
||||||
|
serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"track_id": 100, "album_id": "alb",
|
||||||
|
"translation_langs": ["es", "fr"], "writers": "W. Riter",
|
||||||
|
"original": {"type": "wsync", "lang": "en", "lines": [
|
||||||
|
{"line": "first line", "start": 1000, "end": 3000,
|
||||||
|
"words": [{"word": "first", "start": 1000, "end": 1800},
|
||||||
|
{"word": "line", "start": 1800, "end": 3000}]},
|
||||||
|
{"line": "second line", "start": 9000, "end": 11000,
|
||||||
|
"words": [{"word": "second", "start": 9000, "end": 9900},
|
||||||
|
{"word": "line", "start": 9900, "end": 11000}]}
|
||||||
|
]},
|
||||||
|
"translation": {"type": "wsync", "lang": "es", "lines": [
|
||||||
|
{"line": "primera linea", "start": 1000, "end": 3000,
|
||||||
|
"words": [{"word": "primera", "start": 1000, "end": 1800},
|
||||||
|
{"word": "linea", "start": 1800, "end": 3000}]},
|
||||||
|
{"line": "segunda linea", "start": 9000, "end": 11000,
|
||||||
|
"words": [{"word": "segunda", "start": 9000, "end": 9900},
|
||||||
|
{"word": "linea", "start": 9900, "end": 11000}]}
|
||||||
|
]}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn plain_document() -> QobuzLyricsDocument {
|
fn plain_document() -> QobuzLyricsDocument {
|
||||||
serde_json::from_str(
|
serde_json::from_str(
|
||||||
r#"{
|
r#"{
|
||||||
@@ -593,6 +694,7 @@ mod tests {
|
|||||||
album: Some("Album".into()),
|
album: Some("Album".into()),
|
||||||
duration_secs: Some(200),
|
duration_secs: Some(200),
|
||||||
offline: false,
|
offline: false,
|
||||||
|
language: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,6 +899,160 @@ mod tests {
|
|||||||
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 2);
|
assert_eq!(providers.lrclib_calls.load(Ordering::SeqCst), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- translation (v10) ----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn language_threads_to_qobuz_and_translation_is_mapped() {
|
||||||
|
let providers = Arc::new(FakeProviders::default());
|
||||||
|
providers
|
||||||
|
.qobuz_queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(Ok(Some(wsync_document_with_translation())));
|
||||||
|
let (service, _dir) = service_with(providers.clone()).await;
|
||||||
|
|
||||||
|
let mut translated = request();
|
||||||
|
translated.language = Some("es".into());
|
||||||
|
let response = service.get(translated).await.unwrap();
|
||||||
|
let result = found(&response);
|
||||||
|
|
||||||
|
// The language reached the provider; the doc carries the mapped
|
||||||
|
// translation (wsync -> WordSynced, native word stamps, same timings).
|
||||||
|
assert_eq!(
|
||||||
|
providers.qobuz_languages.lock().unwrap().as_slice(),
|
||||||
|
&[Some("es".to_string())]
|
||||||
|
);
|
||||||
|
assert!(result.doc.has_translation);
|
||||||
|
let translation = result.doc.translation.as_ref().expect("translation mapped");
|
||||||
|
assert_eq!(translation.kind, crate::LyricsKind::WordSynced);
|
||||||
|
assert_eq!(translation.lang.as_deref(), Some("es"));
|
||||||
|
assert_eq!(translation.lines.len(), 2);
|
||||||
|
assert_eq!(translation.lines[0].text, "primera linea");
|
||||||
|
assert_eq!(translation.lines[0].time_ms, Some(1000));
|
||||||
|
let words = translation.lines[0].words.as_ref().expect("words survive");
|
||||||
|
assert_eq!(words[0].text, "primera");
|
||||||
|
// Sync stays keyed on the ORIGINAL lines/timings.
|
||||||
|
assert_eq!(result.doc.lines[0].text, "first line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn translation_survives_the_cache_for_the_same_language() {
|
||||||
|
let providers = Arc::new(FakeProviders::default());
|
||||||
|
providers
|
||||||
|
.qobuz_queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(Ok(Some(wsync_document_with_translation())));
|
||||||
|
let (service, _dir) = service_with(providers.clone()).await;
|
||||||
|
|
||||||
|
let mut translated = request();
|
||||||
|
translated.language = Some("es".into());
|
||||||
|
service.get(translated.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Same language again -> synced cache hit WITH the stored
|
||||||
|
// translation; no second provider call.
|
||||||
|
let response = service.get(translated).await.unwrap();
|
||||||
|
let result = found(&response);
|
||||||
|
assert!(result.payload.cached);
|
||||||
|
assert!(result.doc.has_translation);
|
||||||
|
let translation = result.doc.translation.as_ref().expect("cached translation");
|
||||||
|
assert_eq!(translation.lang.as_deref(), Some("es"));
|
||||||
|
assert!(translation.lines[0].words.is_some());
|
||||||
|
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn different_language_refetches_original_only_uses_the_cache() {
|
||||||
|
let providers = Arc::new(FakeProviders::default());
|
||||||
|
{
|
||||||
|
let mut q = providers.qobuz_queue.lock().unwrap();
|
||||||
|
q.push_back(Ok(Some(wsync_document_with_translation())));
|
||||||
|
q.push_back(Ok(Some(wsync_document_with_translation())));
|
||||||
|
}
|
||||||
|
let (service, _dir) = service_with(providers.clone()).await;
|
||||||
|
|
||||||
|
// Seed the cache with the es translation.
|
||||||
|
let mut es = request();
|
||||||
|
es.language = Some("es".into());
|
||||||
|
service.get(es).await.unwrap();
|
||||||
|
|
||||||
|
// A request for a DIFFERENT language misses the stored translation
|
||||||
|
// and refetches (spec §A.5: the stored translation records its lang).
|
||||||
|
let mut fr = request();
|
||||||
|
fr.language = Some("fr".into());
|
||||||
|
let response = service.get(fr).await.unwrap();
|
||||||
|
assert!(!found(&response).payload.cached);
|
||||||
|
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
|
// Original-only requests are still served by the synced cache hit.
|
||||||
|
let response = service.get(request()).await.unwrap();
|
||||||
|
let result = found(&response);
|
||||||
|
assert!(result.payload.cached);
|
||||||
|
assert!(!result.doc.has_translation);
|
||||||
|
assert_eq!(providers.qobuz_calls.load(Ordering::SeqCst), 2);
|
||||||
|
// The default fetch sent NO language.
|
||||||
|
assert_eq!(
|
||||||
|
providers.qobuz_languages.lock().unwrap().as_slice(),
|
||||||
|
&[Some("es".to_string()), Some("fr".to_string())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_fetch_maps_original_only_even_when_langs_are_listed() {
|
||||||
|
let providers = Arc::new(FakeProviders::default());
|
||||||
|
providers
|
||||||
|
.qobuz_queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(Ok(Some(wsync_document())));
|
||||||
|
let (service, _dir) = service_with(providers.clone()).await;
|
||||||
|
|
||||||
|
let response = service.get(request()).await.unwrap();
|
||||||
|
let result = found(&response);
|
||||||
|
// translation_langs passthrough present, but nothing was requested:
|
||||||
|
// no translation, has_translation false.
|
||||||
|
assert_eq!(result.doc.translation_langs, vec!["es"]);
|
||||||
|
assert!(result.doc.translation.is_none());
|
||||||
|
assert!(!result.doc.has_translation);
|
||||||
|
assert_eq!(
|
||||||
|
providers.qobuz_languages.lock().unwrap().as_slice(),
|
||||||
|
&[None]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plain_original_with_plain_translation_is_mapped() {
|
||||||
|
let providers = Arc::new(FakeProviders::default());
|
||||||
|
providers
|
||||||
|
.qobuz_queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(Ok(Some(serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"track_id": 100, "album_id": "alb",
|
||||||
|
"translation_langs": ["de"],
|
||||||
|
"original": {"type": "plain", "lang": "en",
|
||||||
|
"lines": [{"line": "plain one"}, {"line": "plain two"}]},
|
||||||
|
"translation": {"type": "plain", "lang": "de",
|
||||||
|
"lines": [{"line": "einfach eins"}, {"line": "einfach zwei"}]}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap())));
|
||||||
|
let (service, _dir) = service_with(providers.clone()).await;
|
||||||
|
|
||||||
|
let mut translated = request();
|
||||||
|
translated.language = Some("de".into());
|
||||||
|
let response = service.get(translated).await.unwrap();
|
||||||
|
let result = found(&response);
|
||||||
|
assert_eq!(result.payload.provider, LyricsProvider::Qobuz);
|
||||||
|
assert!(!result.doc.synced);
|
||||||
|
assert!(result.doc.has_translation);
|
||||||
|
let translation = result.doc.translation.as_ref().expect("translation mapped");
|
||||||
|
assert_eq!(translation.kind, crate::LyricsKind::Plain);
|
||||||
|
assert_eq!(translation.lines.len(), 2);
|
||||||
|
assert_eq!(translation.lines[0].text, "einfach eins");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- cache policy ----------
|
// ---------- cache policy ----------
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -10,14 +10,18 @@
|
|||||||
//! live captures `qbz-nix-docs/qobuz-api/lyrics-doc-wsync.json`), so a wire
|
//! live captures `qbz-nix-docs/qobuz-api/lyrics-doc-wsync.json`), so a wire
|
||||||
//! content union parses as a [`QobuzWsync`] and a stored [`QobuzWsync`] is
|
//! content union parses as a [`QobuzWsync`] and a stored [`QobuzWsync`] is
|
||||||
//! wire-shaped. The Qobuz wrapper metadata (`translation_langs`, `writers`)
|
//! wire-shaped. The Qobuz wrapper metadata (`translation_langs`, `writers`)
|
||||||
//! is carried as additive optional fields so it survives the cache.
|
//! and the v10 `translation` block are carried as additive optional fields
|
||||||
|
//! so they survive the cache.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use qbz_qobuz::{QobuzLyricsContent, QobuzLyricsDocument};
|
use qbz_qobuz::{QobuzLyricsContent, QobuzLyricsDocument};
|
||||||
|
|
||||||
use crate::lrc::{emit_lrc, LrcSpan};
|
use crate::lrc::{emit_lrc, LrcSpan};
|
||||||
use crate::model::{LyricsDoc, LyricsLine, LyricsProvider, Word};
|
use crate::model::{
|
||||||
|
derive_has_translation, LyricsDoc, LyricsKind, LyricsLine, LyricsProvider, TranslatedLyrics,
|
||||||
|
Word,
|
||||||
|
};
|
||||||
|
|
||||||
/// Discriminator tag — always `"wsync"` on output; accepts `"lsync"` on input
|
/// Discriminator tag — always `"wsync"` on output; accepts `"lsync"` on input
|
||||||
/// (the yaml's guess, kept as alias like the wire DTO does) and defaults when
|
/// (the yaml's guess, kept as alias like the wire DTO does) and defaults when
|
||||||
@@ -43,6 +47,13 @@ pub struct QobuzWsync {
|
|||||||
pub translation_langs: Vec<String>,
|
pub translation_langs: Vec<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub writers: Option<String>,
|
pub writers: Option<String>,
|
||||||
|
/// Embedded translation (API v10), kept in the wire content-union shape
|
||||||
|
/// so it persists verbatim inside the cache JSON; the content's own
|
||||||
|
/// `lang` records which language the translation is for (a request for a
|
||||||
|
/// DIFFERENT language refetches). `serde(default)` — no DB migration:
|
||||||
|
/// rows written before this member existed still deserialize.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translation: Option<QobuzLyricsContent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One synced line, wire-shaped. Gap separator lines come as
|
/// One synced line, wire-shaped. Gap separator lines come as
|
||||||
@@ -99,6 +110,7 @@ impl QobuzWsync {
|
|||||||
.collect(),
|
.collect(),
|
||||||
translation_langs: doc.translation_langs.clone(),
|
translation_langs: doc.translation_langs.clone(),
|
||||||
writers: doc.writers.clone(),
|
writers: doc.writers.clone(),
|
||||||
|
translation: doc.translation.clone(),
|
||||||
}),
|
}),
|
||||||
QobuzLyricsContent::Plain { .. } => None,
|
QobuzLyricsContent::Plain { .. } => None,
|
||||||
}
|
}
|
||||||
@@ -107,8 +119,10 @@ impl QobuzWsync {
|
|||||||
/// Convert to the domain model. Gap separator lines (empty text) and
|
/// Convert to the domain model. Gap separator lines (empty text) and
|
||||||
/// unplaceable lines (text without a `start` stamp) are skipped — in
|
/// unplaceable lines (text without a `start` stamp) are skipped — in
|
||||||
/// wsync the previous line already carries its explicit `end`, so nothing
|
/// wsync the previous line already carries its explicit `end`, so nothing
|
||||||
/// is lost. Native word stamps are preserved.
|
/// is lost. Native word stamps are preserved. `requested_lang` (the
|
||||||
pub fn to_doc(&self) -> LyricsDoc {
|
/// active translation target, if any) drives the client-derived
|
||||||
|
/// `has_translation`; the embedded translation maps like the original.
|
||||||
|
pub fn to_doc(&self, requested_lang: Option<&str>) -> LyricsDoc {
|
||||||
let lines: Vec<LyricsLine> = self
|
let lines: Vec<LyricsLine> = self
|
||||||
.lines
|
.lines
|
||||||
.iter()
|
.iter()
|
||||||
@@ -142,6 +156,11 @@ impl QobuzWsync {
|
|||||||
provider: LyricsProvider::Qobuz,
|
provider: LyricsProvider::Qobuz,
|
||||||
translation_langs: self.translation_langs.clone(),
|
translation_langs: self.translation_langs.clone(),
|
||||||
writers: self.writers.clone(),
|
writers: self.writers.clone(),
|
||||||
|
translation: self
|
||||||
|
.translation
|
||||||
|
.as_ref()
|
||||||
|
.map(|content| Box::new(translated_from_content(content))),
|
||||||
|
has_translation: derive_has_translation(&self.translation_langs, requested_lang),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +184,69 @@ impl QobuzWsync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a wire content union to the domain [`TranslatedLyrics`] — used for a
|
||||||
|
/// document's embedded `translation` block. Kind dispatch mirrors the
|
||||||
|
/// Android v10 mapper (`qt6.java`): wsync (any word stamps) -> WordSynced,
|
||||||
|
/// lsync (synced, no words) -> LineSynced, plain -> Plain. Synced lines keep
|
||||||
|
/// their stamps (the SAME timings as the original) and are filtered exactly
|
||||||
|
/// like `to_doc`, so translation lines stay 1:1 aligned with the rendered
|
||||||
|
/// original lines.
|
||||||
|
pub(crate) fn translated_from_content(content: &QobuzLyricsContent) -> TranslatedLyrics {
|
||||||
|
match content {
|
||||||
|
QobuzLyricsContent::Synced { lang, lines } => {
|
||||||
|
let mapped: Vec<LyricsLine> = lines
|
||||||
|
.iter()
|
||||||
|
.filter(|line| !line.line.trim().is_empty())
|
||||||
|
.filter_map(|line| {
|
||||||
|
let start = line.start?;
|
||||||
|
Some(LyricsLine {
|
||||||
|
time_ms: Some(start),
|
||||||
|
end_ms: line.end,
|
||||||
|
text: line.line.clone(),
|
||||||
|
words: if line.words.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
line.words
|
||||||
|
.iter()
|
||||||
|
.map(|word| Word {
|
||||||
|
start: word.start,
|
||||||
|
end: word.end,
|
||||||
|
text: word.word.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let kind = if mapped.iter().any(|line| line.words.is_some()) {
|
||||||
|
LyricsKind::WordSynced
|
||||||
|
} else {
|
||||||
|
LyricsKind::LineSynced
|
||||||
|
};
|
||||||
|
TranslatedLyrics {
|
||||||
|
kind,
|
||||||
|
lang: lang.clone(),
|
||||||
|
lines: mapped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QobuzLyricsContent::Plain { lang, lines } => TranslatedLyrics {
|
||||||
|
kind: LyricsKind::Plain,
|
||||||
|
lang: lang.clone(),
|
||||||
|
lines: lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| LyricsLine {
|
||||||
|
time_ms: None,
|
||||||
|
end_ms: None,
|
||||||
|
text: line.line.clone(),
|
||||||
|
words: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -228,7 +310,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn to_doc_skips_gaps_and_preserves_words() {
|
fn to_doc_skips_gaps_and_preserves_words() {
|
||||||
let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
|
let wsync = QobuzWsync::from_document(&sample_document()).unwrap();
|
||||||
let doc = wsync.to_doc();
|
let doc = wsync.to_doc(None);
|
||||||
assert!(doc.synced);
|
assert!(doc.synced);
|
||||||
assert_eq!(doc.provider, LyricsProvider::Qobuz);
|
assert_eq!(doc.provider, LyricsProvider::Qobuz);
|
||||||
assert_eq!(doc.lines.len(), 2); // gap separator dropped from render model
|
assert_eq!(doc.lines.len(), 2); // gap separator dropped from render model
|
||||||
@@ -282,4 +364,125 @@ mod tests {
|
|||||||
let parsed: QobuzWsync = serde_json::from_str(lsync).unwrap();
|
let parsed: QobuzWsync = serde_json::from_str(lsync).unwrap();
|
||||||
assert_eq!(parsed.lines[0].start, Some(1));
|
assert_eq!(parsed.lines[0].start, Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_document_with_translation() -> QobuzLyricsDocument {
|
||||||
|
// v10 shape: the document embeds a `translation` block (same content
|
||||||
|
// union as `original`) when a `language` was requested.
|
||||||
|
let json = r#"{
|
||||||
|
"album_id": "gvcirtodd95kc",
|
||||||
|
"track_id": 266725027,
|
||||||
|
"translation_langs": ["pt", "de", "fr", "es", "it"],
|
||||||
|
"writers": "Billie Eilish O'Connell",
|
||||||
|
"original": {
|
||||||
|
"type": "wsync",
|
||||||
|
"lang": "en",
|
||||||
|
"lines": [
|
||||||
|
{"line": "Oh, mm-mm", "start": 1750, "end": 4770,
|
||||||
|
"words": [
|
||||||
|
{"word": "Oh,", "start": 1750, "end": 2480},
|
||||||
|
{"word": "mm-mm", "start": 2480, "end": 4770}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"type": "wsync",
|
||||||
|
"lang": "es",
|
||||||
|
"lines": [
|
||||||
|
{"line": "Oh, mm-mm (es)", "start": 1750, "end": 4770,
|
||||||
|
"words": [
|
||||||
|
{"word": "Oh,", "start": 1750, "end": 2480},
|
||||||
|
{"word": "mm-mm", "start": 2480, "end": 4770}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
serde_json::from_str(json).expect("valid translation fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_document_carries_the_embedded_translation() {
|
||||||
|
let wsync =
|
||||||
|
QobuzWsync::from_document(&sample_document_with_translation()).expect("synced doc");
|
||||||
|
let translation = wsync.translation.expect("translation preserved");
|
||||||
|
assert_eq!(translation.lang(), Some("es"));
|
||||||
|
assert_eq!(translation.line_count(), 1);
|
||||||
|
|
||||||
|
// 9.x documents (no `translation` member) keep `translation: None`.
|
||||||
|
let wsync = QobuzWsync::from_document(&sample_document()).expect("synced doc");
|
||||||
|
assert!(wsync.translation.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_doc_maps_translation_and_derives_has_translation() {
|
||||||
|
let wsync = QobuzWsync::from_document(&sample_document_with_translation()).unwrap();
|
||||||
|
|
||||||
|
// Requested lang listed -> has_translation; translation mapped with
|
||||||
|
// kind dispatch (wsync -> WordSynced) and native word stamps.
|
||||||
|
let doc = wsync.to_doc(Some("es"));
|
||||||
|
assert!(doc.has_translation);
|
||||||
|
let translation = doc.translation.expect("translation mapped");
|
||||||
|
assert_eq!(translation.kind, LyricsKind::WordSynced);
|
||||||
|
assert_eq!(translation.lang.as_deref(), Some("es"));
|
||||||
|
assert_eq!(translation.lines.len(), 1);
|
||||||
|
assert_eq!(translation.lines[0].time_ms, Some(1750));
|
||||||
|
let words = translation.lines[0].words.as_ref().expect("words survive");
|
||||||
|
assert_eq!(words[1].text, "mm-mm");
|
||||||
|
assert_eq!(words[1].start, 2480);
|
||||||
|
|
||||||
|
// Requested lang NOT in translation_langs -> no has_translation (the
|
||||||
|
// mapped block may still be present; the caller renders original-only).
|
||||||
|
let doc = wsync.to_doc(Some("ja"));
|
||||||
|
assert!(!doc.has_translation);
|
||||||
|
|
||||||
|
// No language requested -> has_translation false by construction.
|
||||||
|
let doc = wsync.to_doc(None);
|
||||||
|
assert!(!doc.has_translation);
|
||||||
|
assert!(doc.translation.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translation_kind_dispatch_covers_lsync_and_plain() {
|
||||||
|
// lsync: synced without words -> LineSynced.
|
||||||
|
let content: QobuzLyricsContent = serde_json::from_str(
|
||||||
|
r#"{"type":"lsync","lang":"fr","lines":[{"line":"salut","start":100,"end":200}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let translated = translated_from_content(&content);
|
||||||
|
assert_eq!(translated.kind, LyricsKind::LineSynced);
|
||||||
|
assert_eq!(translated.lines[0].time_ms, Some(100));
|
||||||
|
assert!(translated.lines[0].words.is_none());
|
||||||
|
|
||||||
|
// plain: text-only lines -> Plain, no stamps.
|
||||||
|
let content: QobuzLyricsContent = serde_json::from_str(
|
||||||
|
r#"{"type":"plain","lang":"de","lines":[{"line":"hallo"},{"line":"welt"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let translated = translated_from_content(&content);
|
||||||
|
assert_eq!(translated.kind, LyricsKind::Plain);
|
||||||
|
assert_eq!(translated.lines.len(), 2);
|
||||||
|
assert_eq!(translated.lines[0].time_ms, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_json_with_translation_round_trips_and_old_rows_still_load() {
|
||||||
|
let wsync = QobuzWsync::from_document(&sample_document_with_translation()).unwrap();
|
||||||
|
let json = serde_json::to_string(&wsync).unwrap();
|
||||||
|
let back: QobuzWsync = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, wsync);
|
||||||
|
assert_eq!(
|
||||||
|
back.translation.as_ref().and_then(|t| t.lang().map(str::to_owned)),
|
||||||
|
Some("es".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// NO DB migration: a row written BEFORE the translation member
|
||||||
|
// existed must still deserialize (serde default -> None).
|
||||||
|
let old_format = r#"{"type":"wsync","lang":"en","translation_langs":["es","fr"],
|
||||||
|
"lines":[{"line":"hi","start":1,"end":2,
|
||||||
|
"words":[{"word":"hi","start":1,"end":2}]}]}"#;
|
||||||
|
let parsed: QobuzWsync = serde_json::from_str(old_format).unwrap();
|
||||||
|
assert!(parsed.translation.is_none());
|
||||||
|
let doc = parsed.to_doc(Some("es"));
|
||||||
|
assert!(doc.translation.is_none());
|
||||||
|
assert!(doc.has_translation, "lang listed -> has_translation");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,14 @@ pub struct UserSession {
|
|||||||
pub subscription_label: String,
|
pub subscription_label: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subscription_valid_until: Option<String>,
|
pub subscription_valid_until: Option<String>,
|
||||||
|
/// Account territory (ISO 3166-1 alpha-2, e.g. "FR") from the login
|
||||||
|
/// response. `serde(default)` keeps pre-v10 persisted sessions loadable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub country_code: Option<String>,
|
||||||
|
/// Account language (ISO 639-1, e.g. "fr") from the login response —
|
||||||
|
/// the default target for lyrics translation ("Auto").
|
||||||
|
#[serde(default)]
|
||||||
|
pub language_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Stream Types ============
|
// ============ Stream Types ============
|
||||||
@@ -1475,3 +1483,46 @@ pub struct ArtistStoryAuthor {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_session_deserializes_pre_v10_json() {
|
||||||
|
// Sessions persisted before the country/language capture must still
|
||||||
|
// load: both new fields default to None (feature stays Auto-off).
|
||||||
|
let json = r#"{
|
||||||
|
"user_auth_token": "token",
|
||||||
|
"user_id": 1705826,
|
||||||
|
"email": "a@b.c",
|
||||||
|
"display_name": "Tester",
|
||||||
|
"subscription_label": "Studio",
|
||||||
|
"subscription_valid_until": null
|
||||||
|
}"#;
|
||||||
|
let session: UserSession = serde_json::from_str(json).expect("old session json loads");
|
||||||
|
assert_eq!(session.country_code, None);
|
||||||
|
assert_eq!(session.language_code, None);
|
||||||
|
assert_eq!(session.user_id, 1705826);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_session_round_trips_country_and_language() {
|
||||||
|
let json = r#"{
|
||||||
|
"user_auth_token": "token",
|
||||||
|
"user_id": 1705826,
|
||||||
|
"email": "a@b.c",
|
||||||
|
"display_name": "Tester",
|
||||||
|
"subscription_label": "Studio",
|
||||||
|
"subscription_valid_until": null,
|
||||||
|
"country_code": "FR",
|
||||||
|
"language_code": "fr"
|
||||||
|
}"#;
|
||||||
|
let session: UserSession = serde_json::from_str(json).expect("v10 session json loads");
|
||||||
|
assert_eq!(session.country_code.as_deref(), Some("FR"));
|
||||||
|
assert_eq!(session.language_code.as_deref(), Some("fr"));
|
||||||
|
let back: UserSession =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap();
|
||||||
|
assert_eq!(back.language_code.as_deref(), Some("fr"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,12 @@
|
|||||||
//! `qbz-nix-docs/qobuz-api/` before freezing the serde structs.
|
//! `qbz-nix-docs/qobuz-api/` before freezing the serde structs.
|
||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//! cargo run -p qbz-qobuz --example lyrics_probe # default track ids
|
//! cargo run -p qbz-qobuz --example lyrics_probe # default track ids
|
||||||
//! cargo run -p qbz-qobuz --example lyrics_probe 123 456 # explicit ids
|
//! cargo run -p qbz-qobuz --example lyrics_probe 123 456 # explicit ids
|
||||||
|
//! cargo run -p qbz-qobuz --example lyrics_probe --lang=es 123 # + translation target
|
||||||
|
//!
|
||||||
|
//! `--lang=<iso639-1>` exercises the v10 `language` query param (translation
|
||||||
|
//! request); omitted = original-only fetch.
|
||||||
//!
|
//!
|
||||||
//! Strictly read-only: login + GETs, no mutations.
|
//! Strictly read-only: login + GETs, no mutations.
|
||||||
|
|
||||||
@@ -41,15 +45,23 @@ const DEFAULT_TRACK_IDS: &[u64] = &[266725027, 29006863, 34];
|
|||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug));
|
let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug));
|
||||||
|
|
||||||
let args: Vec<u64> = std::env::args()
|
let mut language: Option<String> = None;
|
||||||
.skip(1)
|
let mut ids: Vec<u64> = Vec::new();
|
||||||
.filter_map(|a| a.parse().ok())
|
for arg in std::env::args().skip(1) {
|
||||||
.collect();
|
if let Some(lang) = arg.strip_prefix("--lang=") {
|
||||||
let track_ids: Vec<u64> = if args.is_empty() {
|
language = Some(lang.to_string());
|
||||||
|
} else if let Ok(id) = arg.parse() {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let track_ids: Vec<u64> = if ids.is_empty() {
|
||||||
DEFAULT_TRACK_IDS.to_vec()
|
DEFAULT_TRACK_IDS.to_vec()
|
||||||
} else {
|
} else {
|
||||||
args
|
ids
|
||||||
};
|
};
|
||||||
|
if let Some(lang) = &language {
|
||||||
|
eprintln!("[probe] translation target language: {}", lang);
|
||||||
|
}
|
||||||
|
|
||||||
let client = QobuzClient::new()?;
|
let client = QobuzClient::new()?;
|
||||||
let warm = client.init().await?;
|
let warm = client.init().await?;
|
||||||
@@ -135,7 +147,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1 raw: signed GET /track/lyricsUrl
|
// Step 1 raw: signed GET /track/lyricsUrl
|
||||||
let (status, body) = match client.get_lyrics_url_raw(track_id).await {
|
let (status, body) = match client.get_lyrics_url_raw(track_id, language.as_deref()).await {
|
||||||
Ok(pair) => pair,
|
Ok(pair) => pair,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("[lyricsUrl] transport error: {}", e);
|
println!("[lyricsUrl] transport error: {}", e);
|
||||||
@@ -146,7 +158,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!("[lyricsUrl] raw body:\n{}", body);
|
println!("[lyricsUrl] raw body:\n{}", body);
|
||||||
|
|
||||||
// Typed step 1
|
// Typed step 1
|
||||||
let urls = match client.get_lyrics_url(track_id).await {
|
let urls = match client.get_lyrics_url(track_id, language.as_deref()).await {
|
||||||
Ok(Some(urls)) => urls,
|
Ok(Some(urls)) => urls,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
println!("[lyricsUrl] typed result: MISS (no lyrics)");
|
println!("[lyricsUrl] typed result: MISS (no lyrics)");
|
||||||
@@ -179,7 +191,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Typed full chain
|
// Typed full chain
|
||||||
match client.get_lyrics(track_id).await {
|
match client.get_lyrics(track_id, language.as_deref()).await {
|
||||||
Ok(Some(doc)) => {
|
Ok(Some(doc)) => {
|
||||||
let content = doc.original.as_ref().expect("get_lyrics guarantees original");
|
let content = doc.original.as_ref().expect("get_lyrics guarantees original");
|
||||||
println!(
|
println!(
|
||||||
@@ -189,6 +201,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
doc.translation_langs,
|
doc.translation_langs,
|
||||||
doc.writers
|
doc.writers
|
||||||
);
|
);
|
||||||
|
match doc.translation.as_ref() {
|
||||||
|
Some(translation) => println!(
|
||||||
|
"[get_lyrics] embedded translation: {} with {} lines (lang: {:?})",
|
||||||
|
if translation.is_synced() { "SYNCED" } else { "PLAIN" },
|
||||||
|
translation.line_count(),
|
||||||
|
translation.lang()
|
||||||
|
),
|
||||||
|
None => println!("[get_lyrics] no embedded translation"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => println!("[get_lyrics] typed result: MISS"),
|
Ok(None) => println!("[get_lyrics] typed result: MISS"),
|
||||||
Err(e) => println!("[get_lyrics] typed error: {}", e),
|
Err(e) => println!("[get_lyrics] typed error: {}", e),
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
|
|||||||
.and_then(|c| c.get("parameters"))
|
.and_then(|c| c.get("parameters"))
|
||||||
.and_then(parse_subscription_valid_until);
|
.and_then(parse_subscription_valid_until);
|
||||||
|
|
||||||
|
// Account territory + language (snake_case wire names, verbatim in
|
||||||
|
// Qobuz's own embedded /user/login fixture — see qbz-nix-docs
|
||||||
|
// offline-mode/tauri-review-2026-06-09/10-subscription-trial-offline-
|
||||||
|
// gating.md §1.2). Absent on older captures -> None (feature stays off).
|
||||||
|
let country_code = user
|
||||||
|
.get("country_code")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
let language_code = user
|
||||||
|
.get("language_code")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
// Check if user has valid subscription
|
// Check if user has valid subscription
|
||||||
let has_subscription = credential
|
let has_subscription = credential
|
||||||
.and_then(|c| c.get("parameters"))
|
.and_then(|c| c.get("parameters"))
|
||||||
@@ -163,6 +176,8 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
|
|||||||
display_name,
|
display_name,
|
||||||
subscription_label,
|
subscription_label,
|
||||||
subscription_valid_until,
|
subscription_valid_until,
|
||||||
|
country_code,
|
||||||
|
language_code,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,4 +216,41 @@ mod tests {
|
|||||||
let sig = sign_get_file_url(123456, 27, 1234567890, "testsecret");
|
let sig = sign_get_file_url(123456, 27, 1234567890, "testsecret");
|
||||||
assert_eq!(sig.len(), 32);
|
assert_eq!(sig.len(), 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn login_response(user_extra: serde_json::Value) -> serde_json::Value {
|
||||||
|
let mut user = serde_json::json!({
|
||||||
|
"id": 1705826,
|
||||||
|
"email": "a@b.c",
|
||||||
|
"display_name": "Tester",
|
||||||
|
"credential": {"parameters": {"short_label": "Studio"}}
|
||||||
|
});
|
||||||
|
user.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.extend(user_extra.as_object().unwrap().clone());
|
||||||
|
serde_json::json!({
|
||||||
|
"user_auth_token": "token",
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_login_response_captures_country_and_language() {
|
||||||
|
let response = login_response(serde_json::json!({
|
||||||
|
"country_code": "FR",
|
||||||
|
"language_code": "fr",
|
||||||
|
}));
|
||||||
|
let session = parse_login_response(&response).expect("valid login response");
|
||||||
|
assert_eq!(session.country_code.as_deref(), Some("FR"));
|
||||||
|
assert_eq!(session.language_code.as_deref(), Some("fr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_login_response_tolerates_missing_country_and_language() {
|
||||||
|
// Older captures / partial payloads: both stay None (feature off),
|
||||||
|
// the rest of the session parses as before.
|
||||||
|
let response = login_response(serde_json::json!({}));
|
||||||
|
let session = parse_login_response(&response).expect("valid login response");
|
||||||
|
assert_eq!(session.country_code, None);
|
||||||
|
assert_eq!(session.language_code, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+148
-12
@@ -13,7 +13,9 @@ use super::bundle::{self, BundleTokens};
|
|||||||
use super::endpoints::{self, paths};
|
use super::endpoints::{self, paths};
|
||||||
use super::error::{ApiError, Result};
|
use super::error::{ApiError, Result};
|
||||||
use super::forbidden_breaker::ForbiddenBreaker;
|
use super::forbidden_breaker::ForbiddenBreaker;
|
||||||
use super::lyrics::{QobuzLyricsDocument, QobuzLyricsUrls};
|
use super::lyrics::{
|
||||||
|
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsUrls,
|
||||||
|
};
|
||||||
use qbz_models::*;
|
use qbz_models::*;
|
||||||
|
|
||||||
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
|
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
|
||||||
@@ -324,6 +326,17 @@ impl QobuzClient {
|
|||||||
*self.session.write().await = Some(session);
|
*self.session.write().await = Some(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Account language (ISO 639-1) of the active session, if the login
|
||||||
|
/// response carried one — the "Auto" target for lyrics translation.
|
||||||
|
/// `None` pre-login and for pre-v10 persisted sessions.
|
||||||
|
pub async fn session_language_code(&self) -> Option<String> {
|
||||||
|
self.session
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.language_code.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Exchange an OAuth code for a full user session.
|
/// Exchange an OAuth code for a full user session.
|
||||||
///
|
///
|
||||||
/// This implements the new Qobuz OAuth flow:
|
/// This implements the new Qobuz OAuth flow:
|
||||||
@@ -1397,14 +1410,27 @@ impl QobuzClient {
|
|||||||
/// client, so this is the only way diagnostic tooling (e.g.
|
/// client, so this is the only way diagnostic tooling (e.g.
|
||||||
/// `examples/lyrics_probe.rs`) can observe the exact wire shape; the
|
/// `examples/lyrics_probe.rs`) can observe the exact wire shape; the
|
||||||
/// typed [`Self::get_lyrics_url`] builds on it.
|
/// typed [`Self::get_lyrics_url`] builds on it.
|
||||||
pub async fn get_lyrics_url_raw(&self, track_id: u64) -> Result<(u16, String)> {
|
///
|
||||||
|
/// `language` (ISO 639-1, API v10) requests a translation; it is appended
|
||||||
|
/// to the signed param list ONLY when `Some` (`sign_request` sorts the
|
||||||
|
/// kv pairs alphabetically, so signing needs no change). Pass `None` for
|
||||||
|
/// the original-only fetch.
|
||||||
|
pub async fn get_lyrics_url_raw(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<(u16, String)> {
|
||||||
let url = endpoints::build_url(paths::TRACK_LYRICS_URL);
|
let url = endpoints::build_url(paths::TRACK_LYRICS_URL);
|
||||||
// Signing method name per the concatenation convention ("trackget" at
|
// Signing method name per the concatenation convention ("trackget" at
|
||||||
// get_track, "trackgetFileUrl" in auth.rs): path with slashes removed,
|
// get_track, "trackgetFileUrl" in auth.rs): path with slashes removed,
|
||||||
// case preserved -> "tracklyricsUrl". Verified against the live API
|
// case preserved -> "tracklyricsUrl". Verified against the live API
|
||||||
// 2026-06-10 (HTTP 200 with a valid envelope).
|
// 2026-06-10 (HTTP 200 with a valid envelope).
|
||||||
|
let mut params = vec![("track_id", track_id.to_string())];
|
||||||
|
if let Some(language) = language {
|
||||||
|
params.push(("language", language.to_string()));
|
||||||
|
}
|
||||||
let response = self
|
let response = self
|
||||||
.signed_get_auth(&url, "tracklyricsUrl", &[("track_id", track_id.to_string())])
|
.signed_get_auth(&url, "tracklyricsUrl", ¶ms)
|
||||||
.await?;
|
.await?;
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
let body = response.text().await?;
|
let body = response.text().await?;
|
||||||
@@ -1418,8 +1444,12 @@ impl QobuzClient {
|
|||||||
/// `Err` is reserved for transport/auth/offline failures — the lyrics
|
/// `Err` is reserved for transport/auth/offline failures — the lyrics
|
||||||
/// orchestrator degrades those to a miss as well (spec §1.1); nothing in
|
/// orchestrator degrades those to a miss as well (spec §1.1); nothing in
|
||||||
/// this path is ever a user-facing error.
|
/// this path is ever a user-facing error.
|
||||||
pub async fn get_lyrics_url(&self, track_id: u64) -> Result<Option<QobuzLyricsUrls>> {
|
pub async fn get_lyrics_url(
|
||||||
let (status, body) = self.get_lyrics_url_raw(track_id).await?;
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsUrls>> {
|
||||||
|
let (status, body) = self.get_lyrics_url_raw(track_id, language).await?;
|
||||||
if status != 200 {
|
if status != 200 {
|
||||||
// Live-observed miss: HTTP 404 with body
|
// Live-observed miss: HTTP 404 with body
|
||||||
// {"status":"error","code":404,"message":"No lyrics found"}
|
// {"status":"error","code":404,"message":"No lyrics found"}
|
||||||
@@ -1458,14 +1488,74 @@ impl QobuzClient {
|
|||||||
/// — a pre-signed CloudFront URL (`Expires`/`Signature`/`Key-Pair-Id`);
|
/// — a pre-signed CloudFront URL (`Expires`/`Signature`/`Key-Pair-Id`);
|
||||||
/// no `X-App-Id`/auth headers are sent (verified live 2026-06-10, HTTP
|
/// no `X-App-Id`/auth headers are sent (verified live 2026-06-10, HTTP
|
||||||
/// 200 on a bare GET). `Ok(None)` = typed miss, same contract as
|
/// 200 on a bare GET). `Ok(None)` = typed miss, same contract as
|
||||||
/// [`Self::get_lyrics_url`]; a document without `original` content also
|
/// [`Self::get_lyrics_url`]; a document without usable content counts as
|
||||||
/// counts as a miss.
|
/// a miss.
|
||||||
pub async fn get_lyrics(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>> {
|
///
|
||||||
let urls = match self.get_lyrics_url(track_id).await? {
|
/// `language` (ISO 639-1, API v10) requests a translation; `None` fetches
|
||||||
|
/// original-only (the default flow). LIVE WIRE (verified 2026-07-22):
|
||||||
|
/// when a translation is requested AND available, `lyrics_url` points at
|
||||||
|
/// the TRANSLATED document (`<id>-<lang>.json`), which carries ONLY
|
||||||
|
/// `translation` and no `original` — so this fn then fetches the
|
||||||
|
/// original-only document too and merges both into one bilingual
|
||||||
|
/// document. When requested but NOT available the signed CDN URL 403s →
|
||||||
|
/// typed miss (fail soft). When the document has an `original` but does
|
||||||
|
/// not EMBED a `translation` block, the envelope's
|
||||||
|
/// `translation_requested.lyrics_url` is fetched as a fallback and its
|
||||||
|
/// content becomes the translation (fail soft: any gap in that fallback
|
||||||
|
/// just leaves the document original-only).
|
||||||
|
pub async fn get_lyrics(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsDocument>> {
|
||||||
|
let doc = match self.fetch_lyrics_doc(track_id, language).await? {
|
||||||
|
Some(doc) => doc,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if language.is_some() && doc.original.is_none() && doc.translation.is_some() {
|
||||||
|
// Translation-only document (the common v10 live shape): fetch the
|
||||||
|
// original document separately and merge into one bilingual doc.
|
||||||
|
return match self.fetch_lyrics_doc(track_id, None).await? {
|
||||||
|
Some(mut original_doc) if original_doc.original.is_some() => {
|
||||||
|
merge_translation_into(&mut original_doc, doc);
|
||||||
|
Ok(Some(original_doc))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::debug!(
|
||||||
|
"[API] get_lyrics({}) translation found but original doc missing -> no lyrics",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the original-only miss semantics: a document with neither
|
||||||
|
// `original` nor a mergeable `translation` is unusable.
|
||||||
|
if doc.original.is_none() {
|
||||||
|
log::debug!(
|
||||||
|
"[API] get_lyrics({}) document without original content -> no lyrics",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lyrics round trip (signed lyricsUrl + bare CDN GET). Accepts any
|
||||||
|
/// document carrying `original` OR `translation` content; the caller
|
||||||
|
/// decides which shapes are usable (see [`Self::get_lyrics`]).
|
||||||
|
async fn fetch_lyrics_doc(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsDocument>> {
|
||||||
|
let urls = match self.get_lyrics_url(track_id, language).await? {
|
||||||
Some(urls) => urls,
|
Some(urls) => urls,
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
let lyrics_url = match urls.lyrics_url {
|
let lyrics_url = match urls.lyrics_url.clone() {
|
||||||
Some(url) => url,
|
Some(url) => url,
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
@@ -1482,10 +1572,17 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
let body = response.text().await?;
|
let body = response.text().await?;
|
||||||
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
||||||
Ok(doc) if doc.original.is_some() => Ok(Some(doc)),
|
Ok(mut doc) if doc.original.is_some() || doc.translation.is_some() => {
|
||||||
|
if language.is_some() && doc.original.is_some() && doc.translation.is_none() {
|
||||||
|
doc.translation = self
|
||||||
|
.fetch_translation_fallback(track_id, &urls)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(Some(doc))
|
||||||
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[API] get_lyrics({}) document without original content -> no lyrics",
|
"[API] get_lyrics({}) document without any lyrics content -> no lyrics",
|
||||||
track_id
|
track_id
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -1501,6 +1598,45 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback translation source (spec §B.3): when the main document did
|
||||||
|
/// not embed a `translation` block, fetch the document the envelope's
|
||||||
|
/// `translation_requested.lyrics_url` points at (same bare CDN GET) and
|
||||||
|
/// take its content as the translation. Every failure mode degrades to
|
||||||
|
/// `None` — translation is best-effort, never an error.
|
||||||
|
async fn fetch_translation_fallback(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
urls: &QobuzLyricsUrls,
|
||||||
|
) -> Option<QobuzLyricsContent> {
|
||||||
|
let translation_url = urls
|
||||||
|
.translation_requested
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|t| t.lyrics_url.as_deref())?;
|
||||||
|
let response = self.http().ok()?.get(translation_url).send().await.ok()?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
log::debug!(
|
||||||
|
"[API] get_lyrics({}) translation doc status={} -> original only",
|
||||||
|
track_id,
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = response.text().await.ok()?;
|
||||||
|
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
||||||
|
// The translation document carries its content under `original`
|
||||||
|
// (same wrapper shape as the main document).
|
||||||
|
Ok(doc) => doc.original,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"[API] get_lyrics({}) unparsable translation doc ({}) -> original only",
|
||||||
|
track_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get artist by ID (basic info only - no albums, faster response)
|
/// Get artist by ID (basic info only - no albums, faster response)
|
||||||
pub async fn get_artist_basic(&self, artist_id: u64) -> Result<Artist> {
|
pub async fn get_artist_basic(&self, artist_id: u64) -> Result<Artist> {
|
||||||
let url = endpoints::build_url(paths::ARTIST_GET);
|
let url = endpoints::build_url(paths::ARTIST_GET);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ pub use cmaf::{
|
|||||||
pub use error::{ApiError, Result};
|
pub use error::{ApiError, Result};
|
||||||
pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
|
pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
|
||||||
pub use lyrics::{
|
pub use lyrics::{
|
||||||
QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine, QobuzLyricsPlainLine,
|
merge_translation_into, QobuzLyricsContent, QobuzLyricsDocument, QobuzLyricsLine,
|
||||||
QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls, QobuzLyricsWord,
|
QobuzLyricsPlainLine, QobuzLyricsPublisher, QobuzLyricsTranslation, QobuzLyricsUrls,
|
||||||
|
QobuzLyricsWord,
|
||||||
};
|
};
|
||||||
+229
-11
@@ -21,7 +21,7 @@
|
|||||||
//! All fields stay serde-tolerant (`Option` / `#[serde(default)]`) — the
|
//! All fields stay serde-tolerant (`Option` / `#[serde(default)]`) — the
|
||||||
//! endpoint is a beta-era delta and may still move.
|
//! endpoint is a beta-era delta and may still move.
|
||||||
|
|
||||||
use serde::{Deserialize, Deserializer};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
/// Accept either a JSON string or a JSON number and normalize to `String`.
|
/// Accept either a JSON string or a JSON number and normalize to `String`.
|
||||||
///
|
///
|
||||||
@@ -62,15 +62,16 @@ pub struct QobuzLyricsUrls {
|
|||||||
/// URL to the original-language lyrics document (Qobuz CDN host).
|
/// URL to the original-language lyrics document (Qobuz CDN host).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lyrics_url: Option<String>,
|
pub lyrics_url: Option<String>,
|
||||||
/// Present only when the `translation` query param was sent AND a matching
|
/// Present only when the `language` query param was sent AND a matching
|
||||||
/// translation exists (yaml:208-213). We never send `translation` in v1,
|
/// translation exists (yaml:208-213). Absent when no translation was
|
||||||
/// so this stays `None` (confirmed absent in the live capture).
|
/// requested (confirmed absent in the live capture) or none is available
|
||||||
|
/// for the requested language.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub translation_requested: Option<QobuzLyricsTranslation>,
|
pub translation_requested: Option<QobuzLyricsTranslation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translation pointer inside [`QobuzLyricsUrls`] (yaml `LyricsTranslationDto`, `:221-231`).
|
/// Translation pointer inside [`QobuzLyricsUrls`] (yaml `LyricsTranslationDto`, `:221-231`).
|
||||||
/// Unverified live (v1 never requests translations); kept yaml-shaped + tolerant.
|
/// Kept yaml-shaped + tolerant.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct QobuzLyricsTranslation {
|
pub struct QobuzLyricsTranslation {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -92,7 +93,8 @@ pub struct QobuzLyricsDocument {
|
|||||||
/// ISO 639-1 codes a translation exists for (e.g. `["pt","de","fr","es","it"]`).
|
/// ISO 639-1 codes a translation exists for (e.g. `["pt","de","fr","es","it"]`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub translation_langs: Vec<String>,
|
pub translation_langs: Vec<String>,
|
||||||
/// Publisher copyright entries with applicability zones.
|
/// Publisher copyright entries with applicability zones. Parsed but never
|
||||||
|
/// enforced (owner decision 2026-07-22: no zone/copyright gating).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub publishers: Vec<QobuzLyricsPublisher>,
|
pub publishers: Vec<QobuzLyricsPublisher>,
|
||||||
/// Songwriter credits as a single display string.
|
/// Songwriter credits as a single display string.
|
||||||
@@ -101,6 +103,31 @@ pub struct QobuzLyricsDocument {
|
|||||||
/// The actual lyrics content (the yaml's "LyricsContentDto" union).
|
/// The actual lyrics content (the yaml's "LyricsContentDto" union).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub original: Option<QobuzLyricsContent>,
|
pub original: Option<QobuzLyricsContent>,
|
||||||
|
/// Translated lyrics content (NEW in API v10) — same union shape as
|
||||||
|
/// `original`. LIVE WIRE (verified 2026-07-22): when `language` is
|
||||||
|
/// requested and available, the step-1 `lyrics_url` itself points at the
|
||||||
|
/// TRANSLATED document (`<id>-<lang>.json`), which carries ONLY this
|
||||||
|
/// `translation` block and no `original` — the client must fetch the
|
||||||
|
/// original document separately and merge. `translation_requested` was
|
||||||
|
/// not observed in practice (kept as a fallback source only).
|
||||||
|
#[serde(default)]
|
||||||
|
pub translation: Option<QobuzLyricsContent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a translation-only document into its original document (v10 live
|
||||||
|
/// wire: a `language` request returns a document carrying ONLY `translation`).
|
||||||
|
/// Moves the `translation` block over and unions `translation_langs`.
|
||||||
|
/// `original` keeps its own content/writers/publishers.
|
||||||
|
pub fn merge_translation_into(
|
||||||
|
original: &mut QobuzLyricsDocument,
|
||||||
|
mut translated: QobuzLyricsDocument,
|
||||||
|
) {
|
||||||
|
original.translation = translated.translation.take();
|
||||||
|
for lang in translated.translation_langs.drain(..) {
|
||||||
|
if !original.translation_langs.contains(&lang) {
|
||||||
|
original.translation_langs.push(lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One publisher credit inside [`QobuzLyricsDocument`] (live-only; not in the yaml).
|
/// One publisher credit inside [`QobuzLyricsDocument`] (live-only; not in the yaml).
|
||||||
@@ -118,8 +145,9 @@ pub struct QobuzLyricsPublisher {
|
|||||||
/// Live discriminator is `"wsync"` (word-synced — richer than the yaml's
|
/// Live discriminator is `"wsync"` (word-synced — richer than the yaml's
|
||||||
/// `"lsync"` guess: per-word timestamps inside each line). `"lsync"` is kept
|
/// `"lsync"` guess: per-word timestamps inside each line). `"lsync"` is kept
|
||||||
/// as an alias in case line-only documents exist in the catalog (same shape
|
/// as an alias in case line-only documents exist in the catalog (same shape
|
||||||
/// minus `words`, which defaults to empty).
|
/// minus `words`, which defaults to empty). `Serialize` exists so the lyrics
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
/// cache can persist a document's embedded translation verbatim.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum QobuzLyricsContent {
|
pub enum QobuzLyricsContent {
|
||||||
/// Time-synced lyrics; line + per-word timestamps, ms from track start.
|
/// Time-synced lyrics; line + per-word timestamps, ms from track start.
|
||||||
@@ -154,6 +182,14 @@ impl QobuzLyricsContent {
|
|||||||
QobuzLyricsContent::Plain { lines, .. } => lines.len(),
|
QobuzLyricsContent::Plain { lines, .. } => lines.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ISO 639-1 of the content's language, when the document carries it.
|
||||||
|
pub fn lang(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
QobuzLyricsContent::Synced { lang, .. } => lang.as_deref(),
|
||||||
|
QobuzLyricsContent::Plain { lang, .. } => lang.as_deref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One synced line.
|
/// One synced line.
|
||||||
@@ -162,7 +198,7 @@ impl QobuzLyricsContent {
|
|||||||
/// required): instrumental-gap separator lines come as `{"line":"","words":[]}`
|
/// required): instrumental-gap separator lines come as `{"line":"","words":[]}`
|
||||||
/// with NO `start`/`end` at all — both must be `Option`. Regular lines carry
|
/// with NO `start`/`end` at all — both must be `Option`. Regular lines carry
|
||||||
/// line-level `start`/`end` plus per-word stamps (lyrics-doc-wsync.json).
|
/// line-level `start`/`end` plus per-word stamps (lyrics-doc-wsync.json).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct QobuzLyricsLine {
|
pub struct QobuzLyricsLine {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub line: String,
|
pub line: String,
|
||||||
@@ -178,7 +214,7 @@ pub struct QobuzLyricsLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One word inside a synced line (live-only; not in the yaml).
|
/// One word inside a synced line (live-only; not in the yaml).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct QobuzLyricsWord {
|
pub struct QobuzLyricsWord {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub word: String,
|
pub word: String,
|
||||||
@@ -193,7 +229,7 @@ pub struct QobuzLyricsWord {
|
|||||||
/// One plain line. Live wire form is an object `{"line": "..."}`
|
/// One plain line. Live wire form is an object `{"line": "..."}`
|
||||||
/// (lyrics-doc-plain.json); the yaml guessed bare strings (`:287-303`) —
|
/// (lyrics-doc-plain.json); the yaml guessed bare strings (`:287-303`) —
|
||||||
/// accept both.
|
/// accept both.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
pub struct QobuzLyricsPlainLine {
|
pub struct QobuzLyricsPlainLine {
|
||||||
pub line: String,
|
pub line: String,
|
||||||
}
|
}
|
||||||
@@ -229,8 +265,18 @@ mod tests {
|
|||||||
// synced/plain docs are truncated to a few lines here to avoid vendoring
|
// synced/plain docs are truncated to a few lines here to avoid vendoring
|
||||||
// full copyrighted lyrics in the repo — the STRUCTURE is verbatim).
|
// full copyrighted lyrics in the repo — the STRUCTURE is verbatim).
|
||||||
const URL_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-url-response.json");
|
const URL_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-url-response.json");
|
||||||
|
const URL_RESPONSE_TRANSLATION: &str =
|
||||||
|
include_str!("../tests/fixtures/lyrics-url-response-translation.json");
|
||||||
const DOC_WSYNC: &str = include_str!("../tests/fixtures/lyrics-doc-wsync.json");
|
const DOC_WSYNC: &str = include_str!("../tests/fixtures/lyrics-doc-wsync.json");
|
||||||
const DOC_PLAIN: &str = include_str!("../tests/fixtures/lyrics-doc-plain.json");
|
const DOC_PLAIN: &str = include_str!("../tests/fixtures/lyrics-doc-plain.json");
|
||||||
|
const DOC_WSYNC_TRANSLATION: &str =
|
||||||
|
include_str!("../tests/fixtures/lyrics-doc-wsync-translation.json");
|
||||||
|
const DOC_LSYNC_TRANSLATION: &str =
|
||||||
|
include_str!("../tests/fixtures/lyrics-doc-lsync-translation.json");
|
||||||
|
const DOC_PLAIN_TRANSLATION: &str =
|
||||||
|
include_str!("../tests/fixtures/lyrics-doc-plain-translation.json");
|
||||||
|
const DOC_TRANSLATION_ONLY: &str =
|
||||||
|
include_str!("../tests/fixtures/lyrics-doc-translation-only.json");
|
||||||
const MISS_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-miss-response.json");
|
const MISS_RESPONSE: &str = include_str!("../tests/fixtures/lyrics-miss-response.json");
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -320,6 +366,178 @@ mod tests {
|
|||||||
assert_eq!(urls.album_id.as_deref(), Some("2"));
|
assert_eq!(urls.album_id.as_deref(), Some("2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_lyrics_url_response_with_translation_requested() {
|
||||||
|
// v10: when a `language` was requested and a translation exists, the
|
||||||
|
// envelope carries the `translation_requested` pointer (yaml
|
||||||
|
// `LyricsTranslationDto`).
|
||||||
|
let urls: QobuzLyricsUrls =
|
||||||
|
serde_json::from_str(URL_RESPONSE_TRANSLATION).expect("parse translation envelope");
|
||||||
|
assert_eq!(urls.track_id.as_deref(), Some("266725027"));
|
||||||
|
let requested = urls
|
||||||
|
.translation_requested
|
||||||
|
.expect("translation_requested present");
|
||||||
|
assert_eq!(requested.lang.as_deref(), Some("es"));
|
||||||
|
assert!(requested
|
||||||
|
.lyrics_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.starts_with("https://"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_document_with_wsync_translation() {
|
||||||
|
let doc: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_WSYNC_TRANSLATION).expect("parse wsync+translation document");
|
||||||
|
assert_eq!(doc.translation_langs.len(), 5);
|
||||||
|
|
||||||
|
let original = doc.original.expect("original present");
|
||||||
|
assert!(original.is_synced());
|
||||||
|
assert_eq!(original.lang(), Some("en"));
|
||||||
|
|
||||||
|
let translation = doc.translation.expect("embedded translation present");
|
||||||
|
assert!(translation.is_synced());
|
||||||
|
assert_eq!(translation.lang(), Some("es"));
|
||||||
|
match &translation {
|
||||||
|
QobuzLyricsContent::Synced { lines, .. } => {
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert_eq!(lines[0].line, "Primera linea traducida");
|
||||||
|
assert_eq!(lines[0].words.len(), 3);
|
||||||
|
assert_eq!(lines[0].words[2].word, "traducida");
|
||||||
|
assert!(lines[1].start.is_none(), "gap line without stamps");
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_document_with_lsync_translation() {
|
||||||
|
// Line-synced variant: lsync alias on BOTH original and translation,
|
||||||
|
// no per-word stamps.
|
||||||
|
let doc: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_LSYNC_TRANSLATION).expect("parse lsync+translation document");
|
||||||
|
let original = doc.original.expect("original present");
|
||||||
|
assert!(original.is_synced());
|
||||||
|
let translation = doc.translation.expect("translation present");
|
||||||
|
match &translation {
|
||||||
|
QobuzLyricsContent::Synced { lang, lines } => {
|
||||||
|
assert_eq!(lang.as_deref(), Some("fr"));
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
assert!(lines[0].words.is_empty());
|
||||||
|
assert_eq!(lines[1].start, Some(5000));
|
||||||
|
}
|
||||||
|
_ => panic!("expected synced translation"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_document_with_plain_translation() {
|
||||||
|
let doc: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_PLAIN_TRANSLATION).expect("parse plain+translation document");
|
||||||
|
let original = doc.original.expect("original present");
|
||||||
|
assert!(!original.is_synced());
|
||||||
|
let translation = doc.translation.expect("translation present");
|
||||||
|
match &translation {
|
||||||
|
QobuzLyricsContent::Plain { lang, lines } => {
|
||||||
|
assert_eq!(lang.as_deref(), Some("de"));
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
assert_eq!(lines[0].line, "Einfache Uebersetzung eins");
|
||||||
|
}
|
||||||
|
_ => panic!("expected plain translation"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_without_translation_stays_none() {
|
||||||
|
// 9.x back-compat: existing captures have no `translation` member.
|
||||||
|
for fixture in [DOC_WSYNC, DOC_PLAIN] {
|
||||||
|
let doc: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(fixture).expect("parse 9.x document");
|
||||||
|
assert!(doc.translation.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_translation_langs_means_feature_off() {
|
||||||
|
// Feature-off document: no translations offered at all.
|
||||||
|
let json = r#"{"track_id": 1, "translation_langs": [],
|
||||||
|
"original": {"type": "plain", "lang": "en", "lines": [{"line": "hi"}]}}"#;
|
||||||
|
let doc: QobuzLyricsDocument = serde_json::from_str(json).expect("parse");
|
||||||
|
assert!(doc.translation_langs.is_empty());
|
||||||
|
assert!(doc.translation.is_none());
|
||||||
|
|
||||||
|
// Missing member entirely behaves the same.
|
||||||
|
let json = r#"{"track_id": 1,
|
||||||
|
"original": {"type": "plain", "lang": "en", "lines": [{"line": "hi"}]}}"#;
|
||||||
|
let doc: QobuzLyricsDocument = serde_json::from_str(json).expect("parse");
|
||||||
|
assert!(doc.translation_langs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_translation_only_document() {
|
||||||
|
// v10 LIVE WIRE (verified 2026-07-22, track 266725027 with
|
||||||
|
// language=es): a translation request returns a document carrying
|
||||||
|
// ONLY `translation` — no `original` at all.
|
||||||
|
let doc: QobuzLyricsDocument = serde_json::from_str(DOC_TRANSLATION_ONLY)
|
||||||
|
.expect("parse translation-only document");
|
||||||
|
assert!(doc.original.is_none(), "no original on the translated doc");
|
||||||
|
assert_eq!(doc.translation_langs.len(), 5);
|
||||||
|
let translation = doc.translation.expect("translation present");
|
||||||
|
assert!(translation.is_synced(), "lsync alias maps to Synced");
|
||||||
|
assert_eq!(translation.lang(), Some("es"));
|
||||||
|
match &translation {
|
||||||
|
QobuzLyricsContent::Synced { lines, .. } => {
|
||||||
|
assert_eq!(lines.len(), 4);
|
||||||
|
assert!(lines[1].start.is_none(), "gap line without stamps");
|
||||||
|
assert_eq!(lines[2].line, "Podría comerme a esa chica para el almuerzo");
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_translation_into_original_document() {
|
||||||
|
// The get_lyrics merge path: original doc (9.x shape) + translation-
|
||||||
|
// only doc combine into one bilingual document.
|
||||||
|
let mut original: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_WSYNC).expect("parse original document");
|
||||||
|
assert!(original.translation.is_none());
|
||||||
|
let translated: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_TRANSLATION_ONLY).expect("parse translation-only document");
|
||||||
|
|
||||||
|
merge_translation_into(&mut original, translated);
|
||||||
|
|
||||||
|
assert!(original.original.is_some(), "original content kept");
|
||||||
|
let translation = original.translation.expect("merged translation");
|
||||||
|
assert_eq!(translation.lang(), Some("es"));
|
||||||
|
// translation_langs unioned WITHOUT duplicates (both fixtures carry
|
||||||
|
// the same 5 codes).
|
||||||
|
assert_eq!(original.translation_langs.len(), 5);
|
||||||
|
let mut sorted = original.translation_langs.clone();
|
||||||
|
sorted.sort();
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sorted.len(), original.translation_langs.len());
|
||||||
|
// Writers/publishers stay the original document's.
|
||||||
|
assert!(original.writers.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_serializes_back_to_wire_shape() {
|
||||||
|
// The lyrics cache persists an embedded translation verbatim, so the
|
||||||
|
// content union must round-trip through serde.
|
||||||
|
let doc: QobuzLyricsDocument =
|
||||||
|
serde_json::from_str(DOC_WSYNC_TRANSLATION).expect("parse");
|
||||||
|
let translation = doc.translation.expect("translation present");
|
||||||
|
let json = serde_json::to_string(&translation).expect("serialize");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(value["type"], "wsync");
|
||||||
|
assert_eq!(value["lang"], "es");
|
||||||
|
assert_eq!(value["lines"][0]["words"][0]["word"], "Primera");
|
||||||
|
let back: QobuzLyricsContent = serde_json::from_str(&json).expect("re-parse");
|
||||||
|
assert_eq!(back.lang(), Some("es"));
|
||||||
|
assert_eq!(back.line_count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lsync_alias_parses_yaml_shape() {
|
fn lsync_alias_parses_yaml_shape() {
|
||||||
// The yaml's "lsync" guess ({line,start,end}, no words) must still
|
// The yaml's "lsync" guess ({line,start,end}, no words) must still
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"album_id": "abc123",
|
||||||
|
"track_id": 111111111,
|
||||||
|
"translation_langs": ["fr"],
|
||||||
|
"publishers": [],
|
||||||
|
"writers": "Writer Three",
|
||||||
|
"original": {
|
||||||
|
"type": "lsync",
|
||||||
|
"lang": "en",
|
||||||
|
"lines": [
|
||||||
|
{"line": "Line-synced original one", "start": 1000, "end": 4000},
|
||||||
|
{"line": "Line-synced original two", "start": 5000, "end": 9000}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"type": "lsync",
|
||||||
|
"lang": "fr",
|
||||||
|
"lines": [
|
||||||
|
{"line": "Traduction synchronisee un", "start": 1000, "end": 4000},
|
||||||
|
{"line": "Traduction synchronisee deux", "start": 5000, "end": 9000}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"album_id": "def456",
|
||||||
|
"track_id": 222222222,
|
||||||
|
"translation_langs": ["de"],
|
||||||
|
"publishers": [],
|
||||||
|
"writers": null,
|
||||||
|
"original": {
|
||||||
|
"type": "plain",
|
||||||
|
"lang": "en",
|
||||||
|
"lines": [
|
||||||
|
{"line": "Plain original one"},
|
||||||
|
{"line": "Plain original two"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"type": "plain",
|
||||||
|
"lang": "de",
|
||||||
|
"lines": [
|
||||||
|
{"line": "Einfache Uebersetzung eins"},
|
||||||
|
{"line": "Einfache Uebersetzung zwei"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"album_id": "gvcirtodd95kc",
|
||||||
|
"track_id": 266725027,
|
||||||
|
"translation_langs": ["pt", "de", "fr", "es", "it"],
|
||||||
|
"publishers": [
|
||||||
|
{
|
||||||
|
"copyright": "Kobalt Music Publishing Ltd., Universal Music Publishing Group",
|
||||||
|
"zones": ["WW"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"writers": "Billie EIlish O'Connell, Finneas O'Connell",
|
||||||
|
"translation": {
|
||||||
|
"type": "lsync",
|
||||||
|
"lang": "es",
|
||||||
|
"lines": [
|
||||||
|
{ "line": "Oh, mm-mm", "start": 1750, "end": 4770 },
|
||||||
|
{ "line": "" },
|
||||||
|
{ "line": "Podría comerme a esa chica para el almuerzo", "start": 5320, "end": 6990 },
|
||||||
|
{ "line": "Sí, ella baila en mi lengua", "start": 7230, "end": 8840 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"album_id": "gvcirtodd95kc",
|
||||||
|
"track_id": 266725027,
|
||||||
|
"translation_langs": ["pt", "de", "fr", "es", "it"],
|
||||||
|
"publishers": [
|
||||||
|
{"copyright": "(c) Example Publishing", "zones": ["WW"]}
|
||||||
|
],
|
||||||
|
"writers": "Writer One, Writer Two",
|
||||||
|
"original": {
|
||||||
|
"type": "wsync",
|
||||||
|
"lang": "en",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"line": "First original line",
|
||||||
|
"start": 1750,
|
||||||
|
"end": 4770,
|
||||||
|
"words": [
|
||||||
|
{"word": "First", "start": 1750, "end": 2480},
|
||||||
|
{"word": "original", "start": 2480, "end": 3600},
|
||||||
|
{"word": "line", "start": 3600, "end": 4770}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"line": "", "words": []},
|
||||||
|
{
|
||||||
|
"line": "Second original line",
|
||||||
|
"start": 19690,
|
||||||
|
"end": 22180,
|
||||||
|
"words": [
|
||||||
|
{"word": "Second", "start": 19690, "end": 20560},
|
||||||
|
{"word": "original", "start": 20560, "end": 21310},
|
||||||
|
{"word": "line", "start": 21310, "end": 22180}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"type": "wsync",
|
||||||
|
"lang": "es",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"line": "Primera linea traducida",
|
||||||
|
"start": 1750,
|
||||||
|
"end": 4770,
|
||||||
|
"words": [
|
||||||
|
{"word": "Primera", "start": 1750, "end": 2900},
|
||||||
|
{"word": "linea", "start": 2900, "end": 3800},
|
||||||
|
{"word": "traducida", "start": 3800, "end": 4770}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"line": "", "words": []},
|
||||||
|
{
|
||||||
|
"line": "Segunda linea traducida",
|
||||||
|
"start": 19690,
|
||||||
|
"end": 22180,
|
||||||
|
"words": [
|
||||||
|
{"word": "Segunda", "start": 19690, "end": 20800},
|
||||||
|
{"word": "linea", "start": 20800, "end": 21500},
|
||||||
|
{"word": "traducida", "start": 21500, "end": 22180}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"track_id": 266725027,
|
||||||
|
"album_id": "gvcirtodd95kc",
|
||||||
|
"lyrics_url": "https://ga-cdn.qobuz.com/lyrics/266725027/original.json?Expires=2000000000&Signature=example&Key-Pair-Id=EXAMPLE",
|
||||||
|
"translation_requested": {
|
||||||
|
"lyrics_url": "https://ga-cdn.qobuz.com/lyrics/266725027/es.json?Expires=2000000000&Signature=example&Key-Pair-Id=EXAMPLE",
|
||||||
|
"lang": "es"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4778,3 +4778,19 @@ msgstr "Meistgespielte Alben"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} Wiedergaben"
|
msgstr "{} Wiedergaben"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "Songtext-Übersetzung anzeigen"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "Songtext-Übersetzung ausblenden"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "Übersetzungssprache"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "Automatisch (Kontosprache)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "Die Übersetzung ist gerade nicht verfügbar. Bitte versuche es später erneut."
|
||||||
@@ -571,3 +571,19 @@ msgstr "Most Played Albums"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} plays"
|
msgstr "{} plays"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "Show lyrics translation"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "Hide lyrics translation"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "Translation language"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "Auto (account language)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "Translation is not available right now. Please try again later."
|
||||||
@@ -4808,3 +4808,19 @@ msgstr "Álbumes más escuchados"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} reproducciones"
|
msgstr "{} reproducciones"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "Mostrar traducción de la letra"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "Ocultar traducción de la letra"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "Idioma de traducción"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "Automático (idioma de la cuenta)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "La traducción no está disponible ahora mismo. Inténtalo de nuevo más tarde."
|
||||||
@@ -4778,3 +4778,19 @@ msgstr "Albums les plus écoutés"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} lectures"
|
msgstr "{} lectures"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "Afficher la traduction des paroles"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "Masquer la traduction des paroles"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "Langue de traduction"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "Automatique (langue du compte)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "La traduction n'est pas disponible pour le moment. Veuillez réessayer plus tard."
|
||||||
@@ -4766,3 +4766,19 @@ msgstr "最も再生したアルバム"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} 回再生"
|
msgstr "{} 回再生"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "歌詞の翻訳を表示"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "歌詞の翻訳を隠す"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "翻訳言語"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "自動(アカウントの言語)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "翻訳は現在利用できません。後でもう一度お試しください。"
|
||||||
@@ -4790,3 +4790,19 @@ msgstr "Meest afgespeelde albums"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} keer afgespeeld"
|
msgstr "{} keer afgespeeld"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "Songtekstvertaling tonen"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "Songtekstvertaling verbergen"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "Vertaaltaal"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "Automatisch (accounttaal)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "Vertaling is nu niet beschikbaar. Probeer het later opnieuw."
|
||||||
@@ -4778,3 +4778,19 @@ msgstr "Álbuns mais tocados"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} reproduções"
|
msgstr "{} reproduções"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "Mostrar tradução da letra"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "Ocultar tradução da letra"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "Idioma da tradução"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "Automático (idioma da conta)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "A tradução não está disponível de momento. Tente novamente mais tarde."
|
||||||
@@ -4792,3 +4792,19 @@ msgstr "Самые прослушиваемые альбомы"
|
|||||||
# Most Played Albums View-all — per-card play count
|
# Most Played Albums View-all — per-card play count
|
||||||
msgid "{} plays"
|
msgid "{} plays"
|
||||||
msgstr "{} воспроизведений"
|
msgstr "{} воспроизведений"
|
||||||
|
|
||||||
|
# Lyrics translation (Qobuz API v10) — floating-toggle tooltips, flyout row, error toast
|
||||||
|
msgid "Show lyrics translation"
|
||||||
|
msgstr "Показать перевод текста"
|
||||||
|
|
||||||
|
msgid "Hide lyrics translation"
|
||||||
|
msgstr "Скрыть перевод текста"
|
||||||
|
|
||||||
|
msgid "Translation language"
|
||||||
|
msgstr "Язык перевода"
|
||||||
|
|
||||||
|
msgid "Auto (account language)"
|
||||||
|
msgstr "Автоматически (язык аккаунта)"
|
||||||
|
|
||||||
|
msgid "Translation is not available right now. Please try again later."
|
||||||
|
msgstr "Перевод сейчас недоступен. Попробуйте ещё раз позже."
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-languages"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m5 8 6 6" />
|
||||||
|
<path d="m4 14 6-6 2-3" />
|
||||||
|
<path d="M2 5h12" />
|
||||||
|
<path d="M7 2h1" />
|
||||||
|
<path d="m22 22-5-10-5 10" />
|
||||||
|
<path d="M14 18h6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 437 B |
@@ -8,7 +8,9 @@
|
|||||||
// so Tauri's segmented S/M/L/XL and Off/Soft/Strong rows become selects).
|
// so Tauri's segmented S/M/L/XL and Off/Soft/Strong rows become selects).
|
||||||
//
|
//
|
||||||
// Row order is Tauri's exactly: Auto-follow / Font / Size / Active color /
|
// Row order is Tauri's exactly: Auto-follow / Font / Size / Active color /
|
||||||
// Uppercase / Dimming / footer (Copy lyrics + Reset).
|
// Uppercase / Dimming / footer (Copy lyrics + Reset) — plus two Slint-side
|
||||||
|
// additions in order: Translation language (v10, after Size) and Lite fill
|
||||||
|
// (perf, after Uppercase).
|
||||||
//
|
//
|
||||||
// Deviations (spec §6): D6 — active color is a fixed swatch palette (Slint
|
// Deviations (spec §6): D6 — active color is a fixed swatch palette (Slint
|
||||||
// has no native color-picker input); the "Theme" reset link shows only while
|
// has no native color-picker input); the "Theme" reset link shows only while
|
||||||
@@ -132,6 +134,25 @@ export component LyricsControlsPanel inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Translation language (Qobuz v10) — Auto (account language) + the
|
||||||
|
// 9 approved ISO 639-1 targets; options reseeded from Rust on
|
||||||
|
// language change (the array carries one @tr'd label).
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 30px;
|
||||||
|
spacing: 8px;
|
||||||
|
RowLabel { text: @tr("Translation language"); }
|
||||||
|
QbzSelect {
|
||||||
|
sm: true;
|
||||||
|
menu-width: 178px;
|
||||||
|
options: LyricsState.translation-language-options;
|
||||||
|
current-index: LyricsState.translation-language-index;
|
||||||
|
selected(i) => {
|
||||||
|
LyricsState.translation-language-index = i;
|
||||||
|
LyricsState.prefs-changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Active color — label (+ "Theme" reset while custom), swatch palette
|
// Active color — label (+ "Theme" reset while custom), swatch palette
|
||||||
// underneath, right-aligned on the control grid (D6).
|
// underneath, right-aligned on the control grid (D6).
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ import "../../../../static/fonts/SourceSans3-VariableFont_wght.ttf";
|
|||||||
component LyricsLineRow inherits VerticalLayout {
|
component LyricsLineRow inherits VerticalLayout {
|
||||||
in property <int> index;
|
in property <int> index;
|
||||||
in property <string> line-text;
|
in property <string> line-text;
|
||||||
|
// Translation of this line (Qobuz v10); empty when the doc has none.
|
||||||
|
// Rendered under the original line while LyricsState.show-translation.
|
||||||
|
in property <string> translation-text;
|
||||||
in property <length> size-inactive;
|
in property <length> size-inactive;
|
||||||
in property <length> size-active;
|
in property <length> size-active;
|
||||||
// 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5).
|
// 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5).
|
||||||
@@ -86,6 +89,10 @@ component LyricsLineRow inherits VerticalLayout {
|
|||||||
// eager init already saw it active.
|
// eager init already saw it active.
|
||||||
in property <int> recenter-epoch;
|
in property <int> recenter-epoch;
|
||||||
|
|
||||||
|
// Gap between the original line and its translation (no effect while
|
||||||
|
// the translation is hidden — a single child has no spacing).
|
||||||
|
spacing: 3px;
|
||||||
|
|
||||||
// Tauri renders uppercase via CSS text-transform; Slint transforms the
|
// Tauri renders uppercase via CSS text-transform; Slint transforms the
|
||||||
// string (display only — the engine still matches on the raw text).
|
// string (display only — the engine still matches on the raw text).
|
||||||
property <string> display-text:
|
property <string> display-text:
|
||||||
@@ -255,6 +262,26 @@ component LyricsLineRow inherits VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Translation (Qobuz v10): static text UNDER the original line — smaller
|
||||||
|
// (~72%, mobile reference) and dimmer. The karaoke fill above stays keyed
|
||||||
|
// on the ORIGINAL timings/text; this follows its line statically and
|
||||||
|
// joins the same dimming ladder (never uppercased — it's a translation,
|
||||||
|
// not the lyric). No-op while the toggle is off or the doc has none.
|
||||||
|
if LyricsState.show-translation && root.translation-text != "": Text {
|
||||||
|
text: root.translation-text;
|
||||||
|
wrap: word-wrap;
|
||||||
|
opacity: root.line-opacity();
|
||||||
|
color: root.is-past ? Theme.text-muted : Theme.text-secondary.with-alpha(0.7);
|
||||||
|
font-family: root.font-name;
|
||||||
|
font-size: (root.is-active ? root.size-active : root.size-inactive) * 0.72;
|
||||||
|
font-weight: Typography.regular;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
animate opacity {
|
||||||
|
duration: 220ms;
|
||||||
|
easing: ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component LyricsLinesView inherits Rectangle {
|
export component LyricsLinesView inherits Rectangle {
|
||||||
@@ -386,6 +413,7 @@ export component LyricsLinesView inherits Rectangle {
|
|||||||
for line[idx] in LyricsState.lines: LyricsLineRow {
|
for line[idx] in LyricsState.lines: LyricsLineRow {
|
||||||
index: idx;
|
index: idx;
|
||||||
line-text: line.text;
|
line-text: line.text;
|
||||||
|
translation-text: line.translation-text;
|
||||||
size-inactive: root.size-inactive;
|
size-inactive: root.size-inactive;
|
||||||
size-active: root.size-active;
|
size-active: root.size-active;
|
||||||
dimming-mode: root.dimming-mode;
|
dimming-mode: root.dimming-mode;
|
||||||
|
|||||||
@@ -23,7 +23,55 @@ import { IconButton } from "../primitives/BarControls.slint";
|
|||||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
import { LyricsLinesView } from "LyricsLinesView.slint";
|
import { LyricsLinesView } from "LyricsLinesView.slint";
|
||||||
import { LyricsControlsPanel } from "LyricsControlsFlyout.slint";
|
import { LyricsControlsPanel } from "LyricsControlsFlyout.slint";
|
||||||
import { ShellState, LyricsState, ArtworkActions, AppearanceState } from "../state.slint";
|
import { ShellState, LyricsState, ArtworkActions, AppearanceState, TooltipState } from "../state.slint";
|
||||||
|
|
||||||
|
// Floating translate toggle (Qobuz v10, owner decision 2026-07-22): circular
|
||||||
|
// button anchored bottom-right of the lyrics body, overlaying it — mobile
|
||||||
|
// parity (the 文A button on iOS/Android). Off = dim, on = accent. Tooltip
|
||||||
|
// rides the shared TooltipState channel like the bar's IconButton.
|
||||||
|
component TranslateButton inherits Rectangle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: LyricsState.show-translation
|
||||||
|
? Theme.accent.with-alpha(0.15)
|
||||||
|
: Theme.surface-card;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: LyricsState.show-translation ? Theme.accent : Theme.border-muted;
|
||||||
|
drop-shadow-blur: 12px;
|
||||||
|
drop-shadow-color: Theme.card-shadow;
|
||||||
|
drop-shadow-offset-y: 3px;
|
||||||
|
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/languages.svg");
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||||
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||||
|
tint: LyricsState.show-translation
|
||||||
|
? Theme.accent
|
||||||
|
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
LyricsState.toggle-translation();
|
||||||
|
}
|
||||||
|
changed has-hover => {
|
||||||
|
if (self.has-hover) {
|
||||||
|
TooltipState.text = LyricsState.show-translation
|
||||||
|
? @tr("Hide lyrics translation")
|
||||||
|
: @tr("Show lyrics translation");
|
||||||
|
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
|
||||||
|
TooltipState.anchor-y = root.absolute-position.y;
|
||||||
|
TooltipState.show = true;
|
||||||
|
} else {
|
||||||
|
TooltipState.show = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
component CloseButton inherits Rectangle {
|
component CloseButton inherits Rectangle {
|
||||||
callback clicked();
|
callback clicked();
|
||||||
@@ -128,9 +176,10 @@ export component LyricsSidebar inherits Rectangle {
|
|||||||
x: 32px + 8px + 28px - 260px;
|
x: 32px + 8px + 28px - 260px;
|
||||||
y: 32px + 8px;
|
y: 32px + 8px;
|
||||||
width: 260px;
|
width: 260px;
|
||||||
// padding 28 + rows (22+30+30+44+22+30+34 = 212) +
|
// padding 28 + rows (22+30+30+30+44+22+22+30+34 =
|
||||||
// spacing 12 x 6 = 72.
|
// 264, incl. lite-fill + translation language) +
|
||||||
height: 312px;
|
// spacing 12 x 8 = 96.
|
||||||
|
height: 388px;
|
||||||
close-policy: PopupClosePolicy.close-on-click-outside;
|
close-policy: PopupClosePolicy.close-on-click-outside;
|
||||||
LyricsControlsPanel { }
|
LyricsControlsPanel { }
|
||||||
}
|
}
|
||||||
@@ -278,6 +327,14 @@ export component LyricsSidebar inherits Rectangle {
|
|||||||
? LyricsState.custom-color
|
? LyricsState.custom-color
|
||||||
: Theme.accent;
|
: Theme.accent;
|
||||||
}
|
}
|
||||||
|
// Floating translate toggle — bottom-right overlay of the lyrics
|
||||||
|
// body (mobile parity; NOT in the header, NOT in the flyout).
|
||||||
|
// Mounted only while the current doc advertises translations;
|
||||||
|
// otherwise the sidebar renders exactly as before.
|
||||||
|
if LyricsState.translation-available: TranslateButton {
|
||||||
|
x: parent.width - self.width - 12px;
|
||||||
|
y: parent.height - self.height - 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source attribution — a tiny line pinned at the very bottom, always
|
// Source attribution — a tiny line pinned at the very bottom, always
|
||||||
|
|||||||
@@ -4454,6 +4454,11 @@ export struct LyricsLineItem {
|
|||||||
time-ms: int,
|
time-ms: int,
|
||||||
end-ms: int,
|
end-ms: int,
|
||||||
has-words: bool,
|
has-words: bool,
|
||||||
|
// Translation of this line (Qobuz v10), aligned 1:1 with `text` by the
|
||||||
|
// Rust glue; empty when the doc has no translation (or it wasn't
|
||||||
|
// requested). Rendered under the original line only while
|
||||||
|
// `LyricsState.show-translation` is on.
|
||||||
|
translation-text: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
// One VISUAL line of the ACTIVE logical line, for the per-visual-line karaoke
|
// One VISUAL line of the ACTIVE logical line, for the per-visual-line karaoke
|
||||||
@@ -4488,6 +4493,18 @@ export global LyricsState {
|
|||||||
// Playing-track meta for the header ("{title} - {artist}").
|
// Playing-track meta for the header ("{title} - {artist}").
|
||||||
in property <string> track-title;
|
in property <string> track-title;
|
||||||
in property <string> track-artist;
|
in property <string> track-artist;
|
||||||
|
|
||||||
|
// ---- Translation (Qobuz API v10) ---------------------------------------
|
||||||
|
// The current doc advertises at least one translation language
|
||||||
|
// (`translation_langs` non-empty) — gates the sidebar's floating toggle.
|
||||||
|
// False on every other state, so the sidebar renders exactly as before.
|
||||||
|
in property <bool> translation-available: false;
|
||||||
|
// User intent: render the translation under each original line. Driven by
|
||||||
|
// Rust (`toggle-translation`): ON refetches with the target language and
|
||||||
|
// flips this only when the translation actually arrived; OFF is pure UI.
|
||||||
|
in-out property <bool> show-translation: false;
|
||||||
|
// Sidebar floating-button action (wired in main.rs).
|
||||||
|
callback toggle-translation();
|
||||||
// Surfaced backend error text (status == 4) — dynamic content, not chrome.
|
// Surfaced backend error text (status == 4) — dynamic content, not chrome.
|
||||||
in property <string> error;
|
in property <string> error;
|
||||||
// Active line index; -1 = none. The S4 sync engine drives it.
|
// Active line index; -1 = none. The S4 sync engine drives it.
|
||||||
@@ -4538,6 +4555,25 @@ export global LyricsState {
|
|||||||
// between line changes). Default false (smooth karaoke). Kiosk / weak-GPU
|
// between line changes). Default false (smooth karaoke). Kiosk / weak-GPU
|
||||||
// tiers force it on via ShellState.reduce-motion regardless of this pref.
|
// tiers force it on via ShellState.reduce-motion regardless of this pref.
|
||||||
in-out property <bool> lite-fill: false;
|
in-out property <bool> lite-fill: false;
|
||||||
|
// Translation language (Qobuz v10, persisted like the other prefs):
|
||||||
|
// 0 Auto (account language) · 1..9 = en es fr de it pt nl ja ru. Option
|
||||||
|
// labels: the Auto entry is @tr'd, the language names are native
|
||||||
|
// literals — the same convention as AppearanceState.languages. The @tr
|
||||||
|
// DEFAULT is not reactive, so `reseed_i18n_labels` re-pushes the array
|
||||||
|
// on language change.
|
||||||
|
in-out property <int> translation-language-index: 0;
|
||||||
|
in-out property <[string]> translation-language-options: [
|
||||||
|
@tr("Auto (account language)"),
|
||||||
|
"English",
|
||||||
|
"Español",
|
||||||
|
"Français",
|
||||||
|
"Deutsch",
|
||||||
|
"Italiano",
|
||||||
|
"Português",
|
||||||
|
"Nederlands",
|
||||||
|
"日本語",
|
||||||
|
"Русский",
|
||||||
|
];
|
||||||
// Active (karaoke fill) color. `use-custom-color: false` = follow the theme
|
// Active (karaoke fill) color. `use-custom-color: false` = follow the theme
|
||||||
// accent (Tauri activeColor: ''). The palette swatches set both (deviation
|
// accent (Tauri activeColor: ''). The palette swatches set both (deviation
|
||||||
// D6: swatches instead of a native color input — Slint has none).
|
// D6: swatches instead of a native color input — Slint has none).
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ const AUTHOR_HANDLE: &str = "vicrodh";
|
|||||||
|
|
||||||
/// The contributor handles + their GitHub profile URLs. First the Tauri About
|
/// The contributor handles + their GitHub profile URLs. First the Tauri About
|
||||||
/// modal's order, then the Slint-era external-PR contributors: `hoyon`
|
/// modal's order, then the Slint-era external-PR contributors: `hoyon`
|
||||||
/// (classical "work" grouping, PR #536) and `mxnix` (Russian translation,
|
/// (classical "work" grouping, PR #536), `mxnix` (Russian translation,
|
||||||
/// PR #517).
|
/// PR #517) and `TerminalTilt`.
|
||||||
const CONTRIBUTORS: &[&str] = &[
|
const CONTRIBUTORS: &[&str] = &[
|
||||||
"vorce",
|
"vorce",
|
||||||
"boxdot",
|
"boxdot",
|
||||||
@@ -66,12 +66,13 @@ const CONTRIBUTORS: &[&str] = &[
|
|||||||
"DoubleGate",
|
"DoubleGate",
|
||||||
"hoyon",
|
"hoyon",
|
||||||
"mxnix",
|
"mxnix",
|
||||||
|
"TerminalTilt",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// How many contributor chips per wrap row. Slint has no flex-wrap, so the flat
|
/// How many contributor chips per wrap row. Slint has no flex-wrap, so the flat
|
||||||
/// list is pre-grouped into fixed rows (see `AboutContributorGroup`). 5 fills
|
/// list is pre-grouped into fixed rows (see `AboutContributorGroup`). 5 fills
|
||||||
/// the widened ~840px panel (10 contributors → 2 even rows) instead of the old
|
/// the widened ~840px panel instead of the old 4-per-row / 3-row layout that
|
||||||
/// 4-per-row / 3-row layout that left the wider modal half-empty.
|
/// left the wider modal half-empty.
|
||||||
const CONTRIBUTORS_PER_ROW: usize = 5;
|
const CONTRIBUTORS_PER_ROW: usize = 5;
|
||||||
|
|
||||||
/// Build the row-grouped contributor model. Avatars start blank (default image)
|
/// Build the row-grouped contributor model. Avatars start blank (default image)
|
||||||
|
|||||||
+257
-7
@@ -31,6 +31,16 @@
|
|||||||
//! Rust-side in [`CURRENT_DOC`]; the UI model only carries the line list +
|
//! Rust-side in [`CURRENT_DOC`]; the UI model only carries the line list +
|
||||||
//! a `has-words` flag. The S4 sync engine consumes the doc for the
|
//! a `has-words` flag. The S4 sync engine consumes the doc for the
|
||||||
//! word-anchored karaoke fill.
|
//! word-anchored karaoke fill.
|
||||||
|
//!
|
||||||
|
//! Translation (Qobuz API v10): the default flow fetches original-only and
|
||||||
|
//! pushes `translation-available` (doc `translation_langs` non-empty), which
|
||||||
|
//! gates the sidebar's floating toggle. Toggling ON refetches the current
|
||||||
|
//! track WITH the resolved target (`LyricsRequest.language`) via
|
||||||
|
//! [`enable_translation`] and flips `show-translation` only when a
|
||||||
|
//! translation actually arrived — any gap toasts and reverts (fail soft,
|
||||||
|
//! never an error state). Toggling OFF ([`disable_translation`]) is pure
|
||||||
|
//! UI. While ON, track-change requests ride the resolved target so the
|
||||||
|
//! translation follows across tracks.
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
@@ -58,6 +68,11 @@ const STATUS_OFFLINE: i32 = 5;
|
|||||||
/// the per-user cache handle re-binds via `init_at` on every activation.
|
/// the per-user cache handle re-binds via `init_at` on every activation.
|
||||||
static SERVICE: OnceLock<Arc<LyricsService>> = OnceLock::new();
|
static SERVICE: OnceLock<Arc<LyricsService>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// The shared core client lock, kept for the translation "Auto" resolution
|
||||||
|
/// (the account `language_code` lives on the client's session). Installed
|
||||||
|
/// alongside [`SERVICE`] on the first session activation.
|
||||||
|
static CLIENT: OnceLock<Arc<tokio::sync::RwLock<Option<QobuzClient>>>> = OnceLock::new();
|
||||||
|
|
||||||
/// Identity of the latest requested track + whether a Found result has been
|
/// Identity of the latest requested track + whether a Found result has been
|
||||||
/// committed for it. `key` doubles as the stale guard (F2) and the
|
/// committed for it. `key` doubles as the stale guard (F2) and the
|
||||||
/// duplicate-fetch guard (`loaded` mirrors Tauri's `status === 'loaded'`).
|
/// duplicate-fetch guard (`loaded` mirrors Tauri's `status === 'loaded'`).
|
||||||
@@ -71,6 +86,20 @@ static CURRENT: Mutex<CurrentLyrics> = Mutex::new(CurrentLyrics {
|
|||||||
loaded: false,
|
loaded: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Active translation session (Qobuz v10), the Rust-side source of truth
|
||||||
|
/// behind `LyricsState.show-translation`: `enabled` = the toggle is ON,
|
||||||
|
/// `language` = the target it was resolved with (kept so track changes
|
||||||
|
/// re-request the same target — spec §B: translation follows across tracks).
|
||||||
|
struct TranslationSession {
|
||||||
|
enabled: bool,
|
||||||
|
language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static TRANSLATION: Mutex<TranslationSession> = Mutex::new(TranslationSession {
|
||||||
|
enabled: false,
|
||||||
|
language: None,
|
||||||
|
});
|
||||||
|
|
||||||
/// The parsed document for the current track, held Rust-side so the native
|
/// The parsed document for the current track, held Rust-side so the native
|
||||||
/// Qobuz word timestamps survive for the S4 sync engine (the UI model only
|
/// Qobuz word timestamps survive for the S4 sync engine (the UI model only
|
||||||
/// carries text + line bounds + a has-words flag).
|
/// carries text + line bounds + a has-words flag).
|
||||||
@@ -96,12 +125,19 @@ struct SharedClientProviders {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl LyricsProviders for SharedClientProviders {
|
impl LyricsProviders for SharedClientProviders {
|
||||||
async fn qobuz(&self, track_id: u64) -> Result<Option<QobuzLyricsDocument>, String> {
|
async fn qobuz(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
language: Option<&str>,
|
||||||
|
) -> Result<Option<QobuzLyricsDocument>, String> {
|
||||||
let guard = self.client.read().await;
|
let guard = self.client.read().await;
|
||||||
let client = guard
|
let client = guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Qobuz client not initialized".to_string())?;
|
.ok_or_else(|| "Qobuz client not initialized".to_string())?;
|
||||||
client.get_lyrics(track_id).await.map_err(|e| e.to_string())
|
client
|
||||||
|
.get_lyrics(track_id, language)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn lrclib(
|
async fn lrclib(
|
||||||
@@ -126,6 +162,10 @@ impl LyricsProviders for SharedClientProviders {
|
|||||||
/// Best-effort: failures are logged, never block entry. Must run within the
|
/// Best-effort: failures are logged, never block entry. Must run within the
|
||||||
/// tokio runtime context (the bind is spawned).
|
/// tokio runtime context (the bind is spawned).
|
||||||
pub fn init_for_user(client: Arc<tokio::sync::RwLock<Option<QobuzClient>>>, user_id: u64) {
|
pub fn init_for_user(client: Arc<tokio::sync::RwLock<Option<QobuzClient>>>, user_id: u64) {
|
||||||
|
// Keep a handle for the translation "Auto" resolution (the account
|
||||||
|
// language lives on the client's session). Same lock per process, so a
|
||||||
|
// later activation's re-set is a no-op.
|
||||||
|
let _ = CLIENT.set(client.clone());
|
||||||
let service = SERVICE
|
let service = SERVICE
|
||||||
.get_or_init(move || {
|
.get_or_init(move || {
|
||||||
Arc::new(LyricsService::new(Arc::new(SharedClientProviders {
|
Arc::new(LyricsService::new(Arc::new(SharedClientProviders {
|
||||||
@@ -161,6 +201,7 @@ pub fn teardown() {
|
|||||||
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
*doc = None;
|
*doc = None;
|
||||||
}
|
}
|
||||||
|
reset_translation_session();
|
||||||
if let Some(service) = SERVICE.get().cloned() {
|
if let Some(service) = SERVICE.get().cloned() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
service.teardown().await;
|
service.teardown().await;
|
||||||
@@ -242,6 +283,187 @@ pub fn clear_cache(handle: &tokio::runtime::Handle, weak: slint::Weak<AppWindow>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Translation (Qobuz API v10) -------------------------------------------
|
||||||
|
|
||||||
|
/// Whether the translation toggle is currently ON (Rust-side source of truth
|
||||||
|
/// behind `LyricsState.show-translation`). main.rs reads it to route the
|
||||||
|
/// toggle callback; the fetch path reads [`active_translation_language`] to
|
||||||
|
/// keep requesting the target across track changes.
|
||||||
|
pub fn translation_enabled() -> bool {
|
||||||
|
TRANSLATION.lock().map(|t| t.enabled).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_translation_session() {
|
||||||
|
if let Ok(mut t) = TRANSLATION.lock() {
|
||||||
|
t.enabled = false;
|
||||||
|
t.language = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target the toggle was enabled with, while ON — the track-change fetch
|
||||||
|
/// rides it so the translation follows across tracks (spec §B).
|
||||||
|
fn active_translation_language() -> Option<String> {
|
||||||
|
TRANSLATION
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.enabled.then(|| t.language.clone()).flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account language (ISO 639-1) from the shared client's session — the
|
||||||
|
/// "Auto" resolution's first hop. None pre-login / pre-v10 sessions.
|
||||||
|
async fn account_language() -> Option<String> {
|
||||||
|
let client = CLIENT.get()?.clone();
|
||||||
|
let guard = client.read().await;
|
||||||
|
guard.as_ref()?.session_language_code().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the effective translation target (spec §B.3): the explicit
|
||||||
|
/// setting when set; "Auto" = account `language_code`, falling back to the
|
||||||
|
/// UI locale. `None` when nothing resolves — the caller fails soft (toast +
|
||||||
|
/// revert); the lyrics view never enters an error state over translation.
|
||||||
|
async fn resolve_target_language() -> Option<String> {
|
||||||
|
let pref = crate::lyrics_prefs::load().translation_language;
|
||||||
|
if pref != "auto" {
|
||||||
|
return Some(pref);
|
||||||
|
}
|
||||||
|
if let Some(lang) = account_language().await {
|
||||||
|
return Some(lang);
|
||||||
|
}
|
||||||
|
let ui_locale = qbz_i18n::current_language();
|
||||||
|
(!ui_locale.is_empty()).then(|| ui_locale.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle OFF: drop the session and re-render original-only. Pure UI — the
|
||||||
|
/// committed line model keeps its (now hidden) translation texts; NO
|
||||||
|
/// network (spec §B.3).
|
||||||
|
pub fn disable_translation(weak: &slint::Weak<AppWindow>) {
|
||||||
|
reset_translation_session();
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LyricsState>().set_show_translation(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast + revert, for every translation failure path (toggle ON with no
|
||||||
|
/// current track, no resolvable target, fetch error, or a response without
|
||||||
|
/// a translation). Fail soft: the current lyrics view is left untouched.
|
||||||
|
pub fn translation_unavailable(weak: &slint::Weak<AppWindow>) {
|
||||||
|
reset_translation_session();
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LyricsState>().set_show_translation(false);
|
||||||
|
});
|
||||||
|
crate::toast::error_weak(
|
||||||
|
weak,
|
||||||
|
qbz_i18n::t("Translation is not available right now. Please try again later."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle ON: refetch the current track's lyrics WITH the resolved target
|
||||||
|
/// language (spec §B.3). The committed doc then carries the embedded
|
||||||
|
/// translation and `show-translation` flips on — but ONLY when the response
|
||||||
|
/// actually holds one; any gap toasts and reverts (Android
|
||||||
|
/// `player_lyrics_translation_error` parity). When the current doc already
|
||||||
|
/// holds the translation for the target (toggle off -> on on the same
|
||||||
|
/// track), the flip is local — no refetch.
|
||||||
|
pub fn enable_translation(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
|
||||||
|
let Some(service) = SERVICE.get().cloned() else {
|
||||||
|
translation_unavailable(&weak);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let source = source_kind(track);
|
||||||
|
let track_id = (source == LyricsSourceKind::Qobuz).then_some(track.id);
|
||||||
|
let duration_secs = (track.duration_secs > 0).then_some(track.duration_secs);
|
||||||
|
let key = request_identity(
|
||||||
|
track_id,
|
||||||
|
&build_cache_key(track.title.trim(), track.artist.trim(), duration_secs),
|
||||||
|
);
|
||||||
|
let request = LyricsRequest {
|
||||||
|
track_id,
|
||||||
|
source,
|
||||||
|
title: track.title.clone(),
|
||||||
|
artist: track.artist.clone(),
|
||||||
|
album: (!track.album.is_empty()).then(|| track.album.clone()),
|
||||||
|
duration_secs,
|
||||||
|
offline: crate::offline_mode::engine().is_offline(),
|
||||||
|
// Resolved inside the task (the session read is async).
|
||||||
|
language: None,
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let Some(target) = resolve_target_language().await else {
|
||||||
|
translation_unavailable(&weak);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Fast path: the current doc already carries this translation.
|
||||||
|
let already = with_current_doc(|doc| {
|
||||||
|
doc.and_then(|d| d.translation.as_ref())
|
||||||
|
.and_then(|t| t.lang.as_deref())
|
||||||
|
== Some(target.as_str())
|
||||||
|
});
|
||||||
|
if already {
|
||||||
|
if let Ok(mut t) = TRANSLATION.lock() {
|
||||||
|
t.enabled = true;
|
||||||
|
t.language = Some(target);
|
||||||
|
}
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LyricsState>().set_show_translation(true);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut request = request;
|
||||||
|
request.language = Some(target.clone());
|
||||||
|
let result = service.get(request).await;
|
||||||
|
// Same stale guard (F2) as the track-change path: a superseded
|
||||||
|
// response is dropped whole.
|
||||||
|
let response_key = match &result {
|
||||||
|
Ok(response) => {
|
||||||
|
request_identity(response.request_track_id, &response.request_key)
|
||||||
|
}
|
||||||
|
Err(_) => key.clone(),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut current = CURRENT.lock().expect("lyrics CURRENT lock poisoned");
|
||||||
|
if current.key != response_key {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
result.as_ref().map(|r| &r.outcome),
|
||||||
|
Ok(LyricsOutcome::Found(_))
|
||||||
|
) {
|
||||||
|
current.loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(response) if matches!(response.outcome, LyricsOutcome::Found(_)) => {
|
||||||
|
let has_translation = match &response.outcome {
|
||||||
|
LyricsOutcome::Found(found) => found.doc.translation.is_some(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if has_translation {
|
||||||
|
if let Ok(mut t) = TRANSLATION.lock() {
|
||||||
|
t.enabled = true;
|
||||||
|
t.language = Some(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply_result(weak.clone(), Ok(response));
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
w.global::<LyricsState>().set_show_translation(has_translation);
|
||||||
|
});
|
||||||
|
if !has_translation {
|
||||||
|
// The track lacks the target language — the original
|
||||||
|
// committed above stays; toast + revert (spec §B.4).
|
||||||
|
translation_unavailable(&weak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NotFound / offline-miss: keep the current view untouched.
|
||||||
|
Ok(_) => translation_unavailable(&weak),
|
||||||
|
// Fetch error: NEVER an error state because of translation.
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[qbz-slint] translation refetch failed: {e}");
|
||||||
|
translation_unavailable(&weak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Qobuz vs non-Qobuz from the queue track's source tag. `qobuz_download`
|
/// Qobuz vs non-Qobuz from the queue track's source tag. `qobuz_download`
|
||||||
/// rows carry the REAL Qobuz catalog id (`local_queue_track`), so the Qobuz
|
/// rows carry the REAL Qobuz catalog id (`local_queue_track`), so the Qobuz
|
||||||
/// primary applies to them too; local user files / ephemeral folders / Plex
|
/// primary applies to them too; local user files / ephemeral folders / Plex
|
||||||
@@ -299,6 +521,9 @@ pub fn prefetch_lyrics(track: &QueueTrack) {
|
|||||||
album: (!track.album.is_empty()).then(|| track.album.clone()),
|
album: (!track.album.is_empty()).then(|| track.album.clone()),
|
||||||
duration_secs: (track.duration_secs > 0).then_some(track.duration_secs),
|
duration_secs: (track.duration_secs > 0).then_some(track.duration_secs),
|
||||||
offline: false,
|
offline: false,
|
||||||
|
// Prefetch warms the original-only entry; the translation toggle
|
||||||
|
// (UI task) triggers the refetch-with-language when needed.
|
||||||
|
language: None,
|
||||||
};
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Resolution upserts into lyrics.db; the result is discarded (no UI).
|
// Resolution upserts into lyrics.db; the result is discarded (no UI).
|
||||||
@@ -332,6 +557,10 @@ pub fn on_track_changed(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
|
|||||||
// Offline as data, not lookup (spec §2.2.4): the engine verdict is
|
// Offline as data, not lookup (spec §2.2.4): the engine verdict is
|
||||||
// read here and travels with the request.
|
// read here and travels with the request.
|
||||||
offline: crate::offline_mode::engine().is_offline(),
|
offline: crate::offline_mode::engine().is_offline(),
|
||||||
|
// Default flow fetches original-only (spec §B.1). While the
|
||||||
|
// translation toggle is ON the request rides the resolved target so
|
||||||
|
// the translation follows across tracks.
|
||||||
|
language: active_translation_language(),
|
||||||
};
|
};
|
||||||
let key = request_identity(
|
let key = request_identity(
|
||||||
request.track_id,
|
request.track_id,
|
||||||
@@ -372,6 +601,9 @@ pub fn on_track_changed(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
|
|||||||
state.set_provider("".into());
|
state.set_provider("".into());
|
||||||
state.set_provider_label("".into());
|
state.set_provider_label("".into());
|
||||||
state.set_error("".into());
|
state.set_error("".into());
|
||||||
|
// Hide the toggle while loading — the availability of the NEXT
|
||||||
|
// track's doc is unknown until its commit.
|
||||||
|
state.set_translation_available(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +643,7 @@ pub fn on_track_cleared(weak: slint::Weak<AppWindow>) {
|
|||||||
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
*doc = None;
|
*doc = None;
|
||||||
}
|
}
|
||||||
|
reset_translation_session();
|
||||||
let _ = weak.upgrade_in_event_loop(|w| {
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
let state = w.global::<LyricsState>();
|
let state = w.global::<LyricsState>();
|
||||||
state.set_status(STATUS_IDLE);
|
state.set_status(STATUS_IDLE);
|
||||||
@@ -424,27 +657,42 @@ pub fn on_track_cleared(weak: slint::Weak<AppWindow>) {
|
|||||||
state.set_provider("".into());
|
state.set_provider("".into());
|
||||||
state.set_provider_label("".into());
|
state.set_provider_label("".into());
|
||||||
state.set_error("".into());
|
state.set_error("".into());
|
||||||
|
state.set_translation_available(false);
|
||||||
|
state.set_show_translation(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map the engine response into `LyricsState` (UI thread push).
|
/// Map the engine response into `LyricsState` (UI thread push).
|
||||||
fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, String>) {
|
fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, String>) {
|
||||||
let (status, items, synced, provider, label, error) = match result {
|
let (status, items, synced, provider, label, error, translation_available) = match result {
|
||||||
Ok(response) => match response.outcome {
|
Ok(response) => match response.outcome {
|
||||||
LyricsOutcome::Found(found) => {
|
LyricsOutcome::Found(found) => {
|
||||||
|
let translation = found.doc.translation.as_deref();
|
||||||
let items: Vec<LyricsLineItem> = found
|
let items: Vec<LyricsLineItem> = found
|
||||||
.doc
|
.doc
|
||||||
.lines
|
.lines
|
||||||
.iter()
|
.iter()
|
||||||
.map(|line| LyricsLineItem {
|
.enumerate()
|
||||||
|
.map(|(i, line)| LyricsLineItem {
|
||||||
text: line.text.clone().into(),
|
text: line.text.clone().into(),
|
||||||
time_ms: line.time_ms.map(|v| v as i32).unwrap_or(-1),
|
time_ms: line.time_ms.map(|v| v as i32).unwrap_or(-1),
|
||||||
end_ms: line.end_ms.map(|v| v as i32).unwrap_or(-1),
|
end_ms: line.end_ms.map(|v| v as i32).unwrap_or(-1),
|
||||||
has_words: line.words.is_some(),
|
has_words: line.words.is_some(),
|
||||||
|
// 1:1 with the rendered lines — the core filters the
|
||||||
|
// translation lines the same way as the originals;
|
||||||
|
// a missing entry degrades to "" (fail soft).
|
||||||
|
translation_text: translation
|
||||||
|
.and_then(|t| t.lines.get(i))
|
||||||
|
.map(|l| l.text.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let synced = found.doc.synced;
|
let synced = found.doc.synced;
|
||||||
let provider = found.doc.provider;
|
let provider = found.doc.provider;
|
||||||
|
// The toggle lights only when Qobuz advertises translations
|
||||||
|
// for this track (spec §B.2).
|
||||||
|
let translation_available = !found.doc.translation_langs.is_empty();
|
||||||
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
*doc = Some(found.doc);
|
*doc = Some(found.doc);
|
||||||
}
|
}
|
||||||
@@ -455,13 +703,14 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
|
|||||||
provider.as_str(),
|
provider.as_str(),
|
||||||
provider_label(provider),
|
provider_label(provider),
|
||||||
String::new(),
|
String::new(),
|
||||||
|
translation_available,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
LyricsOutcome::NotFound => {
|
LyricsOutcome::NotFound => {
|
||||||
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
*doc = None;
|
*doc = None;
|
||||||
}
|
}
|
||||||
(STATUS_NOT_FOUND, Vec::new(), false, "", "", String::new())
|
(STATUS_NOT_FOUND, Vec::new(), false, "", "", String::new(), false)
|
||||||
}
|
}
|
||||||
// Typed offline miss (F3) — the view maps it to a translated
|
// Typed offline miss (F3) — the view maps it to a translated
|
||||||
// string, never a hardcoded message.
|
// string, never a hardcoded message.
|
||||||
@@ -469,7 +718,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
|
|||||||
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
if let Ok(mut doc) = CURRENT_DOC.lock() {
|
||||||
*doc = None;
|
*doc = None;
|
||||||
}
|
}
|
||||||
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new())
|
(STATUS_OFFLINE, Vec::new(), false, "", "", String::new(), false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -477,7 +726,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
|
|||||||
*doc = None;
|
*doc = None;
|
||||||
}
|
}
|
||||||
log::warn!("[qbz-slint] lyrics fetch failed: {e}");
|
log::warn!("[qbz-slint] lyrics fetch failed: {e}");
|
||||||
(STATUS_ERROR, Vec::new(), false, "", "", e)
|
(STATUS_ERROR, Vec::new(), false, "", "", e, false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
@@ -487,6 +736,7 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
|
|||||||
state.set_provider(provider.into());
|
state.set_provider(provider.into());
|
||||||
state.set_provider_label(label.into());
|
state.set_provider_label(label.into());
|
||||||
state.set_error(error.into());
|
state.set_error(error.into());
|
||||||
|
state.set_translation_available(translation_available);
|
||||||
state.set_active_index(-1);
|
state.set_active_index(-1);
|
||||||
state.set_line_progress(0.0);
|
state.set_line_progress(0.0);
|
||||||
state.set_fill_anim_ms(0);
|
state.set_fill_anim_ms(0);
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ pub struct LyricsPrefs {
|
|||||||
/// karaoke sweep, so the lyrics surface stops driving continuous repaints.
|
/// karaoke sweep, so the lyrics surface stops driving continuous repaints.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lite_fill: bool,
|
pub lite_fill: bool,
|
||||||
|
/// Translation target (Qobuz v10): "auto" (account language, UI-locale
|
||||||
|
/// fallback) or an explicit ISO 639-1 code from [`TRANSLATION_LANGS`].
|
||||||
|
#[serde(default = "d_translation_lang")]
|
||||||
|
pub translation_language: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn d_true() -> bool {
|
fn d_true() -> bool {
|
||||||
@@ -70,6 +74,9 @@ fn d_size() -> String {
|
|||||||
fn d_dimming() -> String {
|
fn d_dimming() -> String {
|
||||||
"strong".to_string()
|
"strong".to_string()
|
||||||
}
|
}
|
||||||
|
fn d_translation_lang() -> String {
|
||||||
|
"auto".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for LyricsPrefs {
|
impl Default for LyricsPrefs {
|
||||||
/// Tauri defaults (`lyricsDisplayStore.ts:37-44`).
|
/// Tauri defaults (`lyricsDisplayStore.ts:37-44`).
|
||||||
@@ -82,6 +89,7 @@ impl Default for LyricsPrefs {
|
|||||||
active_color: String::new(),
|
active_color: String::new(),
|
||||||
uppercase: false,
|
uppercase: false,
|
||||||
lite_fill: false,
|
lite_fill: false,
|
||||||
|
translation_language: d_translation_lang(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +97,11 @@ impl Default for LyricsPrefs {
|
|||||||
const FONTS: [&str; 5] = ["system", "line-seed-jp", "montserrat", "noto-sans", "source-sans-3"];
|
const FONTS: [&str; 5] = ["system", "line-seed-jp", "montserrat", "noto-sans", "source-sans-3"];
|
||||||
const SIZES: [&str; 4] = ["small", "medium", "large", "xl"];
|
const SIZES: [&str; 4] = ["small", "medium", "large", "xl"];
|
||||||
const DIMMINGS: [&str; 3] = ["off", "soft", "strong"];
|
const DIMMINGS: [&str; 3] = ["off", "soft", "strong"];
|
||||||
|
/// Translation targets (owner-approved list, spec §Decisions.2): "auto" +
|
||||||
|
/// the observed `translation_langs` ∪ app UI locales. Order matches the
|
||||||
|
/// flyout's `translation-language-options` (Auto, en es fr de it pt nl ja ru).
|
||||||
|
const TRANSLATION_LANGS: [&str; 10] =
|
||||||
|
["auto", "en", "es", "fr", "de", "it", "pt", "nl", "ja", "ru"];
|
||||||
|
|
||||||
/// `#RRGGBB` (or empty = theme) — the Tauri validator
|
/// `#RRGGBB` (or empty = theme) — the Tauri validator
|
||||||
/// (`lyricsDisplayStore.ts:46-52`).
|
/// (`lyricsDisplayStore.ts:46-52`).
|
||||||
@@ -115,6 +128,9 @@ impl LyricsPrefs {
|
|||||||
if !valid_color(&self.active_color) {
|
if !valid_color(&self.active_color) {
|
||||||
self.active_color.clear();
|
self.active_color.clear();
|
||||||
}
|
}
|
||||||
|
if !TRANSLATION_LANGS.contains(&self.translation_language.as_str()) {
|
||||||
|
self.translation_language = d_translation_lang();
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,6 +204,13 @@ fn dimming_index(dimming: &str) -> i32 {
|
|||||||
DIMMINGS.iter().position(|d| *d == dimming).unwrap_or(2) as i32
|
DIMMINGS.iter().position(|d| *d == dimming).unwrap_or(2) as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn translation_lang_index(lang: &str) -> i32 {
|
||||||
|
TRANSLATION_LANGS
|
||||||
|
.iter()
|
||||||
|
.position(|l| *l == lang)
|
||||||
|
.unwrap_or(0) as i32
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_color(hex: &str) -> Option<slint::Color> {
|
fn parse_color(hex: &str) -> Option<slint::Color> {
|
||||||
if hex.len() != 7 || !hex.starts_with('#') {
|
if hex.len() != 7 || !hex.starts_with('#') {
|
||||||
return None;
|
return None;
|
||||||
@@ -218,6 +241,7 @@ pub fn apply_to_ui(window: &AppWindow, prefs: &LyricsPrefs) {
|
|||||||
None => state.set_use_custom_color(false),
|
None => state.set_use_custom_color(false),
|
||||||
}
|
}
|
||||||
state.set_lite_fill(prefs.lite_fill);
|
state.set_lite_fill(prefs.lite_fill);
|
||||||
|
state.set_translation_language_index(translation_lang_index(&prefs.translation_language));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the in-out props back into a pref set and persist — the
|
/// Read the in-out props back into a pref set and persist — the
|
||||||
@@ -248,6 +272,11 @@ pub fn persist_from_ui(window: &AppWindow) {
|
|||||||
},
|
},
|
||||||
uppercase: state.get_uppercase(),
|
uppercase: state.get_uppercase(),
|
||||||
lite_fill: state.get_lite_fill(),
|
lite_fill: state.get_lite_fill(),
|
||||||
|
translation_language: TRANSLATION_LANGS
|
||||||
|
.get(state.get_translation_language_index().max(0) as usize)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("auto")
|
||||||
|
.to_string(),
|
||||||
};
|
};
|
||||||
save(&prefs);
|
save(&prefs);
|
||||||
}
|
}
|
||||||
@@ -273,6 +302,7 @@ mod tests {
|
|||||||
assert_eq!(p.dimming, "strong");
|
assert_eq!(p.dimming, "strong");
|
||||||
assert_eq!(p.active_color, "");
|
assert_eq!(p.active_color, "");
|
||||||
assert!(!p.uppercase);
|
assert!(!p.uppercase);
|
||||||
|
assert_eq!(p.translation_language, "auto");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -291,6 +321,7 @@ mod tests {
|
|||||||
active_color: "purple".into(),
|
active_color: "purple".into(),
|
||||||
uppercase: true,
|
uppercase: true,
|
||||||
lite_fill: false,
|
lite_fill: false,
|
||||||
|
translation_language: "klingon".into(),
|
||||||
}
|
}
|
||||||
.sanitized();
|
.sanitized();
|
||||||
assert!(!p.auto_follow); // bools pass through
|
assert!(!p.auto_follow); // bools pass through
|
||||||
@@ -299,6 +330,8 @@ mod tests {
|
|||||||
assert_eq!(p.font_size, "medium");
|
assert_eq!(p.font_size, "medium");
|
||||||
assert_eq!(p.dimming, "strong");
|
assert_eq!(p.dimming, "strong");
|
||||||
assert_eq!(p.active_color, "");
|
assert_eq!(p.active_color, "");
|
||||||
|
// Unknown translation language falls back to Auto.
|
||||||
|
assert_eq!(p.translation_language, "auto");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -311,12 +344,14 @@ mod tests {
|
|||||||
active_color: "#8b5cf6".into(),
|
active_color: "#8b5cf6".into(),
|
||||||
uppercase: false,
|
uppercase: false,
|
||||||
lite_fill: true,
|
lite_fill: true,
|
||||||
|
translation_language: "it".into(),
|
||||||
}
|
}
|
||||||
.sanitized();
|
.sanitized();
|
||||||
assert_eq!(p.font, "line-seed-jp");
|
assert_eq!(p.font, "line-seed-jp");
|
||||||
assert_eq!(p.font_size, "xl");
|
assert_eq!(p.font_size, "xl");
|
||||||
assert_eq!(p.dimming, "off");
|
assert_eq!(p.dimming, "off");
|
||||||
assert_eq!(p.active_color, "#8b5cf6");
|
assert_eq!(p.active_color, "#8b5cf6");
|
||||||
|
assert_eq!(p.translation_language, "it");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -340,10 +375,14 @@ mod tests {
|
|||||||
for (i, d) in DIMMINGS.iter().enumerate() {
|
for (i, d) in DIMMINGS.iter().enumerate() {
|
||||||
assert_eq!(dimming_index(d), i as i32);
|
assert_eq!(dimming_index(d), i as i32);
|
||||||
}
|
}
|
||||||
|
for (i, l) in TRANSLATION_LANGS.iter().enumerate() {
|
||||||
|
assert_eq!(translation_lang_index(l), i as i32);
|
||||||
|
}
|
||||||
// Unknowns fall back to the defaults' indices.
|
// Unknowns fall back to the defaults' indices.
|
||||||
assert_eq!(font_index("nope"), 0);
|
assert_eq!(font_index("nope"), 0);
|
||||||
assert_eq!(size_index("nope"), 1);
|
assert_eq!(size_index("nope"), 1);
|
||||||
assert_eq!(dimming_index("nope"), 2);
|
assert_eq!(dimming_index("nope"), 2);
|
||||||
|
assert_eq!(translation_lang_index("nope"), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -4962,6 +4962,23 @@ fn reseed_i18n_labels(window: &AppWindow) {
|
|||||||
t("Lite"),
|
t("Lite"),
|
||||||
t("Off"),
|
t("Off"),
|
||||||
])));
|
])));
|
||||||
|
// Lyrics translation-language options (v10): the Auto label is @tr'd,
|
||||||
|
// the language names are native literals — the same convention as
|
||||||
|
// `languages` above.
|
||||||
|
window.global::<LyricsState>().set_translation_language_options(ModelRc::new(
|
||||||
|
VecModel::from(vec![
|
||||||
|
t("Auto (account language)"),
|
||||||
|
"English".into(),
|
||||||
|
"Español".into(),
|
||||||
|
"Français".into(),
|
||||||
|
"Deutsch".into(),
|
||||||
|
"Italiano".into(),
|
||||||
|
"Português".into(),
|
||||||
|
"Nederlands".into(),
|
||||||
|
"日本語".into(),
|
||||||
|
"Русский".into(),
|
||||||
|
]),
|
||||||
|
));
|
||||||
// Cast picker: per-renderer cap options (#638 fix 4) — option 0 embeds
|
// Cast picker: per-renderer cap options (#638 fix 4) — option 0 embeds
|
||||||
// the live global streaming-quality label, so this also re-reads
|
// the live global streaming-quality label, so this also re-reads
|
||||||
// ui_prefs (the Settings streaming-quality arm re-pushes on change).
|
// ui_prefs (the Settings streaming-quality arm re-pushes on change).
|
||||||
@@ -14130,6 +14147,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
lyrics::copy_current_lyrics(&weak);
|
lyrics::copy_current_lyrics(&weak);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// Translation toggle (Qobuz v10, sidebar floating button). OFF is
|
||||||
|
// pure UI (no network); ON resolves the target language and refetches
|
||||||
|
// the current track's lyrics with it — any gap toasts and reverts
|
||||||
|
// inside lyrics::enable_translation / translation_unavailable.
|
||||||
|
let runtime = app_runtime.clone();
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window.global::<LyricsState>().on_toggle_translation(move || {
|
||||||
|
if lyrics::translation_enabled() {
|
||||||
|
lyrics::disable_translation(&weak);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let runtime = runtime.clone();
|
||||||
|
let weak = weak.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let state = runtime.core().get_queue_state().await;
|
||||||
|
match state.current_track {
|
||||||
|
Some(track) => lyrics::enable_translation(weak, &track),
|
||||||
|
None => lyrics::translation_unavailable(&weak),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
// Settings > Offline lyrics-cache row: stats refresh on section
|
// Settings > Offline lyrics-cache row: stats refresh on section
|
||||||
// mount + clear action (F1: stats from the real per-user DB).
|
// mount + clear action (F1: stats from the real per-user DB).
|
||||||
|
|||||||
@@ -607,6 +607,9 @@ fn mirror_lyrics_gated(main: &AppWindow, mini: &MiniPlayerWindow) {
|
|||||||
d.set_active_index(s.get_active_index());
|
d.set_active_index(s.get_active_index());
|
||||||
d.set_line_progress(s.get_line_progress());
|
d.set_line_progress(s.get_line_progress());
|
||||||
d.set_fill_anim_ms(s.get_fill_anim_ms());
|
d.set_fill_anim_ms(s.get_fill_anim_ms());
|
||||||
|
// Translation toggle (v10): the mini surface has no button of its own —
|
||||||
|
// it follows the sidebar's bilingual state through the shared model.
|
||||||
|
d.set_show_translation(s.get_show_translation());
|
||||||
refresh_mini_segments(&d);
|
refresh_mini_segments(&d);
|
||||||
|
|
||||||
// Gate the lines-model copy on (status, line-count) so we don't thrash the
|
// Gate the lines-model copy on (status, line-count) so we don't thrash the
|
||||||
|
|||||||
@@ -988,6 +988,8 @@ mod tests {
|
|||||||
display_name: "Tester".into(),
|
display_name: "Tester".into(),
|
||||||
subscription_label: "studio".into(),
|
subscription_label: "studio".into(),
|
||||||
subscription_valid_until: None,
|
subscription_valid_until: None,
|
||||||
|
country_code: None,
|
||||||
|
language_code: None,
|
||||||
};
|
};
|
||||||
set_logged_in(&shared, &session);
|
set_logged_in(&shared, &session);
|
||||||
let s = shared.lock().unwrap();
|
let s = shared.lock().unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user