use std::time::Duration;
use futures::{StreamExt, stream};
use reqwest::Url;
use serde_json::Value;
use tokio::pin;
use crate::{fetchers::{Fetcher, MAX_DEVIATION, MAX_TITLE_LENGTH, RESPONSE_TIMEOUT}, info_log, lyrics::Lyrics, song::SongData};
pub struct Lrclib;
const BASE_URL: &str = "https://lrclib.net/";
impl Fetcher for Lrclib {
async fn fetch(data: &SongData) -> Option<Vec<Lyrics>> {
let mut url_get = Url::parse(BASE_URL).unwrap();
url_get.set_path("api/get");
url_get.query_pairs_mut().append_pair("track_name", &data.title);
if let Some(artist) = &data.artist {
url_get.query_pairs_mut().append_pair("artist_name", artist);
}
if let Some(album) = &data.album {
url_get.query_pairs_mut().append_pair("album_name", album);
}
if let Some(duration) = &data.duration {
url_get.query_pairs_mut().append_pair("duration", &duration.to_string());
}
let mut url_search = Url::parse(BASE_URL).unwrap();
url_search.set_path("api/search");
url_search.query_pairs_mut().append_pair("q", &format!(
"{} {}",
data.title,
data.artist.as_ref().map_or("", |s| s),
).trim());
let race = stream::iter(vec![
request_and_parse(url_get),
request_and_parse(url_search),
]).buffer_unordered(2)
.filter_map(|choices| async move {
let choices = choices?;
let Some(duration) = data.duration else { return Some(choices) };
let choices: Vec<Lyrics> = choices
.into_iter()
.filter(|l| (l.duration - duration).abs() < MAX_DEVIATION)
.collect();
Some(choices)
})
.filter(|v| {
let empty = v.is_empty();
async move { !empty }
});
pin!(race);
let mut choices: Vec<Lyrics> = vec![];
let mut timeout = Box::pin(
tokio::time::sleep(Duration::from_secs_f64(RESPONSE_TIMEOUT)),
);
loop {
tokio::select! {
n = race.next() => {
let Some(mut n) = n else {
break;
};
choices.append(&mut n);
}
_ = (&mut timeout) => if !choices.is_empty() {
info_log("Timed out, only some lyrics found");
break;
}
}
}
if choices.is_empty() {
info_log(format!("Couldn't retrieve lyrics for {}", data.get_title_truncated(MAX_TITLE_LENGTH)));
return None;
}
if let Some(duration) = data.duration {
choices.sort_by(|a, b| (a.duration - duration).abs().total_cmp(&(b.duration - duration).abs()));
}
Some(choices)
}
}
async fn request_and_parse(request: Url) -> Option<Vec<Lyrics>> {
let response = match reqwest::get(request).await {
Ok(res) => res,
Err(e) => {
info_log(format!("Error requesting: {:?}", e));
return None;
},
};
let body = response.text().await.ok()?;
let json: Value = serde_json::from_str(&body).ok()?;
if json.is_array() {
let lyrics: Vec<Lyrics> = json.as_array()?
.iter()
.filter_map(|json| Lyrics::from_lrc_json(json))
.collect();
Some(lyrics)
} else {
Lyrics::from_lrc_json(&json).map(|l| vec![l])
}
}
pub fn convert_lrc(unformatted: String) -> Option<Vec<(f64, String)>> {
unformatted.split('\n')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| {
get_timestamp(s)
})
.filter(|s| match s {
Some((t, _)) => *t > 0.,
None => true,
})
.collect()
}
fn get_timestamp(line: String) -> Option<(f64, String)> {
let (Some(tag), line) = split_on_tag(&line) else {
if line.is_empty() { return None } else { return Some((-1., line)) }
};
let Some((minutes, seconds, milliseconds)): Option<(f64, f64, f64)> = tag.split_once(':')
.and_then(|(minutes, rest)| rest
.split_once('.')
.and_then(
|(seconds, milliseconds)| Some((
minutes.parse().ok()?,
seconds.parse().ok()?,
milliseconds.parse().ok()?,
)),
)
)
else {
return Some((-1., line));
};
Some(((minutes * 60.) + (seconds) + (milliseconds / 100.), line))
}
fn split_on_tag(line: &str) -> (Option<String>, String){
let line = line.trim();
if line.starts_with('[') {
match line.split_once(']') {
Some((tag, line)) => (
tag.strip_prefix('[').map(|s| s.to_string()),
line.trim().to_string(),
),
None => (None, line.to_string()),
}
} else {
(None, line.to_string())
}
}