1667 lines
77 KiB
Markdown
1667 lines
77 KiB
Markdown
# 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 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`:
|
||
|
||
```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 的 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` 是全部签名算法的地基。
|
||
|
||
```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-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 侧默认不启用):
|
||
|
||
```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(¶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 一致):
|
||
|
||
```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_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`
|
||
|
||
```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 的 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 存在才保留):
|
||
|
||
```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(¶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`
|
||
|
||
```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¬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`;无 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`:
|
||
- 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[]`。
|
||
|
||
- **专辑搜索**(对应 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 函数)。
|
||
|
||
---
|
||
|
||
## 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.parse,serde_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("")` 是字典序排序(ASCII),Rust 的 `Vec::sort()` 对 ASCII 一致;但要注意 JS `Array.prototype.sort()` 默认按 UTF-16 code unit,ASCII 场景一致。
|
||
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` |
|