use std::collections::HashSet;
use std::sync::{Arc, LazyLock, PoisonError, RwLock};
use std::time::{Duration, Instant};
use reqwest::{Method, StatusCode};
use tokio::sync::Mutex;
use super::response::{LyricsEntry, RawIndexLine};
use crate::error::SearchError;
use crate::providers::web::base_api;
pub const GITHUB_BASE_URL: &str =
"https://raw.githubusercontent.com/amll-dev/amll-ttml-db/refs/heads/main";
pub const BIKONOO_BASE_URL: &str = "https://amlldb.bikonoo.com";
pub const GBCLSTUDIO_BASE_URL: &str = "https://amll-ttml-db.gbclstudio.cn";
const INDEX_TTL: Duration = Duration::from_secs(60 * 60);
const MAX_SEARCH_RESULTS: usize = 50;
static BASE_URL: LazyLock<RwLock<String>> =
LazyLock::new(|| RwLock::new(GITHUB_BASE_URL.to_string()));
static INDEX: LazyLock<Mutex<Option<CachedIndex>>> = LazyLock::new(|| Mutex::new(None));
struct CachedIndex {
base_url: String,
fetched_at: Instant,
entries: Arc<[LyricsEntry]>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Netease,
QQMusic,
AppleMusic,
Spotify,
}
impl Platform {
fn folder(self) -> &'static str {
match self {
Self::Netease => "ncm-lyrics",
Self::QQMusic => "qq-lyrics",
Self::AppleMusic => "am-lyrics",
Self::Spotify => "spotify-lyrics",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Ttml,
Lrc,
Yrc,
Qrc,
Lys,
Eslrc,
}
impl Format {
fn extension(self) -> &'static str {
match self {
Self::Ttml => "ttml",
Self::Lrc => "lrc",
Self::Yrc => "yrc",
Self::Qrc => "qrc",
Self::Lys => "lys",
Self::Eslrc => "eslrc",
}
}
}
pub fn base_url() -> String {
BASE_URL
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub fn set_base_url(url: &str) -> Result<(), SearchError> {
let url = url.trim().trim_end_matches('/');
if !(url.starts_with("https://") || url.starts_with("http://")) {
return Err(SearchError::InvalidConfig(format!(
"AMLL TTML DB 地址须以 http(s):// 开头:{url}"
)));
}
*BASE_URL.write().unwrap_or_else(PoisonError::into_inner) = url.to_string();
Ok(())
}
pub async fn get_lyrics(
platform: Platform,
id: &str,
format: Format,
) -> Result<Option<String>, SearchError> {
let path = format!(
"{}/{}.{}",
platform.folder(),
urlencoding::encode(id),
format.extension()
);
get_text(&path).await
}
pub async fn get_raw_lyrics(raw_lyric_file: &str) -> Result<Option<String>, SearchError> {
get_text(&format!(
"raw-lyrics/{}",
urlencoding::encode(raw_lyric_file)
))
.await
}
pub async fn search(keyword: &str) -> Result<Vec<LyricsEntry>, SearchError> {
let terms = search_terms(keyword);
if terms.is_empty() {
return Ok(Vec::new());
}
let entries = index().await?;
Ok(entries
.iter()
.filter(|entry| matches_terms(entry, &terms))
.take(MAX_SEARCH_RESULTS)
.cloned()
.collect())
}
pub async fn index() -> Result<Arc<[LyricsEntry]>, SearchError> {
let base_url = base_url();
let mut cache = INDEX.lock().await;
if let Some(cached) = cache.as_ref()
&& cached.base_url == base_url
&& cached.fetched_at.elapsed() < INDEX_TTL
{
return Ok(cached.entries.clone());
}
let url = format!("{base_url}/metadata/raw-lyrics-index.jsonl");
let response = base_api::send(Method::GET, &url, &[]).await?;
let entries: Arc<[LyricsEntry]> = parse_index(&base_api::text(response).await?).into();
*cache = Some(CachedIndex {
base_url,
fetched_at: Instant::now(),
entries: entries.clone(),
});
Ok(entries)
}
async fn get_text(path: &str) -> Result<Option<String>, SearchError> {
let url = format!("{}/{path}", base_url());
let response = base_api::send(Method::GET, &url, &[]).await?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
Ok(Some(base_api::text(response).await?))
}
fn parse_index(text: &str) -> Vec<LyricsEntry> {
let mut seen = HashSet::new();
let mut entries = Vec::new();
for line in text.lines().rev() {
let Ok(raw) = serde_json::from_str::<RawIndexLine>(line) else {
continue;
};
let entry = LyricsEntry::from(raw);
let keys = identity_keys(&entry);
if keys.iter().any(|key| seen.contains(key)) {
continue;
}
seen.extend(keys);
entries.push(entry);
}
entries
}
fn identity_keys(entry: &LyricsEntry) -> Vec<String> {
let platforms = [
("ncm", &entry.ncm_music_ids),
("qq", &entry.qq_music_ids),
("am", &entry.apple_music_ids),
("spotify", &entry.spotify_ids),
];
let keys: Vec<String> = platforms
.iter()
.flat_map(|(platform, ids)| ids.iter().map(move |id| format!("{platform}:{id}")))
.collect();
if !keys.is_empty() {
return keys;
}
vec![format!(
"meta:{}|{}",
entry.title().to_lowercase(),
entry.artists.join(",").to_lowercase()
)]
}
fn search_terms(keyword: &str) -> Vec<String> {
keyword
.split_whitespace()
.filter(|term| term.chars().any(char::is_alphanumeric))
.map(str::to_lowercase)
.collect()
}
fn matches_terms(entry: &LyricsEntry, terms: &[String]) -> bool {
let haystack = entry
.music_names
.iter()
.chain(&entry.artists)
.chain(&entry.albums)
.map(|field| field.to_lowercase())
.collect::<Vec<_>>()
.join("\n");
terms.iter().all(|term| haystack.contains(term.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
const INDEX: &str = r#"{"metadata":[["album",["Idol"]],["artists",["YOASOBI"]],["musicName",["Idol"]],["ncmMusicId",["2048982668"]],["ttmlAuthorGithubLogin",["Steve-xmh"]]],"rawLyricFile":"1-old.ttml"}
not json
{"metadata":[["album",["ウェザーステーション"]],["artists",["稲葉曇","歌愛ユキ"]],["musicName",["ラグトレイン"]]],"rawLyricFile":"2-lagtrain.ttml"}
{"metadata":[["album",["Idol"]],["artists",["YOASOBI"]],["musicName",["Idol"]],["ncmMusicId",["2048982668"]],["qqMusicId",["003tS1Ss1q2Y9A"]],["Composer",["Ayase"]]],"rawLyricFile":"3-new.ttml"}"#;
#[test]
fn keeps_only_the_newest_revision_of_each_song() {
let entries = parse_index(INDEX);
assert_eq!(
entries
.iter()
.map(|entry| entry.raw_lyric_file.as_str())
.collect::<Vec<_>>(),
vec!["3-new.ttml", "2-lagtrain.ttml"]
);
let idol = &entries[0];
assert_eq!(idol.title(), "Idol");
assert_eq!(idol.qq_music_ids, vec!["003tS1Ss1q2Y9A".to_string()]);
assert_eq!(entries[1].artists, vec!["稲葉曇", "歌愛ユキ"]);
}
#[test]
fn every_search_term_must_match_some_field() {
let entries = parse_index(INDEX);
let find = |keyword: &str| {
let terms = search_terms(keyword);
entries
.iter()
.filter(|entry| matches_terms(entry, &terms))
.map(|entry| entry.raw_lyric_file.as_str())
.collect::<Vec<_>>()
};
assert_eq!(find("idol yoasobi"), vec!["3-new.ttml"]);
assert_eq!(find("ラグトレイン - 歌愛ユキ"), vec!["2-lagtrain.ttml"]);
assert!(find("idol 稲葉曇").is_empty());
assert!(search_terms(" - ").is_empty());
}
#[test]
fn rejects_a_base_url_without_scheme() {
assert!(matches!(
set_base_url("amlldb.bikonoo.com"),
Err(SearchError::InvalidConfig(_))
));
}
}