use rand::RngExt;
use serde_json::{Value, json};
use tracing::debug;
use super::Session;
use super::crypto::eapi_params;
use crate::model::{
Level, ProviderCheck, ResolveOptions, Resolved, Song, below_320k, check_provider,
};
use crate::probe::{ProbeResult, probe};
const PLAYER_URL: &str = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1";
const LEVELS: &[(&str, &str, Level)] = &[
("jymaster", "超清母带", Level::Master),
("sky", "沉浸环绕声", Level::Atmos),
("dolby", "杜比全景声", Level::Atmos),
("jyeffect", "高清臻音", Level::Hires),
("hires", "Hi-Res", Level::Hires),
("lossless", "无损", Level::Sq),
("exhigh", "极高 320k", Level::Hq),
("higher", "较高 192k", Level::Std),
("standard", "标准", Level::Std),
];
const THIRD_PARTY_TRY: &[&str] = &[
"jymaster", "sky", "jyeffect", "hires", "lossless", "exhigh", "standard",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provider {
Tmetu,
Chksz,
Jfjt,
}
impl Provider {
pub const ALL: [Provider; 3] = [Provider::Tmetu, Provider::Chksz, Provider::Jfjt];
pub fn name(self) -> &'static str {
match self {
Provider::Tmetu => "tmetu",
Provider::Chksz => "chksz",
Provider::Jfjt => "jfjt",
}
}
fn base(self, session: &Session) -> &str {
let c = &session.config;
match self {
Provider::Tmetu => &c.tmetu.url,
Provider::Chksz => &c.chksz.url,
Provider::Jfjt => &c.jfjt.url,
}
.trim()
}
fn request(
self,
session: &Session,
base: &str,
id: &str,
level: &str,
) -> reqwest::RequestBuilder {
let c = &session.client;
match self {
Provider::Tmetu => c
.get(base)
.header("Referer", "https://music.tmetu.cn/")
.query(&[
("miss", "songAll"),
("id", id),
("level", level),
("withLyric", "false"),
]),
Provider::Chksz => c
.get(base)
.header("Referer", "https://cp.chksz.top/")
.header("Origin", "https://cp.chksz.top")
.query(&[("id", id), ("level", level)]),
Provider::Jfjt => c
.post(base)
.header("Referer", "https://dm.jfjt.cc/")
.form(&[("url", id), ("level", level), ("type", "json")]),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Reply {
Got(String, String),
Lower,
Stop,
Broken,
}
pub fn parse_reply(p: Provider, status: u16, v: &Value) -> Reply {
let s = |ptr: &str| {
v.pointer(ptr)
.and_then(Value::as_str)
.filter(|x| !x.is_empty())
};
let code = |k: &str| v.get(k).and_then(Value::as_i64);
match p {
Provider::Tmetu => {
if code("code") == Some(200) {
return match s("/data/audioUrl") {
Some(u) => Reply::Got(u.into(), s("/data/level").unwrap_or_default().into()),
None => Reply::Lower,
};
}
let verify = v.pointer("/extra/needVerify").and_then(Value::as_bool) == Some(true)
|| s("/message").is_some_and(|m| m.contains("验证"));
if verify { Reply::Broken } else { Reply::Stop }
}
Provider::Chksz => match (status, s("/data/url")) {
(200, Some(u)) => Reply::Got(u.into(), s("/data/level").unwrap_or_default().into()),
(404, _) => Reply::Lower,
_ => Reply::Stop,
},
Provider::Jfjt => match (code("status"), s("/data/url")) {
(Some(200), Some(u)) => {
Reply::Got(u.into(), s("/data/level").unwrap_or_default().into())
}
_ => Reply::Stop,
},
}
}
const SUFFICIENT_EXTS: &[&str] = &["flac"];
fn level_info(level: &str) -> Option<(&'static str, Level)> {
LEVELS
.iter()
.find(|(l, ..)| *l == level)
.map(|&(_, label, lv)| (label, lv))
}
fn resolved(via: &'static str, label: &str, level: Level, probe: ProbeResult) -> Resolved {
Resolved {
via,
quality: label.to_string(),
level,
probe,
referer: None,
fallback: None,
}
}
async fn accept(
session: &Session,
song: &Song,
url: &str,
third_party: bool,
) -> Option<ProbeResult> {
if !url.starts_with("http") {
return None;
}
let p = probe(&session.client, url).await?;
if third_party && below_320k(p.size, song.interval) {
debug!(id = %song.id, size = p.size, "低于 320kbps,丢弃");
return None;
}
Some(p)
}
async fn via_provider(
session: &Session,
p: Provider,
song: &Song,
max_level: Level,
) -> Option<Resolved> {
let base = p.base(session);
let mut tried_got = std::collections::HashSet::new();
for &want in THIRD_PARTY_TRY
.iter()
.filter(|l| level_info(l).is_some_and(|(_, lv)| lv <= max_level))
{
if session.is_broken(p) {
return None;
}
let (status, v) = match crate::net::send(p.request(session, base, &song.id, want)).await {
Ok(r) => {
let status = r.status().as_u16();
match r.json::<Value>().await {
Ok(v) => (status, v),
Err(e) => {
debug!(id = %song.id, via = p.name(), "响应不是 JSON: {e}");
return None;
}
}
}
Err(e) => {
debug!(id = %song.id, via = p.name(), "请求失败: {e}");
return None;
}
};
let (url, got) = match parse_reply(p, status, &v) {
Reply::Got(u, g) => (u, if g.is_empty() { want.to_string() } else { g }),
Reply::Lower => {
debug!(id = %song.id, via = p.name(), want, "该档无链接,降档");
continue;
}
Reply::Stop => {
debug!(id = %song.id, via = p.name(), %status, "无此歌曲或接口不可用");
return None;
}
Reply::Broken => {
session.mark_broken(p);
return None;
}
};
if !tried_got.insert(got.clone()) {
continue;
}
let Some((label, level)) = level_info(&got).filter(|(_, lv)| *lv <= max_level) else {
debug!(id = %song.id, via = p.name(), got, "返回的档位不可用");
continue;
};
if let Some(pr) = accept(session, song, &url, true).await {
return Some(resolved(p.name(), label, level, pr));
}
}
None
}
async fn via_third_party(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
let mut best: Option<Resolved> = None;
for p in Provider::ALL {
if best
.as_ref()
.is_some_and(|b| b.level >= Level::Sq.min(max_level))
{
break;
}
if p.base(session).is_empty() {
continue;
}
if let Some(r) = via_provider(session, p, song, max_level).await
&& best.as_ref().is_none_or(|b| r.level > b.level)
{
best = Some(r);
}
}
best
}
fn official_level(max_level: Level) -> &'static str {
match max_level {
Level::Master | Level::Atmos | Level::Hires => "hires",
Level::Sq => "lossless",
Level::Hq => "exhigh",
Level::Std => "standard",
}
}
pub fn player_payload(id: u64, level: &str, request_id: u32) -> String {
let header = json!({"os": "pc", "appver": "", "osver": "", "deviceId": "pyncm!", "requestId": request_id.to_string()});
json!({"ids": [id], "level": level, "encodeType": "flac", "header": header.to_string()})
.to_string()
}
async fn via_official(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
let id: u64 = song.id.parse().ok()?;
let request_id = rand::rng().random_range(20_000_000..30_000_000);
let payload = player_payload(id, official_level(max_level), request_id);
let v: Value = match crate::net::send(
session
.client
.post(PLAYER_URL)
.header("Cookie", "os=pc; appver=; osver=; deviceId=pyncm!")
.form(&[("params", eapi_params(PLAYER_URL, &payload))]),
)
.await
{
Ok(r) => r.json().await.ok()?,
Err(e) => {
debug!(id = %song.id, "网易官方取链失败: {e}");
return None;
}
};
let d = v.pointer("/data/0")?;
if !d.get("freeTrialInfo").is_none_or(Value::is_null) {
debug!(id = %song.id, "官方只给试听片段,丢弃");
return None;
}
let url = d
.get("url")
.and_then(Value::as_str)
.filter(|u| !u.is_empty())?;
let got = d.get("level").and_then(Value::as_str).unwrap_or("standard");
let (label, level) = level_info(got)?;
if level > max_level {
return None;
}
let p = accept(session, song, url, false).await?;
Some(resolved("官方", label, level, p))
}
pub async fn check(session: &Session, paid: &Song, free: &Song) -> Vec<ProviderCheck> {
let max = Level::Master;
let third = futures::future::join_all(Provider::ALL.map(|p| {
check_provider(
p.name(),
!p.base(session).is_empty(),
via_provider(session, p, paid, max),
)
}));
let (third, official) = tokio::join!(
third,
check_provider("官方", true, via_official(session, free, max))
);
let mut out: Vec<ProviderCheck> = third;
for (c, p) in out.iter_mut().zip(Provider::ALL) {
if c.result.is_none() && session.is_broken(p) {
c.note = Some("上游需要验证(被风控)".into());
}
}
out.push(official);
out
}
pub async fn resolve(session: &Session, song: &Song, opts: ResolveOptions) -> Option<Resolved> {
let l1 = via_third_party(session, song, opts.max_level).await;
if opts.lossless_only {
return l1.filter(|r| r.probe.is_lossless());
}
if let Some(r) = &l1
&& SUFFICIENT_EXTS.contains(&r.probe.ext.as_str())
{
return l1;
}
let official = via_official(session, song, opts.max_level).await;
match (l1, official) {
(Some(a), Some(b)) => Some(if a.probe.size > b.probe.size { a } else { b }),
(a, b) => a.or(b),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels() {
assert_eq!(level_info("jymaster"), Some(("超清母带", Level::Master)));
assert_eq!(level_info("exhigh").unwrap().1, Level::Hq);
assert_eq!(level_info("nope"), None);
let lv: Vec<Level> = THIRD_PARTY_TRY
.iter()
.map(|l| level_info(l).unwrap().1)
.collect();
assert!(lv.windows(2).all(|w| w[0] >= w[1]));
assert_eq!(official_level(Level::Master), "hires");
assert_eq!(official_level(Level::Hq), "exhigh");
}
#[test]
fn replies() {
use Provider::*;
let got = |u: &str, l: &str| Reply::Got(u.into(), l.into());
let tm_ok = json!({"code": 200, "data": {"audioUrl": "https://m8.music.126.net/a.flac", "level": "jymaster"}});
assert_eq!(
parse_reply(Tmetu, 200, &tm_ok),
got("https://m8.music.126.net/a.flac", "jymaster")
);
let tm_404 =
json!({"status": "error", "code": 404, "message": "未找到歌曲信息", "data": null});
assert_eq!(parse_reply(Tmetu, 200, &tm_404), Reply::Stop);
let tm_verify = json!({"status": "error", "code": 500, "message": "获取歌曲详情失败: 请完成验证操作",
"data": null, "extra": {"source": "netease", "needVerify": true}});
assert_eq!(parse_reply(Tmetu, 200, &tm_verify), Reply::Broken);
let ck_ok = json!({"code": 200, "msg": "success", "data": {"url": "https://m7.music.126.net/b", "level": "jymaster", "size": "170686944"}});
assert_eq!(
parse_reply(Chksz, 200, &ck_ok),
got("https://m7.music.126.net/b", "jymaster")
);
let ck_404 = json!({"code": 404, "msg": "Music URL not found, song may be unavailable at this quality level"});
assert_eq!(parse_reply(Chksz, 404, &ck_404), Reply::Lower);
assert_eq!(parse_reply(Chksz, 500, &json!({})), Reply::Stop);
let jf_ok = json!({"status": 200, "success": true, "data": {"url": "http://m7.music.126.net/c", "level": "jyeffect", "size": "95.25MB"}});
assert_eq!(
parse_reply(Jfjt, 200, &jf_ok),
got("http://m7.music.126.net/c", "jyeffect")
);
let jf_404 = json!({"status": 404, "success": false, "message": "未找到歌曲信息"});
assert_eq!(parse_reply(Jfjt, 200, &jf_404), Reply::Stop);
}
#[test]
fn payload_shape() {
let p: Value =
serde_json::from_str(&player_payload(1481929839, "lossless", 23456789)).unwrap();
assert_eq!(p["ids"], json!([1481929839u64]));
assert_eq!(p["level"], "lossless");
let h: Value = serde_json::from_str(p["header"].as_str().unwrap()).unwrap();
assert_eq!(
(
h["os"].as_str(),
h["deviceId"].as_str(),
h["requestId"].as_str()
),
(Some("pc"), Some("pyncm!"), Some("23456789"))
);
}
}
#[cfg(test)]
mod net_tests {
use super::*;
use crate::config::{EndpointConfig, NeteaseConfig};
use std::time::Duration;
fn session(tmetu: &str) -> Session {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()
.unwrap();
Session::new(
client,
NeteaseConfig {
tmetu: EndpointConfig { url: tmetu.into() },
..NeteaseConfig::default()
},
)
}
fn song(id: &str, interval: u64) -> Song {
Song {
source: crate::model::SourceKind::Netease,
id: id.into(),
interval,
..Song::default()
}
}
#[tokio::test]
#[ignore = "需要网络"]
async fn official_free_song_320k() {
let opts = ResolveOptions {
lossless_only: false,
max_level: Level::Master,
};
let r = via_official(&session(""), &song("1498983530", 314), opts.max_level)
.await
.unwrap();
assert_eq!(
(r.via, r.level, r.probe.ext.as_str()),
("官方", Level::Hq, "mp3")
);
assert!(
via_official(&session(""), &song("1481929839", 288), opts.max_level)
.await
.is_none()
);
}
}