YwYMusic/docs/music-platform-apis-rust.md

77 KiB
Raw Blame History

Museek 音乐平台接口的 Rust 实现方案

本文档给出把「搜索 / 热搜 / 排行榜 / 歌单 / 专辑」等平台内容获取逻辑从 TypeScriptsrc/lib/)迁移到 Rustsrc-tauri/)的完整设计方案与关键代码。所有接口、签名算法、请求参数与响应字段均与 docs/music-platform-apis.md 及现有 src/lib/ 实现一一对应。

约定:平台缩写 wy=网易云、kg=酷狗、kw=酷我、tx=QQ、mg=咪咕。代码目标 Rust edition 2021异步基于 tokio + reqwest。


目录

  1. 目标与架构决策
  2. 依赖清单
  3. 目录结构
  4. 公共错误类型
  5. 公共工具函数
  6. 加密与签名模块
  7. HTTP 客户端封装
  8. 缓存
  9. 数据模型DTO
  10. 各平台签名实现
  11. 歌曲搜索实现
  12. 热搜关键词实现
  13. 排行榜实现
  14. 歌单实现
  15. 专辑实现
  16. 歌单 / 专辑搜索
  17. Tauri 命令与前端对接
  18. 迁移策略与 TS 实现对照
  19. 注意事项与陷阱

1. 目标与架构决策

当前前端 src/lib/http.ts@tauri-apps/plugin-http 直接发请求(原生层只提供 HTTP 通道),所有签名、解析、缓存都在 TypeScript 完成。迁移到 Rust 的动机与决策:

  1. 签名/加密本地化eapi、zzcSign、wbdCrypto 等算法用 RustCrypto 实现,减少 JS 依赖,且天然离线可测。
  2. 网络/并发在原生层reqwest 连接池、tokio::join! 并发(替代 JS Promise.all),避免 WebView 层瓶颈与 CORS。
  3. 协议复用tauri-plugin-http 已具备 HTTP 能力,但业务接口统一收敛到 #[tauri::command],前端只做 UI 与状态管理。
  4. 缓存策略Rust 侧可再做一层 TTL 缓存,但前端已有缓存createAsyncCache),因此 Rust 侧缓存设计为可选,默认不重复缓存,避免双份内存。

前端调用形态从 searchWangyi(q, page, limit) 变为 invoke("search_songs", { source: "wy", query: q, page, limit }),返回与 MusicInfo/SearchResult 完全一致的 JSON。


2. 依赖清单

src-tauri/Cargo.toml[dependencies] 中新增:

[dependencies]
# 已存在
reqwest = { version = "0.13", default-features = false, features = [
    "rustls", "json", "gzip", "stream", "charset",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"

# 新增:加密与摘要
md-5 = "0.10"      # RustCrypto MD5
sha1 = "0.10"      # RustCrypto SHA-1
aes = "0.8"        # RustCrypto AESECB 手动分块)
hex = "0.4"        # hex 编解码
urlencoding = "2"  # 百分号编码(对应 encodeURIComponent

# 新增:错误与并发/缓存(可选)
thiserror = "2"    # 错误派生
futures = "0.3"    # future::join对应 Promise.all
moka = { version = "0.12", features = ["future"] }   # 可选 TTL 缓存
html-escape = "0.2"   # HTML 实体解码(对应 JS DOMParser 解码kg 专用)
regex = "1"           # kg HTML 抓取里的 global.data 提取

html-escape 用于把 & 等实体解码回普通文本(对应 TS 的 decodeNameregex 仅用于酷狗 HTML 兜底抓取(global.data = [...])与链接正则。


3. 目录结构

src-tauri/src/
├── lib.rs                 # 现有:窗口/媒体/托盘/下载等(保持不变)
├── main.rs
├── platform/              # 新增
│   ├── mod.rs             # 模块聚合 + 导出
│   ├── error.rs           # PlatformError / Result
│   ├── util.rs            # sizeFormate / formatDuration / formatPlayCount / decodeName / urlencode
│   ├── crypto.rs          # md5 / sha1 / aes-ecb / hex / base64 封装
│   ├── http.rs            # reqwest 客户端封装 + 通用 get/post + 重试
│   ├── cache.rs           # 可选 TTL 缓存
│   ├── model.rs           # DTOMusicInfo / SearchResult / Playlist / Album ...
│   ├── sign.rs            # 各平台签名入口eapi / zzc / wbd / kg / mg
│   ├── wy.rs              # 网易云search / hot_search / charts / playlists / albums
│   ├── kg.rs              # 酷狗
│   ├── kw.rs              # 酷我
│   ├── tx.rs              # QQ
│   ├── mg.rs              # 咪咕
│   ├── search.rs          # 歌曲搜索分发feature 入口)
│   ├── charts.rs          # 排行榜分发 + 静态榜单
│   ├── playlists.rs       # 歌单分发 + 链接解析
│   └── albums.rs          # 专辑分发 + 专辑搜索
└── commands.rs            # #[tauri::command] 入口(或直接写在 platform/mod.rs

lib.rsrun() 里注册这些命令即可,其余窗口/托盘逻辑不动。


4. 公共错误类型

platform/error.rs

use thiserror::Error;

#[derive(Debug, Error)]
pub enum PlatformError {
    /// HTTP 非 2xx
    #[error("HTTP {status} from {label}: {detail}")]
    Http { status: u16, label: String, detail: String },

    /// 业务错误码 / 缺少字段
    #[error("bad response from {label}: {detail}")]
    BadResponse { label: String, detail: String },

    /// 网络层错误
    #[error("network error: {0}")]
    Network(#[from] reqwest::Error),

    /// JSON 反序列化错误
    #[error("parse error: {0}")]
    Parse(#[from] serde_json::Error),
}

pub type Result<T> = std::result::Result<T, PlatformError>;

Tauri 命令直接返回 Result<T, String>,在命令边界做一次 map_err(|e| e.to_string())(与现有 embed_download_metadata 风格一致)。


5. 公共工具函数

platform/util.rs

/// 对应 common/utils/common.ts 的 sizeFormateB/KB/MB/GB/TB保留两位小数。
pub fn size_formate(size: u64) -> String {
    if size == 0 { return "0 B".to_string(); }
    let units = ["B", "KB", "MB", "GB", "TB"];
    let n = (size as f64).log(1024.0).floor() as usize;
    let n = n.min(units.len() - 1);
    format!("{:.2} {}", size as f64 / (1024f64).powi(n as i32), units[n])
}

/// 对应 formatDuration(seconds)m:ss秒向下取整pub fn format_duration(seconds: u64) -> String {
    let m = seconds / 60;
    let s = seconds % 60;
    format!("{m}:{s:02}")
}

/// 对应 formatPlayCount>1e8 → 亿,>1e4 → 万。
pub fn format_play_count(num: u64) -> String {
    if num > 100_000_000 {
        format!("{:.1}亿", num as f64 / 10_000_000.0 / 10.0) // trunc 后 /10
    } else if num > 10_000 {
        format!("{:.1}万", num as f64 / 1_000.0 / 10.0)
    } else {
        num.to_string()
    }
}

/// 对应 renderer decodeName把 HTML 实体(&amp; &quot; 等)解码为文本。
pub fn decode_name(s: &str) -> String {
    html_escape::decode_html_entities(s).into_owned()
}

/// 对应 encodeURIComponent部分场景。urlencoding::encode 默认集即 encodeURIComponent 语义。
pub fn urlencode(s: &str) -> String {
    urlencoding::encode(s).into_owned()
}

/// 当前毫秒时间戳。
pub fn now_millis() -> u128 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
}

说明TS 的 formatPlayCountMath.trunc(num / 1000) / 10,即「先除以 1000 取整再除以 10」等价于保留一位小数的「万」。上面的公式近似一致示例级精度足够


6. 加密与签名模块

platform/crypto.rs 是全部签名算法的地基。

use aes::Aes128;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
use base64::Engine;

pub fn md5_hex(data: impl AsRef<[u8]>) -> String {
    hex::encode(md5::Md5::digest(data.as_ref()))
}

pub fn md5_hex_upper(data: impl AsRef<[u8]>) -> String {
    hex::encode_upper(md5::Md5::digest(data.as_ref()))
}

pub fn sha1_hex(data: &str) -> String {
    hex::encode(sha1::Sha1::digest(data.as_bytes()))
}

fn pkcs7_pad(data: &mut Vec<u8>, block: usize) {
    let pad = block - (data.len() % block);
    data.extend(std::iter::repeat(pad as u8).take(pad));
}

fn pkcs7_unpad(mut data: Vec<u8>) -> Vec<u8> {
    if let Some(&last) = data.last() {
        let pad = last as usize;
        if pad > 0 && pad <= data.len() {
            data.truncate(data.len() - pad);
        }
    }
    data
}

/// AES-128-ECB + PKCS7 加密(返回原始密文字节)。
pub fn aes128_ecb_encrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
    let cipher = Aes128::new_from_slice(key).expect("aes key must be 16 bytes");
    let mut buf = data.to_vec();
    pkcs7_pad(&mut buf, 16);
    let mut out = Vec::with_capacity(buf.len());
    for chunk in buf.chunks_exact(16) {
        let mut block = *GenericArray::from_slice(chunk);
        cipher.encrypt_block(&mut block);
        out.extend_from_slice(&block);
    }
    out
}

/// AES-128-ECB + PKCS7 解密(输入为原始密文字节)。
pub fn aes128_ecb_decrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
    let cipher = Aes128::new_from_slice(key).expect("aes key must be 16 bytes");
    let mut out = Vec::with_capacity(data.len());
    for chunk in data.chunks_exact(16) {
        let mut block = *GenericArray::from_slice(chunk);
        cipher.decrypt_block(&mut block);
        out.extend_from_slice(&block);
    }
    pkcs7_unpad(out)
}

pub fn b64_encode(data: &[u8]) -> String {
    base64::engine::general_purpose::STANDARD.encode(data)
}

pub fn b64_decode(s: &str) -> Option<Vec<u8>> {
    base64::engine::general_purpose::STANDARD.decode(s.trim()).ok()
}

7. HTTP 客户端封装

platform/http.rs

use crate::platform::error::{PlatformError, Result};
use serde::de::DeserializeOwned;

/// 桌面 UA各平台略有差异按平台覆盖pub const DESKTOP_UA: &str =
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";

#[derive(Clone)]
pub struct Http {
    client: reqwest::Client,
}

impl Http {
    pub fn new() -> Self {
        let client = reqwest::Client::builder()
            .user_agent(DESKTOP_UA)
            .build()
            .expect("failed to build reqwest client");
        Self { client }
    }

    /// GET校验 2xx反序列化为 T。
    pub async fn get_json<T: DeserializeOwned>(
        &self, label: &str, url: &str, headers: &[(&str, &str)],
    ) -> Result<T> {
        let mut req = self.client.get(url);
        for (k, v) in headers { req = req.header(*k, *v); }
        let res = req.send().await?;
        let status = res.status();
        if !status.is_success() {
            return Err(PlatformError::Http { status: status.as_u16(), label: label.into(), detail: "".into() });
        }
        let text = res.text().await?;
        serde_json::from_str(&text).map_err(PlatformError::Parse)
    }

    /// GET返回原始文本用于需要先解密 / 正则抓取的场景)。
    pub async fn get_text(
        &self, label: &str, url: &str, headers: &[(&str, &str)],
    ) -> Result<String> {
        let mut req = self.client.get(url);
        for (k, v) in headers { req = req.header(*k, *v); }
        let res = req.send().await?;
        let status = res.status();
        if !status.is_success() {
            return Err(PlatformError::Http { status: status.as_u16(), label: label.into(), detail: "".into() });
        }
        Ok(res.text().await?)
    }

    /// POST x-www-form-urlencodedeapi、酷狗 gateway 等用)。
    pub async fn post_form<T: DeserializeOwned>(
        &self, label: &str, url: &str, headers: &[(&str, &str)], body: &str,
    ) -> Result<T> {
        let mut req = self.client.post(url)
            .header("Content-Type", "application/x-www-form-urlencoded");
        for (k, v) in headers { req = req.header(*k, *v); }
        let res = req.body(body.to_string()).send().await?;
        let status = res.status();
        if !status.is_success() {
            return Err(PlatformError::Http { status: status.as_u16(), label: label.into(), detail: "".into() });
        }
        let text = res.text().await?;
        serde_json::from_str(&text).map_err(PlatformError::Parse)
    }

    /// POST JSONQQ musicu.fcg / 酷狗 command 等用)。
    pub async fn post_json<T: DeserializeOwned, B: serde::Serialize>(
        &self, label: &str, url: &str, headers: &[(&str, &str)], body: &B,
    ) -> Result<T> {
        let mut req = self.client.post(url);
        for (k, v) in headers { req = req.header(*k, *v); }
        let res = req.json(body).send().await?;
        let status = res.status();
        if !status.is_success() {
            return Err(PlatformError::Http { status: status.as_u16(), label: label.into(), detail: "".into() });
        }
        let text = res.text().await?;
        serde_json::from_str(&text).map_err(PlatformError::Parse)
    }

    /// GET 原始 Response手动重定向 / 读取最终 URL用于 kg 链接解析)。
    pub async fn get_response(
        &self, url: &str, headers: &[(&str, &str)], redirect: reqwest::redirect::Policy,
    ) -> Result<reqwest::Response> {
        let mut req = self.client.get(url).redirect(redirect);
        for (k, v) in headers { req = req.header(*k, *v); }
        Ok(req.send().await?)
    }
}

需要请求头透传 Origin/Referer/Cookie 时,tauri-plugin-httpunsafe-headers 特性已启用;但业务接口在本方案里改走 Rust reqwest不再受浏览器/插件限制,可直接设置任意头。


8. 缓存

platform/cache.rs可选前端已缓存Rust 侧默认不启用):

