diff --git a/rust_backend/crates/mobile/src/native_lyrics.rs b/rust_backend/crates/mobile/src/native_lyrics.rs index d05b6c88..ced3e850 100644 --- a/rust_backend/crates/mobile/src/native_lyrics.rs +++ b/rust_backend/crates/mobile/src/native_lyrics.rs @@ -1,50 +1,18 @@ //! The player's existing LRC/TTML contract, parsed on the bounded data worker. //! Milliseconds cross the bridge; playback timeline lookup stays in Flutter. -use base64::Engine; -use regex::{Captures, Regex}; -use roxmltree::{Document, Node}; +mod credits; +mod lrc; +#[cfg(test)] +mod tests; +mod text; +mod ttml; + +use credits::{WRITER_CREDIT, with_credits}; +use lrc::parse_lrc; use serde::Serialize; use serde_json::Value; -use std::{ - collections::{HashMap, HashSet}, - sync::LazyLock, -}; - -const META_NS: &str = "http://www.w3.org/ns/ttml#metadata"; -const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; -const WS: &str = r"[\t\n\v\f\r \u{00a0}\u{1680}\u{2000}-\u{200a}\u{2028}\u{2029}\u{202f}\u{205f}\u{3000}\u{feff}]"; -static LINE_TIME: LazyLock = - LazyLock::new(|| Regex::new(r"\[([0-9]{1,3}):([0-9]{1,2})(?:[.:]([0-9]{1,3}))?\]").unwrap()); -static WORD_TIME: LazyLock = - LazyLock::new(|| Regex::new(r"<([0-9]{1,3}):([0-9]{1,2})(?:[.:]([0-9]{1,3}))?>").unwrap()); -static WHITE_SPACE: LazyLock = LazyLock::new(|| Regex::new(&format!("{WS}+")).unwrap()); -static VOICE_PREFIX: LazyLock = LazyLock::new(|| { - Regex::new(&format!(r"(?i)^({WS}*(?:<[0-9]{{1,3}}:[0-9]{{1,2}}(?:[.:][0-9]{{1,3}})?>{WS}*)*)(v[1-9][0-9]*):[ \t]*")).unwrap() -}); -static BACKGROUND: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\[bg:(.*)\]$").unwrap()); -static ID_TAG: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder|instrumental|x-[a-z0-9_-]+):.*\]$").unwrap() -}); -static SUPPLEMENT: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)^\[x-(romaji-words|romaji|translation):([0-9]+):([^\]]*)\]$").unwrap() -}); -static NEW_LINE: LazyLock = LazyLock::new(|| Regex::new(r"\r\n|\r|\n").unwrap()); -static WRITER_CREDIT: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"(?i)^Written{WS}+by{WS}*:{WS}*(.+)$")).unwrap()); -static AU_TAG: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"(?im)^\[au:([^\]\r\n]*)\]{WS}*$")).unwrap()); -static PROVIDER_TAG: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"(?im)^\[x-provider:([^\]\r\n]*)\]{WS}*$")).unwrap()); -static BY_TAG: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"(?im)^\[by:([^\]\r\n]*)\]{WS}*$")).unwrap()); -static VIA_CREDIT: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"(?i)\bvia{WS}+([^\(]+)")).unwrap()); -static API_SUFFIX: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"(?i){WS}+API$")).unwrap()); -static SOURCE_CREDIT: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"(?i)\(source:{WS}*([^\)]+)\)")).unwrap()); -static EXTENSION_PREFIX: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)^extension:").unwrap()); +use text::{looks_ttml, trim}; +use ttml::parse_ttml; #[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -151,921 +119,3 @@ pub(crate) fn execute( check()?; serde_json::to_value(parsed).map_err(|e| e.to_string()) } - -fn whitespace(c: char) -> bool { - matches!( - c, - '\t' | '\n' - | '\u{000b}' - | '\u{000c}' - | '\r' - | ' ' - | '\u{0085}' - | '\u{00a0}' - | '\u{1680}' - | '\u{2028}' - | '\u{2029}' - | '\u{202f}' - | '\u{205f}' - | '\u{3000}' - | '\u{feff}' - ) || ('\u{2000}'..='\u{200a}').contains(&c) -} -fn trim(s: &str) -> &str { - s.trim_matches(whitespace) -} -fn trim_end(s: &str) -> &str { - s.trim_end_matches(whitespace) -} -fn parse_int(s: &str) -> Option { - let s = trim(s); - let (negative, rest) = if let Some(s) = s.strip_prefix('-') { - (true, s) - } else { - (false, s.strip_prefix('+').unwrap_or(s)) - }; - let unsigned = if rest.starts_with("0x") || rest.starts_with("0X") { - i64::from_str_radix(&rest[2..], 16).ok()? - } else { - rest.parse::().ok()? - }; - if negative { - unsigned.checked_neg() - } else { - Some(unsigned) - } -} -fn looks_ttml(s: &str) -> bool { - let head = s.trim_start_matches(whitespace); - head.starts_with(") -> i64 { - let fraction = c - .get(3) - .map(|v| { - format!("{v:0<3}", v = v.as_str())[..3] - .parse::() - .unwrap_or(0) - }) - .unwrap_or(0); - parse_int(&c[1]).unwrap_or(0) * 60_000 + parse_int(&c[2]).unwrap_or(0) * 1000 + fraction -} -fn vocal_text(content: &str) -> (String, Option) { - let Some(c) = VOICE_PREFIX.captures(content) else { - return (content.into(), None); - }; - let id = c[2].to_ascii_lowercase(); - let voice = Voice { - index: parse_int(&id[1..]).unwrap_or(1) - 1, - is_group: id == "v3", - id, - }; - ( - format!( - "{}{}", - WHITE_SPACE.replace_all(&c[1], ""), - &content[c.get(0).unwrap().end()..] - ), - Some(voice), - ) -} -fn parse_words(content: &str) -> Vec { - let matches: Vec<_> = WORD_TIME.captures_iter(content).collect(); - let mut words: Vec = Vec::new(); - for (i, c) in matches.iter().enumerate() { - let time_ms = duration(c); - let start = c.get(0).unwrap().end(); - let end = matches - .get(i + 1) - .map(|c| c.get(0).unwrap().start()) - .unwrap_or(content.len()); - let text = &content[start..end]; - if trim(text).is_empty() { - if let Some(previous) = words.last_mut() - && previous.end_ms.is_none() - && time_ms >= previous.time_ms - { - previous.end_ms = Some(time_ms); - } - } else { - words.push(Word { - time_ms, - end_ms: None, - text: text.into(), - }); - } - } - words -} -fn decode_supplement(encoded: &str) -> Option { - // Base64Decoder accepts URL-safe characters and %3D padding, but does - // not synthesize missing padding or decode other percent escapes. - let mut normalized = String::new(); - let bytes = encoded.as_bytes(); - let mut index = 0; - while index < bytes.len() { - let byte = if bytes[index] == b'%' { - let value = u8::from_str_radix( - std::str::from_utf8(bytes.get(index + 1..index + 3)?).ok()?, - 16, - ) - .ok()?; - if value != b'=' { - return None; - } - index += 3; - value - } else { - let value = bytes[index]; - index += 1; - value - }; - normalized.push(match byte { - b'-' => '+', - b'_' => '/', - _ => char::from(byte), - }); - } - let bytes = base64::engine::general_purpose::STANDARD - .decode(normalized) - .ok()?; - Some(trim(&String::from_utf8(bytes).ok()?).into()) -} -fn romanization_words(raw: &str) -> Vec { - let Ok(Value::Array(values)) = serde_json::from_str::(raw) else { - return vec![]; - }; - let mut words: Vec = Vec::new(); - for value in values { - let (Some(text), Some(start), Some(end)) = ( - value["text"].as_str(), - value["startTimeMs"].as_i64(), - value["endTimeMs"].as_i64(), - ) else { - return vec![]; - }; - if trim(text).is_empty() - || start < 0 - || end < start - || words.last().is_some_and(|w| w.time_ms > start) - { - return vec![]; - } - words.push(Word { - time_ms: start, - end_ms: Some(end), - text: text.into(), - }); - } - words -} -struct Supplements { - entries: Vec<(i64, T)>, - seen: HashSet, -} -impl Supplements { - fn new() -> Self { - Self { - entries: Vec::new(), - seen: HashSet::new(), - } - } -} -fn insert_first(supplements: &mut Supplements, time: i64, value: T) { - if supplements.seen.insert(time) { - supplements.entries.push((time, value)); - } -} -fn align(lines: &[Line], supplements: Supplements) -> HashMap { - let mut matched: HashMap = HashMap::new(); - for (time, value) in supplements.entries { - let lo = lines.partition_point(|line| line.time_ms < time); - let mut nearest = None; - let mut distance = 11; - for index in [lo.checked_sub(1), Some(lo)].into_iter().flatten() { - let Some(line) = lines.get(index) else { - continue; - }; - let delta = line.time_ms.abs_diff(time); - if delta < distance as u64 { - nearest = Some(line.time_ms); - distance = delta as i64; - } - } - if let Some(nearest) = nearest - && matched.get(&nearest).is_none_or(|(old, _)| distance < *old) - { - matched.insert(nearest, (distance, value)); - } - } - matched - .into_iter() - .map(|(key, (_, value))| (key, value)) - .collect() -} -fn shift(time: i64, offset: i64) -> i64 { - time.saturating_sub(offset).max(0) -} -fn shift_words(words: &mut [Word], offset: i64) { - if offset == 0 { - return; - } - for word in words { - word.time_ms = shift(word.time_ms, offset); - word.end_ms = word.end_ms.map(|time| shift(time, offset)); - } -} -fn parse_lrc(text: &str, check: &dyn Fn() -> Result<(), String>) -> Result { - let mut parsed: Vec = Vec::new(); - let mut plain = Vec::new(); - let (mut romaji, mut romaji_words, mut translation) = - (Supplements::new(), Supplements::new(), Supplements::new()); - let (mut saw_timestamp, mut saw_words, mut offset) = (false, false, 0); - for raw in NEW_LINE.split(text) { - check()?; - let mut line = trim_end(raw); - if trim(line).is_empty() { - continue; - } - let background = BACKGROUND.captures(trim(line)); - if let Some(ref c) = background { - line = trim(c.get(1).unwrap().as_str()); - } - if let Some(c) = SUPPLEMENT.captures(trim(line)) { - if let (Some(time), Some(value)) = (parse_int(&c[2]), decode_supplement(&c[3])) - && !value.is_empty() - { - match c[1].to_ascii_lowercase().as_str() { - "romaji-words" => { - let words = romanization_words(&value); - if !words.is_empty() { - insert_first(&mut romaji_words, time, words); - } - } - "romaji" => insert_first(&mut romaji, time, value), - _ => insert_first(&mut translation, time, value), - } - } - continue; - } - if let Some(c) = ID_TAG.captures(trim(line)) { - if c[1].eq_ignore_ascii_case("offset") { - offset = - parse_int(&line[line.find(':').unwrap() + 1..].replace(']', "")).unwrap_or(0); - } - continue; - } - let times: Vec<_> = LINE_TIME.captures_iter(line).collect(); - if times.is_empty() { - let (content, voice) = vocal_text(trim(line)); - let clean = trim(&WORD_TIME.replace_all(&content, "")).to_string(); - plain.push(clean.clone()); - if background.is_some() && !parsed.is_empty() && !clean.is_empty() { - let words = parse_words(&content); - let previous = parsed.last().unwrap(); - let time = words.first().map(|w| w.time_ms).unwrap_or(previous.time_ms); - let end = words.last().and_then(|w| w.end_ms).or(previous.end_ms); - let voice = voice.or_else(|| previous.voice.clone()); - saw_words |= !words.is_empty(); - parsed.push(Line::new( - time, - end, - clean, - words, - voice, - true, - previous.vocal_group, - )); - } - continue; - } - saw_timestamp = true; - let (content, voice) = - vocal_text(trim(&line[times.last().unwrap().get(0).unwrap().end()..])); - let words = parse_words(&content); - saw_words |= !words.is_empty(); - let clean = trim(&WORD_TIME.replace_all(&content, "")).to_string(); - plain.push(clean.clone()); - for time in times { - let background = background.is_some(); - let previous = parsed.last(); - let line_voice = voice.clone().or_else(|| { - if background { - previous.and_then(|line| line.voice.clone()) - } else { - None - } - }); - let group = if background { - previous.and_then(|line| line.vocal_group) - } else { - Some(parsed.len() as i64) - }; - parsed.push(Line::new( - duration(&time), - words.last().and_then(|w| w.end_ms), - clean.clone(), - words.clone(), - line_voice, - background, - group, - )); - } - } - let plain_text = plain - .into_iter() - .filter(|line| !line.is_empty()) - .collect::>() - .join("\n"); - if !saw_timestamp { - return Ok(Parsed { - plain_text, - ..Parsed::default() - }); - } - // All-zero provider placeholders are plain lyrics. Validate before offsets - // so a negative offset cannot turn them into apparently useful timing. - let synced = has_usable_timing(&parsed); - parsed.sort_by_key(|line| line.time_ms); - let romaji = align(&parsed, romaji); - let romaji_words = align(&parsed, romaji_words); - let translation = align(&parsed, translation); - if offset != 0 || !romaji.is_empty() || !translation.is_empty() { - for line in &mut parsed { - check()?; - line.romanization = romaji.get(&line.time_ms).cloned(); - let words = romaji_words.get(&line.time_ms).cloned().unwrap_or_default(); - if line.romanization.as_deref() - == Some( - words - .iter() - .map(|w| w.text.as_str()) - .collect::() - .as_str(), - ) - { - line.romanization_words = words; - } - line.translation = translation.get(&line.time_ms).cloned(); - line.time_ms = shift(line.time_ms, offset); - line.end_ms = line.end_ms.map(|time| shift(time, offset)); - shift_words(&mut line.words, offset); - shift_words(&mut line.romanization_words, offset); - } - } - Ok(Parsed { - synced, - word_synced: synced && saw_words, - lines: parsed, - plain_text, - ..Parsed::default() - }) -} - -#[derive(Debug)] -struct Run { - voice: Option, - background: bool, - fragments: Vec<(String, Option, Option)>, -} -impl Run { - fn add(&mut self, value: &str, begin: Option, end: Option) { - let normalized = WHITE_SPACE.replace_all(value, " "); - let text = if self - .fragments - .last() - .is_none_or(|(text, _, _)| text.ends_with(' ')) - { - normalized.trim_start_matches(whitespace) - } else { - &normalized - }; - if text.is_empty() { - return; - } - if let Some((previous, old_begin, old_end)) = self.fragments.last_mut() - && (trim(text).is_empty() || (begin == *old_begin && end == *old_end)) - { - previous.push_str(text); - return; - } - self.fragments.push((text.into(), begin, end)); - } - fn text(&self) -> String { - trim( - &self - .fragments - .iter() - .map(|(text, _, _)| text.as_str()) - .collect::(), - ) - .into() - } - fn words(&self) -> Vec { - if self.fragments.iter().any(|(_, begin, _)| begin.is_none()) { - return vec![]; - } - self.fragments - .iter() - .enumerate() - .map(|(i, (text, begin, end))| Word { - time_ms: begin.unwrap(), - end_ms: end.filter(|end| *end >= begin.unwrap()), - text: if i + 1 == self.fragments.len() { - trim_end(text).into() - } else { - text.clone() - }, - }) - .collect() - } -} -fn voice_of( - element: Node<'_, '_>, - inherited: Option, - voices: &mut HashMap, - next: &mut i64, -) -> Option { - let Some(id) = element - .attribute((META_NS, "agent")) - .filter(|id| !id.is_empty()) - else { - return inherited; - }; - Some( - voices - .entry(id.into()) - .or_insert_with(|| { - let voice = Voice { - id: id.into(), - index: *next, - is_group: id.eq_ignore_ascii_case("v3"), - }; - *next += 1; - voice - }) - .clone(), - ) -} -fn rounded_ms(seconds: &str) -> Option { - let seconds = trim(seconds).parse::().ok()?; - let ms = seconds * 1000.0; - if !ms.is_finite() || ms < i64::MIN as f64 || ms >= -(i64::MIN as f64) { - return None; - } - Some(ms.round() as i64) -} -fn clock(value: Option<&str>) -> Option { - let value = trim(value?); - if let Some(seconds) = value.strip_suffix('s').filter(|_| !value.contains(':')) { - return rounded_ms(seconds); - } - let parts: Vec<_> = value.split(':').collect(); - match parts.as_slice() { - [hours, minutes, seconds] => parse_int(hours)? - .checked_mul(3_600_000)? - .checked_add(parse_int(minutes)?.checked_mul(60_000)?)? - .checked_add(rounded_ms(seconds)?), - [minutes, seconds] => parse_int(minutes)? - .checked_mul(60_000)? - .checked_add(rounded_ms(seconds)?), - _ => None, - } -} -#[allow(clippy::too_many_arguments)] -fn visit( - node: Node<'_, '_>, - paragraph: Node<'_, '_>, - voice: Option, - background: bool, - begin: Option, - end: Option, - voices: &mut HashMap, - next: &mut i64, - runs: &mut Vec, - check: &dyn Fn() -> Result<(), String>, -) -> Result<(), String> { - // Use a heap stack so a deeply nested span tree cannot exhaust the native - // worker's call stack. Reverse pushes preserve the original document order. - let mut pending = vec![(node, voice, background, begin, end)]; - while let Some((node, voice, background, begin, end)) = pending.pop() { - check()?; - if node.is_text() { - let value = node.text().unwrap_or_default(); - if trim(value).is_empty() && runs.is_empty() { - continue; - } - let index = runs.iter().position(|run| { - run.voice.as_ref().map(|v| &v.id) == voice.as_ref().map(|v| &v.id) - && run.background == background - }); - let index = match index { - Some(index) => index, - None => { - if trim(value).is_empty() { - continue; - } - runs.push(Run { - voice, - background, - fragments: vec![], - }); - runs.len() - 1 - } - }; - runs[index].add(value, begin, end); - } else if node.is_element() { - let voice = voice_of(node, voice, voices, next); - let background = background || node.attribute((META_NS, "role")) == Some("x-bg"); - let begin = if node == paragraph { - None - } else { - clock(node.attribute("begin")) - } - .or(begin); - let end = if node == paragraph { - None - } else { - clock(node.attribute("end")) - } - .or(end); - for child in node.children().rev() { - pending.push((child, voice.clone(), background, begin, end)); - } - } - } - Ok(()) -} -fn parse_ttml( - text: &str, - check: &dyn Fn() -> Result<(), String>, -) -> Result, String> { - spotiflac_core::xml::check_depth(text)?; - let normalized = xml_source(text, true); - let Ok(doc) = Document::parse_with_options( - &normalized, - roxmltree::ParsingOptions { - allow_dtd: true, - ..Default::default() - }, - ) else { - return Ok(None); - }; - let elements: Vec<_> = doc.descendants().filter(Node::is_element).collect(); - let paragraphs: Vec<_> = elements - .iter() - .copied() - .filter(|n| n.tag_name().name() == "p") - .collect(); - if paragraphs.is_empty() { - return Ok(None); - } - let mut voices = HashMap::new(); - let mut next = 0; - for agent in elements.iter().filter(|node| { - node.tag_name().name() == "agent" && node.tag_name().namespace() == Some(META_NS) - }) { - let Some(id) = agent.attribute((XML_NS, "id")).filter(|id| !id.is_empty()) else { - continue; - }; - let group = agent.attribute("type") == Some("group") || id.eq_ignore_ascii_case("v3"); - voices.insert( - id.into(), - Voice { - id: id.into(), - index: next, - is_group: group, - }, - ); - if !group { - next += 1; - } - } - let (mut lines, mut plain, mut saw_words, mut next_group) = - (Vec::::new(), Vec::new(), false, 0); - for p in paragraphs { - check()?; - let begin = clock(p.attribute("begin")); - let end = clock(p.attribute("end")); - let mut voice = None; - let ancestors: Vec<_> = p.ancestors().skip(1).filter(Node::is_element).collect(); - for ancestor in ancestors.into_iter().rev() { - voice = voice_of(ancestor, voice, &mut voices, &mut next); - } - let mut runs = Vec::new(); - visit( - p, - p, - voice, - false, - None, - None, - &mut voices, - &mut next, - &mut runs, - check, - )?; - let mut lead_groups = Vec::new(); - for (i, run) in runs.iter().enumerate() { - if !run.background && !run.text().is_empty() { - lead_groups.push((i, next_group)); - next_group += 1; - } - } - for (i, run) in runs.iter().enumerate() { - let text = run.text(); - if text.is_empty() { - continue; - } - plain.push(text.clone()); - let words = run.words(); - let Some(begin) = (if runs.len() == 1 { - begin - } else { - words.first().map(|word| word.time_ms).or(begin) - }) else { - continue; - }; - saw_words |= !words.is_empty(); - let lead = if run.background { - lead_groups - .iter() - .find(|(index, _)| { - runs[*index].voice.as_ref().map(|v| &v.id) - == run.voice.as_ref().map(|v| &v.id) - }) - .or(lead_groups.first()) - .map(|(index, _)| *index) - } else { - Some(i) - }; - let group = lead - .and_then(|index| { - lead_groups - .iter() - .find(|(i, _)| *i == index) - .map(|(_, group)| *group) - }) - .or_else(|| { - if run.background { - lines.last().and_then(|line| line.vocal_group) - } else { - None - } - }); - let end = if runs.len() == 1 { - end - } else { - words.last().and_then(|word| word.end_ms).or(end) - }; - lines.push(Line::new( - begin, - end, - text, - words, - run.voice.clone(), - run.background, - group, - )); - } - } - lines.sort_by_key(|line| line.time_ms); - let synced = has_usable_timing(&lines); - Ok(Some(Parsed { - synced, - word_synced: synced && saw_words, - lines, - plain_text: plain.join("\n"), - ..Parsed::default() - })) -} - -fn xml_source(input: &str, drop_cdata: bool) -> String { - // Dart's default entity mapping leaves bare '&' and unknown references - // literal, including entities declared in a DOCTYPE. Escape those before - // using the stricter XML reader; never resolve external resources. - let mut result = String::with_capacity(input.len()); - let mut index = 0; - while index < input.len() { - let tail = &input[index..]; - let opaque = if tail.starts_with("", drop_cdata)) - } else if tail.starts_with("", false)) - } else if tail.starts_with("", false)) - } else { - None - }; - if let Some((ending, drop)) = opaque { - if let Some(end) = tail.find(ending) { - let end = end + ending.len(); - if !drop { - result.push_str(&tail[..end]); - } - index += end; - continue; - } - // A missing terminator is a syntax error; preserve it for the - // parser without repeatedly scanning the remainder of the input. - result.push_str(tail); - break; - } - if tail.starts_with("().ok()) }; - number.is_some_and(|value| matches!(value, 9 | 10 | 13 | 0x20..=0xd7ff | 0xe000..=0xfffd | 0x10000..=0x10ffff)) - }); - if known { - result.push('&'); - } else { - result.push_str("&"); - } - } else { - result.push(character); - } - index += character.len_utf8(); - } - result -} - -fn with_credits(raw: &str, lyrics: &mut Parsed) -> Result<(), String> { - let tag = |pattern: &Regex| pattern.captures(raw).map(|c| trim(&c[1]).to_string()); - let mut writers = tag(&AU_TAG); - let mut provider = tag(&PROVIDER_TAG); - let credit = tag(&BY_TAG).unwrap_or_default(); - if provider.is_none() - && let Some(c) = VIA_CREDIT.captures(&credit) - { - provider = Some(API_SUFFIX.replace(trim(&c[1]), "").into()); - } - if provider.is_none() { - provider = SOURCE_CREDIT.captures(&credit).map(|c| trim(&c[1]).into()); - } - provider = provider.map(|value| EXTENSION_PREFIX.replace(&value, "").into()); - if looks_ttml(raw) { - spotiflac_core::xml::check_depth(raw)?; - let normalized = xml_source(raw, false); - match Document::parse_with_options( - &normalized, - roxmltree::ParsingOptions { - allow_dtd: true, - ..Default::default() - }, - ) { - Ok(doc) => { - let mut names: Vec = Vec::new(); - for node in doc - .descendants() - .filter(|node| node.is_element() && node.tag_name().name() == "songwriter") - { - let name = node - .descendants() - .filter(Node::is_text) - .filter_map(|node| node.text()) - .collect::(); - let name = trim(&name); - if !name.is_empty() && !names.iter().any(|old| old == name) { - names.push(name.into()); - } - } - if !names.is_empty() { - writers = Some(names.join(", ")); - } - } - // XmlTagException/XmlNamespaceException propagate in the existing - // parser; syntax-only XmlParserException falls back to plain text. - Err( - error @ (roxmltree::Error::UnclosedRootNode - | roxmltree::Error::UnexpectedCloseTag(..) - | roxmltree::Error::UnknownNamespace(..)), - ) => return Err(error.to_string()), - Err(_) => {} - } - } - if let Some(line) = lyrics.lines.last() - && let Some(c) = WRITER_CREDIT.captures(trim(&line.text)) - { - if writers.is_none() { - writers = Some(trim(&c[1]).into()); - } - lyrics.lines.pop(); - lyrics.plain_text = lyrics - .lines - .iter() - .map(|line| line.text.as_str()) - .collect::>() - .join("\n"); - } else if lyrics.lines.is_empty() { - let (text, footer) = lyrics - .plain_text - .rsplit_once('\n') - .unwrap_or(("", &lyrics.plain_text)); - if let Some(c) = WRITER_CREDIT.captures(trim(footer)) { - if writers.is_none() { - writers = Some(trim(&c[1]).into()); - } - lyrics.plain_text = text.into(); - } - } - lyrics.writers = writers.filter(|s| !s.is_empty()); - lyrics.provider = provider.filter(|s| !s.is_empty()); - if !lyrics.synced { - lyrics.lines.clear(); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn shared_dart_parser_goldens() { - let fixtures: Value = serde_json::from_str(include_str!( - "../../../../test/fixtures/native_lyrics_parsers.json" - )) - .unwrap(); - for fixture in fixtures.as_array().unwrap() { - let actual = execute(&fixture["request"], None, &|| Ok(())).unwrap(); - assert_eq!(actual, fixture["expected"], "{}", fixture["name"]); - } - } - #[test] - fn cancellation_stops_before_result_publication() { - assert_eq!( - execute(&serde_json::json!({"raw":"[00:01]Line"}), None, &|| Err( - "cancelled".into() - )) - .unwrap_err(), - "cancelled" - ); - } - #[test] - fn malformed_structural_ttml_remains_an_error() { - for raw in [ - "

