use std::time::Duration;
use serde_json::{Value, json};
use tracing::{debug, warn};
use super::Session;
use super::api::random_guid;
use crate::model::{
Level, ProviderCheck, ResolveOptions, Resolved, Song, below_320k, check_provider,
};
use crate::probe::{ProbeResult, probe};
const VKEYS_QUALITIES: &[(u32, &str, Level)] = &[
(14, "臻品母带2.0", Level::Master),
(13, "臻品全景声", Level::Atmos),
(12, "杜比全景声", Level::Atmos),
(11, "Hi-Res", Level::Hires),
(10, "SQ无损", Level::Sq),
(9, "HQ增强", Level::Hq),
(8, "MP3 320k", Level::Hq),
];
pub const OFFICIAL_QUALITIES: &[(&str, &str, &str, Level)] = &[
("AI00", ".flac", "臻品母带", Level::Master),
("Q000", ".flac", "全景声2.0", Level::Atmos),
("Q001", ".flac", "全景声5.1", Level::Atmos),
("F000", ".flac", "无损", Level::Sq),
("O801", ".ogg", "OGG 640k", Level::Hq),
("O800", ".ogg", "OGG 320k", Level::Hq),
("M800", ".mp3", "MP3 320k", Level::Hq),
("O600", ".ogg", "OGG 192k", Level::Std),
("O400", ".ogg", "OGG 96k", Level::Std),
("M500", ".mp3", "MP3 128k", Level::Std),
("C600", ".m4a", "AAC 192k", Level::Std),
("C400", ".m4a", "AAC 96k", Level::Std),
("C200", ".m4a", "AAC 48k", Level::Std),
];
const STREAM_DOMAIN: &str = "https://isure.stream.qqmusic.qq.com/";
const SUFFICIENT_EXTS: &[&str] = &["flac", "ogg"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Source {
Vkeys,
Tang,
Official,
}
impl Source {
fn name(self) -> &'static str {
match self {
Source::Vkeys => "vkeys",
Source::Tang => "tang",
Source::Official => "官方",
}
}
}
const REFERER: &str = "http://y.qq.com";
fn resolved(source: Source, quality: &str, level: Level, probe: ProbeResult) -> Resolved {
Resolved {
via: source.name(),
quality: quality.to_string(),
level,
probe,
referer: Some(REFERER),
fallback: None,
}
}
async fn get_json(session: &Session, url: &str, query: &[(&str, &str)]) -> Option<Value> {
let resp = match crate::net::send(session.client.get(url).query(query)).await {
Ok(r) => r,
Err(e) => {
debug!(url, "请求失败: {e}");
return None;
}
};
let status = resp.status();
let text = resp.text().await.ok()?;
match serde_json::from_str(&text) {
Ok(v) if status.is_success() => Some(v),
_ => {
let head: String = text.chars().take(80).collect();
debug!(url, %status, "响应不是有效 JSON: {head}");
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!(mid = %song.id, size = p.size, "低于 320kbps,丢弃");
return None;
}
Some(p)
}
async fn via_vkeys(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
let base = session.config.vkeys.url.trim();
for &(q, label, level) in VKEYS_QUALITIES.iter().filter(|q| q.2 <= max_level) {
let qs = q.to_string();
let Some(v) = get_json(session, base, &[("mid", &song.id), ("quality", &qs)]).await else {
continue;
};
let Some(url) = v.pointer("/data/url").and_then(Value::as_str) else {
debug!(mid = %song.id, q, "vkeys 该档无 url");
continue;
};
if let Some(p) = accept(session, song, url, true).await {
return Some(resolved(Source::Vkeys, label, level, p));
}
debug!(mid = %song.id, q, "vkeys 链接无效");
}
None
}
const TANG_FIELDS: &[(&str, &str, Level)] = &[
("song_play_url_pq", "全景声", Level::Atmos),
("song_play_url_sq", "SQ无损", Level::Sq),
];
pub fn tang_urls(v: &Value, max_level: Level) -> Vec<(&'static str, Level, String)> {
TANG_FIELDS
.iter()
.filter(|f| f.2 <= max_level)
.filter_map(|&(field, label, level)| {
let url = v.get(field)?.as_str()?;
url.starts_with("http")
.then(|| (label, level, url.to_string()))
})
.collect()
}
pub fn tang_rate_limited(body: &str) -> bool {
let b = body.trim_start();
!b.starts_with('{') && b.contains("频繁")
}
async fn tang_json(session: &Session, mid: &str) -> Option<Value> {
use std::sync::atomic::Ordering;
const ATTEMPTS: u64 = 3;
let url = session.config.tang.url.trim();
for attempt in 1..=ATTEMPTS {
if session.tang_exhausted.load(Ordering::Relaxed) {
return None;
}
let text = {
let _permit = session.tang_permits.acquire().await.ok()?;
let resp = match crate::net::send(session.client.get(url).query(&[("mid", mid)])).await
{
Ok(r) => r,
Err(e) => {
debug!(mid, "tang 请求失败: {e}");
return None;
}
};
resp.text().await.ok()?
};
if tang_rate_limited(&text) {
debug!(mid, attempt, "tang 限流,稍后重试");
if attempt < ATTEMPTS {
tokio::time::sleep(Duration::from_millis(1500 * attempt)).await;
}
continue;
}
return match serde_json::from_str(&text) {
Ok(v) => Some(v),
Err(_) => {
let head: String = text.chars().take(80).collect();
debug!(mid, "tang 响应不是有效 JSON: {head}");
None
}
};
}
if !session.tang_exhausted.swap(true, Ordering::Relaxed) {
warn!("tang 请求过于频繁,本次运行不再使用 tang(实测约 1 分钟后恢复)");
}
None
}
async fn via_tang(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
let v = tang_json(session, &song.id).await?;
for (label, level, url) in tang_urls(&v, max_level) {
if let Some(p) = accept(session, song, &url, true).await {
return Some(resolved(Source::Tang, label, level, p));
}
}
debug!(mid = %song.id, "tang 无可用链接");
None
}
async fn via_l1(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
let cfg = &session.config;
let mut best: Option<Resolved> = None;
for source in [Source::Vkeys, Source::Tang] {
if best
.as_ref()
.is_some_and(|b| b.level >= Level::Sq.min(max_level))
{
break;
}
let r = match source {
Source::Vkeys if cfg.vkeys.enabled() => via_vkeys(session, song, max_level).await,
Source::Tang if cfg.tang.enabled() => via_tang(session, song, max_level).await,
_ => None,
};
if let Some(r) = r
&& best.as_ref().is_none_or(|b| r.level > b.level)
{
best = Some(r);
}
}
best
}
pub fn official_param(mid: &str, guid: &str) -> Value {
let n = OFFICIAL_QUALITIES.len();
json!({
"filename": OFFICIAL_QUALITIES
.iter()
.map(|(p, e, ..)| format!("{p}{mid}{mid}{e}"))
.collect::<Vec<_>>(),
"guid": guid,
"songmid": vec![mid; n],
"songtype": vec![0; n],
})
}
pub fn official_urls(node: &Value) -> Vec<(&'static str, Level, String)> {
let Some(infos) = node.pointer("/data/midurlinfo").and_then(Value::as_array) else {
return Vec::new();
};
let mut out = Vec::new();
for &(prefix, ext, label, level) in OFFICIAL_QUALITIES {
let hit = infos.iter().find(|m| {
m.get("filename")
.and_then(Value::as_str)
.is_some_and(|f| f.starts_with(prefix) && f.ends_with(ext))
});
let purl = hit.and_then(|m| {
[m.get("purl"), m.get("wifiurl")]
.into_iter()
.flatten()
.filter_map(Value::as_str)
.find(|s| !s.is_empty())
});
if let Some(purl) = purl {
out.push((label, level, format!("{STREAM_DOMAIN}{purl}")));
}
}
out
}
async fn via_official(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
let qimei36 = session.qimei36().await;
let node = match session
.call(
&qimei36,
"19",
"music.vkey.GetVkey",
"UrlGetVkey",
official_param(&song.id, &random_guid()),
)
.await
{
Ok(n) => n,
Err(e) => {
debug!(mid = %song.id, "GetVkey 失败: {e:#}");
return None;
}
};
for (label, level, url) in official_urls(&node)
.into_iter()
.filter(|u| u.1 <= max_level)
{
if let Some(p) = accept(session, song, &url, false).await {
return Some(resolved(Source::Official, label, level, p));
}
}
debug!(mid = %song.id, "官方接口无可用链接");
None
}
pub async fn check(session: &Session, paid: &Song, free: &Song) -> Vec<ProviderCheck> {
let max = Level::Master;
let cfg = &session.config;
let (vkeys, tang, official) = tokio::join!(
check_provider("vkeys", cfg.vkeys.enabled(), via_vkeys(session, paid, max)),
check_provider("tang", cfg.tang.enabled(), via_tang(session, paid, max)),
check_provider("官方", true, via_official(session, free, max)),
);
let mut tang = tang;
if tang.result.is_none()
&& session
.tang_exhausted
.load(std::sync::atomic::Ordering::Relaxed)
{
tang.note = Some("请求过于频繁(约 1 分钟后恢复)".into());
}
vec![vkeys, tang, official]
}
pub async fn resolve(session: &Session, song: &Song, opts: ResolveOptions) -> Option<Resolved> {
let l1 = via_l1(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 level_tables_sorted() {
let sorted = |v: Vec<Level>| v.windows(2).all(|w| w[0] >= w[1]);
assert!(sorted(VKEYS_QUALITIES.iter().map(|q| q.2).collect()));
assert!(sorted(OFFICIAL_QUALITIES.iter().map(|q| q.3).collect()));
let sq: Vec<u32> = VKEYS_QUALITIES
.iter()
.filter(|q| q.2 <= Level::Sq)
.map(|q| q.0)
.collect();
assert_eq!(sq, [10, 9, 8]);
}
#[test]
fn tang_rate_limit_detection() {
assert!(tang_rate_limited("请求过于频繁"));
assert!(tang_rate_limited(" 请求过于频繁,请稍后再试"));
assert!(!tang_rate_limited(r#"{"song_name": "夜曲"}"#));
assert!(!tang_rate_limited(r#"{"song_name": "频繁"}"#));
}
#[test]
fn tang_fields() {
let v = json!({
"song_play_url_sq": "http://isure6.stream.qqmusic.qq.com/F0000024jrso28p8VA.flac?vkey=1",
"song_play_url_pq": "http://isure6.stream.qqmusic.qq.com/Q000003nAlv92U3mOy.flac?vkey=2",
"song_play_url_accom": "http://isure6.stream.qqmusic.qq.com/O801003KMMlh4Cgwwk.ogg?vkey=3",
"song_play_url_hq": "http://isure6.stream.qqmusic.qq.com/C6000024jrso28p8VA.m4a?vkey=4"
});
let got = tang_urls(&v, Level::Master);
assert_eq!(
got.iter().map(|g| (g.0, g.1)).collect::<Vec<_>>(),
[("全景声", Level::Atmos), ("SQ无损", Level::Sq)]
);
assert!(got.iter().all(|g| !g.2.contains("O801")), "伴奏不能用");
assert_eq!(tang_urls(&v, Level::Sq).len(), 1);
assert!(tang_urls(&v, Level::Hq).is_empty());
assert!(
tang_urls(
&json!({"song_play_url_sq": "", "song_play_url_pq": null}),
Level::Master
)
.is_empty()
);
}
#[test]
fn official_param_shape() {
let p = official_param("001zMQr71F1Qo8", "g");
let names = p["filename"].as_array().unwrap();
assert_eq!(names.len(), OFFICIAL_QUALITIES.len());
assert_eq!(names[0], "AI00001zMQr71F1Qo8001zMQr71F1Qo8.flac");
assert_eq!(names[3], "F000001zMQr71F1Qo8001zMQr71F1Qo8.flac");
assert_eq!(
p["songmid"].as_array().unwrap().len(),
OFFICIAL_QUALITIES.len()
);
assert_eq!(p["songtype"][0], 0);
assert_eq!(p["guid"], "g");
}
#[test]
fn official_urls_ordered_by_quality() {
let node = json!({"data": {"midurlinfo": [
{"filename": "C400xx.m4a", "purl": "C400xx.m4a?vkey=1"},
{"filename": "F000xx.flac", "purl": ""},
{"filename": "M500xx.mp3", "purl": "", "wifiurl": "M500xx.mp3?vkey=2"},
]}});
let urls = official_urls(&node);
assert_eq!(
urls,
vec![
(
"MP3 128k",
Level::Std,
format!("{STREAM_DOMAIN}M500xx.mp3?vkey=2")
),
(
"AAC 96k",
Level::Std,
format!("{STREAM_DOMAIN}C400xx.m4a?vkey=1")
),
]
);
assert!(official_urls(&json!({})).is_empty());
}
}
#[cfg(test)]
mod net_tests {
use super::*;
use crate::config::Config;
use std::time::Duration;
#[tokio::test]
#[ignore = "需要网络"]
async fn official_batch_free_song() {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()
.unwrap();
let session = Session::new(client, Config::default());
let song = Song {
id: "002MicCm2pZIuc".into(),
title: "小星星".into(),
..Song::default()
};
let opts = ResolveOptions {
lossless_only: false,
max_level: Level::Master,
};
let r = resolve(&session, &song, opts).await.expect("应能解析");
assert_eq!(r.via, "官方");
assert_eq!(r.referer, Some(REFERER));
assert!(r.probe.size > 0);
}
}