diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index e81d7a3d..69448dfb 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -143,6 +143,14 @@ class AboutPage extends StatelessWidget { onTap: () => _launchUrl('https://squigly.link'), showDivider: true, ), + _AboutSettingsItem( + icon: Icons.lyrics_outlined, + title: 'SpotiFLAC-Mobile Lyrics', + subtitle: context.l10n.lyricsProviderAppleMusicDesc, + onTap: () => + _launchUrl('https://api.zarz.moe/apple-music/docs'), + showDivider: true, + ), _AboutSettingsItem( icon: Icons.lyrics_outlined, title: 'Paxsenix', diff --git a/rust_backend/crates/core/src/lyrics/config.rs b/rust_backend/crates/core/src/lyrics/config.rs index 6a6702ed..655e7411 100644 --- a/rust_backend/crates/core/src/lyrics/config.rs +++ b/rust_backend/crates/core/src/lyrics/config.rs @@ -103,7 +103,7 @@ pub fn available_providers() -> Vec { "apple_music", "Apple Music", true, - "Apple Music synced lyrics", + "Apple Music synced lyrics with a SpotiFLAC-Mobile fallback", ), ( "qqmusic", diff --git a/rust_backend/crates/core/src/lyrics/lrc.rs b/rust_backend/crates/core/src/lyrics/lrc.rs index 9a9ba151..ff632ee9 100644 --- a/rust_backend/crates/core/src/lyrics/lrc.rs +++ b/rust_backend/crates/core/src/lyrics/lrc.rs @@ -157,6 +157,7 @@ pub fn source_uses_proxy(source: &str) -> bool { !source.is_empty() && ![ "lrclib", + "spotiflac-mobile lyrics", "kugou direct", "qq music direct", "genius direct", @@ -249,6 +250,21 @@ pub fn with_metadata(lyrics: &LyricsResponse, track: &str, artist: &str) -> Stri mod tests { use super::*; + #[test] + fn first_party_lyrics_do_not_credit_paxsenix() { + for source in [ + "SpotiFLAC-Mobile Lyrics", + "SpotiFLAC-Mobile Lyrics (cached)", + ] { + let lyrics = LyricsResponse::from_text("[00:01.00]Example", source, source); + let saved = with_metadata(&lyrics, "Track", "Artist"); + assert!(!saved.contains("Paxsenix")); + assert_eq!(extract_source(&saved), source); + } + let lyrics = LyricsResponse::from_text("[00:01.00]Example", "Spotify", "Spotify"); + assert!(with_metadata(&lyrics, "Track", "Artist").contains("via Paxsenix API")); + } + #[test] fn zero_placeholders_are_plain_lyrics_without_losing_source_or_text() { let raw = "[ti:Example]\n[by:Example app (source: extension:example)]\n\n[00:00.00]First line\n[00:00.00]Second line\n[01:00.00]"; diff --git a/rust_backend/crates/core/src/lyrics/payloads.rs b/rust_backend/crates/core/src/lyrics/payloads.rs index 1bc8eb98..2a02ce94 100644 --- a/rust_backend/crates/core/src/lyrics/payloads.rs +++ b/rust_backend/crates/core/src/lyrics/payloads.rs @@ -24,6 +24,8 @@ pub struct PaxLine { pub opposite_turn: bool, pub background: bool, pub background_text: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_lines: Option>, pub endtime: isize, #[serde(skip_serializing_if = "String::is_empty")] pub agent: String, @@ -31,6 +33,7 @@ pub struct PaxLine { json::go_deserialize!(PaxLine { "text" => text, "timestamp" => timestamp, "oppositeturn" => opposite_turn, "background" => background, "backgroundtext" => background_text, "endtime" => endtime, + "backgroundlines" => background_lines, "agent" => agent, }); @@ -227,6 +230,72 @@ pub fn format_pax_content( output.trim().into() } +/// The first-party service's exported eLRC flattens backing vocals into lead +/// lines. Read its v1 JSON instead to preserve background groups and exact +/// word boundaries, including adjacent syllables and line-only lyrics. +pub fn format_spotiflac_apple( + raw: &str, + multi_person: bool, + word_timing: bool, +) -> Result { + let payload: ApplePayload = json::decode(raw) + .map_err(|error| format!("invalid SpotiFLAC-Mobile Lyrics response: {error}"))?; + let kind = json::field_name(&payload.kind); + if kind == "unsynced" && !payload.plain.trim().is_empty() { + return Ok(payload.plain.trim().into()); + } + if !matches!(kind.as_str(), "syllable" | "line" | "unsynced") { + return Err("unsupported SpotiFLAC-Mobile Lyrics sync type".into()); + } + let lines = payload + .content + .as_deref() + .ok_or("missing SpotiFLAC-Mobile Lyrics content")?; + let timed = kind != "unsynced"; + let words = word_timing && kind == "syllable"; + let mut output = String::new(); + for line in lines { + let details = line.text.as_deref().unwrap_or_default(); + if !details.iter().any(|detail| !detail.text.trim().is_empty()) { + continue; + } + if !output.is_empty() { + output.push('\n'); + } + if timed { + output.push_str(&lrc::timestamp(line.timestamp as i64)); + if multi_person { + output.push_str(if line.opposite_turn { "v2:" } else { "v1:" }); + } + } + append_detail(&mut output, details, words); + if timed && multi_person { + for background in line.background_lines.as_deref().unwrap_or_default() { + let details = background.text.as_deref().unwrap_or_default(); + if !details.iter().any(|detail| !detail.text.trim().is_empty()) { + continue; + } + output.push_str("\n[bg:"); + // Group bounds retain backing timing even in line-only mode. + output.push_str(&format!( + "<{}>", + lrc::timestamp_inline(background.timestamp as i64) + )); + append_detail(&mut output, details, words); + output.push_str(&format!( + "<{}>]", + lrc::timestamp_inline(background.endtime as i64) + )); + } + } + } + Ok(if timed && multi_person { + apple_vocal_sides(output.trim(), &payload) + } else { + output.trim().into() + }) +} + pub fn format_apple(raw: &str, multi_person: bool, word_timing: bool) -> Result { if let Ok(Some(value)) = json::decode::>(raw) && !value.trim().is_empty() @@ -577,6 +646,78 @@ pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: boo mod supplement_tests { use super::*; + #[test] + fn spotiflac_apple_preserves_syllables_and_separate_background_groups() { + let raw = serde_json::json!({ + "type": "Syllable", + "elrcMultiPerson": "[00:01.00]Lead\n[00:02.00]Echo", + "content": [{ + "timestamp": 1009, "endtime": 4000, "agent": "v3", "oppositeTurn": true, + "text": [ + {"text": "To", "part": true, "timestamp": 1009, "endtime": 1500}, + {"text": "gether", "timestamp": 1500, "endtime": 4000} + ], + "backgroundLines": [ + {"timestamp": 2000, "endtime": 2500, "text": [ + {"text": "Echo", "timestamp": 2000, "endtime": 2500} + ]}, + {"timestamp": 3000, "endtime": 3500, "text": [ + {"text": "Again", "timestamp": 3000, "endtime": 3500} + ]} + ] + }] + }) + .to_string(); + for words in [false, true] { + let text = format_spotiflac_apple(&raw, true, words).unwrap(); + assert!(text.starts_with("[00:01.009]v1:")); + assert_eq!(text.matches("[bg:").count(), 2); + let lyrics = LyricsResponse::from_text( + &text, + "SpotiFLAC-Mobile Lyrics", + "SpotiFLAC-Mobile Lyrics", + ); + assert_eq!(lyrics.lines().len(), 1); + assert_eq!(lyrics.lines()[0].start_time_ms, 1009); + if words { + assert!(text.contains("To<00:01.50><00:01.50>gether")); + } else { + assert!(text.contains("Together")); + } + let solo = format_spotiflac_apple(&raw, false, words).unwrap(); + assert!(!solo.contains("[bg:")); + assert!(!solo.contains("v1:")); + } + } + + #[test] + fn spotiflac_apple_handles_line_only_unsynced_and_invalid_responses() { + let mut raw = serde_json::json!({ + "type": "Line", "content": [{ + "timestamp": 1500, "text": [{"text": "Whole"}, {"text": "line"}] + }] + }); + assert_eq!( + format_spotiflac_apple(&raw.to_string(), false, true).unwrap(), + "[00:01.50]Whole line" + ); + raw["type"] = "Unsynced".into(); + raw["content"][0]["timestamp"] = serde_json::Value::Null; + let text = format_spotiflac_apple(&raw.to_string(), true, true).unwrap(); + assert_eq!(text, "Whole line"); + assert_eq!( + LyricsResponse::from_text(&text, "Example", "Example").sync_type, + "UNSYNCED" + ); + for raw in [ + "Error", + r#"{"error":{"code":"APPLE_AUTH_REQUIRED"}}"#, + "{}", + ] { + assert!(format_spotiflac_apple(raw, true, true).is_err()); + } + } + #[test] fn apple_agents_restore_vocal_sides_without_reformatting_word_times() { let mut raw = serde_json::json!({ diff --git a/rust_backend/crates/providers/src/lyrics/builtin/apple.rs b/rust_backend/crates/providers/src/lyrics/builtin/apple.rs index 03457f69..74882412 100644 --- a/rust_backend/crates/providers/src/lyrics/builtin/apple.rs +++ b/rust_backend/crates/providers/src/lyrics/builtin/apple.rs @@ -10,6 +10,31 @@ static INDEX: LazyLock = static TOKEN: LazyLock = LazyLock::new(|| Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap()); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AppleLyricsSource { + Paxsenix, + SpotiFlac, +} + +fn apple_with_fallback( + check: &Check<'_>, + mut fetch: impl FnMut(AppleLyricsSource) -> Result, +) -> Result { + check().map_err(LyricsError::Cancelled)?; + match fetch(AppleLyricsSource::Paxsenix) { + Err(primary @ (LyricsError::Unavailable(_) | LyricsError::Other(_))) => { + check().map_err(LyricsError::Cancelled)?; + match fetch(AppleLyricsSource::SpotiFlac) { + // An outage followed by a missing backup must not be cached + // as proof that the primary has no lyrics for this song. + Err(LyricsError::NotFound(_)) => Err(primary), + result => result, + } + } + result => result, + } +} + impl BuiltinLyricsClient { fn lock_apple_token<'a>( &'a self, @@ -141,14 +166,44 @@ impl BuiltinLyricsClient { }) .filter(|(id, _)| !id.trim().is_empty()) .ok_or_else(|| LyricsError::NotFound("no songs found on apple music".into()))?; - let mut get = Request::new("https://lyrics.paxsenix.org/apple-music/lyrics"); - get.timeout = Duration::from_secs(20); - get.params = pairs(&[("id", selected.0.trim())]); - let raw = text_from_bytes(&self.http.get(get, check)?.body); + apple_with_fallback(check, |source| { + self.apple_lyrics(selected.0.trim(), source, request, check) + }) + } + + fn apple_lyrics( + &self, + id: &str, + source: AppleLyricsSource, + request: &SearchRequest, + check: &Check<'_>, + ) -> Result { + let mut get = match source { + AppleLyricsSource::Paxsenix => { + let mut get = Request::new("https://lyrics.paxsenix.org/apple-music/lyrics"); + get.timeout = Duration::from_secs(20); + get.params = pairs(&[("id", id)]); + get + } + AppleLyricsSource::SpotiFlac => { + let mut get = Request::new("https://api.zarz.moe/apple-music/lyrics"); + get.timeout = Duration::from_secs(65); + // Let the backup use its account's storefront, since lyric + // availability differs between catalog regions. + get.params = pairs(&[("id", id), ("v", "1")]); + get + } + }; + get.allowed = &[200, 404]; + let response = self.http.get(get, check)?; + if response.status == 404 { + return Err(LyricsError::NotFound("apple music lyrics not found".into())); + } + let raw = text_from_bytes(&response.body); let raw = raw.trim(); - if raw.is_empty() { - return Err(LyricsError::Other( - "empty lyrics response from apple music".into(), + if raw.is_empty() || raw.starts_with('<') { + return Err(LyricsError::Unavailable( + "invalid lyrics response from apple music".into(), )); } if let Some(message) = errors::detect_payload(raw) { @@ -157,16 +212,26 @@ impl BuiltinLyricsClient { format!("apple music proxy returned non-lyric payload: {message}"), )); } - let text = match payloads::format_apple( - raw, - request.options.multi_person_word_by_word, - request.options.apple_elrc_word_sync, - ) { - Ok(text) => text, - Err(error) if raw.starts_with(['{', '[']) => return Err(LyricsError::Other(error)), - Err(_) => raw.into(), + let multi_person = request.options.multi_person_word_by_word; + let word_timing = request.options.apple_elrc_word_sync; + let (text, credit) = match source { + AppleLyricsSource::Paxsenix => { + let text = match payloads::format_apple(raw, multi_person, word_timing) { + Ok(text) => text, + Err(error) if raw.starts_with(['{', '[']) => { + return Err(LyricsError::Unavailable(error)); + } + Err(_) => raw.into(), + }; + (text, "Apple Music") + } + AppleLyricsSource::SpotiFlac => ( + payloads::format_spotiflac_apple(raw, multi_person, word_timing) + .map_err(LyricsError::Unavailable)?, + "SpotiFLAC-Mobile Lyrics", + ), }; - let mut lyrics = from_text(&text, "Apple Music", "Apple Music")?; + let mut lyrics = from_text(&text, "Apple Music", credit)?; payloads::apple_supplements(raw, &mut lyrics); Ok(lyrics) } @@ -184,6 +249,123 @@ mod tests { } } + #[test] + fn primary_success_never_calls_backup() { + let expected = LyricsResponse::from_text("[00:01.00]Example", "Apple Music", "Apple Music"); + let mut calls = Vec::new(); + let actual = apple_with_fallback(&|| Ok(()), |source| { + calls.push(source); + Ok(expected.clone()) + }); + assert_eq!(actual, Ok(expected)); + assert_eq!(calls, [AppleLyricsSource::Paxsenix]); + } + + #[test] + fn outages_try_backup_after_primary_and_keep_source_credit() { + for error in [ + LyricsError::Unavailable("timeout".into()), + LyricsError::Unavailable("HTTP 503".into()), + LyricsError::Other("HTTP 403".into()), + LyricsError::Unavailable("invalid payload".into()), + ] { + let mut calls = Vec::new(); + let result = apple_with_fallback(&|| Ok(()), |source| { + calls.push(source); + match source { + AppleLyricsSource::Paxsenix => Err(error.clone()), + AppleLyricsSource::SpotiFlac => Ok(LyricsResponse::from_text( + "[00:01.00]Example", + "Apple Music", + "SpotiFLAC-Mobile Lyrics", + )), + } + }) + .unwrap(); + assert_eq!( + calls, + [AppleLyricsSource::Paxsenix, AppleLyricsSource::SpotiFlac] + ); + assert_eq!(result.provider, "Apple Music"); + assert_eq!(result.source, "SpotiFLAC-Mobile Lyrics"); + let saved = spotiflac_core::lyrics::lrc::with_metadata(&result, "Track", "Artist"); + assert!(!saved.contains("Paxsenix")); + } + } + + #[test] + fn missing_lyrics_and_cancellation_do_not_start_backup() { + for error in [ + LyricsError::NotFound("no lyrics".into()), + LyricsError::Cancelled("cancelled".into()), + LyricsError::Recursive("recursive".into()), + ] { + let mut calls = Vec::new(); + let result = apple_with_fallback(&|| Ok(()), |source| { + calls.push(source); + Err(error.clone()) + }); + assert_eq!(result, Err(error)); + assert_eq!(calls, [AppleLyricsSource::Paxsenix]); + } + let checks = AtomicUsize::new(0); + let mut calls = Vec::new(); + let result = apple_with_fallback( + &|| { + if checks.fetch_add(1, Ordering::AcqRel) > 0 { + Err("cancel before backup".into()) + } else { + Ok(()) + } + }, + |source| { + calls.push(source); + Err(LyricsError::Unavailable("timeout".into())) + }, + ); + assert_eq!( + result, + Err(LyricsError::Cancelled("cancel before backup".into())) + ); + assert_eq!(calls, [AppleLyricsSource::Paxsenix]); + } + + #[test] + fn missing_backup_does_not_turn_primary_outage_into_negative_cache() { + let result = apple_with_fallback(&|| Ok(()), |source| match source { + AppleLyricsSource::Paxsenix => Err(LyricsError::Unavailable("HTTP 503".into())), + AppleLyricsSource::SpotiFlac => Err(LyricsError::NotFound("no lyrics".into())), + }); + assert_eq!(result, Err(LyricsError::Unavailable("HTTP 503".into()))); + } + + #[test] + #[ignore = "requires live Apple catalog and SpotiFLAC-Mobile Lyrics access"] + fn live_apple_backup_after_primary_failure() { + let network = NetworkService::new().unwrap(); + let client = BuiltinLyricsClient::with_endpoints( + &network, + "", + Arc::new(Resolver), + BTreeMap::from([( + "https://lyrics.paxsenix.org".into(), + "https://127.0.0.1:9".into(), + )]), + ) + .unwrap(); + let request = SearchRequest { + track: "Never Gonna Give You Up".into(), + artist: "Rick Astley".into(), + duration: 213.0, + ..SearchRequest::default() + }; + let lyrics = client.apple(&request, &|| Ok(())).unwrap(); + assert!(lyrics.has_usable_text()); + assert!(lyrics.is_synced()); + assert_eq!(lyrics.provider, "Apple Music"); + assert_eq!(lyrics.source, "SpotiFLAC-Mobile Lyrics"); + } + #[test] fn waiting_for_another_token_refresh_remains_cancellable() { let network = NetworkService::new().unwrap(); diff --git a/rust_backend/crates/providers/src/lyrics/cache.rs b/rust_backend/crates/providers/src/lyrics/cache.rs index 28cf0dc6..e529f3b0 100644 --- a/rust_backend/crates/providers/src/lyrics/cache.rs +++ b/rust_backend/crates/providers/src/lyrics/cache.rs @@ -13,7 +13,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub const MAX_ENTRIES: usize = 500; pub const TTL: Duration = Duration::from_secs(24 * 60 * 60); const MAX_PERSISTED_BYTES: u64 = 64 << 20; -const SNAPSHOT_VERSION: u32 = 5; +const SNAPSHOT_VERSION: u32 = 6; #[derive(Clone)] struct Entry { @@ -145,9 +145,8 @@ impl LyricsCache { let Some(response) = entry.response else { continue; }; - // Older versions lost Apple supplements or collapsed group vocals - // into the second singer instead of preserving the vocal side. - // Refetch once, preserving other providers' caches. + // Refetch legacy Apple results once to apply current source + // priority and vocal timing, preserving other providers' caches. if loaded.version < SNAPSHOT_VERSION && response.provider == "Apple Music" { continue; } @@ -333,8 +332,11 @@ mod tests { fn current_snapshot_restores_supplementary_text() { let file = tempfile::NamedTempFile::new().unwrap(); let now = UNIX_EPOCH + Duration::from_secs(100); - let mut lyrics = - LyricsResponse::from_text("[00:01.009]Original", "Apple Music", "Apple Music"); + let mut lyrics = LyricsResponse::from_text( + "[00:01.009]Original", + "Apple Music", + "SpotiFLAC-Mobile Lyrics", + ); let line = &mut lyrics.lines.as_mut().unwrap()[0]; line.romanization = Some("Romanized".into()); line.romanization_words = Some(vec![spotiflac_core::lyrics::LyricsWord {