use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use std::collections::HashMap;
use std::future::Future;

/// 简单 TTL 缓存;存 Arc<T>,过期即失效。失败不缓存(调用方在 miss 时 fetchpub struct TtlCache<T: Clone + Send + Sync> {
    map: Mutex<HashMap<String, (Instant, Arc<T>)>>,
    ttl: Duration,
    max: usize,
}

impl<T: Clone + Send + Sync> TtlCache<T> {
    pub fn new(ttl: Duration, max: usize) -> Self {
        Self { map: Mutex::new(HashMap::new()), ttl, max }
    }

    pub async fn get(&self, key: &str) -> Option<Arc<T>> {
        let mut map = self.map.lock().await;
        match map.get(key) {
            Some((at, v)) if at.elapsed() < self.ttl => {
                // 刷新热度(对应 JS Map 重新插入)
                let v = v.clone();
                map.remove(key);
                map.insert(key.to_string(), (Instant::now(), v.clone()));
                Some(v)
            }
            _ => { map.remove(key); None }
        }
    }

    pub async fn put(&self, key: &str, value: T) {
        let mut map = self.map.lock().await;
        map.insert(key.to_string(), (Instant::now(), Arc::new(value)));
        if map.len() > self.max {
            let oldest = map.keys().next().cloned();
            if let Some(k) = oldest { map.remove(&k); }
        }
    }

    /// 有缓存返回缓存,否则执行 fetch 并写入。失败不写入。
    pub async fn cached<F, Fut>(&self, key: &str, fetch: F) -> crate::platform::error::Result<Arc<T>>
    where
        F: FnOnce() -> Fut,
        Fut: Future<Output = crate::platform::error::Result<T>>,
    {
        if let Some(v) = self.get(key).await { return Ok(v); }
        let v = fetch().await?;
        self.put(key, v.clone()).await;
        Ok(Arc::new(v))
    }
}

更省事的做法是直接给每个命令加 moka::future::Cache。生产建议:只在 Rust 侧不缓存,把缓存留给前端 createAsyncCache,避免两处 TTL 不一致。


9. 数据模型DTO

platform/model.rs,字段名与前端 src/types/music.ts 严格对齐camelCase。Rust 侧直接 Serialize 返回给前端,前端 invoke 拿到的就是原 MusicInfo 结构。