Unclosed", - "

Mismatch", - "Unknown namespace", - ] { - assert!(execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).is_err()); - } - } - #[test] - fn deeply_nested_vocals_preserve_timing_and_document_order() { - std::thread::Builder::new().stack_size(512 << 10).spawn(|| { - let depth = 12; // Including tt/body/p and each word: the 16-level limit. - let raw = format!( - "

{}First second{}

", - "".repeat(depth), - "".repeat(depth), - ); - let parsed = execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap(); - assert_eq!(parsed["lines"][0]["text"], "First second"); - assert_eq!(parsed["lines"][0]["words"][0]["timeMs"], 1000); - assert_eq!(parsed["lines"][0]["words"][1]["timeMs"], 2000); - }).unwrap().join().unwrap(); - } - #[test] - fn pathological_ttml_depth_is_rejected_before_recursive_tokenization() { - let raw = format!( - "

{}Line{}

", - "".repeat(10_000), - "".repeat(10_000), - ); - assert_eq!( - execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap_err(), - "XML nesting exceeds 16 elements" - ); - } - #[test] - fn depth_guard_ignores_opaque_markup_and_quoted_tag_boundaries() { - let opaque = "".repeat(1_000); - let raw = format!( - " {opaque} --> ]>

\">Line

