feat(lyrics): add SpotiFLAC fallback for Apple Music outages

This commit is contained in:
zarzet committed 2026-10-14 20:20:44 +07:00
1 parent 62ce404390
commit e049604b24
6 files changed
+372 -23

No files matched your search

+8
View File
@@ -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',
@@ -103,7 +103,7 @@ pub fn available_providers() -> Vec<AvailableProvider> {
"apple_music",
"Apple Music",
true,
"Apple Music synced lyrics",
"Apple Music synced lyrics with a SpotiFLAC-Mobile fallback",
),
(
"qqmusic",
@@ -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]";
@@ -24,6 +24,8 @@ pub struct PaxLine {
pub opposite_turn: bool,
pub background: bool,
pub background_text: Option<Vec<PaxDetail>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_lines: Option<Vec<PaxLine>>,
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<String, String> {
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<String, String> {
if let Ok(Some(value)) = json::decode::<Option<String>>(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 [
"<html>Error</html>",
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!({
@@ -10,6 +10,31 @@ static INDEX: LazyLock<Regex> =
static TOKEN: LazyLock<Regex> =
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<LyricsResponse, LyricsError>,
) -> Result<LyricsResponse, LyricsError> {
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<LyricsResponse, LyricsError> {
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();
@@ -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 {