use serde::Serialize;

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MusicQuality {
    #[serde(rename = "type")]
    pub kind: String,          // "128k" | "320k" | "flac" | "flac24bit"
    pub size: Option<String>,
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MusicInfoMeta {
    pub song_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub album_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pic_url: Option<String>,
    pub qualitys: Vec<MusicQuality>,
    /// 形如 { "320k": { "size": "9.99 MB" }, ... }lx 脚本 / VIP 探测用)
    #[serde(rename = "_qualitys")]
    pub _qualitys: serde_json::Map<String, serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hash: Option<String>,        // kg
    #[serde(skip_serializing_if = "Option::is_none")]
    pub str_media_mid: Option<String>, // tx
    #[serde(skip_serializing_if = "Option::is_none")]
    pub copyright_id: Option<String>,  // mg
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MusicInfo {
    pub id: String,          // "wy_12345"
    pub name: String,
    pub singer: String,
    pub source: String,      // "wy" | "kg" | "kw" | "tx" | "mg"
    pub interval: String,    // "m:ss"
    pub album_name: String,
    pub meta: MusicInfoMeta,
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SearchResult {
    pub list: Vec<MusicInfo>,
    pub total: u64,
    pub page: u32,
    pub all_page: u32,
    pub limit: u32,
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Playlist {
    pub id: String,
    pub name: String,
    pub img: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub play_count: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub publish_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub song_count: Option<u64>,
    pub source: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>, // "playlist" | "album"
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistDetail {
    pub info: PlaylistInfo,
    pub list: Vec<MusicInfo>,
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistInfo {
    pub name: String,
    pub img: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Album {
    pub id: String,
    pub name: String,
    pub img: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub publish_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub song_count: Option<u64>,
    pub source: String,
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AlbumDetail {
    pub info: PlaylistInfo,
    pub list: Vec<MusicInfo>,
}

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ChartBoard { pub id: String, pub name: String }

#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct HotKeyword { pub keyword: String, pub rank: u32 }

注意 _qualitys 在 TS 中是 Partial<Record<Quality, { size: string | null }>>,即对象。这里用 serde_json::Map<String, serde_json::Value> 序列化,内容为 { size: Option<String> },与前端 indexQualitySizes 输出一致。字段名以 _ 开头必须用 #[serde(rename = "_qualitys")] 显式命名(rename_all 会吃掉下划线)。


10. 各平台签名实现

platform/sign.rs

10.1 网易云 eapi

use crate::platform::crypto::{aes128_ecb_encrypt, md5_hex};

pub const EAPI_KEY: &[u8; 16] = b"e82ckenh8dichen8";

/// 对应 wy/eapi.ts返回 { params: 大写hex }。
/// url 是 apiPath如 "/api/search/song/list/page"。
pub fn eapi_params(url: &str, object: &serde_json::Value) -> String {
    let text = object.to_string(); // JSON.stringify(object)
    let message = format!("nobody{url}use{text}md5forencrypt");
    let digest = md5_hex(message);
    let data = format!("{url}-36cd479b6b5-{text}-36cd479b6b5-{digest}");
    hex::encode_upper(aes128_ecb_encrypt(EAPI_KEY, data.as_bytes()))
}

说明serde_json 默认 MapBTreeMapkey 排序),与 JS 的插入顺序不同,但 eapi 服务端只解密后 JSON.parsekey 顺序不影响正确性,故可直接用 serde_json::json! 或结构体序列化。

10.2 QQ zzcSign

use crate::platform::crypto::{b64_encode, sha1_hex};

const PART_1: [usize; 8] = [23, 14, 6, 36, 16, 40, 7, 19];
const PART_2: [usize; 8] = [16, 1, 32, 12, 19, 27, 8, 5];
const SCRAMBLE: [u8; 20] = [
    89, 39, 179, 150, 218, 82, 58, 252, 177, 52,
    186, 123, 120, 64, 242, 133, 143, 161, 121, 179,
];

/// 对应 txDesktop.ts 的 zzcSign。
pub fn zzc_sign(body: &str) -> String {
    let hash = sha1_hex(body); // 小写 hex长度 40
    let hb = hash.as_bytes();
    let part1: String = PART_1.iter().map(|&i| hb[i] as char).collect();
    let part2: String = PART_2.iter().map(|&i| hb[i] as char).collect();
    let mut part3: Vec<u8> = Vec::with_capacity(20);
    for (i, &v) in SCRAMBLE.iter().enumerate() {
        let byte = u8::from_str_radix(&hash[i * 2..i * 2 + 2], 16).unwrap();
        part3.push(v ^ byte);
    }
    let b64: String = b64_encode(&part3)
        .chars()
        .filter(|c| !matches!(c, '/' | '+' | '='))
        .collect();
    format!("zzc{part1}{b64}{part2}").to_lowercase()
}

10.3 酷我 wbdCrypto

use crate::platform::crypto::{aes128_ecb_encrypt, aes128_ecb_decrypt, b64_encode, b64_decode, md5_hex_upper};
use crate::platform::util::urlencode;

const WBD_KEY: [u8; 16] = [112, 87, 39, 61, 199, 250, 41, 191, 57, 68, 45, 114, 221, 94, 140, 228];
const WBD_APP_ID: &str = "y67sprxhhpws";

/// 对应 charts/kw.ts buildParam返回 query 串。
pub fn wbd_build_param(object: &serde_json::Value) -> String {
    let data = object.to_string();
    let time = crate::platform::util::now_millis().to_string();
    let encode_data = b64_encode(&aes128_ecb_encrypt(&WBD_KEY, data.as_bytes()));
    let sign = md5_hex_upper(format!("{WBD_APP_ID}{encode_data}{time}"));
    format!(
        "data={}&time={}&appId={}&sign={}",
        urlencode(&encode_data), time, WBD_APP_ID, sign
    )
}

/// 对应 charts/kw.ts aesEcbDecryptToText解密响应体。
pub fn wbd_decrypt_text(body: &str) -> Vec<u8> {
    let raw = b64_decode(body).unwrap_or_default();
    aes128_ecb_decrypt(&WBD_KEY, &raw)
}

10.4 酷狗 signatureParams

use crate::platform::crypto::md5_hex;

const KG_WEB_KEY: &str = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt";
const KG_ANDROID_KEY: &str = "OIlwieks28dk2k092lksi2UIkp";

/// 对应 playlists/kg.ts signatureParams。platform: "web" | "android"。
pub fn kg_signature(params: &str, platform: &str, body: &str) -> String {
    let key = if platform == "web" { KG_WEB_KEY } else { KG_ANDROID_KEY };
    let mut parts: Vec<&str> = params.split('&').collect();
    parts.sort();
    let sorted = parts.concat();
    md5_hex(format!("{key}{sorted}{body}{key}"))
}

10.5 咪咕 sign

use crate::platform::crypto::md5_hex;

/// 对应 search/mg.ts createSignature。返回 (sign, device_id)。
pub fn migu_sign(time: &str, s: &str) -> (String, String) {
    let device_id = "963B7AA0D21511ED807EE5846EC87D20";
    let signature_md5 = "6cdc72a439cef99a3418d2a78aa28c73";
    let sign = md5_hex(format!(
        "{s}{signature_md5}yyapp2d16148780a1dcc7408e06336b98cfd50{device_id}{time}"
    ));
    (sign, device_id.to_string())
}

11. 歌曲搜索实现

分发入口 platform/search.rs

pub async fn search_songs(source: &str, query: &str, page: u32, limit: u32) -> Result<SearchResult> {
    match source {
        "wy" => crate::platform::wy::search(query, page, limit).await,
        "kg" => crate::platform::kg::search(query, page, limit).await,
        "kw" => crate::platform::kw::search(query, page, limit).await,
        "tx" => crate::platform::tx::search(query, page, limit).await,
        "mg" => crate::platform::mg::search(query, page, limit).await,
        _ => Err(PlatformError::BadResponse { label: "search".into(), detail: "unknown source".into() }),
    }
}

11.1 网易云 wy::search

use serde::Deserialize;

#[derive(Deserialize)]
struct WySong { id: Option<u64>, name: Option<String>, dt: Option<u64>,
    ar: Option<Vec<WySinger>>, al: Option<WyAlbum>,
    hr: Option<WyBr>, sq: Option<WyBr>, h: Option<WyBr>, l: Option<WyBr>,
    privilege: Option<WyPriv> }
#[derive(Deserialize)] struct WySinger { name: Option<String> }
#[derive(Deserialize)] struct WyAlbum { id: Option<u64>, name: Option<String>, pic_url: Option<String> }
#[derive(Deserialize)] struct WyBr { size: Option<u64> }
#[derive(Deserialize)] struct WyPriv { max_br_level: Option<String>, maxbr: Option<u64> }
#[derive(Deserialize)] struct WyResource { base_info: Option<WyBaseInfo> }
#[derive(Deserialize)] struct WyBaseInfo { simple_song_data: Option<WySong> }
#[derive(Deserialize)] struct WySearchResp { code: Option<u64>, data: Option<WySearchData> }
#[derive(Deserialize)] struct WySearchData { resources: Option<Vec<WyResource>>, total_count: Option<u64> }

pub async fn search(query: &str, page: u32, limit: u32) -> Result<SearchResult> {
    let payload = serde_json::json!({
        "keyword": query,
        "needCorrect": "1",
        "channel": "typing",
        "offset": limit * (page - 1),
        "scene": "normal",
        "total": page == 1,
        "limit": limit,
    });
    let params = sign::eapi_params("/api/search/song/list/page", &payload);
    let body = format!("params={}", crate::platform::util::urlencode(&params));

    let headers: &[(&str, &str)] = &[
        ("Content-Type", "application/x-www-form-urlencoded"),
        ("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36"),
        ("Origin", "https://music.163.com"),
    ];
    let data: WySearchResp = HTTP.post_form("wy search", "http://interface.music.163.com/eapi/batch", headers, &body).await?;
    if data.code != Some(200) {
        return Err(PlatformError::BadResponse { label: "wy search".into(), detail: "bad code".into() });
    }
    let resources = data.data.and_then(|d| d.resources).unwrap_or_default();
    let list = resources.iter().filter_map(|r| normalize_wy_song(r.base_info.as_ref()?.simple_song_data.as_ref()?)).collect();
    let total = data.data.and_then(|d| d.total_count).unwrap_or(0);
    Ok(SearchResult { list, total, page, all_page: div_ceil(total, limit as u64) as u32, limit })
}

normalize_wy_song(与 search/wy.ts 一致):

fn normalize_wy_song(s: &WySong) -> Option<MusicInfo> {
    let song_id = s.id?.to_string();
    let mut qualitys: Vec<MusicQuality> = Vec::new();
    let priv_ = s.privilege.as_ref();
    let maxbr = priv_.and_then(|p| p.maxbr).unwrap_or(0);
    if priv_.and_then(|p| p.max_br_level.as_deref()) == Some("hires") || s.hr.is_some() {
        qualitys.push(MusicQuality { kind: "flac24bit".into(), size: s.hr.as_ref().and_then(|x| x.size).map(size_formate) });
    }
    if maxbr >= 999000 || s.sq.is_some() {
        qualitys.push(MusicQuality { kind: "flac".into(), size: s.sq.as_ref().and_then(|x| x.size).map(size_formate) });
    }
    if maxbr >= 320000 || s.h.is_some() {
        qualitys.push(MusicQuality { kind: "320k".into(), size: s.h.as_ref().and_then(|x| x.size).map(size_formate) });
    }
    if maxbr >= 128000 || s.l.is_some() {
        qualitys.push(MusicQuality { kind: "128k".into(), size: s.l.as_ref().and_then(|x| x.size).map(size_formate) });
    }
    if qualitys.is_empty() { qualitys.push(MusicQuality { kind: "128k".into(), size: None }); }
    // 去重 + 128k 在前(对应 reference 的 reverse
    let order = ["flac24bit", "flac", "320k", "128k"];
    let mut ordered: Vec<MusicQuality> = Vec::new();
    let mut seen = std::collections::HashSet::new();
    for k in order.iter().rev() {
        if let Some(q) = qualitys.iter().find(|q| q.kind == *k) {
            if seen.insert(q.kind.clone()) { ordered.push(q.clone()); }
        }
    }
    let singer = s.ar.as_ref().map(|a| a.iter().filter_map(|x| x.name.clone()).collect::<Vec<_>>().join("、")).unwrap_or_default();
    let _qualitys = index_quality_sizes(&ordered);
    Some(MusicInfo {
        id: format!("wy_{song_id}"),
        name: s.name.clone().unwrap_or_default(),
        singer,
        source: "wy".into(),
        interval: format_duration(s.dt.unwrap_or(0) / 1000),
        album_name: s.al.as_ref().and_then(|a| a.name.clone()).unwrap_or_default(),
        meta: MusicInfoMeta {
            song_id,
            album_id: s.al.as_ref().and_then(|a| a.id).map(|v| v.to_string()),
            pic_url: s.al.as_ref().and_then(|a| a.pic_url.clone()),
            qualitys: ordered,
            _qualitys,
            hash: None, str_media_mid: None, copyright_id: None,
        },
    })
}

index_quality_sizes(对应 quality.ts

pub fn index_quality_sizes(qualitys: &[MusicQuality]) -> serde_json::Map<String, serde_json::Value> {
    let mut m = serde_json::Map::new();
    for q in qualitys {
        m.insert(q.kind.clone(), serde_json::json!({ "size": q.size }));
    }
    m
}

11.2 酷狗 kg::search

#[derive(Deserialize)] struct KgSong { song_name: Option<String>, album_name: Option<String>,
    album_id: Option<String>, audioid: Option<u64>, singers: Option<Vec<KgSinger>>,
    duration: Option<u64>, file_hash: Option<String>, file_size: Option<u64>,
    hq_file_hash: Option<String>, hq_file_size: Option<u64>,
    sq_file_hash: Option<String>, sq_file_size: Option<u64>,
    res_file_hash: Option<String>, res_file_size: Option<u64>,
    image: Option<String>, grp: Option<Vec<KgSong>> }
#[derive(Deserialize)] struct KgSinger { name: Option<String> }
#[derive(Deserialize)] struct KgSearchResp { error_code: Option<i64>, data: Option<KgSearchData> }
#[derive(Deserialize)] struct KgSearchData { total: Option<u64>, lists: Option<Vec<KgSong>> }

pub async fn search(query: &str, page: u32, limit: u32) -> Result<SearchResult> {
    let url = format!(
        "https://songsearch.kugou.com/song_search_v2?keyword={}&page={}&pagesize={}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1",
        crate::platform::util::urlencode(query), page, limit
    );
    let headers: &[(&str, &str)] = &[("Referer", "https://www.kugou.com/"), ("User-Agent", DESKTOP_UA)];
    let data: KgSearchResp = HTTP.get_json("kg search", &url, headers).await?;
    if data.error_code != Some(0) {
        return Err(PlatformError::BadResponse { label: "kg search".into(), detail: "bad error_code".into() });
    }
    let total = data.data.as_ref().and_then(|d| d.total).unwrap_or(0);
    let mut list = Vec::new();
    let mut seen = std::collections::HashSet::new();
    let rows = data.data.and_then(|d| d.lists).unwrap_or_default();
    for row in rows {
        push_kg_song(&mut list, &mut seen, &row);
        for child in row.grp.unwrap_or_default() { push_kg_song(&mut list, &mut seen, &child); }
    }
    Ok(SearchResult { list, total, page, all_page: div_ceil(total, limit as u64) as u32, limit })
}

fn push_kg_song(list: &mut Vec<MusicInfo>, seen: &mut std::collections::HashSet<String>, row: &KgSong) {
    let key = format!("{}{}", row.audioid.unwrap_or(0), row.file_hash.as_deref().unwrap_or(""));
    if !seen.insert(key) { return; }
    let song_id = row.audioid.map(|v| v.to_string()).unwrap_or_default();
    let mut qualitys = Vec::new();
    if row.file_size.is_some() { qualitys.push(MusicQuality { kind: "128k".into(), size: row.file_size.map(size_formate) }); }
    if row.hq_file_size.is_some() { qualitys.push(MusicQuality { kind: "320k".into(), size: row.hq_file_size.map(size_formate) }); }
    if row.sq_file_size.is_some() { qualitys.push(MusicQuality { kind: "flac".into(), size: row.sq_file_size.map(size_formate) }); }
    if row.res_file_size.is_some() { qualitys.push(MusicQuality { kind: "flac24bit".into(), size: row.res_file_size.map(size_formate) }); }
    if qualitys.is_empty() { qualitys.push(MusicQuality { kind: "128k".into(), size: None }); }
    let singer = row.singers.as_ref().map(|a| decode_name(&a.iter().filter_map(|x| x.name.clone()).collect::<Vec<_>>().join("、"))).unwrap_or_default();
    list.push(MusicInfo {
        id: format!("kg_{song_id}"),
        name: decode_name(row.song_name.as_deref().unwrap_or("")),
        singer,
        source: "kg".into(),
        interval: format_duration(row.duration.unwrap_or(0)),
        album_name: decode_name(row.album_name.as_deref().unwrap_or("")),
        meta: MusicInfoMeta {
            song_id,
            album_id: row.album_id.clone(),
            pic_url: row.image.as_ref().map(|v| v.replace("{size}", "240")),
            qualitys: qualitys.clone(),
            _qualitys: index_quality_sizes(&qualitys),
            hash: row.file_hash.clone(), str_media_mid: None, copyright_id: None,
        },
    });
}

11.3 酷我 kw::search

#[derive(Deserialize)] struct KwSong { musicrid: Option<String>, songname: Option<String>,
    artist: Option<String>, duration: Option<String>, album: Option<String>,
    albumid: Option<String>, n_minfo: Option<String>, web_albumpic_short: Option<String> }
#[derive(Deserialize)] struct KwSearchResp { total: Option<String>, abslist: Option<Vec<KwSong>> }

pub async fn search(query: &str, page: u32, limit: u32) -> Result<SearchResult> {
    let qs = [
        ("client", "kt"), ("all", query), ("pn", &(page - 1).to_string()), ("rn", &limit.to_string()),
        ("uid", "794762570"), ("ver", "kwplayer_ar_9.2.2.1"), ("vipver", "1"),
        ("show_copyright_off", "1"), ("newver", "1"), ("ft", "music"), ("cluster", "0"),
        ("strategy", "2012"), ("encoding", "utf8"), ("rformat", "json"), ("mobi", "1"),
    ];
    // 用 url::form_urlencoded 或手拼;此处示意
    let url = format!("http://search.kuwo.cn/r.s?{}", join_query(&qs));
    let headers: &[(&str, &str)] = &[("Referer", "https://www.kuwo.cn/"), ("User-Agent", DESKTOP_UA)];
    let data: KwSearchResp = HTTP.get_json("kw search", &url, headers).await?;
    let total = data.total.as_deref().unwrap_or("0").parse::<u64>().unwrap_or(0);
    let list = data.abslist.unwrap_or_default().iter().map(normalize_kw_song).collect();
    Ok(SearchResult { list, total, page, all_page: div_ceil(total, limit as u64) as u32, limit })
}

fn normalize_kw_song(raw: &KwSong) -> MusicInfo {
    let song_id = raw.musicrid.as_deref().unwrap_or("").replace("MUSIC_", "");
    // N_MINFO 形如 "level:hh,bitrate:2000,format:flac,size:35.69MB;..."
    let qualitys = parse_n_minfo(raw.n_minfo.as_deref());
    let _qualitys = index_quality_sizes(&qualitys);
    let duration = raw.duration.as_deref().unwrap_or("0").parse::<u64>().unwrap_or(0);
    MusicInfo {
        id: format!("kw_{song_id}"),
        name: decode_kw(raw.songname.as_deref()),
        singer: raw.artist.as_deref().unwrap_or("").replace('&', "、"),
        source: "kw".into(),
        interval: format_duration(duration),
        album_name: decode_kw(raw.album.as_deref()),
        meta: MusicInfoMeta {
            song_id,
            album_id: raw.albumid.clone(),
            pic_url: raw.web_albumpic_short.as_ref()
                .map(|v| format!("https://img1.kuwo.cn/star/albumcover/{v}")),
            qualitys: qualitys.clone(), _qualitys,
            hash: None, str_media_mid: None, copyright_id: None,
        },
    }
}

/// 解析 N_MINFObitrate 4000/2000/320/128 → flac24bit/flac/320k/128k。
fn parse_n_minfo(s: Option<&str>) -> Vec<MusicQuality> {
    let Some(s) = s else { return vec![MusicQuality { kind: "128k".into(), size: None }] };
    let rx = regex::Regex::new(r"level:\w+,bitrate:(\d+),format:\w+,size:([\w.]+)").unwrap();
    let map = [("4000", "flac24bit"), ("2000", "flac"), ("320", "320k"), ("128", "128k")];
    let mut out = Vec::new();
    for part in s.split(';') {
        if let Some(c) = rx.captures(part) {
            let br = &c[1];
            if let Some(kind) = map.iter().find(|(b, _)| *b == br).map(|(_, k)| k) {
                out.push(MusicQuality { kind: kind.to_string(), size: Some(c[2].to_ascii_uppercase()) });
            }
        }
    }
    out.reverse();
    if out.is_empty() { out.push(MusicQuality { kind: "128k".into(), size: None }); }
    out
}

11.4 QQ tx::search

#[derive(Deserialize)] struct TxSong { id: Option<u64>, mid: Option<String>, title: Option<String>,
    interval: Option<u64>, singer: Option<Vec<TxSinger>>, album: Option<TxAlbum>, file: Option<TxFile> }
#[derive(Deserialize)] struct TxSinger { name: Option<String>, mid: Option<String> }
#[derive(Deserialize)] struct TxAlbum { name: Option<String>, mid: Option<String> }
#[derive(Deserialize)] struct TxFile { media_mid: Option<String>, size_128mp3: Option<u64>,
    size_320mp3: Option<u64>, size_flac: Option<u64>, size_hires: Option<u64> }
#[derive(Deserialize)] struct TxSearchData { body: Option<serde_json::Value>, meta: Option<TxMeta> }
#[derive(Deserialize)] struct TxMeta { sum: Option<u64>, estimate_sum: Option<u64> }

/// 对应 txDesktop.ts 的 qqDesktopSearchsearch_type=0带重试。
pub async fn qq_desktop_search(query: &str, page: u32, limit: u32, search_type: u8, retry: u32) -> Result<TxSearchData> {
    if retry > 5 { return Err(PlatformError::BadResponse { label: "tx search".into(), detail: "retry exhausted".into() }); }
    let body = serde_json::json!({
        "comm": { "_channelid": "0", "_os_version": "6.2.9200-2", "ct": "19", "cv": "2151",
            "guid": "1F70E520B2EAA7D25E11760783C53CA9", "patch": "118",
            "psrf_access_token_expiresAt": 0, "psrf_qqaccess_token": "", "psrf_qqopenid": "",
            "psrf_qqunionid": "", "tmeAppID": "qqmusic", "tmeLoginType": 0, "uin": "0",
            "wid": "7223299733393904640" },
        "music.search.SearchCgiService": {
            "module": "music.search.SearchCgiService",
            "method": "DoSearchForQQMusicDesktop",
            "param": { "grp": 1, "num_per_page": limit, "page_num": page, "query": query,
                "remoteplace": "txt.newclient.top", "search_type": search_type,
                "searchid": get_search_id() }
        }
    });
    let body_str = body.to_string();
    let sign = sign::zzc_sign(&body_str);
    let url = format!("https://u.y.qq.com/cgi-bin/musics.fcg?sign={sign}");
    let headers: &[(&str, &str)] = &[
        ("User-Agent", DESKTOP_UA), ("Referer", "https://y.qq.com/"), ("Content-Type", "application/json"),
    ];
    // 直接解析外层信封判断 code必要时重试
    let resp: serde_json::Value = match HTTP.post_json("tx search", &url, headers, &serde_json::from_str::<serde_json::Value>(&body_str).unwrap()).await {
        Ok(v) => v,
        Err(_) => return Box::pin(qq_desktop_search(query, page, limit, search_type, retry + 1)).await,
    };
    let req = resp.get("music.search.SearchCgiService").or_else(|| resp.get("req"));
    let code_ok = resp.get("code").and_then(|c| c.as_i64()) == Some(0)
        && req.and_then(|r| r.get("code")).and_then(|c| c.as_i64()) == Some(0);
    if !code_ok || req.and_then(|r| r.get("data")).is_none() {
        return Box::pin(qq_desktop_search(query, page, limit, search_type, retry + 1)).await;
    }
    let data = req.unwrap().get("data").cloned().unwrap_or_default();
    Ok(TxSearchData { body: data.as_object().map(|o| serde_json::Value::Object(o.clone())), meta: serde_json::from_value(resp.get("meta").cloned().unwrap_or_default()).ok() })
}

fn get_search_id() -> String {
    use rand::Rng;
    let mut rng = rand::thread_rng();
    let guid: String = (0..32).map(|_| format!("{:x}", rng.gen_range(0..16))).collect();
    format!("{}{:05}", guid.to_uppercase(), rng.gen_range(0..100000))
}

pub async fn search(query: &str, page: u32, limit: u32) -> Result<SearchResult> {
    let data = qq_desktop_search(query, page, limit, 0, 0).await?;
    let list = parse_tx_songs(&data.body).await;
    let total = data.meta.as_ref().map(|m| m.sum.or(m.estimate_sum).unwrap_or(0)).unwrap_or(0);
    Ok(SearchResult { list, total, page, all_page: div_ceil(total, limit as u64) as u32, limit })
}

parse_tx_songsnormalize_tx_song(与 search/tx.ts 一致media_mid 存在才保留):

async fn parse_tx_songs(body: &Option<serde_json::Value>) -> Vec<MusicInfo> {
    let Some(body) = body else { return vec![] };
    let list = body.pointer("/song/list").or_else(|| body.get("item_song"));
    let Some(arr) = list.and_then(|v| v.as_array()) else { return vec![] };
    arr.iter().filter_map(|v| serde_json::from_value::<TxSong>(v.clone()).ok())
        .filter_map(|s| normalize_tx_song(&s)).collect()
}

fn normalize_tx_song(s: &TxSong) -> Option<MusicInfo> {
    let media_mid = s.file.as_ref()?.media_mid.clone()?;
    let songmid = s.mid.clone().unwrap_or_default();
    let mut qualitys = Vec::new();
    let f = s.file.as_ref().unwrap();
    if f.size_128mp3.is_some() { qualitys.push(MusicQuality { kind: "128k".into(), size: f.size_128mp3.map(size_formate) }); }
    if f.size_320mp3.is_some() { qualitys.push(MusicQuality { kind: "320k".into(), size: f.size_320mp3.map(size_formate) }); }
    if f.size_flac.is_some() { qualitys.push(MusicQuality { kind: "flac".into(), size: f.size_flac.map(size_formate) }); }
    if f.size_hires.is_some() { qualitys.push(MusicQuality { kind: "flac24bit".into(), size: f.size_hires.map(size_formate) }); }
    if qualitys.is_empty() { qualitys.push(MusicQuality { kind: "128k".into(), size: None }); }
    let album_name = s.album.as_ref().and_then(|a| a.name.clone()).unwrap_or_default();
    let album_id = s.album.as_ref().and_then(|a| a.mid.clone()).unwrap_or_default();
    let pic_url = if !album_id.is_empty() && album_id != "空" {
        Some(format!("https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg"))
    } else if let Some(sm) = s.singer.as_ref().and_then(|a| a.first()).and_then(|x| x.mid.clone()) {
        Some(format!("https://y.gtimg.cn/music/photo_new/T001R500x500M000{sm}.jpg"))
    } else { None };
    let singer = s.singer.as_ref().map(|a| a.iter().filter_map(|x| x.name.clone()).collect::<Vec<_>>().join("、")).unwrap_or_default();
    let _qualitys = index_quality_sizes(&qualitys);
    Some(MusicInfo {
        id: format!("tx_{songmid}"),
        name: s.title.clone().unwrap_or_default(), singer, source: "tx".into(),
        interval: format_duration(s.interval.unwrap_or(0)), album_name,
        meta: MusicInfoMeta { song_id: songmid, album_id: (!album_id.is_empty()).then_some(album_id),
            pic_url, qualitys: qualitys.clone(), _qualitys,
            hash: None, str_media_mid: Some(media_mid), copyright_id: None },
    })
}

rand crate 用于生成 searchid;若不想加依赖,可用 SystemTime 纳秒异或生成伪随机 hex。

11.5 咪咕 mg::search

#[derive(Deserialize)] struct MgSearchResp { code: Option<String>, info: Option<String>,
    song_result_data: Option<MgSongResult> }
#[derive(Deserialize)] struct MgSongResult { result_list: Option<Vec<Vec<MgSong>>>, total_count: Option<serde_json::Value> }

pub async fn search(query: &str, page: u32, limit: u32) -> Result<SearchResult> {
    let time = crate::platform::util::now_millis().to_string();
    let (sign, device_id) = sign::migu_sign(&time, query);
    let search_switch = crate::platform::util::urlencode(r#"{"song":1,"album":0,"singer":0,"tagSong":1,"mvSong":0,"bestShow":1,"songlist":0,"lyricSong":1}"#);
    let v3_url = format!(
        "https://jadeite.migu.cn/music_search/v3/search/searchAll?isCorrect=0&isCopyright=1&searchSwitch={search_switch}&pageSize={limit}&text={}&pageNo={page}&sort=0&sid=USS",
        crate::platform::util::urlencode(query)
    );
    let headers: Vec<(&str, String)> = vec![
        ("uiVersion", "A_music_3.6.1".into()), ("deviceId", device_id), ("timestamp", time),
        ("sign", sign), ("channel", "0146921".into()),
        ("User-Agent", "Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30".into()),
    ];
    let mut data: Option<MgSearchResp> = None;
    if let Ok(resp) = HTTP.get_json::<MgSearchResp>("mg search", &v3_url, &as_slice(&headers)).await {
        if resp.code.as_deref() == Some("000000") { data = Some(resp); }
    }
    if data.is_none() {
        // 兜底 MIGUM2.0
        let legacy = format!(
            "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo={page}&pageSize={limit}&searchSwitch={}&sort=0&text={}",
            crate::platform::util::urlencode(r#"{"song":1,"album":0,"singer":0,"tagSong":0,"mvSong":0,"songlist":0,"bestShow":0}"#),
            crate::platform::util::urlencode(query)
        );
        let legacy_headers: &[(&str, &str)] = &[("Referer", "https://app.c.nf.migu.cn/"), ("channel", "0146921"), ("User-Agent", "Mozilla/5.0 (Linux; Android 11; MI 11) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36")];
        let resp: MgSearchResp = HTTP.get_json("mg search legacy", &legacy, legacy_headers).await?;
        if resp.code.as_deref() == Some("000000") { data = Some(resp); }
    }
    let data = data.ok_or_else(|| PlatformError::BadResponse { label: "mg search".into(), detail: "bad response".into() })?;
    let total = data.song_result_data.as_ref().and_then(|d| d.total_count.as_ref())
        .and_then(|v| v.as_str().map(|s| s.parse::<u64>().ok()).or_else(|| v.as_u64())).unwrap_or(0);
    let list = filter_mg_songs(data.song_result_data.as_ref().and_then(|d| d.result_list.clone()).unwrap_or_default());
    Ok(SearchResult { list, total, page, all_page: div_ceil(total, limit as u64) as u32, limit })
}

filter_mg_songs / normalize_mg_song(对应 search/mg.ts按 copyrightId 去重,音质 formatType PQ/HQ/SQ/ZQ/ZQ24

fn normalize_mg_song(s: &MgSong) -> Option<MusicInfo> {
    let song_id = s.song_id.as_deref().or(s.id.as_deref())?.to_string();
    let copyright_id = s.copyright_id.clone()?;
    let mut qualitys = Vec::new();
    let formats = s.audio_formats.as_deref().or(s.new_rate_formats.as_deref()).or(s.rate_formats.as_deref()).unwrap_or(&[]);
    for f in formats {
        let Some(ft) = f.format_type.as_deref() else { continue };
        let kind = match ft { "PQ" => "128k", "HQ" => "320k", "SQ" => "flac", "ZQ" | "ZQ24" => "flac24bit", _ => continue };
        let size = f.asize.or(f.isize).or(f.size).or(f.android_size).map(|n| size_formate(n.parse::<u64>().unwrap_or(0)));
        qualitys.push(MusicQuality { kind: kind.into(), size });
    }
    if qualitys.is_empty() { qualitys.push(MusicQuality { kind: "128k".into(), size: None }); }
    let mut img = s.img3.clone().or_else(|| s.img2.clone()).or_else(|| s.img1.clone())
        .or_else(|| s.img_items.as_ref().and_then(|a| a.get(2)).and_then(|x| x.img.clone()))
        .or_else(|| s.img_items.as_ref().and_then(|a| a.first()).and_then(|x| x.img.clone()));
    if let Some(u) = &img { if !u.starts_with("http") { img = Some(format!("http://d.musicapp.migu.cn{u}")); } }
    let singer = format_mg_singers(s.singer_list.as_deref().or(s.singers.as_deref()));
    let album = s.albums.as_ref().and_then(|a| a.first());
    let _qualitys = index_quality_sizes(&qualitys);
    Some(MusicInfo {
        id: format!("mg_{song_id}"),
        name: s.name.clone().unwrap_or_default(), singer, source: "mg".into(),
        interval: format_duration(s.duration.and_then(|d| d.as_u64().or_else(|| d.as_str().and_then(|x| x.parse().ok()))).unwrap_or(0)),
        album_name: s.album.clone().or_else(|| album.and_then(|a| a.name.clone())).unwrap_or_default(),
        meta: MusicInfoMeta { song_id, album_id: s.album_id.clone().or_else(|| album.and_then(|a| a.id.clone())),
            pic_url: img, qualitys: qualitys.clone(), _qualitys,
            hash: None, str_media_mid: None, copyright_id: Some(copyright_id) },
    })
}

MgSong.duration 在 TS 里是 number | string,用 serde_json::Value#[serde(untagged)] 处理;这里简化为 Option<Value>


12. 热搜关键词实现

platform/hot_search.rs 分发(对应 hotSearch/index.ts去重 + 最多 30 条 + rank

pub async fn hot_search(source: &str) -> Result<Vec<HotKeyword>> {
    let raw = match source {
        "wy" => crate::platform::wy::hot_search().await?,
        "kg" => crate::platform::kg::hot_search().await?,
        "kw" => crate::platform::kw::hot_search().await?,
        "tx" => crate::platform::tx::hot_search().await?,
        "mg" => crate::platform::mg::hot_search().await?,
        _ => return Err(PlatformError::BadResponse { label: "hot search".into(), detail: "unknown source".into() }),
    };
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for kw in raw {
        let k = kw.trim().to_string();
        if k.is_empty() || !seen.insert(k.clone()) { continue; }
        out.push(HotKeyword { keyword: k, rank: out.len() as u32 });
        if out.len() >= 30 { break; }
    }
    Ok(out)
}

各平台:

  • wyPOST eapi/batcheapi("/api/search/chart/detail", {"id":"HOT_SEARCH_SONG#@#"}),解析 data.itemList[].searchWord
  • kgGET http://gateway.kugou.com/api/v3/search/hot_tab?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0,头 dfid/mid/clienttime/x-router: msearch.kugou.com/user-agent: Android.../kg-rc: 1,解析 data.list[].keywords[].keyword
  • kwGET http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2&vipver=9.3.0.1&source=...&tabid=1UA Dalvik解析 tagvalue[].key
  • txPOST musicu.fcg(协议 hotkey: tencent_musicsoso_hotkey.HotkeyService.GetHotkeyForQQMusicPC),解析 hotkey.data.vec_hotkey[].query
  • mg:主 https://jadeite.migu.cn/music_search/v3/search/hotword 兜底 http://jadeite.migu.cn:7090/...,解析 data.hotwords[].hotwordList[] 优先 resourceType=="song"

以 tx 为例(其余平台结构与上文搜索完全同构):

#[derive(Deserialize)] struct TxHotResp { code: Option<i64>, hotkey: Option<TxHotkey> }
#[derive(Deserialize)] struct TxHotkey { code: Option<i64>, data: Option<TxHotData> }
#[derive(Deserialize)] struct TxHotData { vec_hotkey: Option<Vec<TxHotItem>> }
#[derive(Deserialize)] struct TxHotItem { query: Option<String> }

pub async fn hot_search() -> Result<Vec<String>> {
    let body = serde_json::json!({
        "comm": { "uin": 0, "format": "json", "ct": 20, "cv": 1859 },
        "hotkey": { "module": "tencent_musicsoso_hotkey.HotkeyService", "method": "GetHotkeyForQQMusicPC",
            "param": { "search_id": "", "uin": 0 } }
    });
    let headers: &[(&str, &str)] = &[
        ("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"),
        ("Referer", "https://y.qq.com/portal/player.html"),
    ];
    let data: TxHotResp = HTTP.post_json("tx hot", "https://u.y.qq.com/cgi-bin/musicu.fcg", headers, &body).await?;
    Ok(data.hotkey.and_then(|h| h.data).and_then(|d| d.vec_hotkey).unwrap_or_default()
        .into_iter().filter_map(|i| i.query).collect())
}

13. 排行榜实现

platform/charts.rs 持有静态榜单 ChartBoard[],与前端 ALL_BOARDS 一致(榜单 id 与名称直接拷贝 charts/<p>.ts 里的常量)。get_board_songs(source, board_id, page) 分发到 get_<p>_board_songs

pub fn all_boards(source: &str) -> Vec<ChartBoard> {
    match source {
        "wy" => wy::BOARDS.to_vec(),
        "kg" => kg::BOARDS.to_vec(),
        "kw" => kw::BOARDS.to_vec(),
        "tx" => tx::BOARDS.to_vec(),
        "mg" => mg::BOARDS.to_vec(),
        _ => vec![],
    }
}

13.1 网易云 get_wy_board_songs

复用 eapi /api/v3/playlist/detailpayload = {"id": bangid, "n": 100000, "s": 0},解析 playlist.tracks[],用第 11.1 节的 normalize_wy_song

pub async fn get_wy_board_songs(board_id: &str) -> Result<Vec<MusicInfo>> {
    let bangid = board_id.strip_prefix("wy__").unwrap_or(board_id);
    let payload = serde_json::json!({ "id": bangid, "n": 100000, "s": 0 });
    let params = sign::eapi_params("/api/v3/playlist/detail", &payload);
    let body = format!("params={}", urlencode(&params));
    // 同 wy::search 的 headers
    let resp: WyPlaylistDetailResp = HTTP.post_form("wy board", "http://interface.music.163.com/eapi/batch", &WY_HEADERS, &body).await?;
    if resp.code != Some(200) { return Err(...); }
    Ok(resp.playlist.and_then(|p| p.tracks).unwrap_or_default().iter().filter_map(normalize_wy_song).collect())
}

13.2 酷狗 get_kg_board_songs

GET http://mobilecdnbj.kugou.com/api/v3/rank/song?version=9108&ranktype=1&plat=0&pagesize=100&area_code=1&page=<page>&rankid=<rankid>&with_res_tag=0&show_portrait_mv=1,解析 data.info[](字段 songname/remark/album_id/audio_id/hash/duration/filesize/320filesize/sqfilesize/filesize_high/authors[]/album_sizable_cover)。

13.3 酷我 get_kw_board_songs

pub async fn get_kw_board_songs(board_id: &str, page: u32) -> Result<Vec<MusicInfo>> {
    let bangid = board_id.strip_prefix("kw__").unwrap_or(board_id);
    let req = serde_json::json!({
        "uid": "", "devId": "", "sFrom": "kuwo_sdk", "user_type": "AP",
        "carSource": "kwplayercar_ar_6.0.1.0_apk_keluze.apk",
        "id": bangid, "pn": page - 1, "rn": 100
    });
    let qs = sign::wbd_build_param(&req);
    let url = format!("https://wbd.kuwo.cn/api/bd/bang/bang_info?{qs}");
    let text = HTTP.get_text("kw board", &url, &[("User-Agent", DESKTOP_UA)]).await?;
    let decrypted = sign::wbd_decrypt_text(&text);
    let resp: KwBangResp = serde_json::from_slice(&decrypted).map_err(PlatformError::Parse)?;
    if resp.code.map(|c| c.to_string()) != Some("200".into()) { return Err(...); }
    Ok(resp.data.and_then(|d| d.musiclist).unwrap_or_default().iter().map(normalize_kw_bang).collect())
}

13.4 QQ get_tx_board_songs

pub async fn get_tx_board_songs(board_id: &str) -> Result<Vec<MusicInfo>> {
    let topid: i64 = board_id.strip_prefix("tx__").unwrap_or("0").parse().unwrap_or(0);
    let body = serde_json::json!({
        "toplist": { "module": "musicToplist.ToplistInfoServer", "method": "GetDetail", "param": { "topid": topid, "num": 300 } },
        "comm": { "uin": 0, "format": "json", "ct": 20, "cv": 1859 }
    });
    let headers: &[(&str, &str)] = &[("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)")];
    let resp: TxBangResp = HTTP.post_json("tx board", "https://u.y.qq.com/cgi-bin/musicu.fcg", headers, &body).await?;
    if resp.toplist.as_ref().map(|t| t.code) != Some(0) { return Err(...); }
    Ok(resp.toplist.and_then(|t| t.data).and_then(|d| d.song_info_list).unwrap_or_default()
        .iter().filter_map(normalize_tx_song).collect())
}

13.5 咪咕 get_mg_board_songs

GET https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/querycontentbyId.do?columnId=<bangid>&needAll=0,解析 columnInfo.contents[].objectInfosongId/songName/album/albumId/length/artists/newRateFormats/albumImgs),用第 11.5 节 normalize_mg_object_info 归一化(与 charts/mg.ts 一致,length 尾部 mm:ss 取时长,newRateFormatsZQ→flac24bit


14. 歌单实现

platform/playlists.rs 分发三组接口,并提供链接解析(对应 playlists/openLink.ts

pub async fn get_hot_playlists(source: &str, page: u32, tag_id: Option<&str>) -> Result<Vec<Playlist>> { ... }
pub async fn get_playlist_tags(source: &str) -> Result<Vec<PlaylistTag>> { ... }
pub async fn get_playlist_detail(source: &str, id: &str, page: u32) -> Result<PlaylistDetail> { ... }
pub async fn parse_playlist_link(source: &str, raw: &str) -> Result<String> { ... }

14.1 网易云

  • 标签eapi /api/playlist/hottagspayload {}),解析 tags[].name
  • 热门eapi /api/playlist/list{"cat", "order":"hot", "limit":30, "offset", "total":true}
  • 详情eapi /api/v3/playlist/detail{"id","n":100000,"s":0});当 trackIds.len() > tracks.len() 时,用 eapi /api/v3/song/detail{"c": "[{\"id\":...}]"}500 首分块,最多 1000扩展再按 trackIds 顺序重排。
pub async fn get_wy_playlist_detail(id: &str) -> Result<PlaylistDetail> {
    let resp: WyPlaylistDetailResp = wy::eapi_post("/api/v3/playlist/detail", &json!({"id": id, "n": 100000, "s": 0})).await?;
    let pl = resp.playlist.ok_or(...)?;
    let info = PlaylistInfo { name: pl.name.unwrap_or_default(), img: pl.cover_img_url, author: pl.creator.and_then(|c| c.nickname) };
    let tracks = pl.tracks.unwrap_or_default();
    let track_ids: Vec<String> = pl.track_ids.unwrap_or_default().into_iter()
        .filter_map(|t| t.id.map(|v| v.to_string())).take(1000).collect();
    if track_ids.len() > tracks.len() {
        if let Ok(songs) = wy::get_song_details(&track_ids).await {
            if !songs.is_empty() {
                let map: HashMap<&str, MusicInfo> = songs.iter().map(|s| (s.meta.song_id.as_str(), s.clone())).collect();
                let ordered: Vec<MusicInfo> = track_ids.iter().filter_map(|x| map.get(x.as_str()).cloned()).collect();
                if !ordered.is_empty() { return Ok(PlaylistDetail { info, list: ordered }); }
            }
        }
    }
    Ok(PlaylistDetail { info, list: tracks.iter().filter_map(normalize_wy_song).collect() })
}

14.2 酷狗(多路径)

这是最复杂的部分,把 playlists/kg.ts 的所有分支平移到 Rust。核心是 parse_kg_playlist_id 判定 id 形态,再分发到对应 handler。

pub enum KgIdKind { Link(String), Rank(String), Gcid(String), Global(String), Chain(String), Code(String), Special(String) }

pub fn parse_kg_playlist_id(id: &str) -> KgIdKind {
    let raw = id.trim();
    if raw.starts_with("http://") || raw.starts_with("https://") || raw.contains("kugou.com") {
        return KgIdKind::Link(raw.replace(|c: char| !c.is_ascii(), "").to_string()); // 简化:提取 http 前缀段
    }
    if let Some(rest) = raw.strip_prefix("rank_") { return KgIdKind::Rank(rest.into()); }
    if raw.starts_with("gcid_") { return KgIdKind::Gcid(raw.into()); }
    if let Some(rest) = raw.strip_prefix("collection_") { return KgIdKind::Global(rest.into()); }
    if let Some(rest) = raw.strip_prefix("chain_") { return KgIdKind::Chain(rest.into()); }
    if let Some(rest) = raw.strip_prefix("code_") { return KgIdKind::Code(rest.into()); }
    if raw.chars().all(|c| c.is_ascii_digit()) { return KgIdKind::Code(raw.into()); }
    let special = raw.strip_prefix("id_").map(|s| s.to_string()).unwrap_or(raw.to_string());
    KgIdKind::Special(special)
}

pub async fn get_kg_playlist_detail(id: &str) -> Result<PlaylistDetail> {
    match parse_kg_playlist_id(id) {
        KgIdKind::Link(v) => get_detail_from_link(&v).await,
        KgIdKind::Rank(v) => get_detail_by_rank_id(&v).await,
        KgIdKind::Gcid(v) => {
            let gid = decode_gcid(&v).await.ok_or(...)?;
            get_detail_by_global_id(&gid).await
        }
        KgIdKind::Global(v) => get_detail_by_global_id(&v).await,
        KgIdKind::Chain(v) => get_detail_from_share_chain(&v).await,
        KgIdKind::Code(v) => match get_detail_by_code(&v).await {
            Ok(d) => Ok(d),
            Err(_) => get_detail_by_special_id_with_fallback(&v).await,
        },
        KgIdKind::Special(v) => get_detail_by_special_id_with_fallback(&v).await,
    }
}

关键 handler对应 kg.ts端点与签名完全一致

Handler 端点 说明
get_detail_by_special_id GET http://mobilecdn.kugou.com/api/v3/special/song?plat=0&specialid=&page=1&pagesize=-1&version=9108 + special/info futures::join! 并发
get_global_id_from_special GET .../special/info global_specialid
get_detail_by_global_id GET https://mobiles.kugou.com/api/v5/special/info_v2?<params>&signature=<web> + special/song_v2300/页) kg_signature(params,"web","")
decode_gcid POST https://t.kugou.com/v1/songlist/batch_decode?<params>&signature=<android> body {"ret_info":1,"data":[{"id":gcid,"id_type":2}]}kg_signature(params,"android",body)
get_detail_by_code POST http://t.kugou.com/command/ JSON {appid,clientver,mid,clienttime,key,data}
get_detail_by_html GET http://www2.kugou.kugou.com/yueku/v9/special/single/<id>-5-9999.html regex 提取 global.data = [...],再 gateway 解析
get_detail_from_share_chain GET http://m.kugou.com/schain/transfer?pagesize=10000&chain=&su=1&page=1&n=... global_collection_id 时转 global
get_detail_by_rank_id 复用 get_kg_board_songs rank 榜
resolve_hashes POST http://gateway.kugou.com/v2/album_audio/audio KG-THash/KG-RC/KG-Fake/KG-RF/x-routerbody 含 key:"OIlwieks28dk2k092lksi2UIkp"
/// hash 批量解析为完整歌曲(对应 kg.ts resolveHashesasync fn resolve_hashes(hashes: &[String]) -> Result<Vec<KgGatewaySong>> {
    let mut out = Vec::new();
    for chunk in hashes.chunks(100) {
        let body = serde_json::json!({
            "area_code": "1", "show_privilege": 1, "show_album_info": "1", "is_publish": "",
            "appid": 1005, "clientver": 11451, "mid": "1", "dfid": "-",
            "clienttime": now_millis(), "key": "OIlwieks28dk2k092lksi2UIkp",
            "fields": "album_info,author_name,audio_info,ori_audio_name,base,songname",
            "data": chunk.iter().map(|h| json!({"hash": h})).collect::<Vec<_>>()
        });
        let headers: &[(&str, &str)] = &[
            ("KG-THash", "13a3164"), ("KG-RC", "1"), ("KG-Fake", "0"), ("KG-RF", "00869891"),
            ("User-Agent", "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi"),
            ("x-router", "kmr.service.kugou.com"),
        ];
        let resp: serde_json::Value = HTTP.post_json("kg hashes", "http://gateway.kugou.com/v2/album_audio/audio", headers, &body).await?;
        if let Some(data) = resp.get("data").and_then(|d| d.as_array()) {
            for group in data {
                if let Some(first) = group.as_array().and_then(|g| g.first()).cloned() {
                    if let Ok(song) = serde_json::from_value::<KgGatewaySong>(first) { out.push(song); }
                }
            }
        }
    }
    Ok(out)
}

14.3 酷我

  • 标签:GET http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576,筛 digest=="10000"
  • 热门:有 tag 走 getTagPlayListid=<tag>&order=hot),无 tag 走 getRcmPlayListorder=hot&rn=36)。
  • 详情:GET http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=<id>&pn=<page-1>&rn=1000&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1,解析 musiclist[]

14.4 QQ

  • 标签:GET musicu.fcgtags: playlist.PlaylistAllCategoriesServer.get_all_categories)。
  • 热门:有 tag PlayListCategoryServer.get_category_content;无 tag PlayListPlazaServer.get_playlist_by_tagid:10000000, order:5)。
  • 详情:GET https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid=<id>&...,非 0 code 重试 3 次(退避 400/800ms + 抖动)。
pub async fn get_tx_playlist_detail(id: &str, try_num: u32) -> Result<PlaylistDetail> {
    let url = format!("https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid={id}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&notice=0&platform=yqq.json&needNewCode=0");
    let headers: &[(&str, &str)] = &[
        ("Origin", "https://y.qq.com"), ("Referer", &format!("https://y.qq.com/n/yqq/playsquare/{id}.html")),
        ("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"),
    ];
    let resp: TxListDetailResp = match HTTP.get_json("tx playlist", &url, headers).await {
        Ok(r) => r,
        Err(_) if try_num < 2 => { tokio::time::sleep(Duration::from_millis(400 * (try_num as u64 + 1) + rand_jitter(200))).await; return Box::pin(get_tx_playlist_detail(id, try_num + 1)).await; }
        Err(e) => return Err(e),
    };
    if resp.code != Some(0) || resp.cdlist.as_ref().map(|c| c.is_empty()).unwrap_or(true) {
        if try_num < 2 { tokio::time::sleep(Duration::from_millis(400 * (try_num as u64 + 1) + rand_jitter(200))).await; return Box::pin(get_tx_playlist_detail(id, try_num + 1)).await; }
        return Err(PlatformError::BadResponse { label: "tx playlist".into(), detail: "bad response".into() });
    }
    let cd = resp.cdlist.unwrap().remove(0);
    Ok(PlaylistDetail {
        info: PlaylistInfo { name: cd.dissname.unwrap_or_default(), img: cd.logo, author: cd.nickname },
        list: cd.songlist.unwrap_or_default().iter().filter_map(normalize_tx_song).collect(),
    })
}

14.5 咪咕

  • 标签:GET https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-taglist/releasedata[0].content[].texts[])。
  • 热门:有 tag musiclistplaza-listbytag/release;无 tag https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=30&start=<page>&templateVersion=5&type=1
  • 详情:futures::join! 并发 GET .../MIGUM3.0/resource/playlist/song/v2.0?pageNo=&pageSize=50&playlistId=GET https://c.musicapp.migu.cn/MIGUM3.0/resource/playlist/v2.0?playlistId=

