lavende-core 0.1.15

Core in-process Discord voice connection and playback engine
Documentation
use std::sync::LazyLock;

use async_trait::async_trait;
use regex::Regex;

use super::{LyricsProvider, utils};
use crate::protocol::{
    models::{LyricsData, LyricsLine},
    tracks::TrackInfo,
};

static PRELOADED_STATE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r#"(?s)window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\('(.*?)'\);"#)
        .expect("invalid PRELOADED_STATE_RE pattern")
});
static ESCAPE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r#"\\(.)"#).expect("invalid ESCAPE_RE pattern"));
static TAG_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r#"<[^>]*>"#).expect("invalid TAG_RE pattern"));

#[derive(Default)]
pub struct GeniusProvider {
    client: reqwest::Client,
}

impl GeniusProvider {
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl LyricsProvider for GeniusProvider {
    fn name(&self) -> &'static str {
        "genius"
    }

    async fn load_lyrics(&self, track: &TrackInfo) -> Option<LyricsData> {
        let title = utils::clean_text(&track.title);
        let author = utils::clean_text(&track.author);

        let query = if title.to_lowercase().starts_with(&author.to_lowercase()) {
            title.clone()
        } else {
            format!("{} {}", title, author)
        };

        let url = format!(
            "https://genius.com/api/search/multi?q={}",
            urlencoding::encode(&query)
        );
        let resp = self.client.get(url).send().await.ok()?;
        let search_data: serde_json::Value = resp.json().await.ok()?;

        let song = search_data["response"]["sections"]
            .as_array()?
            .iter()
            .find(|s| s["type"] == "song")?["hits"]
            .as_array()?
            .first()?["result"]
            .clone();

        let song_path = song["path"].as_str()?;
        let song_url = format!("https://genius.com{}", song_path);

        let song_resp = self.client.get(song_url).send().await.ok()?;
        let song_page = song_resp.text().await.ok()?;

        let re = &*PRELOADED_STATE_RE;
        let caps = re.captures(&song_page)?;
        let lyrics_data_raw = caps.get(1)?.as_str();

        let escape_re = &*ESCAPE_RE;
        let lyrics_data_unescaped = escape_re.replace_all(lyrics_data_raw, "$1");

        let lyrics_json: serde_json::Value = serde_json::from_str(&lyrics_data_unescaped).ok()?;

        let lyrics_content = lyrics_json["songPage"]["lyricsData"]["body"]["html"].as_str()?;

        let tag_re = &*TAG_RE;
        let lyrics_text = lyrics_content
            .replace("<br>", "\n")
            .replace("<br/>", "\n")
            .replace("<br />", "\n");
        let cleaned_lyrics = utils::unescape_html(&tag_re.replace_all(&lyrics_text, ""));

        let lines: Vec<LyricsLine> = cleaned_lyrics
            .lines()
            .map(|l| l.trim())
            .filter(|l| !l.is_empty())
            .filter(|l| !(l.starts_with('[') && l.ends_with(']')))
            .map(|l| LyricsLine {
                text: l.to_string(),
                timestamp: 0,
                duration: 0,
            })
            .collect();

        if lines.is_empty() {
            return None;
        }

        let full_text = lines
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");

        Some(LyricsData {
            name: song["title"].as_str().unwrap_or("original").to_string(),
            author: song["primary_artist"]["name"]
                .as_str()
                .unwrap_or(&track.author)
                .to_string(),
            provider: "genius".to_string(),
            text: full_text,
            lines: None,
        })
    }
}