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);