uta 0.1.2

Command-line music search and downloader for QQ Music and NetEase Cloud Music, lossless first, shipped as a single static binary. For learning and research only; non-commercial use.
//! musicu.fcg 公共请求:comm 构造、请求体组装、发送。

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;
/// qimei 请求失败时 Python 版使用的回落常量。
/// 实测该值被大量客户端共用而频繁被限流,Rust 版不使用,仅留作测试数据。
#[cfg(test)]
pub const FALLBACK_QIMEI36: &str = "6c9d3cd110abca9b16311cee10001e717614";
pub const UID: &str = "3931641530";

/// 构造 comm 公共参数;`ct` 一般为 "11",取 vkey 时为 "19"。
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}")
}

/// 组装完整请求体;param 中的 bool 转为 0/1。
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,
    }
}

/// 搜索 id:`rand(1..20)*18014398509481984 + rand(0..4194304)*4294967296 + now_ms % 86400000`。
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()
}

/// 36 位随机 hex,作为 QIMEI36 的回落值(不用共享常量,避免被限流)。
pub fn random_qimei36() -> String {
    let mut rng = rand::rng();
    (0..36)
        .map(|_| char::from_digit(rng.random_range(0..16), 16).unwrap())
        .collect()
}

/// 32 位随机 hex(GetVkey 的 guid)。
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)
}

/// 发送 musicu 请求并取出 `module.method` 对应的响应节点。
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);
    // ensure_ascii=False + 紧凑分隔符:serde_json 默认即如此
    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()
        );
        // 上界不溢出 u64 也能表示,但用 u128 计算保证安全
        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(": "));
    }
}