use anyhow::{Context, Result, bail};
use rand::RngExt;
use serde_json::{Map, Value, json};
pub const ENDPOINT: &str = "https://u.y.qq.com/cgi-bin/musicu.fcg";
pub const VERSION_CODE: u64 = 13020508;
#[cfg(test)]
pub const FALLBACK_QIMEI36: &str = "6c9d3cd110abca9b16311cee10001e717614";
pub const UID: &str = "3931641530";
pub fn build_comm(qimei36: &str, ct: &str) -> Value {
json!({
"cv": VERSION_CODE,
"v": VERSION_CODE,
"QIMEI36": qimei36,
"ct": ct,
"tmeAppID": "qqmusic",
"format": "json",
"inCharset": "utf-8",
"outCharset": "utf-8",
"uid": UID,
})
}
pub fn module_key(module: &str, method: &str) -> String {
format!("{module}.{method}")
}
pub fn build_request(comm: Value, module: &str, method: &str, param: Value) -> Value {
let param = bools_to_ints(param);
let mut body = Map::new();
body.insert("comm".into(), comm);
body.insert(
module_key(module, method),
json!({ "module": module, "method": method, "param": param }),
);
Value::Object(body)
}
fn bools_to_ints(param: Value) -> Value {
match param {
Value::Object(m) => Value::Object(
m.into_iter()
.map(|(k, v)| match v {
Value::Bool(b) => (k, json!(b as i32)),
other => (k, other),
})
.collect(),
),
other => other,
}
}
pub fn random_searchid() -> String {
let mut rng = rand::rng();
let t: u64 = rng.random_range(1..=20);
let n: u64 = rng.random_range(0..=4_194_304);
searchid_from(t, n, now_ms())
}
fn searchid_from(t: u64, n: u64, now_ms: u128) -> String {
let r = now_ms % 86_400_000;
(t as u128 * 18_014_398_509_481_984 + n as u128 * 4_294_967_296 + r).to_string()
}
pub fn random_qimei36() -> String {
let mut rng = rand::rng();
(0..36)
.map(|_| char::from_digit(rng.random_range(0..16), 16).unwrap())
.collect()
}
pub fn random_guid() -> String {
const CHARS: &[u8] = b"abcdef1234567890";
let mut rng = rand::rng();
(0..32)
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
.collect()
}
fn now_ms() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
pub async fn call(
client: &reqwest::Client,
qimei36: &str,
ct: &str,
module: &str,
method: &str,
param: Value,
) -> Result<Value> {
let body = build_request(build_comm(qimei36, ct), module, method, param);
let bytes = serde_json::to_vec(&body)?;
let resp = crate::net::send(
client
.post(ENDPOINT)
.header("Referer", "https://y.qq.com/")
.header("Origin", "https://y.qq.com/")
.body(bytes),
)
.await
.with_context(|| format!("请求 {module}.{method} 失败"))?
.error_for_status()?;
let mut v: Value = resp
.json()
.await
.with_context(|| format!("{module}.{method} 响应不是 JSON"))?;
let key = module_key(module, method);
let node = v
.get_mut(&key)
.map(Value::take)
.with_context(|| format!("响应缺少 {key}"))?;
let code = node.get("code").and_then(Value::as_i64).unwrap_or(0);
if code != 0 {
bail!("{key} 返回 code={code}");
}
Ok(node)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn searchid_formula() {
assert_eq!(searchid_from(1, 0, 0), "18014398509481984");
assert_eq!(
searchid_from(2, 3, 86_400_000 + 5),
(2u128 * 18_014_398_509_481_984 + 3 * 4_294_967_296 + 5).to_string()
);
let max = searchid_from(20, 4_194_304, 86_399_999);
assert!(max.parse::<u128>().is_ok());
}
#[test]
fn random_searchid_in_range() {
for _ in 0..100 {
let v: u128 = random_searchid().parse().unwrap();
assert!(v >= 18_014_398_509_481_984);
assert!(v <= 20 * 18_014_398_509_481_984 + 4_194_304 * 4_294_967_296 + 86_400_000);
}
}
#[test]
fn guid_shape() {
let g = random_guid();
assert_eq!(g.len(), 32);
assert!(g.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn qimei36_shape() {
let q = random_qimei36();
assert_eq!(q.len(), 36);
assert!(q.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
assert_ne!(q, random_qimei36());
}
#[test]
fn comm_fields() {
let c = build_comm("q36", "19");
assert_eq!(c["cv"], 13020508);
assert_eq!(c["v"], 13020508);
assert_eq!(c["QIMEI36"], "q36");
assert_eq!(c["ct"], "19");
assert_eq!(c["tmeAppID"], "qqmusic");
assert_eq!(c["uid"], "3931641530");
}
#[test]
fn request_shape_and_bools() {
let body = build_request(
build_comm(FALLBACK_QIMEI36, "11"),
"m.S",
"Do",
json!({"a": true, "b": false, "q": "夜曲"}),
);
let node = &body["m.S.Do"];
assert_eq!(node["module"], "m.S");
assert_eq!(node["method"], "Do");
assert_eq!(node["param"]["a"], 1);
assert_eq!(node["param"]["b"], 0);
let s = serde_json::to_string(&body).unwrap();
assert!(s.contains("\"q\":\"夜曲\""));
assert!(!s.contains(": "));
}
}