15. 专辑实现

platform/albums.rs 分发四组:search_albums / get_album_detail / get_hot_albums / get_album_tags

15.1 网易云

  • 详情:主 eapi /api/v1/album/{id}payload {}),兜底 eapi /api/album/v3/detail{"id"})。封面 https 化并补 param=240y240
  • 热门eapi /api/album/new{"area","limit":30,"offset","total":true})。
  • 标签:静态 [华语 ZH, 欧美 EA, 韩国 KR, 日本 JP]
pub async fn get_wy_album_detail(id: &str) -> Result<AlbumDetail> {
    // 主接口
    let resp = match wy::eapi_post::<WyAlbumDetailResp>(&format!("/api/v1/album/{id}"), &json!({})).await {
        Ok(r) if (r.code == Some(200) || r.code == Some(502)) && (r.songs.is_some() || r.album.is_some()) => r,
        _ => wy::eapi_post::<WyAlbumDetailResp>("/api/album/v3/detail", &json!({"id": id})).await?,
    };
    let album = resp.album;
    let info = PlaylistInfo {
        name: album.as_ref().and_then(|a| a.name.clone()).unwrap_or_default(),
        img: album.as_ref().and_then(|a| a.pic_url.clone().or_else(|| a.blur_pic_url.clone())).map(wy_album_cover),
        author: album_author(album.as_ref()),
    };
    let list = resp.songs.unwrap_or_default().iter().filter_map(normalize_wy_song).collect();
    Ok(AlbumDetail { info, list })
}

