use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use tracing::debug;
use super::{Session, date_from_ms, names, song_from_json};
use crate::model::{
Album, AlbumHit, AlbumKind, AlbumOrder, ArtistAlbum, SingerHit, Song, tracks_for,
};
const SEARCH: &str = "https://music.163.com/api/cloudsearch/pc";
const DETAIL: &str = "https://interface3.music.163.com/api/v3/song/detail";
const LYRIC: &str = "https://interface3.music.163.com/api/song/lyric";
const PLAYLIST: &str = "https://music.163.com/api/v6/playlist/detail";
const SEARCH_PAGE: usize = 100;
const DETAIL_BATCH: usize = 200;
const ARTIST_ALBUM_PAGE: usize = 1000;
async fn cloudsearch(
session: &Session,
keyword: &str,
ty: u32,
limit: usize,
offset: usize,
) -> Result<Value> {
let (ty, limit, offset) = (ty.to_string(), limit.to_string(), offset.to_string());
let v = session
.post_form(
SEARCH,
&[
("s", keyword),
("type", &ty),
("limit", &limit),
("offset", &offset),
],
)
.await
.context("网易云搜索失败")?;
Ok(v.get("result").cloned().unwrap_or(Value::Null))
}
pub async fn search(session: &Session, keyword: &str, limit: usize) -> Result<Vec<Song>> {
let mut out: Vec<Song> = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut offset = 0;
while out.len() < limit {
let n = (limit - out.len()).min(SEARCH_PAGE);
let result = cloudsearch(session, keyword, 1, n, offset).await?;
let page: Vec<Song> = result
.get("songs")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(song_from_json).collect())
.unwrap_or_default();
let got = page.len();
out.extend(page.into_iter().filter(|s| seen.insert(s.id.clone())));
if got < n {
break;
}
offset += got;
}
out.truncate(limit);
Ok(out)
}
pub async fn details(session: &Session, ids: &[String]) -> Result<Vec<Song>> {
let mut by_id = std::collections::HashMap::new();
for chunk in ids.chunks(DETAIL_BATCH) {
let c: Vec<Value> = chunk
.iter()
.filter_map(|i| i.parse::<u64>().ok())
.map(|i| json!({"id": i, "v": 0}))
.collect();
let v = session
.post_form(DETAIL, &[("c", &Value::Array(c).to_string())])
.await
.context("获取网易云歌曲详情失败")?;
for s in v
.get("songs")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(song_from_json)
{
by_id.insert(s.id.clone(), s);
}
}
Ok(ids.iter().filter_map(|i| by_id.remove(i)).collect())
}
pub async fn song_detail(session: &Session, id: &str) -> Result<Song> {
details(session, &[id.to_string()])
.await?
.pop()
.with_context(|| format!("网易云歌曲 {id} 不存在"))
}
pub async fn lyric(session: &Session, id: &str) -> Result<Option<String>> {
let v = session
.post_form(
LYRIC,
&[
("id", id),
("cp", "false"),
("tv", "0"),
("lv", "0"),
("rv", "0"),
("kv", "0"),
("yv", "0"),
("ytv", "0"),
("yrv", "0"),
],
)
.await
.context("请求网易云歌词失败")?;
Ok(parse_lyric(&v))
}
fn parse_lyric(v: &Value) -> Option<String> {
let flag = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false);
if flag("nolyric") || flag("uncollected") {
return None;
}
let text = v.pointer("/lrc/lyric").and_then(Value::as_str)?;
let cleaned = crate::lrc::clean(text);
(!cleaned.is_empty()).then_some(cleaned)
}
pub struct Playlist {
pub name: String,
pub songs: Vec<Song>,
}
pub async fn playlist(session: &Session, id: &str) -> Result<Playlist> {
let v = session
.post_form(PLAYLIST, &[("id", id)])
.await
.with_context(|| format!("网易云歌单 {id} 不存在或不可见"))?;
let pl = v
.get("playlist")
.filter(|p| !p.is_null())
.with_context(|| format!("网易云歌单 {id} 不存在或不可见"))?;
let ids: Vec<String> = pl
.get("trackIds")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|t| t.get("id").and_then(Value::as_u64))
.map(|i| i.to_string())
.collect()
})
.unwrap_or_default();
let name = pl
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|n| !n.is_empty())
.map_or_else(|| format!("playlist-{id}"), str::to_string);
let songs = details(session, &ids).await?;
debug!(id, ids = ids.len(), songs = songs.len(), "网易云歌单");
Ok(Playlist { name, songs })
}
fn album_singers(a: &Value) -> Vec<String> {
let list = names(a.get("artists"));
if !list.is_empty() {
return list;
}
a.pointer("/artist/name")
.and_then(Value::as_str)
.map(|n| vec![n.trim().to_string()])
.unwrap_or_default()
}
fn album_id(a: &Value) -> Option<String> {
a.get("id")
.and_then(Value::as_u64)
.filter(|&i| i > 0)
.map(|i| i.to_string())
}
pub async fn search_albums(
session: &Session,
keyword: &str,
limit: usize,
) -> Result<Vec<AlbumHit>> {
let result = cloudsearch(session, keyword, 10, limit.clamp(1, SEARCH_PAGE), 0).await?;
Ok(result
.get("albums")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|a| {
Some(AlbumHit {
id: album_id(a)?,
name: a
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string(),
singer: album_singers(a).join(", "),
song_num: a.get("size").and_then(Value::as_u64).unwrap_or(0),
publish_date: date_from_ms(
a.get("publishTime").and_then(Value::as_i64).unwrap_or(0),
),
})
})
.collect())
}
pub async fn album(session: &Session, id: &str) -> Result<Album> {
let v = session
.get_json(&format!("https://music.163.com/api/v1/album/{id}"))
.await
.with_context(|| format!("网易云专辑 {id} 不存在"))?;
let info = v.get("album").cloned().unwrap_or(Value::Null);
let list = v
.get("songs")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut songs: Vec<Song> = Vec::new();
let mut cds: Vec<u32> = Vec::new();
for item in &list {
if let Some(s) = song_from_json(item) {
let cd = item
.get("cd")
.and_then(Value::as_str)
.and_then(|c| c.trim().parse::<u32>().ok())
.unwrap_or(1);
cds.push(cd.saturating_sub(1));
songs.push(s);
}
}
if songs.is_empty() {
bail!("专辑没有可用曲目");
}
for (s, t) in songs.iter_mut().zip(tracks_for(&cds)) {
s.track = Some(t);
}
let mut name = info
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let mut singers = album_singers(&info);
if name.is_empty() {
name = songs[0].album.clone();
}
if singers.is_empty() {
singers = songs[0].singers.clone();
}
Ok(Album {
id: id.to_string(),
name,
singers,
publish_date: date_from_ms(info.get("publishTime").and_then(Value::as_i64).unwrap_or(0)),
songs,
})
}
pub async fn search_singers(
session: &Session,
keyword: &str,
limit: usize,
) -> Result<Vec<SingerHit>> {
let result = cloudsearch(session, keyword, 100, limit.clamp(1, SEARCH_PAGE), 0).await?;
Ok(result
.get("artists")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|a| {
Some(SingerHit {
id: album_id(a)?,
name: a
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string(),
album_num: a.get("albumSize").and_then(Value::as_u64).unwrap_or(0),
song_num: a.get("musicSize").and_then(Value::as_u64).unwrap_or(0),
})
})
.collect())
}
fn album_kind(ty: &str, sub: &str) -> AlbumKind {
if sub.contains("现场") {
return AlbumKind::Live;
}
match ty.trim() {
"Single" => AlbumKind::Single,
t if t.starts_with("EP") => AlbumKind::Ep,
"专辑" if sub.is_empty() || sub.contains("录音室") => AlbumKind::Studio,
_ => AlbumKind::Other,
}
}
fn artist_album(a: &Value) -> Option<ArtistAlbum> {
let s = |k: &str| {
a.get(k)
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string()
};
let (ty, sub) = (s("type"), s("subType"));
Some(ArtistAlbum {
id: album_id(a)?,
name: s("name"),
singer: album_singers(a).join(", "),
kind: album_kind(&ty, &sub),
album_type: if sub.is_empty() {
ty
} else {
format!("{ty}·{sub}")
},
publish_date: date_from_ms(a.get("publishTime").and_then(Value::as_i64).unwrap_or(0)),
song_num: a.get("size").and_then(Value::as_u64).unwrap_or(0),
})
}
pub async fn artist_albums(
session: &Session,
id: &str,
order: AlbumOrder,
) -> Result<Vec<ArtistAlbum>> {
if order == AlbumOrder::Hot {
eprintln!("提示:网易云的歌手专辑没有热度排序,按发行时间列出");
}
let mut all: Vec<ArtistAlbum> = Vec::new();
let mut offset = 0;
for _ in 0..20 {
let v = session
.get_json(&format!(
"https://music.163.com/api/artist/albums/{id}?limit={ARTIST_ALBUM_PAGE}&offset={offset}"
))
.await
.with_context(|| format!("网易云歌手 {id} 不存在"))?;
let page: Vec<ArtistAlbum> = v
.get("hotAlbums")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(artist_album).collect())
.unwrap_or_default();
let got = page.len();
all.extend(page);
if got == 0 || !v.get("more").and_then(Value::as_bool).unwrap_or(false) {
break;
}
offset += got;
}
let mut seen = std::collections::HashSet::new();
all.retain(|a| seen.insert(a.id.clone()));
if all.is_empty() {
bail!("网易云歌手 {id} 没有专辑");
}
Ok(all)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lyric_flags() {
let none = json!({"uncollected": true, "lrc": {"version": 1, "lyric": "[00:00.00]暂无歌词"}, "code": 200});
assert_eq!(parse_lyric(&none), None);
assert_eq!(parse_lyric(&json!({"nolyric": true, "code": 200})), None);
let ok = json!({"lrc": {"lyric": "[00:00.00] 作词 : 葛大为\n[00:01.00] 作曲 : 谢馨哲\n"}, "code": 200});
assert_eq!(
parse_lyric(&ok).unwrap(),
"[00:00.00] 作词 : 葛大为\n[00:01.00] 作曲 : 谢馨哲"
);
assert_eq!(parse_lyric(&json!({"lrc": {"lyric": ""}})), None);
}
#[test]
fn kinds() {
assert_eq!(album_kind("专辑", "录音室版"), AlbumKind::Studio);
assert_eq!(album_kind("专辑", "现场版"), AlbumKind::Live);
assert_eq!(album_kind("Single", "录音室版"), AlbumKind::Single);
assert_eq!(album_kind("Single", "现场版"), AlbumKind::Live);
assert_eq!(album_kind("EP/Single", "录音室版"), AlbumKind::Ep);
assert_eq!(album_kind("专辑", "伴奏版"), AlbumKind::Other);
}
#[test]
fn parse_artist_album() {
let a = json!({"id": 399723430, "name": "要去什么地方", "size": 10, "type": "专辑",
"subType": "录音室版", "publishTime": 1790179200000i64,
"artist": {"name": "田馥甄"}, "artists": [{"name": "田馥甄"}]});
let x = artist_album(&a).unwrap();
assert_eq!(
(x.id.as_str(), x.name.as_str(), x.singer.as_str()),
("399723430", "要去什么地方", "田馥甄")
);
assert_eq!(
(x.kind, x.album_type.as_str(), x.song_num),
(AlbumKind::Studio, "专辑·录音室版", 10)
);
assert_eq!(x.publish_date, "2026-09-24");
assert!(artist_album(&json!({"name": "无 id"})).is_none());
}
}
#[cfg(test)]
mod net_tests {
use super::*;
use crate::config::NeteaseConfig;
use std::time::Duration;
fn session() -> Session {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()
.unwrap();
Session::new(client, NeteaseConfig::default())
}
#[tokio::test]
#[ignore = "需要网络"]
async fn netease_real() {
let s = session();
let songs = search(&s, "田馥甄", 15).await.unwrap();
assert_eq!(songs.len(), 15);
let d = song_detail(&s, "1481929839").await.unwrap();
assert_eq!(
(d.title.as_str(), d.album.as_str()),
("无人知晓", "无人知晓")
);
assert!(song_detail(&s, "1").await.is_err());
assert!(
lyric(&s, "1481929839")
.await
.unwrap()
.unwrap()
.contains("葛大为")
);
assert_eq!(lyric(&s, "1").await.unwrap(), None);
let al = album(&s, "95902047").await.unwrap();
assert_eq!((al.name.as_str(), al.songs.len()), ("无人知晓", 11));
assert_eq!(al.songs[0].track.unwrap().number, 1);
assert!(album(&s, "1").await.is_err());
let singers = search_singers(&s, "田馥甄", 5).await.unwrap();
assert_eq!(singers[0].id, "9548");
let albums = artist_albums(&s, "9548", AlbumOrder::New).await.unwrap();
assert!(albums.len() >= 28);
assert!(artist_albums(&s, "1", AlbumOrder::New).await.is_err());
let pl = playlist(&s, "3778678").await.unwrap();
assert!(
pl.songs.len() > 100,
"热歌榜应有 200 首,实际 {}",
pl.songs.len()
);
}
}