src/meta.rs
Rama main · réplica de lectura del repositorio original.
//! 网易云公开接口的元数据浏览层:榜单/歌单/歌手/专辑,带缓存与节流
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tokio::sync::Mutex;
use crate::state::TtlCache;
const UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
const BASE: &str = "https://music.163.com";
pub struct Meta {
client: reqwest::Client,
cache: TtlCache<Value>,
last_call: Mutex<Option<Instant>>,
}
impl Meta {
pub fn new(meta_ttl: u64) -> Self {
let client = reqwest::Client::builder()
.user_agent(UA)
.no_proxy()
.timeout(Duration::from_secs(10))
.build()
.expect("meta http client build failed");
Self {
client,
cache: TtlCache::new(meta_ttl),
last_call: Mutex::new(None),
}
}
/// 带节流的 GET(全局最小 1s 间隔),避免触发上游风控
async fn get(&self, path: &str, params: &[(&str, String)]) -> Result<Value, String> {
{
let mut last = self.last_call.lock().await;
if let Some(t) = *last {
let wait = Duration::from_secs(1).saturating_sub(t.elapsed());
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
}
*last = Some(Instant::now());
}
let resp = self
.client
.get(format!("{BASE}{path}"))
.query(params)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Value>().await.map_err(|e| e.to_string())
}
pub fn charts(&self) -> Value {
json!([
{"id": 3778678, "name": "热歌榜"},
{"id": 3779629, "name": "新歌榜"},
{"id": 19723756, "name": "飙升榜"},
{"id": 2884035, "name": "原创榜"},
])
}
/// 全量缓存 + 内存切片,分页零上游成本
pub async fn playlist_detail(
&self,
pid: u64,
limit: usize,
offset: usize,
) -> Result<Value, String> {
let key = format!("pl:{pid}");
let full = match self.cache.get(&key) {
Some(v) => v,
None => {
let d = self.get("/api/playlist/detail", &[("id", pid.to_string())]).await?;
let r = &d["result"];
let tracks = r["tracks"].as_array().cloned().unwrap_or_default();
let total = r["trackCount"].as_u64().unwrap_or(tracks.len() as u64);
let full = json!({
"name": r.get("name").cloned().unwrap_or(Value::Null),
"total": total,
"songs": tracks.iter().map(fmt_song).collect::<Vec<_>>(),
});
self.cache.put(key, full.clone());
full
}
};
let songs = full["songs"].as_array().cloned().unwrap_or_default();
let slice: Vec<Value> = songs.into_iter().skip(offset).take(limit).collect();
Ok(json!({"name": full["name"], "total": full["total"], "songs": slice}))
}
pub async fn playlist_search(
&self,
q: &str,
limit: usize,
offset: usize,
) -> Result<Value, String> {
let key = format!("pls:{q}");
let full = match self.cache.get(&key) {
Some(v) => v,
None => {
let d = self
.get(
"/api/search/get",
&[("s", q.to_string()), ("type", "1000".into()), ("limit", "100".into())],
)
.await?;
let r = &d["result"];
let pls = r["playlists"].as_array().cloned().unwrap_or_default();
let total = r["playlistCount"].as_u64().unwrap_or(pls.len() as u64);
let items: Vec<Value> = pls
.iter()
.map(|p| {
json!({
"id": p["id"].clone(),
"name": p["name"].clone(),
"count": p["trackCount"].clone(),
"play_count": p["playCount"].clone(),
"cover": p["coverImgUrl"].clone(),
})
})
.collect();
let full = json!({"total": total, "items": items});
self.cache.put(key, full.clone());
full
}
};
let items = full["items"].as_array().cloned().unwrap_or_default();
let slice: Vec<Value> = items.into_iter().skip(offset).take(limit).collect();
Ok(json!({"total": full["total"], "items": slice}))
}
/// 搜索歌手并返回热门歌曲
pub async fn artist_hot(&self, q: &str, limit: usize) -> Result<Value, String> {
let key = format!("ar:{q}:{limit}");
if let Some(v) = self.cache.get(&key) {
return Ok(v);
}
let d = self
.get(
"/api/search/get",
&[("s", q.to_string()), ("type", "100".into()), ("limit", "1".into())],
)
.await?;
let artists = d["result"]["artists"].as_array().cloned().unwrap_or_default();
let first = artists
.first()
.ok_or_else(|| format!("未找到歌手: {q}"))?;
let d2 = self.get(&format!("/api/artist/{}", first["id"]), &[]).await?;
let songs = d2["hotSongs"].as_array().cloned().unwrap_or_default();
let out = json!({
"artist": first["name"].clone(),
"songs": songs.iter().take(limit).map(fmt_song).collect::<Vec<_>>(),
});
self.cache.put(key, out.clone());
Ok(out)
}
pub async fn album_search(&self, q: &str, limit: usize) -> Result<Value, String> {
let key = format!("als:{q}");
let full = match self.cache.get(&key) {
Some(v) => v,
None => {
let d = self
.get(
"/api/search/get",
&[
("s", q.to_string()),
("type", "10".into()),
("limit", limit.to_string()),
],
)
.await?;
let r = &d["result"];
let albums = r["albums"].as_array().cloned().unwrap_or_default();
let total = r["albumCount"].as_u64().unwrap_or(albums.len() as u64);
let items: Vec<Value> = albums
.iter()
.map(|a| {
json!({
"id": a["id"].clone(),
"name": a["name"].clone(),
"artist": a["artist"]["name"].clone(),
"cover": a["picUrl"].as_str().map(|s| s.replace("100x100", "300x300")),
})
})
.collect();
let full = json!({"total": total, "items": items});
self.cache.put(key, full.clone());
full
}
};
let items = full["items"].as_array().cloned().unwrap_or_default();
let slice: Vec<Value> = items.into_iter().take(limit).collect();
Ok(json!({"total": full["total"], "items": slice}))
}
pub async fn album_detail(&self, aid: u64) -> Result<Value, String> {
let key = format!("al:{aid}");
if let Some(v) = self.cache.get(&key) {
return Ok(v);
}
let d = self.get(&format!("/api/v1/album/{aid}"), &[]).await?;
let al = &d["album"];
let songs = d["songs"].as_array().cloned().unwrap_or_default();
let out = json!({
"name": al["name"].clone(),
"artist": al["artist"]["name"].clone(),
"songs": songs.iter().map(fmt_song).collect::<Vec<_>>(),
});
self.cache.put(key, out.clone());
Ok(out)
}
}
/// 兼容新旧两种字段(artists/album 与 ar/al)
fn fmt_song(t: &Value) -> Value {
let artists = t
.get("artists")
.or_else(|| t.get("ar"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let album = t.get("album").or_else(|| t.get("al")).cloned().unwrap_or(Value::Null);
let artist = artists
.iter()
.filter_map(|a| a.get("name").and_then(|n| n.as_str()))
.collect::<Vec<_>>()
.join("/");
json!({
"title": t.get("name").cloned().unwrap_or(Value::Null),
"artist": artist,
"album": album.get("name").cloned().unwrap_or(Value::Null),
"cover": album.get("picUrl").cloned().unwrap_or(Value::Null),
})
}