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

1667 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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。
---
## 目录
1. [目标与架构决策](#1-目标与架构决策)
2. [依赖清单](#2-依赖清单)
3. [目录结构](#3-目录结构)
4. [公共错误类型](#4-公共错误类型)
5. [公共工具函数](#5-公共工具函数)
6. [加密与签名模块](#6-加密与签名模块)
7. [HTTP 客户端封装](#7-http-客户端封装)
8. [缓存](#8-缓存)
9. [数据模型DTO](#9-数据模型dto)
10. [各平台签名实现](#10-各平台签名实现)
11. [歌曲搜索实现](#11-歌曲搜索实现)
12. [热搜关键词实现](#12-热搜关键词实现)
13. [排行榜实现](#13-排行榜实现)
14. [歌单实现](#14-歌单实现)
15. [专辑实现](#15-专辑实现)
16. [歌单 / 专辑搜索](#16-歌单--专辑搜索)
17. [Tauri 命令与前端对接](#17-tauri-命令与前端对接)
18. [迁移策略与 TS 实现对照](#18-迁移策略与-ts-实现对照)
19. [注意事项与陷阱](#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]` 中新增:
```toml
[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 的 `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 # 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.rs``run()` 里注册这些命令即可,其余窗口/托盘逻辑不动。
---
## 4. 公共错误类型
`platform/error.rs`
```rust
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`
```rust
/// 对应 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 的 `formatPlayCount` 用 `Math.trunc(num / 1000) / 10`,即「先除以 1000 取整再除以 10」等价于保留一位小数的「万」。上面的公式近似一致示例级精度足够
---
## 6. 加密与签名模块
`platform/crypto.rs` 是全部签名算法的地基。
```rust
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`
```rust
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-http` 的 `unsafe-headers` 特性已启用;但业务接口在本方案里改走 Rust reqwest不再受浏览器/插件限制,可直接设置任意头。
---
## 8. 缓存
`platform/cache.rs`可选前端已缓存Rust 侧默认不启用):
```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` 结构。
```rust
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
```rust
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
```rust
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
```rust
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
```rust
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
```rust
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`
```rust
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`
```rust
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 一致):
```rust
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
```rust
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`
```rust
#[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`
```rust
#[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`
```rust
#[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_songs``normalize_tx_song`(与 search/tx.ts 一致media_mid 存在才保留):
```rust
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`
```rust
#[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
```rust
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
```rust
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 为例(其余平台结构与上文搜索完全同构):
```rust
#[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`
```rust
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`
```rust
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`
```rust
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`
```rust
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
```rust
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` 顺序重排。
```rust
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。
```rust
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"` |
```rust
/// 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`;无 tag `PlayListPlazaServer.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 + 抖动)。
```rust
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/release``data[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]`
```rust
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`
- wyeapi `/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[]`
- **专辑搜索**(对应 albums/search.ts五平台同构解析为 `Album`
- wyeapi `/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 函数)。
---
## 17. Tauri 命令与前端对接
`platform/commands.rs`(或并入 lib.rs注册到 `generate_handler`
```rust
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()`
```rust
.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`
```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("、")` |
建议的迁移顺序(风险从低到高):
1. 热搜(最简单,无签名或已有签名)→ 排行榜(结构清晰)→ 歌曲搜索 → 歌单/专辑热门与标签 → 歌单详情 → 酷狗多路径与 HTML 兜底(最后,最复杂)。
2. 每个平台先落地签名函数 + 一个只读接口,用 `cargo test` 对签名做固定向量断言(例如 eapi 输出与 TS 计算一致),再逐步铺开。
3. 保留前端 `src/lib/http.ts` 一段时间作为灰度开关:设置项决定走 Rust 命令还是走旧 TS 路径。
---
## 19. 注意事项与陷阱
1. **eapi 的 key 顺序无关紧要**:服务端只解密后 JSON.parseserde_json 默认 key 排序BTreeMap不影响正确性但若担心可在 `Cargo.toml``serde_json = { features = ["preserve_order"] }`
2. **`_qualitys` 字段名**:以 `_` 开头必须 `#[serde(rename = "_qualitys")]`,否则 `rename_all = "camelCase"` 会把它变形成错误字段名。
3. **AES-ECB 要手动分块**RustCrypto `aes` 只提供 `encrypt_block`,需自己按 16 字节循环并补 PKCS7`cbc` 套件带 IV 会改变结果,不可用于 ECB。
4. **QQ zzcSign 的 base64**`btoa` 后要去掉 `/`、`+`、`=`,再整体 `to_lowercase()``part3` 的字节是 `SCRAMBLE[i] ^ hex_byte`0..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. **跨线程 Send**Tauri 命令要求返回类型 `Send``reqwest::Client` 是 `Clone``Send + Sync`,可作为 `State` 注入(`.manage(Http::new())`)。
9. **代理/证书**`reqwest` 已配 `rustls`Tauri 打包环境无需系统根证书,注意预置 `webpki-roots``rustls` 特性)以免某些 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` |