feat(lyrics): add lrc.red and BetterLyrics, split SpotiFLAC-Mobile Lyrics

Add two keyless providers that serve Apple Music TTML: lrc.red, matched by
recording, and BetterLyrics, which answers songs it has already cached.
TTML is read into the first-party Apple line structure, so vocal sides,
backing groups, word timing, translations and romanization share one eLRC
formatter. The XML depth guard moves to core so both readers use it.

SpotiFLAC-Mobile Lyrics becomes its own provider instead of a fallback
inside Apple Music. New defaults are Apple Music, lrc.red, SpotiFLAC-Mobile
Lyrics, LyricsPlus and LRCLIB. Migration 13 moves untouched defaults to the
new list and keeps the former fallback right after Apple Music in custom
orders. Replace the dead LyricsPlus Workers mirror.
This commit is contained in:
zarzet committed 2026-10-18 19:24:31 +07:00
1 parent 16cae16562
commit 56ad01ec2c
24 files changed
+784 -132

No files matched your search

+12
View File
@@ -4360,6 +4360,18 @@
"@lyricsProviderLyricsPlusDesc": {
"description": "Description for LyricsPlus provider"
},
"lyricsProviderSpotiflacLyricsDesc": "Word-by-word Apple Music lyrics from SpotiFLAC-Mobile",
"@lyricsProviderSpotiflacLyricsDesc": {
"description": "Description for the first-party SpotiFLAC-Mobile Lyrics provider"
},
"lyricsProviderLrcRedDesc": "Word-by-word Apple Music lyrics, matched by recording",
"@lyricsProviderLrcRedDesc": {
"description": "Description for lrc.red provider"
},
"lyricsProviderBetterLyricsDesc": "Word-by-word Apple Music lyrics for popular songs",
"@lyricsProviderBetterLyricsDesc": {
"description": "Description for BetterLyrics provider, which only answers songs it has already cached"
},
"lyricsProviderExtensionDesc": "Extension provider",
"@lyricsProviderExtensionDesc": {
"description": "Generic description for extension-based lyrics providers"
+12
View File
@@ -4366,6 +4366,18 @@
"@lyricsProviderLyricsPlusDesc": {
"description": "Description for LyricsPlus provider"
},
"lyricsProviderSpotiflacLyricsDesc": "Lirik Apple Music per kata dari SpotiFLAC-Mobile",
"@lyricsProviderSpotiflacLyricsDesc": {
"description": "Description for the first-party SpotiFLAC-Mobile Lyrics provider"
},
"lyricsProviderLrcRedDesc": "Lirik Apple Music per kata, dicocokkan per rekaman",
"@lyricsProviderLrcRedDesc": {
"description": "Description for lrc.red provider"
},
"lyricsProviderBetterLyricsDesc": "Lirik Apple Music per kata untuk lagu populer",
"@lyricsProviderBetterLyricsDesc": {
"description": "Description for BetterLyrics provider, which only answers songs it has already cached"
},
"lyricsProviderExtensionDesc": "Provider ekstensi",
"@lyricsProviderExtensionDesc": {
"description": "Generic description for extension-based lyrics providers"
+7 -1
View File
@@ -257,7 +257,13 @@ class AppSettings {
this.localLibraryShowDuplicates = true,
this.localLibraryAutoScan = 'off',
this.hasCompletedTutorial = false,
this.lyricsProviders = const ['apple_music', 'lrclib'],
this.lyricsProviders = const [
'apple_music',
'lrcred',
'spotiflac_lyrics',
'lyricsplus',
'lrclib',
],
this.lyricsIncludeTranslationNetease = false,
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = true,
+7 -1
View File
@@ -131,7 +131,13 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
(json['lyricsProviders'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const ['apple_music', 'lrclib'],
const [
'apple_music',
'lrcred',
'spotiflac_lyrics',
'lyricsplus',
'lrclib',
],
lyricsIncludeTranslationNetease:
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
lyricsIncludeRomanizationNetease:
+24 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -18,7 +19,8 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _settingsCorruptBackupKey = 'app_settings_corrupt_backup';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 12;
const _currentMigrationVersion = 13;
const _legacyDefaultLyricsProviders = ['apple_music', 'lrclib'];
const _spotifyClientSecretKey = 'spotify_client_secret';
const _retiredBuiltInProviderIds = {'deezer', 'qobuz', 'tidal', 'youtube'};
final _log = AppLogger('SettingsProvider');
@@ -315,6 +317,27 @@ class SettingsNotifier extends Notifier<AppSettings> {
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepthOnly,
);
}
// Migration 13: SpotiFLAC-Mobile Lyrics left Apple Music's Paxsenix
// fallback chain. Untouched defaults move to the new defaults; a custom
// order keeps the former fallback right after Apple Music.
if (lastMigration < 13) {
final providers = state.lyricsProviders;
if (listEquals(providers, _legacyDefaultLyricsProviders)) {
state = state.copyWith(
lyricsProviders: const AppSettings().lyricsProviders,
);
} else if (providers.contains('apple_music') &&
!providers.contains('spotiflac_lyrics')) {
state = state.copyWith(
lyricsProviders: [
for (final provider in providers) ...[
provider,
if (provider == 'apple_music') 'spotiflac_lyrics',
],
],
);
}
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
}
@@ -27,6 +27,7 @@ class _LyricsProviderPriorityPageState
'netease',
'musixmatch',
'apple_music',
'spotiflac_lyrics',
'qqmusic',
'spotify',
'deezer',
@@ -34,6 +35,8 @@ class _LyricsProviderPriorityPageState
'kugou',
'genius',
'lyricsplus',
'lrcred',
'betterlyrics',
];
late List<String> _enabledProviders;
@@ -226,6 +229,12 @@ class _LyricsProviderPriorityPageState
description: context.l10n.lyricsProviderAppleMusicDesc,
icon: Icons.music_note,
);
case 'spotiflac_lyrics':
return _LyricsProviderInfo(
name: 'SpotiFLAC-Mobile Lyrics',
description: context.l10n.lyricsProviderSpotiflacLyricsDesc,
icon: Icons.verified_outlined,
);
case 'qqmusic':
return _LyricsProviderInfo(
name: 'QQ Music',
@@ -268,6 +277,18 @@ class _LyricsProviderPriorityPageState
description: context.l10n.lyricsProviderLyricsPlusDesc,
icon: Icons.lyrics_outlined,
);
case 'lrcred':
return _LyricsProviderInfo(
name: 'lrc.red',
description: context.l10n.lyricsProviderLrcRedDesc,
icon: Icons.fingerprint,
);
case 'betterlyrics':
return _LyricsProviderInfo(
name: 'BetterLyrics',
description: context.l10n.lyricsProviderBetterLyricsDesc,
icon: Icons.local_fire_department_outlined,
);
default:
return _LyricsProviderInfo(
name:
@@ -198,6 +198,7 @@ class LyricsSettingsPage extends ConsumerWidget {
'netease': 'Netease',
'musixmatch': 'Musixmatch',
'apple_music': 'Apple Music',
'spotiflac_lyrics': 'SpotiFLAC-Mobile Lyrics',
'qqmusic': 'QQ Music',
'spotify': 'Spotify',
'deezer': 'Deezer',
@@ -205,6 +206,8 @@ class LyricsSettingsPage extends ConsumerWidget {
'kugou': 'Kugou',
'genius': 'Genius',
'lyricsplus': 'LyricsPlus',
'lrcred': 'lrc.red',
'betterlyrics': 'BetterLyrics',
};
String _getLyricsProvidersSubtitle(
+1
View File
@@ -2475,6 +2475,7 @@ dependencies = [
"claxon",
"image",
"regex",
"roxmltree",
"rustix",
"serde",
"serde_json",
+1
View File
@@ -17,6 +17,7 @@ unicode-general-category.workspace = true
chrono.workspace = true
rustix.workspace = true
claxon.workspace = true
roxmltree = "=0.21.1"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tz-rs.workspace = true
+1
View File
@@ -18,4 +18,5 @@ pub mod progress;
pub mod resolver;
pub mod tags;
mod text;
pub mod xml;
pub use text::json_surrogates as normalize_json_surrogates;
+29 -2
View File
@@ -2,12 +2,19 @@ use crate::matching::lowercase;
use serde::Serialize;
use std::collections::HashSet;
pub const DEFAULT_PROVIDERS: &[&str] = &["apple_music", "lrclib"];
pub const DEFAULT_PROVIDERS: &[&str] = &[
"apple_music",
"lrcred",
"spotiflac_lyrics",
"lyricsplus",
"lrclib",
];
pub const BUILTIN_PROVIDERS: &[&str] = &[
"lrclib",
"netease",
"musixmatch",
"apple_music",
"spotiflac_lyrics",
"qqmusic",
"spotify",
"deezer",
@@ -15,6 +22,8 @@ pub const BUILTIN_PROVIDERS: &[&str] = &[
"kugou",
"genius",
"lyricsplus",
"lrcred",
"betterlyrics",
];
#[derive(Clone, Debug, PartialEq, Serialize)]
@@ -103,7 +112,13 @@ pub fn available_providers() -> Vec<AvailableProvider> {
"apple_music",
"Apple Music",
true,
"Apple Music synced lyrics with a SpotiFLAC-Mobile fallback",
"Apple Music synced lyrics via Paxsenix",
),
(
"spotiflac_lyrics",
"SpotiFLAC-Mobile Lyrics",
false,
"Word-by-word Apple Music lyrics from SpotiFLAC-Mobile",
),
(
"qqmusic",
@@ -122,6 +137,18 @@ pub fn available_providers() -> Vec<AvailableProvider> {
true,
"Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)",
),
(
"lrcred",
"lrc.red",
false,
"Word-by-word Apple Music lyrics matched by recording",
),
(
"betterlyrics",
"BetterLyrics",
false,
"Word-by-word Apple Music lyrics for popular songs",
),
]
.into_iter()
.map(
@@ -161,6 +161,8 @@ pub fn source_uses_proxy(source: &str) -> bool {
"kugou direct",
"qq music direct",
"genius direct",
"lrc.red",
"betterlyrics",
"extension:",
"heuristic",
]
@@ -5,6 +5,9 @@ use serde_json::value::RawValue;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
mod ttml;
pub use ttml::ttml_lyrics;
#[derive(Clone, Debug, Default, Serialize)]
pub struct PaxDetail {
pub text: String,
@@ -240,17 +243,33 @@ pub fn format_spotiflac_apple(
) -> Result<String, String> {
let payload: ApplePayload = json::decode(raw)
.map_err(|error| format!("invalid SpotiFLAC-Mobile Lyrics response: {error}"))?;
format_apple_payload(
&payload,
"SpotiFLAC-Mobile Lyrics",
multi_person,
word_timing,
)
}
/// Shared by every source that exposes Apple's own line structure, either as
/// first-party JSON or as the TTML document it is derived from.
fn format_apple_payload(
payload: &ApplePayload,
label: &str,
multi_person: bool,
word_timing: bool,
) -> Result<String, String> {
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());
return Err(format!("unsupported {label} sync type"));
}
let lines = payload
.content
.as_deref()
.ok_or("missing SpotiFLAC-Mobile Lyrics content")?;
.ok_or_else(|| format!("missing {label} content"))?;
let timed = kind != "unsynced";
let words = word_timing && kind == "syllable";
let mut output = String::new();
@@ -290,7 +309,7 @@ pub fn format_spotiflac_apple(
}
}
Ok(if timed && multi_person {
apple_vocal_sides(output.trim(), &payload)
apple_vocal_sides(output.trim(), payload)
} else {
output.trim().into()
})
@@ -0,0 +1,349 @@
//! Apple's TTML lyric documents, as served by lrc.red and BetterLyrics. They
//! are read into the first-party line structure so that both share a single
//! eLRC formatter, including vocal sides and backing groups.
use super::{AppleAgent, AppleMetadata, ApplePayload, PaxDetail, PaxLine, format_apple_payload};
use crate::lyrics::LyricsResponse;
use roxmltree::{Document, Node};
use std::collections::{BTreeMap, HashMap};
const META_NS: &str = "http://www.w3.org/ns/ttml#metadata";
const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
pub fn ttml_lyrics(
raw: &str,
provider: &str,
source: &str,
multi_person: bool,
word_timing: bool,
) -> Result<LyricsResponse, String> {
crate::xml::check_depth(raw)?;
let document = Document::parse(raw).map_err(|error| format!("invalid TTML lyrics: {error}"))?;
let root = document.root_element();
if root.tag_name().name() != "tt" {
return Err("not a TTML lyrics document".into());
}
let mut keys = HashMap::new();
let payload = payload(root, &mut keys);
let text = format_apple_payload(&payload, "TTML lyrics", multi_person, word_timing)?;
let mut lyrics = LyricsResponse::from_text(&text, provider, source);
if lyrics.sync_type == "LINE_SYNCED" {
let romanization = supplement(root, &keys, "transliteration", |lang| {
lang.split('-')
.any(|part| part.eq_ignore_ascii_case("Latn"))
});
let translation = supplement(root, &keys, "translation", |lang| {
lang.split('-')
.next()
.is_some_and(|part| part.eq_ignore_ascii_case("en"))
});
for line in lyrics.lines.iter_mut().flatten() {
if let Some(text) = romanization.get(&line.start_time_ms) {
line.romanization = Some(text.clone());
}
if let Some(text) = translation.get(&line.start_time_ms) {
line.translation = Some(text.clone());
}
}
}
Ok(lyrics)
}
fn payload(root: Node<'_, '_>, keys: &mut HashMap<String, i64>) -> ApplePayload {
let agents = root
.descendants()
.filter(|node| node.has_tag_name((META_NS, "agent")))
.filter_map(|node| {
Some(AppleAgent {
id: node.attribute((XML_NS, "id"))?.into(),
kind: node.attribute("type").unwrap_or_default().into(),
})
})
.collect();
let (mut content, mut line_timed) = (Vec::new(), false);
for paragraph in root
.descendants()
.filter(|node| node.is_element() && node.tag_name().name() == "p")
{
let (mut lead, mut background) = (Vec::new(), Vec::new());
collect(paragraph, &mut lead, &mut background, false);
let begin = clock(paragraph.attribute("begin"));
line_timed |= begin.is_some();
let start = begin.or_else(|| first_time(&lead)).unwrap_or_default();
let end = clock(paragraph.attribute("end"))
.or_else(|| last_time(&lead))
.unwrap_or(start);
if let Some(key) = attribute(paragraph, "key") {
keys.insert(key.into(), start);
}
let agent = paragraph
.attribute((META_NS, "agent"))
.unwrap_or_default()
.to_owned();
let background_lines = (!background.is_empty()).then(|| {
vec![PaxLine {
timestamp: first_time(&background).unwrap_or(start) as isize,
endtime: last_time(&background).unwrap_or(end) as isize,
text: Some(background),
..PaxLine::default()
}]
});
content.push(PaxLine {
text: Some(lead),
timestamp: start as isize,
endtime: end as isize,
opposite_turn: agent.eq_ignore_ascii_case("v2"),
background_lines,
agent,
..PaxLine::default()
});
}
content.sort_by_key(|line| line.timestamp);
let word_timed = content.iter().any(|line| {
line.text
.iter()
.flatten()
.any(|detail| detail.timestamp.is_some())
});
ApplePayload {
kind: match (word_timed, line_timed) {
(true, _) => "Syllable",
(false, true) => "Line",
(false, false) => "Unsynced",
}
.into(),
content: Some(content),
metadata: Some(AppleMetadata {
agents: Some(agents),
}),
..ApplePayload::default()
}
}
/// Spans are syllables: whitespace between them, not the span boundary,
/// marks the end of a word. Roles other than backing vocals are not sung.
fn collect(
node: Node<'_, '_>,
lead: &mut Vec<PaxDetail>,
background: &mut Vec<PaxDetail>,
in_background: bool,
) {
for child in node.children() {
let target = if in_background {
&mut *background
} else {
&mut *lead
};
if child.is_text() {
push(target, child.text().unwrap_or_default(), None, None);
continue;
}
if !child.is_element() {
continue;
}
match child.attribute((META_NS, "role")) {
Some("x-bg") => collect(child, lead, background, true),
Some(_) => {}
None if child.children().any(|node| node.is_element()) => {
collect(child, lead, background, in_background);
}
None => {
let text: String = child
.descendants()
.filter_map(|node| node.text().filter(|_| node.is_text()))
.collect();
push(
target,
&text,
clock(child.attribute("begin")),
clock(child.attribute("end")),
);
}
}
}
}
fn push(target: &mut Vec<PaxDetail>, text: &str, begin: Option<i64>, end: Option<i64>) {
if text.starts_with(char::is_whitespace)
&& let Some(previous) = target.last_mut()
{
previous.part = false;
}
let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
if words.is_empty() {
return;
}
target.push(PaxDetail {
text: words,
part: !text.ends_with(char::is_whitespace),
timestamp: begin.map(|time| time as isize),
endtime: end.map(|time| time as isize),
});
}
fn first_time(details: &[PaxDetail]) -> Option<i64> {
details
.iter()
.find_map(|detail| detail.timestamp)
.map(|time| time as i64)
}
fn last_time(details: &[PaxDetail]) -> Option<i64> {
details
.iter()
.rev()
.find_map(|detail| detail.endtime)
.map(|time| time as i64)
}
/// Vendor attributes such as `itunes:key` and `lrc:key` differ only in prefix.
fn attribute<'a>(node: Node<'a, '_>, name: &str) -> Option<&'a str> {
node.attributes()
.find(|attribute| attribute.name() == name)
.map(|attribute| attribute.value())
}
/// TTML clock values: `12.5`, `1:02.345`, `00:01:02.345` or `12.5s`.
fn clock(value: Option<&str>) -> Option<i64> {
let value = value?.trim();
let value = value.strip_suffix('s').unwrap_or(value);
let parts: Vec<_> = value.split(':').collect();
if parts.len() > 3 {
return None;
}
let (last, units) = parts.split_last()?;
let mut seconds: f64 = last.parse().ok()?;
for (unit, scale) in units.iter().rev().zip([60.0, 3600.0]) {
seconds += unit.parse::<u32>().ok()? as f64 * scale;
}
(seconds.is_finite() && seconds >= 0.0).then(|| (seconds * 1000.0).round() as i64)
}
/// The best covering group of head translations or transliterations, keyed
/// by the start of the line each `text for=` entry refers to.
fn supplement(
root: Node<'_, '_>,
keys: &HashMap<String, i64>,
group: &str,
accepts_language: impl Fn(&str) -> bool,
) -> BTreeMap<i64, String> {
let mut best = BTreeMap::new();
for node in root
.descendants()
.filter(|node| node.is_element() && node.tag_name().name() == group)
{
if !node
.attribute((XML_NS, "lang"))
.is_some_and(&accepts_language)
{
continue;
}
let lines: BTreeMap<_, _> = node
.children()
.filter(|node| node.is_element() && node.tag_name().name() == "text")
.filter_map(|text| {
let start = *keys.get(text.attribute("for")?)?;
let value: String = text
.descendants()
.filter_map(|node| node.text().filter(|_| node.is_text()))
.collect();
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
(!value.is_empty()).then_some((start, value))
})
.collect();
if lines.len() > best.len() {
best = lines;
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
const HEAD: &str = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata" xmlns:itunes="http://music.apple.com/lyric-ttml-internal" itunes:timing="Word" xml:lang="ko">"#;
fn lyrics(body: &str, multi_person: bool, word_timing: bool) -> LyricsResponse {
ttml_lyrics(
&format!("{HEAD}{body}</tt>"),
"Provider",
"Source",
multi_person,
word_timing,
)
.unwrap()
}
#[test]
fn syllables_join_inside_words_and_keep_word_times() {
let body = r#"<body><div><p begin="1.000" end="3.000" itunes:key="L1" ttm:agent="v1"><span begin="1.000" end="1.500">long</span> <span begin="1.500" end="2.000">e</span><span begin="2.000" end="3.000">nough</span></p></div></body>"#;
let line = lyrics(body, false, false);
assert_eq!(line.sync_type, "LINE_SYNCED");
assert_eq!(line.lines()[0].start_time_ms, 1000);
assert_eq!(line.lines()[0].words, "long enough");
let text = format_apple_payload(
&payload(
Document::parse(&format!("{HEAD}{body}</tt>"))
.unwrap()
.root_element(),
&mut HashMap::new(),
),
"TTML lyrics",
false,
true,
)
.unwrap();
assert_eq!(
text,
"[00:01.00]<00:01.00>long <00:01.50><00:01.50>e<00:02.00><00:02.00>nough<00:03.00>"
);
}
#[test]
fn duet_sides_and_background_groups_follow_apple_formatting() {
let body = r#"<head><metadata><ttm:agent type="person" xml:id="v1"/><ttm:agent type="person" xml:id="v2"/><ttm:agent type="group" xml:id="v1000"/></metadata></head><body><div><p begin="1.000" end="2.000" ttm:agent="v1"><span begin="1.000" end="2.000">First</span></p><p begin="3.000" end="5.000" ttm:agent="v2"><span ttm:role="x-bg"><span begin="3.000" end="3.500">(Yes)</span></span> <span begin="3.200" end="5.000">Second</span></p><p begin="6.000" end="7.000" ttm:agent="v1000"><span begin="6.000" end="7.000">Both</span></p></div></body>"#;
let text = format_apple_payload(
&payload(
Document::parse(&format!("{HEAD}{body}</tt>"))
.unwrap()
.root_element(),
&mut HashMap::new(),
),
"TTML lyrics",
true,
false,
)
.unwrap();
assert_eq!(
text,
"[00:01.00]v1:First\n[00:03.00]v2:Second\n[bg:<00:03.00>(Yes)<00:03.50>]\n[00:06.00]v1:Both"
);
}
#[test]
fn line_timed_documents_and_head_translations() {
let body = r#"<head><metadata><iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal"><translations><translation xml:lang="en-US"><text for="L1">Oppa is Gangnam style</text></translation></translations><transliterations><transliteration xml:lang="ko-Latn"><text for="L1"><span>oppan</span> <span>gangnam</span></text></transliteration></transliterations></iTunesMetadata></metadata></head><body><div><p begin="00:00:01.250" end="00:00:03.000" itunes:key="L1">오빤 강남 스타일</p><p begin="00:00:03.000" end="00:00:04.000" itunes:key="L2">Eh</p></div></body>"#;
let result = lyrics(body, false, true);
assert_eq!(result.sync_type, "LINE_SYNCED");
let first = &result.lines()[0];
assert_eq!(first.start_time_ms, 1250);
assert_eq!(first.words, "오빤 강남 스타일");
assert_eq!(first.translation.as_deref(), Some("Oppa is Gangnam style"));
assert_eq!(first.romanization.as_deref(), Some("oppan gangnam"));
assert_eq!(result.lines()[1].translation, None);
}
#[test]
fn clock_values_and_invalid_documents() {
assert_eq!(clock(Some("27.395")), Some(27_395));
assert_eq!(clock(Some("3:21.570")), Some(201_570));
assert_eq!(clock(Some("01:00:00.5")), Some(3_600_500));
assert_eq!(clock(Some("12.5s")), Some(12_500));
assert_eq!(clock(Some("-1")), None);
assert_eq!(clock(Some("a:1")), None);
assert!(ttml_lyrics("<html/>", "P", "S", true, true).is_err());
assert!(ttml_lyrics("not xml", "P", "S", true, true).is_err());
let deep = format!("{}{}", "<span>".repeat(64), "</span>".repeat(64));
assert!(ttml_lyrics(&format!("{HEAD}{deep}</tt>"), "P", "S", true, true).is_err());
}
}
@@ -1,5 +1,5 @@
//! Bounds for the recursive tokenizer used by our native XML readers.
pub(crate) fn check_depth(input: &str) -> Result<(), String> {
pub fn check_depth(input: &str) -> Result<(), String> {
// roxmltree's tokenizer recurses through nested elements. Guard its input
// before parsing so pathological metadata returns an error, not a native
// stack overflow. Ordinary TTML is only a handful of elements deep.
@@ -48,7 +48,7 @@ pub(crate) fn check_depth(input: &str) -> Result<(), String> {
Ok(())
}
pub(crate) fn markup_end(input: &str, doctype: bool) -> Option<usize> {
pub fn markup_end(input: &str, doctype: bool) -> Option<usize> {
let bytes = input.as_bytes();
let (mut quote, mut brackets, mut position) = (None, 0_usize, 0);
while position < bytes.len() {
-1
View File
@@ -20,7 +20,6 @@ mod native_listing;
mod native_lyrics;
mod native_playlist_write;
mod native_playlists;
mod native_xml;
mod network_tags;
mod progress;
mod repository;
@@ -62,7 +62,7 @@ pub(crate) fn execute(request: &Value, bytes: Option<&[u8]>) -> Result<Value, St
Ok(())
};
if request["protocol"] == "webdav" {
crate::native_xml::check_depth(&body)?;
spotiflac_core::xml::check_depth(&body)?;
let document = roxmltree::Document::parse(&body).map_err(|e| e.to_string())?;
for response in document
.descendants()
@@ -689,7 +689,7 @@ fn parse_ttml(
text: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Option<Parsed>, String> {
crate::native_xml::check_depth(text)?;
spotiflac_core::xml::check_depth(text)?;
let normalized = xml_source(text, true);
let Ok(doc) = Document::parse_with_options(
&normalized,
@@ -861,7 +861,7 @@ fn xml_source(input: &str, drop_cdata: bool) -> String {
break;
}
if tail.starts_with("<!DOCTYPE") {
if let Some(end) = crate::native_xml::markup_end(tail, true) {
if let Some(end) = spotiflac_core::xml::markup_end(tail, true) {
result.push_str(&tail[..end]);
index += end;
continue;
@@ -931,7 +931,7 @@ fn with_credits(raw: &str, lyrics: &mut Parsed) -> Result<(), String> {
.into()
});
if looks_ttml(raw) {
crate::native_xml::check_depth(raw)?;
spotiflac_core::xml::check_depth(raw)?;
let normalized = xml_source(raw, false);
match Document::parse_with_options(
&normalized,
@@ -1070,7 +1070,7 @@ mod tests {
let raw = format!(
"<?xml version=\"1.0\"?><!DOCTYPE tt [<!-- ]> {opaque} --> <!ENTITY markup \"{opaque}\">]><tt><!--{opaque}--><body><p begin='1s' end='2s' data=\">\"><![CDATA[{opaque}]]><span data='>'>Line</span></p></body></tt>"
);
crate::native_xml::check_depth(&raw).unwrap();
spotiflac_core::xml::check_depth(&raw).unwrap();
let parsed = execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap();
assert_eq!(parsed["plainText"], "Line");
}
@@ -1,6 +1,7 @@
mod apple;
mod direct;
mod proxy;
mod ttml;
pub use direct::genius_text;
@@ -77,6 +78,7 @@ impl BuiltinLyricsClient {
"netease" => self.netease(request, check),
"musixmatch" => self.musixmatch(request, check),
"apple_music" => self.apple(request, check),
"spotiflac_lyrics" => self.spotiflac_lyrics(request, check),
"spotify" => self.spotify(request, check),
"deezer" => self.deezer(request, check),
"youtube" => self.youtube(request, check),
@@ -84,6 +86,8 @@ impl BuiltinLyricsClient {
"qqmusic" => self.qqmusic(request, check),
"kugou" => self.kugou(request, check),
"genius" => self.genius(request, check),
"lrcred" => self.lrc_red(request, check),
"betterlyrics" => self.better_lyrics(request, check),
_ => Err(LyricsError::Other(format!("unknown provider: {provider}"))),
}
}
@@ -143,7 +147,7 @@ impl LyricsFetcher for BuiltinLyricsClient {
&& simplified != request.track
&& matches!(
provider,
"netease" | "spotify" | "youtube" | "kugou" | "genius" | "lyricsplus"
"netease" | "spotify" | "youtube" | "kugou" | "genius" | "lyricsplus" | "lrcred"
)
{
attempt.track = simplified;
@@ -10,31 +10,13 @@ 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());
/// Both providers read Apple's catalog song; only the lyrics host differs.
#[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,
@@ -120,6 +102,24 @@ impl BuiltinLyricsClient {
request: &SearchRequest,
check: &Check<'_>,
) -> Result<LyricsResponse, LyricsError> {
let id = self.apple_song_id(request, check)?;
self.apple_lyrics(&id, AppleLyricsSource::Paxsenix, request, check)
}
pub(super) fn spotiflac_lyrics(
&self,
request: &SearchRequest,
check: &Check<'_>,
) -> Result<LyricsResponse, LyricsError> {
let id = self.apple_song_id(request, check)?;
self.apple_lyrics(&id, AppleLyricsSource::SpotiFlac, request, check)
}
fn apple_song_id(
&self,
request: &SearchRequest,
check: &Check<'_>,
) -> Result<String, LyricsError> {
let query = format!("{} {}", request.track, request.artist);
if query.trim().is_empty() {
return Err(LyricsError::NotFound("empty search query".into()));
@@ -166,9 +166,8 @@ impl BuiltinLyricsClient {
})
.filter(|(id, _)| !id.trim().is_empty())
.ok_or_else(|| LyricsError::NotFound("no songs found on apple music".into()))?;
apple_with_fallback(check, |source| {
self.apple_lyrics(selected.0.trim(), source, request, check)
})
check().map_err(LyricsError::Cancelled)?;
Ok(selected.0.trim().into())
}
fn apple_lyrics(
@@ -214,7 +213,7 @@ impl BuiltinLyricsClient {
}
let multi_person = request.options.multi_person_word_by_word;
let word_timing = request.options.apple_elrc_word_sync;
let (text, credit) = match source {
let (text, name) = match source {
AppleLyricsSource::Paxsenix => {
let text = match payloads::format_apple(raw, multi_person, word_timing) {
Ok(text) => text,
@@ -231,7 +230,7 @@ impl BuiltinLyricsClient {
"SpotiFLAC-Mobile Lyrics",
),
};
let mut lyrics = from_text(&text, "Apple Music", credit)?;
let mut lyrics = from_text(&text, name, name)?;
payloads::apple_supplements(raw, &mut lyrics);
Ok(lyrics)
}
@@ -250,98 +249,20 @@ 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()))
},
fn spotiflac_lyrics_credit_never_names_the_proxy() {
let lyrics = LyricsResponse::from_text(
"[00:01.00]Example",
"SpotiFLAC-Mobile Lyrics",
"SpotiFLAC-Mobile Lyrics",
);
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())));
let saved = spotiflac_core::lyrics::lrc::with_metadata(&lyrics, "Track", "Artist");
assert!(saved.contains("source: SpotiFLAC-Mobile Lyrics"));
assert!(!saved.contains("Paxsenix"));
}
#[test]
#[ignore = "requires live Apple catalog and SpotiFLAC-Mobile Lyrics access"]
fn live_apple_backup_after_primary_failure() {
fn live_spotiflac_lyrics_works_without_paxsenix() {
let network = NetworkService::new().unwrap();
let client = BuiltinLyricsClient::with_endpoints(
&network,
@@ -359,10 +280,10 @@ mod tests {
duration: 213.0,
..SearchRequest::default()
};
let lyrics = client.apple(&request, &|| Ok(())).unwrap();
let lyrics = client.spotiflac_lyrics(&request, &|| Ok(())).unwrap();
assert!(lyrics.has_usable_text());
assert!(lyrics.is_synced());
assert_eq!(lyrics.provider, "Apple Music");
assert_eq!(lyrics.provider, "SpotiFLAC-Mobile Lyrics");
assert_eq!(lyrics.source, "SpotiFLAC-Mobile Lyrics");
}
@@ -314,7 +314,7 @@ impl BuiltinLyricsClient {
}
let mut last = LyricsError::NotFound("lyricsplus: no lyrics found".into());
for endpoint in [
"https://lyricsplus.prjktla.workers.dev/v2/lyrics/get",
"https://lyricsplus.prjktla.my.id/v2/lyrics/get",
"https://lyricsplus.binimum.org/v2/lyrics/get",
] {
match self.lyrics_plus_server(endpoint, request, isrc, check) {
@@ -0,0 +1,179 @@
//! Keyless services that return Apple Music's TTML documents directly.
use super::*;
use crate::lyrics::http;
use serde::Deserialize;
use spotiflac_core::lyrics::payloads;
#[derive(Default, Deserialize)]
#[serde(default)]
struct LrcRedSearch {
hits: Vec<LrcRedHit>,
}
#[derive(Default, Deserialize)]
#[serde(default)]
struct LrcRedHit {
isrc: String,
title: String,
artist: String,
duration: f64,
}
#[derive(Default, Deserialize)]
#[serde(default)]
struct BetterLyricsResponse {
ttml: String,
}
/// Only an ISRC is ever placed in the document path.
fn isrc_path(isrc: &str) -> Option<String> {
let isrc = isrc.trim().to_ascii_uppercase();
(isrc.len() == 12 && isrc.bytes().all(|byte| byte.is_ascii_alphanumeric())).then_some(isrc)
}
impl BuiltinLyricsClient {
pub(super) fn lrc_red(
&self,
request: &SearchRequest,
check: &Check<'_>,
) -> Result<LyricsResponse, LyricsError> {
let query = format!("{} {}", request.track, request.artist);
if query.trim().is_empty() {
return Err(LyricsError::NotFound("empty search query".into()));
}
let mut search = Request::new("https://lrc.red/search.json");
search.params = pairs(&[("q", query.trim())]);
search.unavailable_errors = true;
let response: LrcRedSearch =
http::decode(&self.http.get(search, check)?.body, true, check)?;
let hit = select(&response.hits, request, |hit| {
(
hit.title.clone(),
hit.artist.clone(),
hit.duration,
isrc_path(&hit.isrc).is_some()
&& matches(&hit.title, &hit.artist, hit.duration, request),
)
})
.ok_or_else(|| LyricsError::NotFound("no matching song found on lrc.red".into()))?;
let isrc = isrc_path(&hit.isrc).unwrap_or_default();
let endpoint = format!("https://lrc.red/s/{isrc}.ttml");
let mut document = Request::new(&endpoint);
document.headers = pairs(&[("Accept", "application/ttml+xml, application/xml")]);
document.allowed = &[200, 404];
document.unavailable_errors = true;
let response = self.http.get(document, check)?;
if response.status == 404 {
return Err(LyricsError::NotFound(
"lrc.red has no lyrics for this song".into(),
));
}
self.ttml(&text_from_bytes(&response.body), "lrc.red", request)
}
pub(super) fn better_lyrics(
&self,
request: &SearchRequest,
check: &Check<'_>,
) -> Result<LyricsResponse, LyricsError> {
if request.track.trim().is_empty() || request.artist.trim().is_empty() {
return Err(LyricsError::NotFound("empty search query".into()));
}
let mut get = Request::new("https://lyrics-api.boidu.dev/getLyrics");
get.params = pairs(&[("s", request.track.trim()), ("a", request.artist.trim())]);
let seconds = request.duration.round();
if seconds.is_finite() && seconds > 0.0 {
get.params.insert("d".into(), (seconds as i64).to_string());
}
// Without a key the service answers only songs already in its cache,
// and asks for a key otherwise. That is a miss, not an outage.
get.allowed = &[200, 401, 404];
get.unavailable_errors = true;
let response = self.http.get(get, check)?;
if response.status != 200 {
return Err(LyricsError::NotFound(
"betterlyrics has no cached lyrics".into(),
));
}
let payload: BetterLyricsResponse = http::decode(&response.body, true, check)?;
if payload.ttml.trim().is_empty() {
return Err(LyricsError::NotFound(
"betterlyrics returned no lyrics".into(),
));
}
self.ttml(&payload.ttml, "BetterLyrics", request)
}
fn ttml(
&self,
raw: &str,
source: &str,
request: &SearchRequest,
) -> Result<LyricsResponse, LyricsError> {
let lyrics = payloads::ttml_lyrics(
raw,
source,
source,
request.options.multi_person_word_by_word,
request.options.apple_elrc_word_sync,
)
.map_err(LyricsError::Unavailable)?;
if lyrics.has_usable_text() {
Ok(lyrics)
} else {
Err(LyricsError::NotFound(format!(
"no lyrics found on {source}"
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Resolver;
impl TrackResolver for Resolver {
fn deezer_id_from_spotify(&self, _: &str, _: &Check<'_>) -> Result<String, LyricsError> {
unreachable!("ttml providers do not resolve tracks")
}
}
#[test]
fn only_isrcs_reach_the_document_path() {
assert_eq!(isrc_path(" usug11904206 ").as_deref(), Some("USUG11904206"));
for invalid in [
"",
"USUG1190420",
"USUG119042067",
"../../secret",
"USUG1190420/",
] {
assert_eq!(isrc_path(invalid), None, "{invalid}");
}
}
#[test]
#[ignore = "requires live lrc.red and BetterLyrics access"]
fn live_ttml_providers_return_synced_apple_lyrics() {
let network = NetworkService::new().unwrap();
let client = BuiltinLyricsClient::new(&network, "", Arc::new(Resolver));
let request = SearchRequest {
track: "Blinding Lights".into(),
artist: "The Weeknd".into(),
duration: 200.0,
..SearchRequest::default()
};
for (lyrics, source) in [
(client.lrc_red(&request, &|| Ok(())).unwrap(), "lrc.red"),
(
client.better_lyrics(&request, &|| Ok(())).unwrap(),
"BetterLyrics",
),
] {
assert!(lyrics.is_synced(), "{source}");
assert_eq!(lyrics.provider, source);
assert_eq!(lyrics.source, source);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const secure = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
setUpAll(() {
messenger.setMockMethodCallHandler(secure, (call) async => null);
});
tearDownAll(() => messenger.setMockMethodCallHandler(secure, null));
Future<List<String>> load(List<String> providers, int migration) async {
SharedPreferences.setMockInitialValues({
'app_settings': jsonEncode(
AppSettings(lyricsProviders: providers).toJson(),
),
'settings_migration_version': migration,
});
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(settingsProvider.notifier).ensureLoaded();
return container.read(settingsProvider).lyricsProviders;
}
test('the untouched legacy default moves to the new defaults', () async {
expect(
await load(['apple_music', 'lrclib'], 12),
const AppSettings().lyricsProviders,
);
});
test('a custom order keeps the former fallback after Apple Music', () async {
expect(await load(['lrclib', 'apple_music', 'netease'], 12), [
'lrclib',
'apple_music',
'spotiflac_lyrics',
'netease',
]);
});
test('orders without Apple Music are left alone', () async {
expect(await load(['lrclib', 'netease'], 12), ['lrclib', 'netease']);
});
test('choices made after the migration are kept', () async {
expect(await load(['apple_music', 'lrclib'], 13), [
'apple_music',
'lrclib',
]);
});
}
+7 -1
View File
@@ -1070,7 +1070,13 @@ void main() {
expect(settings.artistTagMode, artistTagModeJoined);
expect(settings.embeddedCoverMaxDimension, 0);
expect(settings.autoFallback, isTrue);
expect(settings.lyricsProviders, ['apple_music', 'lrclib']);
expect(settings.lyricsProviders, [
'apple_music',
'lrcred',
'spotiflac_lyrics',
'lyricsplus',
'lrclib',
]);
expect(settings.playerMode, 'internal');
expect(settings.lyricsMultiPersonWordByWord, isTrue);
expect(settings.lyricsAppleElrcWordSync, isTrue);