From 56ad01ec2cc1374a61b11a4fb7626470e655e1a2 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:24:31 +0700 Subject: [PATCH] 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. --- lib/l10n/arb/app_en.arb | 12 + lib/l10n/arb/app_id.arb | 12 + lib/models/settings.dart | 8 +- lib/models/settings.g.dart | 8 +- lib/providers/settings_provider.dart | 25 +- .../lyrics_provider_priority_page.dart | 21 ++ .../settings/lyrics_settings_page.dart | 3 + rust_backend/Cargo.lock | 1 + rust_backend/crates/core/Cargo.toml | 1 + rust_backend/crates/core/src/lib.rs | 1 + rust_backend/crates/core/src/lyrics/config.rs | 31 +- rust_backend/crates/core/src/lyrics/lrc.rs | 2 + .../crates/core/src/lyrics/payloads.rs | 25 +- .../crates/core/src/lyrics/payloads/ttml.rs | 349 ++++++++++++++++++ .../src/native_xml.rs => core/src/xml.rs} | 4 +- rust_backend/crates/mobile/src/lib.rs | 1 - .../crates/mobile/src/native_listing.rs | 2 +- .../crates/mobile/src/native_lyrics.rs | 8 +- .../crates/providers/src/lyrics/builtin.rs | 6 +- .../providers/src/lyrics/builtin/apple.rs | 147 ++------ .../providers/src/lyrics/builtin/proxy.rs | 2 +- .../providers/src/lyrics/builtin/ttml.rs | 179 +++++++++ test/lyrics_provider_migration_test.dart | 60 +++ test/models_and_utils_test.dart | 8 +- 24 files changed, 784 insertions(+), 132 deletions(-) create mode 100644 rust_backend/crates/core/src/lyrics/payloads/ttml.rs rename rust_backend/crates/{mobile/src/native_xml.rs => core/src/xml.rs} (96%) create mode 100644 rust_backend/crates/providers/src/lyrics/builtin/ttml.rs create mode 100644 test/lyrics_provider_migration_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5b9c4b02..0687aa7a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index dfc5ef4d..2c6baf83 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 651a6faf..6054517f 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 1b891c44..dd31d249 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -131,7 +131,13 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( (json['lyricsProviders'] as List?) ?.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: diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 295995e2..afa779d2 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -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 { 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(); } diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index 4da3abdb..4e9c471f 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -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 _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: diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 4d9e2ef8..ec4c5e6f 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -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( diff --git a/rust_backend/Cargo.lock b/rust_backend/Cargo.lock index ee15fa5c..50b6915c 100644 --- a/rust_backend/Cargo.lock +++ b/rust_backend/Cargo.lock @@ -2475,6 +2475,7 @@ dependencies = [ "claxon", "image", "regex", + "roxmltree", "rustix", "serde", "serde_json", diff --git a/rust_backend/crates/core/Cargo.toml b/rust_backend/crates/core/Cargo.toml index 78d3a211..082fc6a5 100644 --- a/rust_backend/crates/core/Cargo.toml +++ b/rust_backend/crates/core/Cargo.toml @@ -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 diff --git a/rust_backend/crates/core/src/lib.rs b/rust_backend/crates/core/src/lib.rs index 15caa8a3..84e85cca 100644 --- a/rust_backend/crates/core/src/lib.rs +++ b/rust_backend/crates/core/src/lib.rs @@ -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; diff --git a/rust_backend/crates/core/src/lyrics/config.rs b/rust_backend/crates/core/src/lyrics/config.rs index 655e7411..1b3919fe 100644 --- a/rust_backend/crates/core/src/lyrics/config.rs +++ b/rust_backend/crates/core/src/lyrics/config.rs @@ -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 { "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 { 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( diff --git a/rust_backend/crates/core/src/lyrics/lrc.rs b/rust_backend/crates/core/src/lyrics/lrc.rs index ff632ee9..4117a58b 100644 --- a/rust_backend/crates/core/src/lyrics/lrc.rs +++ b/rust_backend/crates/core/src/lyrics/lrc.rs @@ -161,6 +161,8 @@ pub fn source_uses_proxy(source: &str) -> bool { "kugou direct", "qq music direct", "genius direct", + "lrc.red", + "betterlyrics", "extension:", "heuristic", ] diff --git a/rust_backend/crates/core/src/lyrics/payloads.rs b/rust_backend/crates/core/src/lyrics/payloads.rs index 2a02ce94..ca8728c8 100644 --- a/rust_backend/crates/core/src/lyrics/payloads.rs +++ b/rust_backend/crates/core/src/lyrics/payloads.rs @@ -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 { 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 { 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() }) diff --git a/rust_backend/crates/core/src/lyrics/payloads/ttml.rs b/rust_backend/crates/core/src/lyrics/payloads/ttml.rs new file mode 100644 index 00000000..11f8990f --- /dev/null +++ b/rust_backend/crates/core/src/lyrics/payloads/ttml.rs @@ -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 { + 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) -> 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, + background: &mut Vec, + 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, text: &str, begin: Option, end: Option) { + if text.starts_with(char::is_whitespace) + && let Some(previous) = target.last_mut() + { + previous.part = false; + } + let words = text.split_whitespace().collect::>().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 { + details + .iter() + .find_map(|detail| detail.timestamp) + .map(|time| time as i64) +} + +fn last_time(details: &[PaxDetail]) -> Option { + 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 { + 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::().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, + group: &str, + accepts_language: impl Fn(&str) -> bool, +) -> BTreeMap { + 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::>().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#""#; + + fn lyrics(body: &str, multi_person: bool, word_timing: bool) -> LyricsResponse { + ttml_lyrics( + &format!("{HEAD}{body}"), + "Provider", + "Source", + multi_person, + word_timing, + ) + .unwrap() + } + + #[test] + fn syllables_join_inside_words_and_keep_word_times() { + let body = r#"

long enough

"#; + 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}")) + .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#"

First

(Yes) Second

Both

"#; + let text = format_apple_payload( + &payload( + Document::parse(&format!("{HEAD}{body}")) + .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#"Oppa is Gangnam styleoppan gangnam

오빤 강남 스타일

Eh

"#; + 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("", "P", "S", true, true).is_err()); + assert!(ttml_lyrics("not xml", "P", "S", true, true).is_err()); + let deep = format!("{}{}", "".repeat(64), "".repeat(64)); + assert!(ttml_lyrics(&format!("{HEAD}{deep}"), "P", "S", true, true).is_err()); + } +} diff --git a/rust_backend/crates/mobile/src/native_xml.rs b/rust_backend/crates/core/src/xml.rs similarity index 96% rename from rust_backend/crates/mobile/src/native_xml.rs rename to rust_backend/crates/core/src/xml.rs index f98078e2..c7c215bd 100644 --- a/rust_backend/crates/mobile/src/native_xml.rs +++ b/rust_backend/crates/core/src/xml.rs @@ -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 { +pub fn markup_end(input: &str, doctype: bool) -> Option { let bytes = input.as_bytes(); let (mut quote, mut brackets, mut position) = (None, 0_usize, 0); while position < bytes.len() { diff --git a/rust_backend/crates/mobile/src/lib.rs b/rust_backend/crates/mobile/src/lib.rs index 3e070f4b..bdaa36b1 100644 --- a/rust_backend/crates/mobile/src/lib.rs +++ b/rust_backend/crates/mobile/src/lib.rs @@ -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; diff --git a/rust_backend/crates/mobile/src/native_listing.rs b/rust_backend/crates/mobile/src/native_listing.rs index 1364ca59..c600f6bc 100644 --- a/rust_backend/crates/mobile/src/native_listing.rs +++ b/rust_backend/crates/mobile/src/native_listing.rs @@ -62,7 +62,7 @@ pub(crate) fn execute(request: &Value, bytes: Option<&[u8]>) -> Result Result<(), String>, ) -> Result, 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(" 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!( " {opaque} --> ]>

\">Line

" ); - 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"); } diff --git a/rust_backend/crates/providers/src/lyrics/builtin.rs b/rust_backend/crates/providers/src/lyrics/builtin.rs index f05d7dcf..24d6dab3 100644 --- a/rust_backend/crates/providers/src/lyrics/builtin.rs +++ b/rust_backend/crates/providers/src/lyrics/builtin.rs @@ -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; diff --git a/rust_backend/crates/providers/src/lyrics/builtin/apple.rs b/rust_backend/crates/providers/src/lyrics/builtin/apple.rs index 74882412..12340afe 100644 --- a/rust_backend/crates/providers/src/lyrics/builtin/apple.rs +++ b/rust_backend/crates/providers/src/lyrics/builtin/apple.rs @@ -10,31 +10,13 @@ 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()); +/// 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, -) -> 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, @@ -120,6 +102,24 @@ impl BuiltinLyricsClient { request: &SearchRequest, check: &Check<'_>, ) -> Result { + 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 { + 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 { 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"); } diff --git a/rust_backend/crates/providers/src/lyrics/builtin/proxy.rs b/rust_backend/crates/providers/src/lyrics/builtin/proxy.rs index 701b349c..a5daf61d 100644 --- a/rust_backend/crates/providers/src/lyrics/builtin/proxy.rs +++ b/rust_backend/crates/providers/src/lyrics/builtin/proxy.rs @@ -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) { diff --git a/rust_backend/crates/providers/src/lyrics/builtin/ttml.rs b/rust_backend/crates/providers/src/lyrics/builtin/ttml.rs new file mode 100644 index 00000000..634d6015 --- /dev/null +++ b/rust_backend/crates/providers/src/lyrics/builtin/ttml.rs @@ -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, +} + +#[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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/test/lyrics_provider_migration_test.dart b/test/lyrics_provider_migration_test.dart new file mode 100644 index 00000000..194821d7 --- /dev/null +++ b/test/lyrics_provider_migration_test.dart @@ -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> load(List 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', + ]); + }); +} diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index ac8007d9..9e31bf62 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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);