pub mod crypto;
pub mod music;
pub mod resolve;
use anyhow::{Context, Result, bail};
use serde_json::Value;
use tracing::debug;
use crate::config::NeteaseConfig;
use crate::model::{Song, SourceKind};
const HOSTS: &[&str] = &["music.163.com", "163cn.tv"];
pub struct Session {
pub client: reqwest::Client,
pub config: NeteaseConfig,
broken: std::sync::Mutex<Vec<resolve::Provider>>,
}
impl Session {
pub fn new(client: reqwest::Client, config: NeteaseConfig) -> Self {
Self {
client,
config,
broken: std::sync::Mutex::new(Vec::new()),
}
}
pub fn is_broken(&self, p: resolve::Provider) -> bool {
self.broken.lock().expect("熔断锁").contains(&p)
}
pub fn mark_broken(&self, p: resolve::Provider) {
let mut b = self.broken.lock().expect("熔断锁");
if !b.contains(&p) {
b.push(p);
tracing::warn!(
"网易云第三方接口 {} 的上游需要验证,本次运行不再使用",
p.name()
);
}
}
pub async fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<Value> {
let v: Value = crate::net::send(
self.client
.post(url)
.header("Referer", "https://music.163.com/")
.form(form),
)
.await
.with_context(|| format!("请求 {url} 失败"))?
.error_for_status()?
.json()
.await
.context("响应不是 JSON")?;
check_code(url, v)
}
pub async fn get_json(&self, url: &str) -> Result<Value> {
let v: Value = crate::net::send(
self.client
.get(url)
.header("Referer", "https://music.163.com/"),
)
.await
.with_context(|| format!("请求 {url} 失败"))?
.error_for_status()?
.json()
.await
.context("响应不是 JSON")?;
check_code(url, v)
}
}
fn check_code(url: &str, v: Value) -> Result<Value> {
match v.get("code").and_then(Value::as_i64) {
Some(200) => Ok(v),
code => {
debug!(url, ?code, "网易接口返回错误");
bail!(
"网易接口返回 code={}",
code.map_or("无".into(), |c| c.to_string())
)
}
}
}
pub fn is_netease_host(url: &str) -> bool {
crate::net::host_in(url, HOSTS)
}
fn is_id(s: &str) -> bool {
!s.is_empty() && s.len() <= 20 && s.bytes().all(|b| b.is_ascii_digit())
}
pub fn parse_id_arg(s: &str) -> Option<String> {
let s = s.trim();
is_id(s).then(|| s.to_string())
}
pub fn parse_link(input: &str, kind: &str) -> Option<String> {
let input = input.trim();
if !input.contains("://") || !is_netease_host(input) {
return None;
}
let rest = input.split_once("://")?.1;
let rest = &rest[rest.find('/').unwrap_or(rest.len())..];
let rest = match rest.split_once('#') {
Some((_, frag)) if frag.starts_with('/') => frag,
Some((before, _)) => before,
None => rest,
};
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let pos = segs.iter().position(|s| *s == kind)?;
if let Some(id) = segs.get(pos + 1).filter(|s| is_id(s)) {
return Some(id.to_string());
}
query
.split('&')
.filter_map(|p| p.split_once('='))
.find(|(k, v)| *k == "id" && is_id(v))
.map(|(_, v)| v.to_string())
}
pub async fn resolve_id(session: &Session, input: &str, kind: &str) -> Result<String> {
if let Some(id) = parse_id_arg(input).or_else(|| parse_link(input, kind)) {
return Ok(id);
}
let input = input.trim();
if !input.contains("://") || !is_netease_host(input) {
bail!("无法识别的网易云 {kind}:{input}");
}
let resp = crate::net::send(session.client.get(input))
.await
.context("访问链接失败")?;
let final_url = resp.url().to_string();
debug!(final_url, "网易链接跳转");
parse_link(&final_url, kind).with_context(|| format!("链接不是网易云 {kind}:{final_url}"))
}
pub fn date_from_ms(ms: i64) -> String {
if ms <= 0 {
return String::new();
}
let days = (ms / 1000 + 8 * 3600).div_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}")
}
pub fn cover_url(pic: &str) -> Option<String> {
let pic = pic.trim();
if pic.is_empty() {
return None;
}
let pic = pic
.strip_prefix("http://")
.map_or(pic.to_string(), |r| format!("https://{r}"));
Some(if pic.contains('?') {
pic
} else {
format!("{pic}?param=800y800")
})
}
fn names(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.get("name").and_then(Value::as_str))
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default()
}
pub fn song_from_json(v: &Value) -> Option<Song> {
let id = v.get("id").and_then(Value::as_u64)?;
let al = v.get("al");
let s = |x: Option<&Value>, k: &str| {
x.and_then(|x| x.get(k))
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string()
};
let size = |k: &str| {
v.pointer(&format!("/{k}/size"))
.and_then(Value::as_u64)
.unwrap_or(0)
};
Some(Song {
source: SourceKind::Netease,
id: id.to_string(),
title: s(Some(v), "name"),
singers: names(v.get("ar")),
album: s(al, "name"),
album_id: al
.and_then(|a| a.get("id"))
.and_then(Value::as_u64)
.filter(|&i| i > 0)
.map(|i| i.to_string())
.unwrap_or_default(),
interval: v.get("dt").and_then(Value::as_u64).unwrap_or(0) / 1000,
size_flac: size("sq"),
size_320mp3: size("h"),
size_hires: size("hr"),
cover_url: cover_url(&s(al, "picUrl")),
track: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn links() {
let id = Some("1481929839".to_string());
assert_eq!(
parse_link("https://music.163.com/#/song?id=1481929839", "song"),
id
);
assert_eq!(
parse_link("https://music.163.com/song?id=1481929839&userid=9", "song"),
id
);
assert_eq!(
parse_link("https://y.music.163.com/m/song?id=1481929839", "song"),
id
);
assert_eq!(
parse_link("https://music.163.com/song/1481929839/?userid=9", "song"),
id
);
assert_eq!(
parse_link("https://music.163.com/#/playlist?id=3778678", "playlist"),
Some("3778678".into())
);
assert_eq!(
parse_link("https://music.163.com/#/album?id=95902047", "album"),
Some("95902047".into())
);
assert_eq!(
parse_link("https://music.163.com/#/artist?id=9548", "artist"),
Some("9548".into())
);
assert_eq!(
parse_link("https://music.163.com/#/playlist?id=3778678", "song"),
None
);
assert_eq!(parse_link("https://163cn.tv/AbCdEf", "song"), None);
assert_eq!(parse_link("https://evil.com/#/song?id=1", "song"), None);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/songDetail/001zMQr71F1Qo8", "song"),
None
);
assert!(is_netease_host("https://163cn.tv/x"));
assert!(!is_netease_host("https://music.163.com.evil.com/"));
assert_eq!(parse_id_arg(" 9548 "), Some("9548".into()));
assert_eq!(parse_id_arg("001zMQr71F1Qo8"), None);
}
#[test]
fn dates() {
assert_eq!(date_from_ms(1_600_963_200_000), "2020-09-25");
assert_eq!(date_from_ms(1_790_179_200_000), "2026-09-24");
assert_eq!(date_from_ms(0), "");
assert_eq!(date_from_ms(951_782_400_000), "2000-02-29");
}
#[test]
fn covers() {
assert_eq!(
cover_url("http://p2.music.126.net/a==/1.jpg").as_deref(),
Some("https://p2.music.126.net/a==/1.jpg?param=800y800")
);
assert_eq!(
cover_url("https://x/1.jpg?param=1y1").as_deref(),
Some("https://x/1.jpg?param=1y1")
);
assert_eq!(cover_url(" "), None);
}
#[test]
fn parse_song() {
let v = json!({"id": 1481929839u64, "name": "无人知晓", "dt": 288733,
"ar": [{"id": 9548, "name": "田馥甄"}],
"al": {"id": 95902047, "name": "无人知晓", "picUrl": "http://p2.music.126.net/x==/109951165350166137.jpg"},
"sq": {"br": 829000, "size": 29908374}, "h": {"br": 320000, "size": 11551725}, "hr": null});
let s = song_from_json(&v).unwrap();
assert_eq!(
(s.source, s.id.as_str(), s.title.as_str()),
(SourceKind::Netease, "1481929839", "无人知晓")
);
assert_eq!(s.singers, ["田馥甄"]);
assert_eq!(
(s.album.as_str(), s.album_id.as_str(), s.interval),
("无人知晓", "95902047", 288)
);
assert_eq!(
(s.size_flac, s.size_320mp3, s.size_hires),
(29908374, 11551725, 0)
);
assert!(
s.cover_url
.unwrap()
.starts_with("https://p2.music.126.net/")
);
assert!(song_from_json(&json!({"name": "无 id"})).is_none());
}
}