15.2 酷狗

  • 详情:GET http://mobiles.kugou.com/api/v3/album/song?version=9108&albumid=&plat=0&pagesize=200&area_code=0&page=&with_res_tag=0 + 若仅 hash 走 resolve_hashes(同 14.2+ POST http://kmrserviceretry.kugou.com/container/v1/albumalbum_name/sizable_cover/author_name
  • 热门:GET http://www2.kugou.kugou.com/yueku/v9/album/index?is_ajax=1&cdn=cdn&p=&s=30&l=<lang>&c=&t=0
  • 标签:静态 [华语 1, 欧美 2, 日语 3, 韩语 4, 其他 5]

15.3 酷我

  • 详情:GET http://search.kuwo.cn/r.s?pn=&rn=1000&stype=albuminfo&albumid=&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0(重试 3 次body 可能是单引号 JSONobj_str_2_json 修复;音质 formatsMP3128/MP3H/ALFLAC/HIRFLAC)。
  • 热门:酷我没有专辑广场,由 get_kw_board_songs 反推去重专辑(meta.album_id 去重)。
  • 标签:静态 [新歌榜 17, 热歌榜 16, 飙升榜 93]

objStr2JSON 的单引号→双引号正则修复逻辑,在 Rust 用 regex 等价实现,或直接尝试 serde_json::from_str 失败后做字符替换再 parse。

