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.
//! QIMEI36 设备标识请求(移植自 qqutils.py `obtainqimei`)。
//!
//! 实测:搜索接口偶发进入"code=0 但 item_song 为空"的窗口,发一次 GetQimei 后恢复,
//! 因此在首次请求时与搜索返回空结果时调用。

use std::time::{SystemTime, UNIX_EPOCH};

use aes::cipher::{BlockEncryptMut, KeyIvInit, block_padding::Pkcs7};
use anyhow::{Context, Result};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use md5::{Digest, Md5};
use rand::RngExt;
use rsa::{Pkcs1v15Encrypt, RsaPublicKey, pkcs8::DecodePublicKey, rand_core::OsRng};
use serde_json::{Value, json};

const ENDPOINT: &str = "https://api.tencentmusic.com/tme/trpc/proxy";
const SECRET: &str = "ZdJqM15EeO2zWc08";
const APP_KEY: &str = "0AND0HD6FE4HY80F";
const APP_VERSION: &str = "13.2.5.8";
/// qqutils.py 中 PEM 公钥的 base64 主体(原 PEM 为单行超长,不符合 RFC 7468,故按 DER 解析)。
const PUBLIC_KEY_DER_B64: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDEIxgwoutfwoJxcGQeedgP7FG9qaIuS0qzfR8gWkrkTZKM2iWHn2ajQpBRZjMSoSf6+KJGvar2ORhBfpDXyVtZCKpqLQ+FLkpncClKVIrBwv6PHyUvuCb0rIarmgDnzkfQAqVufEtR64iazGDKatvJ9y6B9NMbHddGSAUmRTCrHQIDAQAB";
const HEADER_SIGN_SALT: &str = "qimei_qq_androidpzAuCmaFAaFaHrdakPjLIEqKrGnSOOvH";
/// Python 版用的字符集(注意是 "adbcdef",原样保留)。
const KEY_CHARS: &[u8] = b"adbcdef1234567890";

type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;

