selene-core 0.6.0

selene-core is the backend for Selene, a local-first music player
Documentation
use std::{fmt::Write, sync::LazyLock};

use regex::Regex;
use serde::{Deserialize, Serialize};

mod lyric_line;
pub use lyric_line::*;

mod lyric_span;
pub use lyric_span::*;

static SPAN_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"<(\d+):(\d+\.\d+)>([^<]*)").unwrap());

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SyncedLyrics {
    lines: Vec<LyricLineType>,
}

pub struct SyncedLyricsBuilder {
    lines: Vec<LyricLineType>,
    offset: u32,
}

impl SyncedLyrics {
    pub fn lines(&self) -> &[LyricLineType] {
        &self.lines
    }

    pub fn lyric_lines(&self) -> Vec<&LyricLine> {
        self.lines
            .iter()
            .filter_map(|l| match l {
                LyricLineType::Lyric(l) => Some(l),
                LyricLineType::Break => None,
            })
            .collect()
    }

    pub fn from_synced_lyrics(string: impl AsRef<str>) -> Result<Self, LyricParseError> {
        let lines = string.as_ref().lines();

        let mut builder = SyncedLyricsBuilder::new();

        for line in lines {
            match LyricLineType::from_lyric_line(line)? {
                LyricLineParseResult::Line(lyric_line) => builder.add_line(lyric_line),
                LyricLineParseResult::Break => builder.add_break(),
                LyricLineParseResult::Offset(offset) => builder.set_offset(offset),
                LyricLineParseResult::Ignored => (),
            }
        }

        Ok(builder.build())
    }

    pub fn to_lrc_string(&self) -> String {
        let mut buf = String::new();

        for line in &self.lines {
            match line {
                LyricLineType::Break => buf.write_char('\n').unwrap(),
                LyricLineType::Lyric(lyric_line) => {
                    writeln!(buf, "{}", lyric_line.to_lrc_string()).unwrap()
                }
            }
        }

        buf
    }

    pub fn to_plain_lyrics(&self) -> String {
        let mut buf = String::new();

        for line in &self.lines {
            match line {
                LyricLineType::Break => buf.write_char('\n').unwrap(),
                LyricLineType::Lyric(lyric_line) => {
                    writeln!(buf, "{}", lyric_line.to_plain_string()).unwrap()
                }
            }
        }

        buf.trim().to_owned()
    }
}

impl LyricSpan {
    pub fn new(lyric: &str, start_us: u32) -> Self {
        Self {
            start_us,
            vocals: lyric.to_owned(),
            vocalists: vec![0],
        }
    }

    pub fn from_lyric_str(lyric: &str, start_us: u32) -> Result<Vec<Self>, LyricParseError> {
        let first_span_end = lyric.find('<').unwrap_or(lyric.len());
        let initial_text = &lyric[..first_span_end];

        let mut spans: Vec<Self> = SPAN_REGEX
            .captures_iter(lyric)
            .map(|cap| {
                let mins = &cap[1];
                let secs = &cap[2];

                let mins: u32 = mins
                    .parse()
                    .map_err(|_| LyricParseError::InvalidSpanTimestamp(format!("{mins}:{secs}")))?;
                let secs: f64 = secs
                    .parse()
                    .map_err(|_| LyricParseError::InvalidSpanTimestamp(format!("{mins}:{secs}")))?;

                let start_us = mins * 60_000_000 + (secs * 1_000_000.0) as u32;
                let lyric = &cap[3];

                Ok(Self::new(lyric, start_us))
            })
            .collect::<Result<Vec<_>, LyricParseError>>()?;

        if spans.is_empty() {
            Ok(vec![Self::new(lyric, start_us)])
        } else {
            if !initial_text.is_empty() {
                spans.insert(0, Self::new(initial_text, start_us));
            }
            Ok(spans)
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum LyricParseError {
    #[error("Found an invalid span timestamp when parsing a LyricLine: {0}")]
    InvalidLineTimestamp(String),

    #[error("LyricLine timestamp was corrupted")]
    CorruptLineTag,

    #[error("Found an invalid span timestamp when parsing a LyricSpan: {0}")]
    InvalidSpanTimestamp(String),
}

impl Default for SyncedLyricsBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl SyncedLyricsBuilder {
    pub fn new() -> Self {
        Self {
            lines: Vec::new(),
            offset: 0,
        }
    }

    pub fn add_line(&mut self, line: LyricLine) {
        self.lines.push(LyricLineType::Lyric(line));
    }

    pub fn add_break(&mut self) {
        if self.lines.last().is_none_or(|l| *l == LyricLineType::Break) {
            return;
        }

        self.lines.push(LyricLineType::Break);
    }

    pub fn set_offset(&mut self, offset: u32) {
        self.offset = offset
    }

    pub fn build(mut self) -> SyncedLyrics {
        for line in &mut self.lines {
            match line {
                LyricLineType::Break => (),
                LyricLineType::Lyric(lyric_line) => lyric_line.start_us += self.offset,
            }
        }

        SyncedLyrics { lines: self.lines }
    }
}