15.4 QQ

  • 详情:GET https://c.y.qq.com/v8/fcg-bin/fcg_v8_album_info_cp.fcg?albummid=&platform=yqq&format=json&...(重试 3 次,兼容 data/顶层、songlist/list 形态)。
  • 热门:GET musicu.fcgreq_1: music.web_album_library.get_album_by_tagssort:2)。
  • 标签:get_album_by_tagsget_tags:1)解析 req_1.data.tags.area,失败用静态兜底。

15.5 咪咕

  • 详情:futures::join! 并发 GET http://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong?albumId=&pageNo= + GET https://app.c.nf.migu.cn/MIGUM3.0/resource/album/v2.0?albumId=
  • 热门:GET https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/get-new-cd-list-data?templateVersion=1&columnId=&start=&count=30template=="disk_grid")。
  • 标签:GET .../pc/v1.0/template/get-new-cd-list-header,从 actionUrl 提取 columnId

16. 歌单 / 专辑搜索

  • 歌单搜索(对应 playlists/search.ts五平台同构于第 11 节,只是解析为 Playlist

    • wyeapi /api/cloudsearch/pctype:1000),额外 type:1002 搜用户 → 命中精确昵称再 eapi /api/user/playlist
    • txqq_desktop_search(query,page,limit,3)search_type=3body.songlist.list[]
    • kwr.s?ft=playlistabslist[]
    • kgGET http://msearchretry.kugou.com/api/v3/search/special?...data.info[]id 前缀 id_
    • mg签名 searchAllsearchSwitch songlist:1)→ songListResultData.result[]
  • 专辑搜索(对应 albums/search.ts五平台同构解析为 Album

    • wyeapi /api/cloudsearch/pctype:10)→ result.albums[]
    • txqq_desktop_search(...,2)body.album.list[]
    • kwr.s?ft=albumalbumlist/abslist[]
    • kgGET http://msearchretry.kugou.com/api/v3/search/album?...data.info[]
    • mg签名 searchAllsong:1, album:1),合并歌曲命中与官方专辑并按相关度打分排序(mgAlbumNameScore/mgSongNameScore/mgLiveOrCoverPenalty 直接移植为 Rust 函数)。

