use super::api_options::ApiOptions;
use super::response::{Track, TrackResponse};
use crate::error::SearchError;
use crate::providers::web::base_api;
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, RwLock};
const REQUEST_RETRY_COUNT: usize = 5;
const RESULT_RETRY_COUNT: usize = 5;
const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(250);
static OPTIONS: LazyLock<RwLock<ApiOptions>> = LazyLock::new(|| RwLock::new(ApiOptions::new()));
static CLIENT: LazyLock<RwLock<Client>> =
LazyLock::new(|| RwLock::new(create_client(&ApiOptions::new())));
static USER_TOKEN: LazyLock<RwLock<Option<String>>> = LazyLock::new(|| RwLock::new(None));
static TOKEN_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
static REQUEST_LOCK: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(|| Mutex::new(None));
pub async fn options() -> ApiOptions {
OPTIONS.read().await.clone()
}
pub async fn set_options(mut options: ApiOptions) -> Result<(), SearchError> {
validate_options(&mut options)?;
let client = create_client(&options);
*OPTIONS.write().await = options;
*CLIENT.write().await = client;
Ok(())
}
pub async fn set_user_token(token: &str) {
*USER_TOKEN.write().await = if is_usable_token(token) {
Some(token.to_string())
} else {
None
};
}
pub async fn get_user_token() -> Option<String> {
USER_TOKEN.read().await.clone()
}
pub async fn get_token() -> Result<Option<String>, SearchError> {
let response = request_token().await?;
Ok(response
.as_ref()
.and_then(|json| json["message"]["body"]["user_token"].as_str())
.map(str::to_string))
}
pub async fn search_tracks(
keyword: Option<&str>,
track: Option<&str>,
artist: Option<&str>,
duration_secs: Option<i32>,
) -> Result<Vec<Track>, SearchError> {
let mut parameters = vec![
"page_size=10".to_string(),
"page=1".to_string(),
"s_track_rating=desc".to_string(),
];
add_parameter(&mut parameters, "q", keyword);
add_parameter(&mut parameters, "q_track", track);
add_parameter(&mut parameters, "q_artist", artist);
if let Some(duration) = duration_secs.filter(|duration| *duration > 0) {
parameters.push(format!("q_duration={duration}"));
}
let request = format!("track.search?{}", parameters.join("&"));
for attempt in 0..RESULT_RETRY_COUNT {
let response = send_api_request(&request).await?;
if let Some(list) = body_of(&response)["track_list"].as_array() {
let results: Vec<Track> = list
.iter()
.filter_map(|item| Track::deserialize(&item["track"]).ok())
.collect();
if !results.is_empty() && has_related_result(&results, keyword, track, artist) {
return Ok(results);
}
}
if attempt + 1 < RESULT_RETRY_COUNT {
delay_before_result_retry(attempt).await;
}
}
Ok(Vec::new())
}
pub async fn search_track(
q_track: &str,
q_artist: &str,
user_token: &str,
) -> Result<Option<TrackResponse>, SearchError> {
set_user_token(user_token).await;
let track = search_tracks(None, Some(q_track), Some(q_artist), None)
.await?
.into_iter()
.next();
Ok(track.map(TrackResponse::from_track))
}
pub async fn get_full_lyrics_raw(
track_id: &str,
expected_vanity_id: Option<&str>,
) -> Result<Option<String>, SearchError> {
let Some(response) = get_full_lyrics_value(track_id, expected_vanity_id).await? else {
return Ok(None);
};
let raw = serde_json::to_string(&response)
.map_err(|error| SearchError::Payload(format!("Musixmatch 歌词响应序列化失败:{error}")))?;
Ok(Some(raw))
}
async fn get_full_lyrics_value(
track_id: &str,
expected_vanity_id: Option<&str>,
) -> Result<Option<Value>, SearchError> {
let id: i64 = track_id
.parse()
.map_err(|_| SearchError::Payload(format!("Musixmatch 曲目 ID 不是整数:{track_id}")))?;
for attempt in 0..RESULT_RETRY_COUNT {
let response = get_lyrics_response(id).await?;
if let Some(track) = get_matched_track(&response)
&& track.track_id == id
&& vanity_matches(expected_vanity_id, track.commontrack_vanity_id.as_deref())
{
return Ok(Some(response));
}
if attempt + 1 < RESULT_RETRY_COUNT {
delay_before_result_retry(attempt).await;
}
}
Ok(None)
}
pub async fn get_lyrics(track_id: i64, user_token: &str) -> Result<Option<String>, SearchError> {
set_user_token(user_token).await;
let response = get_full_lyrics_value(&track_id.to_string(), None).await?;
Ok(response.as_ref().and_then(unsynced_lyrics_from))
}
pub async fn get_synced_lyrics(
track_id: i64,
user_token: &str,
) -> Result<Option<String>, SearchError> {
set_user_token(user_token).await;
let response = get_full_lyrics_value(&track_id.to_string(), None).await?;
Ok(response.as_ref().and_then(synced_lyrics_from))
}
async fn request_token() -> Result<Option<Value>, SearchError> {
let options = OPTIONS.read().await.clone();
let url = format!(
"{}token.get?user_language=en&app_id={}&t={}",
options.api_base_url,
urlencoding::encode(&options.app_id),
urlencoding::encode(&options.request_id_factory.create()),
);
let response = get_response(&url).await?;
if !response.is_success() {
return Err(SearchError::Status(response.status));
}
if response.content.trim().is_empty() {
return Ok(None);
}
let json = parse_json(&response.content)?;
let header = &json["message"]["header"];
let status_code = header["status_code"].as_i64();
let hint = header["hint"].as_str();
if status_code == Some(401) && is_hint(hint, "captcha") {
return Err(SearchError::Captcha);
}
if status_code != Some(200) {
return Err(SearchError::Api(api_status_message(status_code, hint)));
}
Ok(Some(json))
}
async fn send_api_request(request: &str) -> Result<Value, SearchError> {
let mut last_error = None;
for attempt in 0..REQUEST_RETRY_COUNT {
match send_api_request_once(request).await {
Ok(json) => return Ok(json),
Err(error @ SearchError::Captcha) => return Err(error),
Err(error) => last_error = Some(error),
}
if attempt + 1 < REQUEST_RETRY_COUNT {
delay_before_request_retry(attempt).await;
}
}
Err(last_error.unwrap_or_else(|| {
SearchError::Api("Musixmatch request failed after all retries.".to_string())
}))
}
async fn send_api_request_once(request: &str) -> Result<Value, SearchError> {
let token = ensure_user_token().await?;
let options = OPTIONS.read().await.clone();
let separator = if request.contains('?') { '&' } else { '?' };
let url = format!(
"{}{}{}usertoken={}&format=json&app_id={}&t={}",
options.api_base_url,
request,
separator,
urlencoding::encode(&token),
urlencoding::encode(&options.app_id),
urlencoding::encode(&options.request_id_factory.create()),
);
let response = get_response(&url).await?;
if response.content.trim().is_empty() {
return Err(SearchError::Status(response.status));
}
let json = parse_json(&response.content)?;
let header = &json["message"]["header"];
let status_code = header["status_code"].as_i64();
let hint = header["hint"].as_str();
if status_code == Some(404) {
return Ok(json);
}
if !response.is_success() {
return Err(SearchError::Status(response.status));
}
if status_code == Some(200) {
return Ok(json);
}
if status_code == Some(401) && is_hint(hint, "renew") {
invalidate_token().await;
} else if status_code == Some(401) && is_hint(hint, "captcha") {
return Err(SearchError::Captcha);
}
Err(SearchError::Api(api_status_message(status_code, hint)))
}
async fn ensure_user_token() -> Result<String, SearchError> {
if let Some(token) = usable_token().await {
return Ok(token);
}
let _guard = TOKEN_LOCK.lock().await;
if let Some(token) = usable_token().await {
return Ok(token);
}
let response = request_token().await?;
let token = response
.as_ref()
.and_then(|json| json["message"]["body"]["user_token"].as_str())
.map(str::to_string)
.filter(|token| is_usable_token(token));
match token {
Some(token) => {
*USER_TOKEN.write().await = Some(token.clone());
Ok(token)
}
None => Err(SearchError::Api(
"Musixmatch token request failed.".to_string(),
)),
}
}
async fn usable_token() -> Option<String> {
let token = USER_TOKEN.read().await.clone();
token.filter(|token| is_usable_token(token))
}
async fn invalidate_token() {
*USER_TOKEN.write().await = None;
}
async fn get_response(url: &str) -> Result<RawResponse, SearchError> {
let mut last_request = REQUEST_LOCK.lock().await;
if let Some(previous) = *last_request {
let elapsed = previous.elapsed();
if elapsed < MIN_REQUEST_INTERVAL {
tokio::time::sleep(MIN_REQUEST_INTERVAL - elapsed).await;
}
}
let options = OPTIONS.read().await.clone();
let client = CLIENT.read().await.clone();
let mut request = client.get(url);
if let Some(user_agent) = options
.user_agent
.as_deref()
.filter(|value| !value.trim().is_empty())
{
request = request.header("User-Agent", user_agent);
}
if let Some(cookie) = options
.cookie
.as_deref()
.filter(|value| !value.trim().is_empty())
{
request = request.header("Cookie", cookie);
}
let response = request.send().await?;
let status = response.status();
let content = response.text().await?;
*last_request = Some(Instant::now());
Ok(RawResponse {
status: status.as_u16(),
content,
})
}
async fn get_lyrics_response(track_id: i64) -> Result<Value, SearchError> {
send_api_request(&format!(
"macro.subtitles.get?namespace=lyrics_richsynched\
&optional_calls=track.richsync\
&subtitle_format=lrc\
&track_id={track_id}\
&f_subtitle_length_max_deviation=40"
))
.await
}
fn get_matched_track(response: &Value) -> Option<Track> {
let track = &response["message"]["body"]["macro_calls"]["matcher.track.get"]["message"]["body"]
["track"];
Track::deserialize(track).ok()
}
fn unsynced_lyrics_from(response: &Value) -> Option<String> {
response["message"]["body"]["macro_calls"]["track.lyrics.get"]["message"]["body"]["lyrics"]
["lyrics_body"]
.as_str()
.map(str::to_string)
}
fn synced_lyrics_from(response: &Value) -> Option<String> {
response["message"]["body"]["macro_calls"]["track.subtitles.get"]["message"]["body"]
["subtitle_list"][0]["subtitle"]["subtitle_body"]
.as_str()
.map(str::to_string)
}
fn body_of(response: &Value) -> &Value {
&response["message"]["body"]
}
fn parse_json(content: &str) -> Result<Value, SearchError> {
serde_json::from_str(content).map_err(SearchError::Json)
}
fn add_parameter(parameters: &mut Vec<String>, name: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
parameters.push(format!("{name}={}", urlencoding::encode(value)));
}
}
fn has_related_result(
results: &[Track],
keyword: Option<&str>,
title: Option<&str>,
artist: Option<&str>,
) -> bool {
let keyword_tokens = tokenize(keyword);
let title_tokens = tokenize(title);
let artist_tokens = tokenize(artist);
if keyword_tokens.is_empty() && title_tokens.is_empty() && artist_tokens.is_empty() {
return true;
}
results.iter().any(|result| {
let actual_title = result.track_name.to_lowercase();
let actual_artists = result.artist_name.to_lowercase();
if !title_tokens.is_empty()
&& !title_tokens
.iter()
.all(|token| actual_title.contains(token))
{
return false;
}
if !artist_tokens.is_empty()
&& !artist_tokens
.iter()
.any(|token| actual_artists.contains(token))
{
return false;
}
if keyword_tokens.is_empty() {
return true;
}
let actual = format!("{actual_title} {actual_artists}");
let required_matches =
std::cmp::max(1, (keyword_tokens.len() as f64 * 0.6).ceil() as usize);
keyword_tokens
.iter()
.filter(|token| actual.contains(*token))
.count()
>= required_matches
})
}
fn tokenize(value: Option<&str>) -> Vec<String> {
value
.unwrap_or_default()
.to_lowercase()
.split([' ', '-', '_', '/', ',', '.', '(', ')', '[', ']', '&'])
.filter(|token| token.chars().count() > 1)
.map(str::to_string)
.collect()
}
fn is_usable_token(token: &str) -> bool {
!token.trim().is_empty() && token != "null" && token.chars().any(|c| c != '0')
}
fn normalize_vanity(value: &str) -> String {
let decoded = urlencoding::decode(value)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| value.to_string());
decoded.trim().trim_matches('/').to_string()
}
fn vanity_matches(expected: Option<&str>, actual: Option<&str>) -> bool {
match expected.filter(|expected| !expected.trim().is_empty()) {
Some(expected) => normalize_vanity(expected)
.eq_ignore_ascii_case(&normalize_vanity(actual.unwrap_or_default())),
None => true,
}
}
fn is_hint(hint: Option<&str>, expected: &str) -> bool {
hint.is_some_and(|hint| hint.eq_ignore_ascii_case(expected))
}
fn api_status_message(status_code: Option<i64>, hint: Option<&str>) -> String {
let status = status_code
.map(|status| status.to_string())
.unwrap_or_else(|| "未知".to_string());
match hint.filter(|hint| !hint.trim().is_empty()) {
Some(hint) => format!("Musixmatch 返回业务状态码 {status}(hint: {hint})"),
None => format!("Musixmatch 返回业务状态码 {status}"),
}
}
async fn delay_before_request_retry(attempt: usize) {
let delay = std::cmp::min(500 * (1 << attempt), 2000);
tokio::time::sleep(Duration::from_millis(delay)).await;
}
async fn delay_before_result_retry(attempt: usize) {
let delay = std::cmp::min(200 * (attempt as u64 + 1), 800);
tokio::time::sleep(Duration::from_millis(delay)).await;
}
fn create_client(options: &ApiOptions) -> Client {
Client::builder()
.timeout(options.timeout)
.build()
.expect("Failed to create HTTP client")
}
fn validate_options(options: &mut ApiOptions) -> Result<(), SearchError> {
if options.api_base_url.trim().is_empty() {
return Err(SearchError::InvalidConfig(
"Musixmatch API base URL is required.".to_string(),
));
}
if !options.api_base_url.ends_with('/') {
options.api_base_url.push('/');
}
if options.app_id.trim().is_empty() {
return Err(SearchError::InvalidConfig(
"Musixmatch app ID is required.".to_string(),
));
}
if options.timeout.is_zero() {
return Err(SearchError::InvalidConfig(
"Musixmatch request timeout must be greater than zero.".to_string(),
));
}
Ok(())
}
struct RawResponse {
status: u16,
content: String,
}
impl RawResponse {
fn is_success(&self) -> bool {
base_api::StatusCode::from_u16(self.status).is_ok_and(|status| status.is_success())
}
}