77 KiB
Museek 音乐平台接口的 Rust 实现方案
本文档给出把「搜索 / 热搜 / 排行榜 / 歌单 / 专辑」等平台内容获取逻辑从 TypeScript(src/lib/)迁移到 Rust(src-tauri/)的完整设计方案与关键代码。所有接口、签名算法、请求参数与响应字段均与 docs/music-platform-apis.md 及现有 src/lib/ 实现一一对应。
约定:平台缩写
wy=网易云、kg=酷狗、kw=酷我、tx=QQ、mg=咪咕。代码目标 Rust edition 2021,异步基于 tokio + reqwest。
目录
- 目标与架构决策
- 依赖清单
- 目录结构
- 公共错误类型
- 公共工具函数
- 加密与签名模块
- HTTP 客户端封装
- 缓存
- 数据模型(DTO)
- 各平台签名实现
- 歌曲搜索实现
- 热搜关键词实现
- 排行榜实现
- 歌单实现
- 专辑实现
- 歌单 / 专辑搜索
- Tauri 命令与前端对接
- 迁移策略与 TS 实现对照
- 注意事项与陷阱
1. 目标与架构决策
当前前端 src/lib/http.ts 用 @tauri-apps/plugin-http 直接发请求(原生层只提供 HTTP 通道),所有签名、解析、缓存都在 TypeScript 完成。迁移到 Rust 的动机与决策:
- 签名/加密本地化:eapi、zzcSign、wbdCrypto 等算法用 RustCrypto 实现,减少 JS 依赖,且天然离线可测。
- 网络/并发在原生层:reqwest 连接池、
tokio::join!并发(替代 JSPromise.all),避免 WebView 层瓶颈与 CORS。 - 协议复用:
tauri-plugin-http已具备 HTTP 能力,但业务接口统一收敛到#[tauri::command],前端只做 UI 与状态管理。 - 缓存策略: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 AES(ECB 手动分块)
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 的decodeName);regex仅用于酷狗 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 # DTO(MusicInfo / 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.rs 的 run() 里注册这些命令即可,其余窗口/托盘逻辑不动。
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 的 sizeFormate:B/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 实体(& " 等)解码为文本。
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 的
formatPlayCount用Math.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-urlencoded(eapi、酷狗 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 JSON(QQ 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-http的unsafe-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 时 fetch)。
pub 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 默认
Map为BTreeMap(key 排序),与 JS 的插入顺序不同,但 eapi 服务端只解密后 JSON.parse,key 顺序不影响正确性,故可直接用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(¶ms));
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_MINFO:bitrate 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 的 qqDesktopSearch(search_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_songs 与 normalize_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 },
})
}
randcrate 用于生成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)
}
各平台:
- wy:
POST eapi/batch,eapi("/api/search/chart/detail", {"id":"HOT_SEARCH_SONG#@#"}),解析data.itemList[].searchWord。 - kg:
GET 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。 - kw:
GET http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2&vipver=9.3.0.1&source=...&tabid=1(UA Dalvik),解析tagvalue[].key。 - tx:
POST 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/detail,payload = {"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(¶ms));
// 同 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[].objectInfo(songId/songName/album/albumId/length/artists/newRateFormats/albumImgs),用第 11.5 节 normalize_mg_object_info 归一化(与 charts/mg.ts 一致,length 尾部 mm:ss 取时长,newRateFormats 中 ZQ→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/hottags(payload{}),解析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_v2(300/页) |
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-router,body 含 key:"OIlwieks28dk2k092lksi2UIkp" |
/// hash 批量解析为完整歌曲(对应 kg.ts resolveHashes)。
async 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 走
getTagPlayList(id=<tag>&order=hot),无 tag 走getRcmPlayList(order=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.fcg(tags: playlist.PlaylistAllCategoriesServer.get_all_categories)。 - 热门:有 tag
PlayListCategoryServer.get_category_content;无 tagPlayListPlazaServer.get_playlist_by_tag(id: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¬ice=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/release(data[0].content[].texts[])。 - 热门:有 tag
musiclistplaza-listbytag/release;无 taghttps://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/album取album_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 可能是单引号 JSON,用obj_str_2_json修复;音质formats:MP3128/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.fcg(req_1: music.web_album_library.get_album_by_tags,sort:2)。 - 标签:
get_album_by_tags(get_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=30(template=="disk_grid")。 - 标签:
GET .../pc/v1.0/template/get-new-cd-list-header,从actionUrl提取columnId。
16. 歌单 / 专辑搜索
-
歌单搜索(对应 playlists/search.ts):五平台同构于第 11 节,只是解析为
Playlist:- wy:eapi
/api/cloudsearch/pc(type:1000),额外type:1002搜用户 → 命中精确昵称再 eapi/api/user/playlist。 - tx:
qq_desktop_search(query,page,limit,3)(search_type=3)→body.songlist.list[]。 - kw:
r.s?ft=playlist→abslist[]。 - kg:
GET http://msearchretry.kugou.com/api/v3/search/special?...→data.info[],id 前缀id_。 - mg:签名
searchAll(searchSwitch songlist:1)→songListResultData.result[]。
- wy:eapi
-
专辑搜索(对应 albums/search.ts):五平台同构,解析为
Album:- wy:eapi
/api/cloudsearch/pc(type:10)→result.albums[]。 - tx:
qq_desktop_search(...,2)→body.album.list[]。 - kw:
r.s?ft=album→albumlist/abslist[]。 - kg:
GET http://msearchretry.kugou.com/api/v3/search/album?...→data.info[]。 - mg:签名
searchAll(song:1, album:1),合并歌曲命中与官方专辑并按相关度打分排序(mgAlbumNameScore/mgSongNameScore/mgLiveOrCoverPenalty直接移植为 Rust 函数)。
- wy:eapi
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.rs 的 run()):
.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.ts的searchFns只需把实现替换为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 |
createAsyncCache(TTL + promise 去重) |
platform/cache.rs(可选)或前端保留 |
res.headers.get("location") 手动重定向 |
reqwest::redirect::Policy::none() + response.headers() |
Array.prototype.join("、") |
Vec<String>::join("、") |
建议的迁移顺序(风险从低到高):
- 热搜(最简单,无签名或已有签名)→ 排行榜(结构清晰)→ 歌曲搜索 → 歌单/专辑热门与标签 → 歌单详情 → 酷狗多路径与 HTML 兜底(最后,最复杂)。
- 每个平台先落地签名函数 + 一个只读接口,用
cargo test对签名做固定向量断言(例如 eapi 输出与 TS 计算一致),再逐步铺开。 - 保留前端
src/lib/http.ts一段时间作为灰度开关:设置项决定走 Rust 命令还是走旧 TS 路径。
19. 注意事项与陷阱
- eapi 的 key 顺序无关紧要:服务端只解密后 JSON.parse,serde_json 默认 key 排序(BTreeMap)不影响正确性;但若担心,可在
Cargo.toml加serde_json = { features = ["preserve_order"] }。 _qualitys字段名:以_开头必须#[serde(rename = "_qualitys")],否则rename_all = "camelCase"会把它变形成错误字段名。- AES-ECB 要手动分块:RustCrypto
aes只提供encrypt_block,需自己按 16 字节循环并补 PKCS7;cbc套件带 IV 会改变结果,不可用于 ECB。 - QQ zzcSign 的 base64:
btoa后要去掉/、+、=,再整体to_lowercase();part3的字节是SCRAMBLE[i] ^ hex_byte(0..255)。 - 酷我 wbdCrypto 响应是「URL 编码后的 base64」:先 base64 解码(必要时先
urlencoding::decode),再 AES 解密去 PKCS7,最后serde_json解析。 - 酷狗签名参数顺序:
params.split('&').sort().join("")是字典序排序(ASCII),Rust 的Vec::sort()对 ASCII 一致;但要注意 JSArray.prototype.sort()默认按 UTF-16 code unit,ASCII 场景一致。 - 异步递归:QQ/酷我详情带重试的递归函数,Rust 需用
Box::pin(...)返回Future,或用loop改写避免无限类型。 - 跨线程 Send:Tauri 命令要求返回类型
Send;reqwest::Client是Clone且Send + Sync,可作为State注入(.manage(Http::new()))。 - 代理/证书:
reqwest已配rustls;Tauri 打包环境无需系统根证书,注意预置webpki-roots(rustls特性)以免某些 CDN 证书校验失败。 - 保留前端缓存:迁移后前端
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 |