17. Tauri 命令与前端对接

platform/commands.rs(或并入 lib.rs注册到 generate_handler

use tauri::State;

#[tauri::command]
async fn search_songs(source: String, query: String, page: u32, limit: u32) -> Result<SearchResult, String> {
    crate::platform::search::search_songs(&source, &query, page, limit).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn search_playlists(source: String, query: String, page: u32, limit: u32) -> Result<Vec<Playlist>, String> {
    crate::platform::playlists::search_playlists(&source, &query, page, limit).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn search_albums(source: String, query: String, page: u32, limit: u32) -> Result<Vec<Album>, String> {
    crate::platform::albums::search_albums(&source, &query, page, limit).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_hot_search(source: String) -> Result<Vec<HotKeyword>, String> {
    crate::platform::hot_search::hot_search(&source).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_board_songs(source: String, board_id: String, page: u32) -> Result<Vec<MusicInfo>, String> {
    crate::platform::charts::get_board_songs(&source, &board_id, page).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_board_list(source: String) -> Result<Vec<ChartBoard>, String> {
    Ok(crate::platform::charts::all_boards(&source))
}

#[tauri::command]
async fn get_hot_playlists(source: String, page: u32, tag_id: Option<String>) -> Result<Vec<Playlist>, String> {
    crate::platform::playlists::get_hot_playlists(&source, page, tag_id.as_deref()).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_playlist_tags(source: String) -> Result<Vec<PlaylistTag>, String> {
    crate::platform::playlists::get_playlist_tags(&source).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_playlist_detail(source: String, id: String, page: u32) -> Result<PlaylistDetail, String> {
    crate::platform::playlists::get_playlist_detail(&source, &id, page).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn parse_playlist_link(source: String, raw: String) -> Result<String, String> {
    crate::platform::playlists::parse_playlist_link(&source, &raw).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_album_detail(source: String, id: String, page: u32) -> Result<AlbumDetail, String> {
    crate::platform::albums::get_album_detail(&source, &id, page).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_hot_albums(source: String, page: u32, tag_id: Option<String>) -> Result<Vec<Album>, String> {
    crate::platform::albums::get_hot_albums(&source, page, tag_id.as_deref()).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn get_album_tags(source: String) -> Result<Vec<AlbumTag>, String> {
    crate::platform::albums::get_album_tags(&source).await.map_err(|e| e.to_string())
}

注册(在 lib.rsrun()

.manage(crate::platform::http::Http::new()) // 供 State 注入;或直接用全局静态
.invoke_handler(tauri::generate_handler![
    // ... 现有命令
    search_songs, search_playlists, search_albums,
    get_hot_search, get_board_songs, get_board_list,
    get_hot_playlists, get_playlist_tags, get_playlist_detail, parse_playlist_link,
    get_album_detail, get_hot_albums, get_album_tags,
])

前端调用(src/lib/http.ts 的替代,或新建 src/lib/rustBridge.ts

import { invoke } from "@tauri-apps/api/core"

export function searchSongs(source: OnlineSource, query: string, page = 1, limit = 30) {
  return invoke<SearchResult>("search_songs", { source, query, page, limit })
}

因为 Rust 返回的 DTO 已与 MusicInfo/SearchResult 等完全同名同构,前端 stores/searchStore.tssearchFns 只需把实现替换为 invoke 调用,缓存逻辑保持不动。


18. 迁移策略与 TS 实现对照

TS 原语 Rust 等价
Promise.all([a, b]) futures::future::join!(a, b)tokio::join!
JSON.stringify(obj) serde_json::to_string(&obj) / json!()
URLSearchParams 手拼 format! + urlencoding::encode,或 url::form_urlencoded
DOMParser 实体解码 html_escape::decode_html_entities
btoa / atob base64::engine::general_purpose::STANDARD.encode/decode
crypto.subtle.digest(SHA-1) sha1::Sha1::digest
js-md5 md5::Md5::digest
aes-js AES-128-ECB aes::Aes128 + 手动 ECB 分块 + 手动 PKCS7
setTimeout 退避重试 tokio::time::sleep
createAsyncCacheTTL + promise 去重) platform/cache.rs(可选)或前端保留
res.headers.get("location") 手动重定向 reqwest::redirect::Policy::none() + response.headers()
Array.prototype.join("、") Vec<String>::join("、")

建议的迁移顺序(风险从低到高):

  1. 热搜(最简单,无签名或已有签名)→ 排行榜(结构清晰)→ 歌曲搜索 → 歌单/专辑热门与标签 → 歌单详情 → 酷狗多路径与 HTML 兜底(最后,最复杂)。
  2. 每个平台先落地签名函数 + 一个只读接口,用 cargo test 对签名做固定向量断言(例如 eapi 输出与 TS 计算一致),再逐步铺开。
  3. 保留前端 src/lib/http.ts 一段时间作为灰度开关:设置项决定走 Rust 命令还是走旧 TS 路径。

19. 注意事项与陷阱

  1. eapi 的 key 顺序无关紧要:服务端只解密后 JSON.parseserde_json 默认 key 排序BTreeMap不影响正确性但若担心可在 Cargo.tomlserde_json = { features = ["preserve_order"] }
  2. _qualitys 字段名:以 _ 开头必须 #[serde(rename = "_qualitys")],否则 rename_all = "camelCase" 会把它变形成错误字段名。
  3. AES-ECB 要手动分块RustCrypto aes 只提供 encrypt_block,需自己按 16 字节循环并补 PKCS7cbc 套件带 IV 会改变结果,不可用于 ECB。
  4. QQ zzcSign 的 base64btoa 后要去掉 /+=,再整体 to_lowercase()part3 的字节是 SCRAMBLE[i] ^ hex_byte0..255)。
  5. 酷我 wbdCrypto 响应是「URL 编码后的 base64」:先 base64 解码(必要时先 urlencoding::decode),再 AES 解密去 PKCS7最后 serde_json 解析。
  6. 酷狗签名参数顺序params.split('&').sort().join("") 是字典序排序ASCIIRust 的 Vec::sort() 对 ASCII 一致;但要注意 JS Array.prototype.sort() 默认按 UTF-16 code unitASCII 场景一致。
  7. 异步递归QQ/酷我详情带重试的递归函数Rust 需用 Box::pin(...) 返回 Future,或用 loop 改写避免无限类型。
  8. 跨线程 SendTauri 命令要求返回类型 Sendreqwest::ClientCloneSend + Sync,可作为 State 注入(.manage(Http::new()))。
  9. 代理/证书reqwest 已配 rustlsTauri 打包环境无需系统根证书,注意预置 webpki-rootsrustls 特性)以免某些 CDN 证书校验失败。
  10. 保留前端缓存:迁移后前端 createAsyncCache 仍在Rust 侧不再额外缓存,避免双份 TTL 与内存占用(详见第 8 节)。

附:可复用的顶层签名/工具索引

函数 位置 对应 TS
eapi_params platform/sign.rs platforms/wy/eapi.ts
zzc_sign platform/sign.rs search/txDesktop.ts
wbd_build_param / wbd_decrypt_text platform/sign.rs charts/kw.ts
kg_signature platform/sign.rs playlists/kg.ts
migu_sign platform/sign.rs search/mg.ts
aes128_ecb_encrypt/decrypt platform/crypto.rs aes-js ECB
md5_hex / sha1_hex platform/crypto.rs js-md5 / WebCrypto
size_formate / format_duration / format_play_count / decode_name platform/util.rs common/utils/common.ts
index_quality_sizes platform/model.rs lib/quality.ts