" - ); - 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/mobile/src/native_lyrics/credits.rs b/rust_backend/crates/mobile/src/native_lyrics/credits.rs new file mode 100644 index 00000000..50565ad4 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_lyrics/credits.rs @@ -0,0 +1,110 @@ +use super::Parsed; +use super::text::{WS, looks_ttml, trim}; +use super::ttml::xml_source; +use regex::Regex; +use roxmltree::{Document, Node}; +use std::sync::LazyLock; + +pub(super) static WRITER_CREDIT: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?i)^Written{WS}+by{WS}*:{WS}*(.+)$")).unwrap()); +static AU_TAG: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?im)^\[au:([^\]\r\n]*)\]{WS}*$")).unwrap()); +static PROVIDER_TAG: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?im)^\[x-provider:([^\]\r\n]*)\]{WS}*$")).unwrap()); +static BY_TAG: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?im)^\[by:([^\]\r\n]*)\]{WS}*$")).unwrap()); +static VIA_CREDIT: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?i)\bvia{WS}+([^\(]+)")).unwrap()); +static API_SUFFIX: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?i){WS}+API$")).unwrap()); +static SOURCE_CREDIT: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"(?i)\(source:{WS}*([^\)]+)\)")).unwrap()); +static EXTENSION_PREFIX: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^extension:").unwrap()); + +pub(super) fn with_credits(raw: &str, lyrics: &mut Parsed) -> Result<(), String> { + let tag = |pattern: &Regex| pattern.captures(raw).map(|c| trim(&c[1]).to_string()); + let mut writers = tag(&AU_TAG); + let mut provider = tag(&PROVIDER_TAG); + let credit = tag(&BY_TAG).unwrap_or_default(); + if provider.is_none() + && let Some(c) = VIA_CREDIT.captures(&credit) + { + provider = Some(API_SUFFIX.replace(trim(&c[1]), "").into()); + } + if provider.is_none() { + provider = SOURCE_CREDIT.captures(&credit).map(|c| trim(&c[1]).into()); + } + provider = provider.map(|value| EXTENSION_PREFIX.replace(&value, "").into()); + if looks_ttml(raw) { + spotiflac_core::xml::check_depth(raw)?; + let normalized = xml_source(raw, false); + match Document::parse_with_options( + &normalized, + roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }, + ) { + Ok(doc) => { + let mut names: Vec = Vec::new(); + for node in doc + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "songwriter") + { + let name = node + .descendants() + .filter(Node::is_text) + .filter_map(|node| node.text()) + .collect::(); + let name = trim(&name); + if !name.is_empty() && !names.iter().any(|old| old == name) { + names.push(name.into()); + } + } + if !names.is_empty() { + writers = Some(names.join(", ")); + } + } + // XmlTagException/XmlNamespaceException propagate in the existing + // parser; syntax-only XmlParserException falls back to plain text. + Err( + error @ (roxmltree::Error::UnclosedRootNode + | roxmltree::Error::UnexpectedCloseTag(..) + | roxmltree::Error::UnknownNamespace(..)), + ) => return Err(error.to_string()), + Err(_) => {} + } + } + if let Some(line) = lyrics.lines.last() + && let Some(c) = WRITER_CREDIT.captures(trim(&line.text)) + { + if writers.is_none() { + writers = Some(trim(&c[1]).into()); + } + lyrics.lines.pop(); + lyrics.plain_text = lyrics + .lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n"); + } else if lyrics.lines.is_empty() { + let (text, footer) = lyrics + .plain_text + .rsplit_once('\n') + .unwrap_or(("", &lyrics.plain_text)); + if let Some(c) = WRITER_CREDIT.captures(trim(footer)) { + if writers.is_none() { + writers = Some(trim(&c[1]).into()); + } + lyrics.plain_text = text.into(); + } + } + lyrics.writers = writers.filter(|s| !s.is_empty()); + lyrics.provider = provider.filter(|s| !s.is_empty()); + if !lyrics.synced { + lyrics.lines.clear(); + } + Ok(()) +} diff --git a/rust_backend/crates/mobile/src/native_lyrics/lrc.rs b/rust_backend/crates/mobile/src/native_lyrics/lrc.rs new file mode 100644 index 00000000..be12dc06 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_lyrics/lrc.rs @@ -0,0 +1,166 @@ +use super::text::{ + LINE_TIME, Supplements, WORD_TIME, align, decode_supplement, duration, insert_first, parse_int, + parse_words, romanization_words, shift, shift_words, trim, trim_end, vocal_text, +}; +use super::{Line, Parsed, has_usable_timing}; +use regex::Regex; +use std::sync::LazyLock; + +static BACKGROUND: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\[bg:(.*)\]$").unwrap()); +static ID_TAG: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder|instrumental|x-[a-z0-9_-]+):.*\]$").unwrap() +}); +static SUPPLEMENT: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\[x-(romaji-words|romaji|translation):([0-9]+):([^\]]*)\]$").unwrap() +}); +static NEW_LINE: LazyLock = LazyLock::new(|| Regex::new(r"\r\n|\r|\n").unwrap()); + +pub(super) fn parse_lrc( + text: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + let mut parsed: Vec = Vec::new(); + let mut plain = Vec::new(); + let (mut romaji, mut romaji_words, mut translation) = + (Supplements::new(), Supplements::new(), Supplements::new()); + let (mut saw_timestamp, mut saw_words, mut offset) = (false, false, 0); + for raw in NEW_LINE.split(text) { + check()?; + let mut line = trim_end(raw); + if trim(line).is_empty() { + continue; + } + let background = BACKGROUND.captures(trim(line)); + if let Some(ref c) = background { + line = trim(c.get(1).unwrap().as_str()); + } + if let Some(c) = SUPPLEMENT.captures(trim(line)) { + if let (Some(time), Some(value)) = (parse_int(&c[2]), decode_supplement(&c[3])) + && !value.is_empty() + { + match c[1].to_ascii_lowercase().as_str() { + "romaji-words" => { + let words = romanization_words(&value); + if !words.is_empty() { + insert_first(&mut romaji_words, time, words); + } + } + "romaji" => insert_first(&mut romaji, time, value), + _ => insert_first(&mut translation, time, value), + } + } + continue; + } + if let Some(c) = ID_TAG.captures(trim(line)) { + if c[1].eq_ignore_ascii_case("offset") { + offset = + parse_int(&line[line.find(':').unwrap() + 1..].replace(']', "")).unwrap_or(0); + } + continue; + } + let times: Vec<_> = LINE_TIME.captures_iter(line).collect(); + if times.is_empty() { + let (content, voice) = vocal_text(trim(line)); + let clean = trim(&WORD_TIME.replace_all(&content, "")).to_string(); + plain.push(clean.clone()); + if background.is_some() && !parsed.is_empty() && !clean.is_empty() { + let words = parse_words(&content); + let previous = parsed.last().unwrap(); + let time = words.first().map(|w| w.time_ms).unwrap_or(previous.time_ms); + let end = words.last().and_then(|w| w.end_ms).or(previous.end_ms); + let voice = voice.or_else(|| previous.voice.clone()); + saw_words |= !words.is_empty(); + parsed.push(Line::new( + time, + end, + clean, + words, + voice, + true, + previous.vocal_group, + )); + } + continue; + } + saw_timestamp = true; + let (content, voice) = + vocal_text(trim(&line[times.last().unwrap().get(0).unwrap().end()..])); + let words = parse_words(&content); + saw_words |= !words.is_empty(); + let clean = trim(&WORD_TIME.replace_all(&content, "")).to_string(); + plain.push(clean.clone()); + for time in times { + let background = background.is_some(); + let previous = parsed.last(); + let line_voice = voice.clone().or_else(|| { + if background { + previous.and_then(|line| line.voice.clone()) + } else { + None + } + }); + let group = if background { + previous.and_then(|line| line.vocal_group) + } else { + Some(parsed.len() as i64) + }; + parsed.push(Line::new( + duration(&time), + words.last().and_then(|w| w.end_ms), + clean.clone(), + words.clone(), + line_voice, + background, + group, + )); + } + } + let plain_text = plain + .into_iter() + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n"); + if !saw_timestamp { + return Ok(Parsed { + plain_text, + ..Parsed::default() + }); + } + // All-zero provider placeholders are plain lyrics. Validate before offsets + // so a negative offset cannot turn them into apparently useful timing. + let synced = has_usable_timing(&parsed); + parsed.sort_by_key(|line| line.time_ms); + let romaji = align(&parsed, romaji); + let romaji_words = align(&parsed, romaji_words); + let translation = align(&parsed, translation); + if offset != 0 || !romaji.is_empty() || !translation.is_empty() { + for line in &mut parsed { + check()?; + line.romanization = romaji.get(&line.time_ms).cloned(); + let words = romaji_words.get(&line.time_ms).cloned().unwrap_or_default(); + if line.romanization.as_deref() + == Some( + words + .iter() + .map(|w| w.text.as_str()) + .collect::() + .as_str(), + ) + { + line.romanization_words = words; + } + line.translation = translation.get(&line.time_ms).cloned(); + line.time_ms = shift(line.time_ms, offset); + line.end_ms = line.end_ms.map(|time| shift(time, offset)); + shift_words(&mut line.words, offset); + shift_words(&mut line.romanization_words, offset); + } + } + Ok(Parsed { + synced, + word_synced: synced && saw_words, + lines: parsed, + plain_text, + ..Parsed::default() + }) +} diff --git a/rust_backend/crates/mobile/src/native_lyrics/tests.rs b/rust_backend/crates/mobile/src/native_lyrics/tests.rs new file mode 100644 index 00000000..d47196ec --- /dev/null +++ b/rust_backend/crates/mobile/src/native_lyrics/tests.rs @@ -0,0 +1,69 @@ +use super::*; +#[test] +fn shared_dart_parser_goldens() { + let fixtures: Value = serde_json::from_str(include_str!( + "../../../../../test/fixtures/native_lyrics_parsers.json" + )) + .unwrap(); + for fixture in fixtures.as_array().unwrap() { + let actual = execute(&fixture["request"], None, &|| Ok(())).unwrap(); + assert_eq!(actual, fixture["expected"], "{}", fixture["name"]); + } +} +#[test] +fn cancellation_stops_before_result_publication() { + assert_eq!( + execute(&serde_json::json!({"raw":"[00:01]Line"}), None, &|| Err( + "cancelled".into() + )) + .unwrap_err(), + "cancelled" + ); +} +#[test] +fn malformed_structural_ttml_remains_an_error() { + for raw in [ + "

Unclosed", + "

Mismatch", + "Unknown namespace", + ] { + assert!(execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).is_err()); + } +} +#[test] +fn deeply_nested_vocals_preserve_timing_and_document_order() { + std::thread::Builder::new().stack_size(512 << 10).spawn(|| { + let depth = 12; // Including tt/body/p and each word: the 16-level limit. + let raw = format!( + "

{}First second{}

", + "".repeat(depth), + "".repeat(depth), + ); + let parsed = execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap(); + assert_eq!(parsed["lines"][0]["text"], "First second"); + assert_eq!(parsed["lines"][0]["words"][0]["timeMs"], 1000); + assert_eq!(parsed["lines"][0]["words"][1]["timeMs"], 2000); + }).unwrap().join().unwrap(); +} +#[test] +fn pathological_ttml_depth_is_rejected_before_recursive_tokenization() { + let raw = format!( + "

{}Line{}

", + "".repeat(10_000), + "".repeat(10_000), + ); + assert_eq!( + execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap_err(), + "XML nesting exceeds 16 elements" + ); +} +#[test] +fn depth_guard_ignores_opaque_markup_and_quoted_tag_boundaries() { + let opaque = "".repeat(1_000); + let raw = format!( + " {opaque} --> ]>

