selene-core 0.7.1

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

use lunar_lib::iterator_ext::IteratorExtensions;
use regex::Regex;
use rgb::Rgb;
use serde::{Deserialize, Serialize};

mod lyric_line;
pub use lyric_line::*;

mod lyric_span;
pub use lyric_span::*;

use crate::lyrics::LyricFormat;

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<LyricLine>,
    vocalists: Vec<(String, Option<Rgb<u8>>)>,
    tool: Option<String>,
    lrc_author: Option<String>,
}

pub struct SyncedLyricsBuilder {
    lines: Vec<Option<LyricLine>>,
    offset: u32,
    vocalists: Vec<(String, Option<Rgb<u8>>)>,
    tool: Option<String>,
    lrc_author: Option<String>,
}

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

    #[must_use]
    pub fn vocalists(&self) -> &[(String, Option<Rgb<u8>>)] {
        &self.vocalists
    }

    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 RawLyricLine::from_lyric_line(line)? {
                RawLyricLine::Line(lyric_line) => builder.add_line(lyric_line),
                RawLyricLine::Break => builder.add_break(),
                RawLyricLine::Offset(offset) => builder.set_offset(offset),
                RawLyricLine::Vocalist(vocalist, color) => builder.add_vocalist(vocalist, color),
                RawLyricLine::By(by) => builder.set_lrc_author(by),
                RawLyricLine::Tool(tool) => builder.set_tool(tool),
                RawLyricLine::Ignored => (),
            }
        }

        Ok(builder.build())
    }

    #[must_use]
    pub fn to_lyrics(&self, format: LyricFormat) -> String {
        self.lines
            .iter()
            .map(|l| l.to_lyrics(format))
            .to_vec()
            .join("\n")
    }
}

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

    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)
            .try_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();

        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(
        "Found an invalid span timestamp when parsing a timestamp: '{0}'\nFormat should be '[0,1,2,3]'"
    )]
    InvalidLineVocalists(String),

    #[error("LyricLine timestamp was corrupted: '{0}'")]
    CorruptLineTimestamp(String),

    #[error("A tag was formatted incorrectly: '{0}'")]
    CorruptLineTag(String),

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

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

impl SyncedLyricsBuilder {
    #[must_use]
    pub fn new() -> Self {
        Self {
            lines: Vec::new(),
            offset: 0,
            vocalists: Vec::new(),
            tool: None,
            lrc_author: None,
        }
    }

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

    pub fn add_break(&mut self) {
        if self.lines.last().is_some_and(|l| l.is_some()) {
            self.lines.push(None);
        }
    }

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

    #[must_use]
    pub fn build(self) -> SyncedLyrics {
        let mut iter = self.lines.into_iter().peekable();
        let mut lines = Vec::with_capacity(iter.len());

        while let Some(item) = iter.next() {
            match item {
                Some(l) => lines.push(l),
                None => {
                    if let Some(start_us) = iter.peek().and_then(|x| x.as_ref()).map(|l| l.start_us)
                    {
                        lines.push(LyricLine {
                            start_us,
                            vocals: Vec::new(),
                            vocalist: None,
                        });
                    }
                }
            }
        }

        SyncedLyrics {
            lines,
            vocalists: self.vocalists,
            tool: self.tool,
            lrc_author: self.lrc_author,
        }
    }

    fn add_vocalist(&mut self, vocalist: String, color: Option<Rgb<u8>>) {
        self.vocalists.push((vocalist, color));
    }

    fn set_lrc_author(&mut self, by: String) {
        self.lrc_author = Some(by)
    }

    fn set_tool(&mut self, tool: String) {
        self.tool = Some(tool)
    }
}