/// 请求 QIMEI36。
pub async fn obtain(client: &reqwest::Client) -> Result<String> {
    let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
    let payload = random_payload(ts);
    let crypt_key = random_chars(KEY_CHARS, 16);
    let nonce = random_chars(KEY_CHARS, 16);

    let pk = public_key()?;
    let key = B64.encode(pk.encrypt(&mut OsRng, Pkcs1v15Encrypt, crypt_key.as_bytes())?);
    let params = B64.encode(aes_cbc_encrypt(
        crypt_key.as_bytes(),
        &serde_json::to_vec(&payload)?,
    ));
    let extra = format!(r#"{{"appKey":"{APP_KEY}"}}"#);
    let ts_ms = (ts * 1000).to_string();
    let sign = md5_hex(&[&key, &params, &ts_ms, &nonce, SECRET, &extra]);

    let body = json!({
        "app": 0,
        "os": 1,
        "qimeiParams": {
            "key": key, "params": params, "time": ts.to_string(),
            "nonce": nonce, "sign": sign, "extra": extra,
        }
    });
    let resp: Value = client
        .post(ENDPOINT)
        .header("method", "GetQimei")
        .header("service", "trpc.tme_datasvr.qimeiproxy.QimeiProxy")
        .header("appid", "qimei_qq_android")
        .header("sign", md5_hex(&[HEADER_SIGN_SALT, &ts.to_string()]))
        .header("user-agent", "QQMusic")
        .header("timestamp", ts.to_string())
        .json(&body)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;
    let inner: Value = serde_json::from_str(
        resp.get("data")
            .and_then(Value::as_str)
            .context("qimei 响应缺少 data")?,
    )?;
    inner
        .pointer("/data/q36")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .context("qimei 响应缺少 q36")
}

fn public_key() -> Result<RsaPublicKey> {
    let der = B64.decode(PUBLIC_KEY_DER_B64)?;
    RsaPublicKey::from_public_key_der(&der).context("解析 qimei 公钥失败")
}

fn aes_cbc_encrypt(key: &[u8], data: &[u8]) -> Vec<u8> {
    // key 与 iv 相同
    Aes128CbcEnc::new(key.into(), key.into()).encrypt_padded_vec_mut::<Pkcs7>(data)
}

fn md5_hex(parts: &[&str]) -> String {
    let mut h = Md5::new();
    for p in parts {
        h.update(p.as_bytes());
    }
    format!("{:x}", h.finalize())
}

fn random_chars(charset: &[u8], n: usize) -> String {
    let mut rng = rand::rng();
    (0..n)
        .map(|_| charset[rng.random_range(0..charset.len())] as char)
        .collect()
}

fn random_hex_bytes(n: usize) -> String {
    let mut rng = rand::rng();
    (0..n)
        .map(|_| format!("{:02x}", rng.random::<u8>()))
        .collect()
}

/// 带 Luhn 校验位的 15 位 IMEI(与 Python 版算法一致)。
fn random_imei() -> String {
    let mut rng = rand::rng();
    let digits: Vec<u32> = (0..14).map(|_| rng.random_range(0..10)).collect();
    imei_with_check(&digits)
}

fn imei_with_check(digits: &[u32]) -> String {
    let sum: u32 = digits
        .iter()
        .enumerate()
        .map(|(i, &x)| {
            if i % 2 == 0 {
                x * 2 / 10 + x * 2 % 10
            } else {
                x
            }
        })
        .sum();
    let mut s: String = digits
        .iter()
        .map(|d| char::from_digit(*d, 10).unwrap())
        .collect();
    s.push(char::from_digit(sum * 9 % 10, 10).unwrap());
    s
}

/// (年, 月, 日, 时, 分, 秒),按北京时间(UTC+8)计算。
fn civil_from_unix(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
    let secs = secs as i64 + 8 * 3600;
    let days = secs.div_euclid(86400);
    let rem = secs.rem_euclid(86400);
    // Howard Hinnant 的 civil_from_days
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
    let y = if m <= 2 { y + 1 } else { y };
    (
        y,
        m,
        d,
        (rem / 3600) as u32,
        (rem % 3600 / 60) as u32,
        (rem % 60) as u32,
    )
}

fn random_beacon_id(ts: u64) -> String {
    let mut rng = rand::rng();
    let (y, m, ..) = civil_from_unix(ts);
    let time_month = format!("{y:04}-{m:02}-01");
    let rand1: u32 = rng.random_range(100_000..=999_999);
    let rand2: u32 = rng.random_range(100_000_000..=999_999_999);
    const SAME: [u32; 16] = [1, 2, 13, 14, 17, 18, 21, 22, 25, 26, 29, 30, 33, 34, 37, 38];
    let mut out = String::new();
    for i in 1..=40u32 {
        if SAME.contains(&i) {
            out += &format!("k{i}:{time_month}{rand1}.{rand2}");
        } else if i == 3 {
            out += "k3:0000000000000000";
        } else if i == 4 {
            out += &format!("k4:{}", random_chars(b"123456789abcdef", 16));
        } else {
            out += &format!("k{i}:{}", rng.random_range(0..=9999));
        }
        out.push(';');
    }
    out
}

fn random_payload(ts: u64) -> Value {
    let mut rng = rand::rng();
    let proc_version = format!(
        "Linux 5.4.0-54-generic-{} (android-build@google.com)",
        random_chars(
            b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
            8
        )
    );
    let fixed_rand: u64 = rng.random_range(0..=14400);
    let (y, mo, d, h, mi, s) = civil_from_unix(ts.saturating_sub(fixed_rand));
    let reserved = json!({
        "harmony": "0", "oz": "UhYmelwouA+V2nPWbOvLTgN2/m8jwGB+yUB5v9tysQg=",
        "oo": "Xecjt+9S1+f8Pz2VLSxgpw==", "kelong": "0",
        "uptimes": format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}"),
        "clone": "0", "containe": "", "multiUser": "0", "bod": "Xiaomi", "dv": "sagit",
        "firstLevel": "", "manufact": "Xiaomi", "name": "MI 6", "host": "se.infra",
        "kernel": proc_version,
    });
    json!({
        "androidId": random_hex_bytes(8), "platformId": 1, "appKey": APP_KEY,
        "appVersion": APP_VERSION, "beaconIdSrc": random_beacon_id(ts), "brand": "Xiaomi",
        "channelId": "10003505", "cid": "", "imei": random_imei(), "imsi": "", "mac": "",
        "model": "MI 6", "networkType": "unknown", "oaid": "",
        "osVersion": "Android 10,level 29", "qimei": "", "qimei36": "",
        "sdkVersion": "1.2.13.6", "targetSdkVersion": "33", "audit": "", "userId": "{}",
        "packageId": "com.tencent.qqmusic", "deviceType": "Phone", "sdkName": "",
        "reserved": reserved.to_string(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn md5_concat() {
        assert_eq!(md5_hex(&["a", "bc"]), "900150983cd24fb0d6963f7d28e17f72");
    }

    #[test]
    fn aes_cbc_pkcs7() {
        // 16 字节输入 -> 加一整块填充
        let out = aes_cbc_encrypt(b"0123456789abcdef", b"0123456789abcdef");
        assert_eq!(out.len(), 32);
        assert_eq!(aes_cbc_encrypt(b"0123456789abcdef", b"x").len(), 16);
    }

    #[test]
    fn imei_check_digit() {
        // 14 个 0 -> 校验位 0;Python 算法:sum*9%10
        assert_eq!(imei_with_check(&[0; 14]), "000000000000000");
        let digits = [4, 9, 0, 1, 5, 4, 2, 0, 3, 2, 3, 7, 5, 1];
        // 注意 Python 版不是标准 Luhn(标准结果为 8),这里与 Python 输出对齐
        assert_eq!(imei_with_check(&digits), "490154203237510");
        assert_eq!(random_imei().len(), 15);
    }

    #[test]
    fn civil_date() {
        assert_eq!(civil_from_unix(0), (1970, 1, 1, 8, 0, 0));
        // 2026-10-08 16:00:00 +08:00
        assert_eq!(civil_from_unix(1_791_446_400), (2026, 10, 8, 16, 0, 0));
        // 闰日
        assert_eq!(
            civil_from_unix(1_709_164_800 - 8 * 3600),
            (2024, 2, 29, 0, 0, 0)
        );
    }

    #[test]
    fn beacon_shape() {
        let b = random_beacon_id(1_791_446_400);
        assert_eq!(b.matches(';').count(), 40);
        assert!(b.starts_with("k1:2026-10-01"));
        assert!(b.contains(";k3:0000000000000000;"));
    }

    #[test]
    fn public_key_parses() {
        public_key().unwrap();
    }
}