\">Line

" + ); + 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/mobile/src/native_lyrics/text.rs b/rust_backend/crates/mobile/src/native_lyrics/text.rs new file mode 100644 index 00000000..b4a58aa3 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_lyrics/text.rs @@ -0,0 +1,246 @@ +use super::{Line, Voice, Word}; +use base64::Engine; +use regex::{Captures, Regex}; +use serde_json::Value; +use std::{ + collections::{HashMap, HashSet}, + sync::LazyLock, +}; + +pub(super) const WS: &str = r"[\t\n\v\f\r \u{00a0}\u{1680}\u{2000}-\u{200a}\u{2028}\u{2029}\u{202f}\u{205f}\u{3000}\u{feff}]"; +pub(super) static LINE_TIME: LazyLock = + LazyLock::new(|| Regex::new(r"\[([0-9]{1,3}):([0-9]{1,2})(?:[.:]([0-9]{1,3}))?\]").unwrap()); +pub(super) static WORD_TIME: LazyLock = + LazyLock::new(|| Regex::new(r"<([0-9]{1,3}):([0-9]{1,2})(?:[.:]([0-9]{1,3}))?>").unwrap()); +pub(super) static WHITE_SPACE: LazyLock = + LazyLock::new(|| Regex::new(&format!("{WS}+")).unwrap()); +static VOICE_PREFIX: LazyLock = LazyLock::new(|| { + Regex::new(&format!(r"(?i)^({WS}*(?:<[0-9]{{1,3}}:[0-9]{{1,2}}(?:[.:][0-9]{{1,3}})?>{WS}*)*)(v[1-9][0-9]*):[ \t]*")).unwrap() +}); + +pub(super) fn whitespace(c: char) -> bool { + matches!( + c, + '\t' | '\n' + | '\u{000b}' + | '\u{000c}' + | '\r' + | ' ' + | '\u{0085}' + | '\u{00a0}' + | '\u{1680}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) || ('\u{2000}'..='\u{200a}').contains(&c) +} +pub(super) fn trim(s: &str) -> &str { + s.trim_matches(whitespace) +} +pub(super) fn trim_end(s: &str) -> &str { + s.trim_end_matches(whitespace) +} +pub(super) fn parse_int(s: &str) -> Option { + let s = trim(s); + let (negative, rest) = if let Some(s) = s.strip_prefix('-') { + (true, s) + } else { + (false, s.strip_prefix('+').unwrap_or(s)) + }; + let unsigned = if rest.starts_with("0x") || rest.starts_with("0X") { + i64::from_str_radix(&rest[2..], 16).ok()? + } else { + rest.parse::().ok()? + }; + if negative { + unsigned.checked_neg() + } else { + Some(unsigned) + } +} +pub(super) fn looks_ttml(s: &str) -> bool { + let head = s.trim_start_matches(whitespace); + head.starts_with(") -> i64 { + let fraction = c + .get(3) + .map(|v| { + format!("{v:0<3}", v = v.as_str())[..3] + .parse::() + .unwrap_or(0) + }) + .unwrap_or(0); + parse_int(&c[1]).unwrap_or(0) * 60_000 + parse_int(&c[2]).unwrap_or(0) * 1000 + fraction +} +pub(super) fn vocal_text(content: &str) -> (String, Option) { + let Some(c) = VOICE_PREFIX.captures(content) else { + return (content.into(), None); + }; + let id = c[2].to_ascii_lowercase(); + let voice = Voice { + index: parse_int(&id[1..]).unwrap_or(1) - 1, + is_group: id == "v3", + id, + }; + ( + format!( + "{}{}", + WHITE_SPACE.replace_all(&c[1], ""), + &content[c.get(0).unwrap().end()..] + ), + Some(voice), + ) +} +pub(super) fn parse_words(content: &str) -> Vec { + let matches: Vec<_> = WORD_TIME.captures_iter(content).collect(); + let mut words: Vec = Vec::new(); + for (i, c) in matches.iter().enumerate() { + let time_ms = duration(c); + let start = c.get(0).unwrap().end(); + let end = matches + .get(i + 1) + .map(|c| c.get(0).unwrap().start()) + .unwrap_or(content.len()); + let text = &content[start..end]; + if trim(text).is_empty() { + if let Some(previous) = words.last_mut() + && previous.end_ms.is_none() + && time_ms >= previous.time_ms + { + previous.end_ms = Some(time_ms); + } + } else { + words.push(Word { + time_ms, + end_ms: None, + text: text.into(), + }); + } + } + words +} +pub(super) fn decode_supplement(encoded: &str) -> Option { + // Base64Decoder accepts URL-safe characters and %3D padding, but does + // not synthesize missing padding or decode other percent escapes. + let mut normalized = String::new(); + let bytes = encoded.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let byte = if bytes[index] == b'%' { + let value = u8::from_str_radix( + std::str::from_utf8(bytes.get(index + 1..index + 3)?).ok()?, + 16, + ) + .ok()?; + if value != b'=' { + return None; + } + index += 3; + value + } else { + let value = bytes[index]; + index += 1; + value + }; + normalized.push(match byte { + b'-' => '+', + b'_' => '/', + _ => char::from(byte), + }); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(normalized) + .ok()?; + Some(trim(&String::from_utf8(bytes).ok()?).into()) +} +pub(super) fn romanization_words(raw: &str) -> Vec { + let Ok(Value::Array(values)) = serde_json::from_str::(raw) else { + return vec![]; + }; + let mut words: Vec = Vec::new(); + for value in values { + let (Some(text), Some(start), Some(end)) = ( + value["text"].as_str(), + value["startTimeMs"].as_i64(), + value["endTimeMs"].as_i64(), + ) else { + return vec![]; + }; + if trim(text).is_empty() + || start < 0 + || end < start + || words.last().is_some_and(|w| w.time_ms > start) + { + return vec![]; + } + words.push(Word { + time_ms: start, + end_ms: Some(end), + text: text.into(), + }); + } + words +} +pub(super) struct Supplements { + entries: Vec<(i64, T)>, + seen: HashSet, +} +impl Supplements { + pub(super) fn new() -> Self { + Self { + entries: Vec::new(), + seen: HashSet::new(), + } + } +} +pub(super) fn insert_first(supplements: &mut Supplements, time: i64, value: T) { + if supplements.seen.insert(time) { + supplements.entries.push((time, value)); + } +} +pub(super) fn align(lines: &[Line], supplements: Supplements) -> HashMap { + let mut matched: HashMap = HashMap::new(); + for (time, value) in supplements.entries { + let lo = lines.partition_point(|line| line.time_ms < time); + let mut nearest = None; + let mut distance = 11; + for index in [lo.checked_sub(1), Some(lo)].into_iter().flatten() { + let Some(line) = lines.get(index) else { + continue; + }; + let delta = line.time_ms.abs_diff(time); + if delta < distance as u64 { + nearest = Some(line.time_ms); + distance = delta as i64; + } + } + if let Some(nearest) = nearest + && matched.get(&nearest).is_none_or(|(old, _)| distance < *old) + { + matched.insert(nearest, (distance, value)); + } + } + matched + .into_iter() + .map(|(key, (_, value))| (key, value)) + .collect() +} +pub(super) fn shift(time: i64, offset: i64) -> i64 { + time.saturating_sub(offset).max(0) +} +pub(super) fn shift_words(words: &mut [Word], offset: i64) { + if offset == 0 { + return; + } + for word in words { + word.time_ms = shift(word.time_ms, offset); + word.end_ms = word.end_ms.map(|time| shift(time, offset)); + } +} diff --git a/rust_backend/crates/mobile/src/native_lyrics/ttml.rs b/rust_backend/crates/mobile/src/native_lyrics/ttml.rs new file mode 100644 index 00000000..52fda764 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_lyrics/ttml.rs @@ -0,0 +1,392 @@ +use super::text::{WHITE_SPACE, parse_int, trim, trim_end, whitespace}; +use super::{Line, Parsed, Voice, Word, has_usable_timing}; +use roxmltree::{Document, Node}; +use std::collections::HashMap; + +const META_NS: &str = "http://www.w3.org/ns/ttml#metadata"; +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; + +#[derive(Debug)] +struct Run { + voice: Option, + background: bool, + fragments: Vec<(String, Option, Option)>, +} +impl Run { + fn add(&mut self, value: &str, begin: Option, end: Option) { + let normalized = WHITE_SPACE.replace_all(value, " "); + let text = if self + .fragments + .last() + .is_none_or(|(text, _, _)| text.ends_with(' ')) + { + normalized.trim_start_matches(whitespace) + } else { + &normalized + }; + if text.is_empty() { + return; + } + if let Some((previous, old_begin, old_end)) = self.fragments.last_mut() + && (trim(text).is_empty() || (begin == *old_begin && end == *old_end)) + { + previous.push_str(text); + return; + } + self.fragments.push((text.into(), begin, end)); + } + fn text(&self) -> String { + trim( + &self + .fragments + .iter() + .map(|(text, _, _)| text.as_str()) + .collect::(), + ) + .into() + } + fn words(&self) -> Vec { + if self.fragments.iter().any(|(_, begin, _)| begin.is_none()) { + return vec![]; + } + self.fragments + .iter() + .enumerate() + .map(|(i, (text, begin, end))| Word { + time_ms: begin.unwrap(), + end_ms: end.filter(|end| *end >= begin.unwrap()), + text: if i + 1 == self.fragments.len() { + trim_end(text).into() + } else { + text.clone() + }, + }) + .collect() + } +} +fn voice_of( + element: Node<'_, '_>, + inherited: Option, + voices: &mut HashMap, + next: &mut i64, +) -> Option { + let Some(id) = element + .attribute((META_NS, "agent")) + .filter(|id| !id.is_empty()) + else { + return inherited; + }; + Some( + voices + .entry(id.into()) + .or_insert_with(|| { + let voice = Voice { + id: id.into(), + index: *next, + is_group: id.eq_ignore_ascii_case("v3"), + }; + *next += 1; + voice + }) + .clone(), + ) +} +fn rounded_ms(seconds: &str) -> Option { + let seconds = trim(seconds).parse::().ok()?; + let ms = seconds * 1000.0; + if !ms.is_finite() || ms < i64::MIN as f64 || ms >= -(i64::MIN as f64) { + return None; + } + Some(ms.round() as i64) +} +fn clock(value: Option<&str>) -> Option { + let value = trim(value?); + if let Some(seconds) = value.strip_suffix('s').filter(|_| !value.contains(':')) { + return rounded_ms(seconds); + } + let parts: Vec<_> = value.split(':').collect(); + match parts.as_slice() { + [hours, minutes, seconds] => parse_int(hours)? + .checked_mul(3_600_000)? + .checked_add(parse_int(minutes)?.checked_mul(60_000)?)? + .checked_add(rounded_ms(seconds)?), + [minutes, seconds] => parse_int(minutes)? + .checked_mul(60_000)? + .checked_add(rounded_ms(seconds)?), + _ => None, + } +} +#[allow(clippy::too_many_arguments)] +fn visit( + node: Node<'_, '_>, + paragraph: Node<'_, '_>, + voice: Option, + background: bool, + begin: Option, + end: Option, + voices: &mut HashMap, + next: &mut i64, + runs: &mut Vec, + check: &dyn Fn() -> Result<(), String>, +) -> Result<(), String> { + // Use a heap stack so a deeply nested span tree cannot exhaust the native + // worker's call stack. Reverse pushes preserve the original document order. + let mut pending = vec![(node, voice, background, begin, end)]; + while let Some((node, voice, background, begin, end)) = pending.pop() { + check()?; + if node.is_text() { + let value = node.text().unwrap_or_default(); + if trim(value).is_empty() && runs.is_empty() { + continue; + } + let index = runs.iter().position(|run| { + run.voice.as_ref().map(|v| &v.id) == voice.as_ref().map(|v| &v.id) + && run.background == background + }); + let index = match index { + Some(index) => index, + None => { + if trim(value).is_empty() { + continue; + } + runs.push(Run { + voice, + background, + fragments: vec![], + }); + runs.len() - 1 + } + }; + runs[index].add(value, begin, end); + } else if node.is_element() { + let voice = voice_of(node, voice, voices, next); + let background = background || node.attribute((META_NS, "role")) == Some("x-bg"); + let begin = if node == paragraph { + None + } else { + clock(node.attribute("begin")) + } + .or(begin); + let end = if node == paragraph { + None + } else { + clock(node.attribute("end")) + } + .or(end); + for child in node.children().rev() { + pending.push((child, voice.clone(), background, begin, end)); + } + } + } + Ok(()) +} +pub(super) fn parse_ttml( + text: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result, String> { + spotiflac_core::xml::check_depth(text)?; + let normalized = xml_source(text, true); + let Ok(doc) = Document::parse_with_options( + &normalized, + roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }, + ) else { + return Ok(None); + }; + let elements: Vec<_> = doc.descendants().filter(Node::is_element).collect(); + let paragraphs: Vec<_> = elements + .iter() + .copied() + .filter(|n| n.tag_name().name() == "p") + .collect(); + if paragraphs.is_empty() { + return Ok(None); + } + let mut voices = HashMap::new(); + let mut next = 0; + for agent in elements.iter().filter(|node| { + node.tag_name().name() == "agent" && node.tag_name().namespace() == Some(META_NS) + }) { + let Some(id) = agent.attribute((XML_NS, "id")).filter(|id| !id.is_empty()) else { + continue; + }; + let group = agent.attribute("type") == Some("group") || id.eq_ignore_ascii_case("v3"); + voices.insert( + id.into(), + Voice { + id: id.into(), + index: next, + is_group: group, + }, + ); + if !group { + next += 1; + } + } + let (mut lines, mut plain, mut saw_words, mut next_group) = + (Vec::::new(), Vec::new(), false, 0); + for p in paragraphs { + check()?; + let begin = clock(p.attribute("begin")); + let end = clock(p.attribute("end")); + let mut voice = None; + let ancestors: Vec<_> = p.ancestors().skip(1).filter(Node::is_element).collect(); + for ancestor in ancestors.into_iter().rev() { + voice = voice_of(ancestor, voice, &mut voices, &mut next); + } + let mut runs = Vec::new(); + visit( + p, + p, + voice, + false, + None, + None, + &mut voices, + &mut next, + &mut runs, + check, + )?; + let mut lead_groups = Vec::new(); + for (i, run) in runs.iter().enumerate() { + if !run.background && !run.text().is_empty() { + lead_groups.push((i, next_group)); + next_group += 1; + } + } + for (i, run) in runs.iter().enumerate() { + let text = run.text(); + if text.is_empty() { + continue; + } + plain.push(text.clone()); + let words = run.words(); + let Some(begin) = (if runs.len() == 1 { + begin + } else { + words.first().map(|word| word.time_ms).or(begin) + }) else { + continue; + }; + saw_words |= !words.is_empty(); + let lead = if run.background { + lead_groups + .iter() + .find(|(index, _)| { + runs[*index].voice.as_ref().map(|v| &v.id) + == run.voice.as_ref().map(|v| &v.id) + }) + .or(lead_groups.first()) + .map(|(index, _)| *index) + } else { + Some(i) + }; + let group = lead + .and_then(|index| { + lead_groups + .iter() + .find(|(i, _)| *i == index) + .map(|(_, group)| *group) + }) + .or_else(|| { + if run.background { + lines.last().and_then(|line| line.vocal_group) + } else { + None + } + }); + let end = if runs.len() == 1 { + end + } else { + words.last().and_then(|word| word.end_ms).or(end) + }; + lines.push(Line::new( + begin, + end, + text, + words, + run.voice.clone(), + run.background, + group, + )); + } + } + lines.sort_by_key(|line| line.time_ms); + let synced = has_usable_timing(&lines); + Ok(Some(Parsed { + synced, + word_synced: synced && saw_words, + lines, + plain_text: plain.join("\n"), + ..Parsed::default() + })) +} + +pub(super) fn xml_source(input: &str, drop_cdata: bool) -> String { + // Dart's default entity mapping leaves bare '&' and unknown references + // literal, including entities declared in a DOCTYPE. Escape those before + // using the stricter XML reader; never resolve external resources. + let mut result = String::with_capacity(input.len()); + let mut index = 0; + while index < input.len() { + let tail = &input[index..]; + let opaque = if tail.starts_with("", drop_cdata)) + } else if tail.starts_with("", false)) + } else if tail.starts_with("", false)) + } else { + None + }; + if let Some((ending, drop)) = opaque { + if let Some(end) = tail.find(ending) { + let end = end + ending.len(); + if !drop { + result.push_str(&tail[..end]); + } + index += end; + continue; + } + // A missing terminator is a syntax error; preserve it for the + // parser without repeatedly scanning the remainder of the input. + result.push_str(tail); + break; + } + if tail.starts_with("().ok()) }; + number.is_some_and(|value| matches!(value, 9 | 10 | 13 | 0x20..=0xd7ff | 0xe000..=0xfffd | 0x10000..=0x10ffff)) + }); + if known { + result.push('&'); + } else { + result.push_str("&"); + } + } else { + result.push(character); + } + index += character.len_utf8(); + } + result +}