use super::response::{SearchResponse, TrackDetailResponse};
use crate::providers::web::base_api;
use rand::Rng;
use std::sync::LazyLock;
pub const USER_AGENT: &str = "LunaPC/2.1.0(12292405)";
const SEARCH_USER_AGENT: &str = "com.luna.music/100198030 (Linux; U; Android 15; zh_CN_#Hans; ABR-AL80; Build/V417IR;tt-ok/3.12.13.19)";
const WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
static SEARCH_DEVICE_ID: LazyLock<String> = LazyLock::new(generate_client_id);
static SEARCH_INSTALL_ID: LazyLock<String> = LazyLock::new(generate_client_id);
fn generate_client_id() -> String {
let mut rng = rand::rng();
let a = rng.random_range(10_000_000..99_999_999);
let b = rng.random_range(10_000_000..99_999_999);
format!("{}{}", a, b)
}
const API_BASE: &str = "https://api.qishui.com/luna/";
const H5_BASE: &str = "https://beta-luna.douyin.com/luna/h5/";
fn build_url(base: &str, path: &str, query: &[(&str, String)]) -> String {
let qs: Vec<String> = query
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect();
format!("{}{}?{}", base, path, qs.join("&"))
}
pub async fn get_detail(track_id: &str) -> Option<TrackDetailResponse> {
let query = [
("track_id", track_id.to_string()),
("device_platform", "web".to_string()),
];
let url = build_url(H5_BASE, "seo_track", &query);
let headers = [
("Accept", "application/json"),
("User-Agent", WEB_USER_AGENT),
];
let mut result: TrackDetailResponse = base_api::get_json_with_headers(&url, &headers).await?;
if let Some(seo_track) = result.seo_track.as_ref() {
if result.track.is_none() {
result.track = seo_track.track.clone();
}
if result.track_player.is_none() {
result.track_player = seo_track.track_player.clone();
}
}
Some(result)
}
pub async fn get_lyrics(track_id: &str) -> Option<(Option<String>, Option<String>)> {
let detail = get_detail(track_id).await?;
let lyric = detail.lyric?;
let original = lyric.content.filter(|c| !c.is_empty());
let translation = lyric
.translations
.and_then(|t| t.cn)
.filter(|c| !c.is_empty());
Some((original, translation))
}
pub(crate) async fn search(keyword: &str) -> Option<SearchResponse> {
let query = [
("device_platform", "android".to_string()),
("os", "android".to_string()),
("ssmix", "a".to_string()),
("cdid", "46556f98-1720-4248-83da-62b74b60b46a".to_string()),
("channel", "xiaomi_8478_64".to_string()),
("aid", "386088".to_string()),
("app_name", "luna".to_string()),
("version_code", "100198030".to_string()),
("version_name", "19.8.0".to_string()),
("manifest_version_code", "100198030".to_string()),
("update_version_code", "100198030".to_string()),
("resolution", "1080*1920".to_string()),
("dpi", "480".to_string()),
("device_type", "ABR-AL80".to_string()),
("device_brand", "HUAWEI".to_string()),
("language", "zh".to_string()),
("os_api", "35".to_string()),
("os_version", "15".to_string()),
("ac", "wifi".to_string()),
("device_model", "ABR-AL80".to_string()),
("tz_name", "Asia/Shanghai".to_string()),
("tz_offset", "28800".to_string()),
("package", "com.luna.music".to_string()),
("sim_region", "cn".to_string()),
("iid", SEARCH_INSTALL_ID.clone()),
("device_id", SEARCH_DEVICE_ID.clone()),
("_rticket", base_api::unix_millis().to_string()),
("q", keyword.to_string()),
("cursor", "0".to_string()),
("count", "20".to_string()),
];
let url = build_url(API_BASE, "search/track", &query);
let headers = [("Accept", "*/*"), ("User-Agent", SEARCH_USER_AGENT)];
base_api::get_json_with_headers(&url, &headers).await
}