# 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 = std::result::Result; ``` Tauri 命令直接返回 `Result`,在命令边界做一次 `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, block: usize) { let pad = block - (data.len() % block); data.extend(std::iter::repeat(pad as u8).take(pad)); } fn pkcs7_unpad(mut data: Vec) -> Vec { 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 { 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 { 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> { 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( &self, label: &str, url: &str, headers: &[(&str, &str)], ) -> Result { 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 { 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( &self, label: &str, url: &str, headers: &[(&str, &str)], body: &str, ) -> Result { 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( &self, label: &str, url: &str, headers: &[(&str, &str)], body: &B, ) -> Result { 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 { 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,过期即失效。失败不缓存(调用方在 miss 时 fetch)。 pub struct TtlCache { map: Mutex)>>, ttl: Duration, max: usize, } impl TtlCache { pub fn new(ttl: Duration, max: usize) -> Self { Self { map: Mutex::new(HashMap::new()), ttl, max } } pub async fn get(&self, key: &str) -> Option> { 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(&self, key: &str, fetch: F) -> crate::platform::error::Result> where F: FnOnce() -> Fut, Fut: Future>, { 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, } #[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, #[serde(skip_serializing_if = "Option::is_none")] pub pic_url: Option, pub qualitys: Vec, /// 形如 { "320k": { "size": "9.99 MB" }, ... }(lx 脚本 / VIP 探测用) #[serde(rename = "_qualitys")] pub _qualitys: serde_json::Map, #[serde(skip_serializing_if = "Option::is_none")] pub hash: Option, // kg #[serde(skip_serializing_if = "Option::is_none")] pub str_media_mid: Option, // tx #[serde(skip_serializing_if = "Option::is_none")] pub copyright_id: Option, // 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, 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, #[serde(skip_serializing_if = "Option::is_none")] pub play_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, #[serde(skip_serializing_if = "Option::is_none")] pub publish_time: Option, #[serde(skip_serializing_if = "Option::is_none")] pub song_count: Option, pub source: String, #[serde(skip_serializing_if = "Option::is_none")] pub kind: Option, // "playlist" | "album" } #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct PlaylistDetail { pub info: PlaylistInfo, pub list: Vec, } #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct PlaylistInfo { pub name: String, pub img: Option, #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, } #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct Album { pub id: String, pub name: String, pub img: Option, #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, #[serde(skip_serializing_if = "Option::is_none")] pub publish_time: Option, #[serde(skip_serializing_if = "Option::is_none")] pub song_count: Option, pub source: String, } #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct AlbumDetail { pub info: PlaylistInfo, pub list: Vec, } #[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>`,即对象。这里用 `serde_json::Map` 序列化,内容为 `{ size: Option }`,与前端 `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 = 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 { 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 { 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, name: Option, dt: Option, ar: Option>, al: Option, hr: Option, sq: Option, h: Option, l: Option, privilege: Option } #[derive(Deserialize)] struct WySinger { name: Option } #[derive(Deserialize)] struct WyAlbum { id: Option, name: Option, pic_url: Option } #[derive(Deserialize)] struct WyBr { size: Option } #[derive(Deserialize)] struct WyPriv { max_br_level: Option, maxbr: Option } #[derive(Deserialize)] struct WyResource { base_info: Option } #[derive(Deserialize)] struct WyBaseInfo { simple_song_data: Option } #[derive(Deserialize)] struct WySearchResp { code: Option, data: Option } #[derive(Deserialize)] struct WySearchData { resources: Option>, total_count: Option } pub async fn search(query: &str, page: u32, limit: u32) -> Result { 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 { let song_id = s.id?.to_string(); let mut qualitys: Vec = 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 = 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::>().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 { 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, album_name: Option, album_id: Option, audioid: Option, singers: Option>, duration: Option, file_hash: Option, file_size: Option, hq_file_hash: Option, hq_file_size: Option, sq_file_hash: Option, sq_file_size: Option, res_file_hash: Option, res_file_size: Option, image: Option, grp: Option> } #[derive(Deserialize)] struct KgSinger { name: Option } #[derive(Deserialize)] struct KgSearchResp { error_code: Option, data: Option } #[derive(Deserialize)] struct KgSearchData { total: Option, lists: Option> } pub async fn search(query: &str, page: u32, limit: u32) -> Result { 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, seen: &mut std::collections::HashSet, 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::>().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, songname: Option, artist: Option, duration: Option, album: Option, albumid: Option, n_minfo: Option, web_albumpic_short: Option } #[derive(Deserialize)] struct KwSearchResp { total: Option, abslist: Option> } pub async fn search(query: &str, page: u32, limit: u32) -> Result { 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::().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::().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 { 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, mid: Option, title: Option, interval: Option, singer: Option>, album: Option, file: Option } #[derive(Deserialize)] struct TxSinger { name: Option, mid: Option } #[derive(Deserialize)] struct TxAlbum { name: Option, mid: Option } #[derive(Deserialize)] struct TxFile { media_mid: Option, size_128mp3: Option, size_320mp3: Option, size_flac: Option, size_hires: Option } #[derive(Deserialize)] struct TxSearchData { body: Option, meta: Option } #[derive(Deserialize)] struct TxMeta { sum: Option, estimate_sum: Option } /// 对应 txDesktop.ts 的 qqDesktopSearch(search_type=0),带重试。 pub async fn qq_desktop_search(query: &str, page: u32, limit: u32, search_type: u8, retry: u32) -> Result { 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::(&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 { 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) -> Vec { 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::(v.clone()).ok()) .filter_map(|s| normalize_tx_song(&s)).collect() } fn normalize_tx_song(s: &TxSong) -> Option { 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::>().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, info: Option, song_result_data: Option } #[derive(Deserialize)] struct MgSongResult { result_list: Option>>, total_count: Option } pub async fn search(query: &str, page: u32, limit: u32) -> Result { 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 = None; if let Ok(resp) = HTTP.get_json::("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::().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 { 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::().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`。 --- ## 12. 热搜关键词实现 `platform/hot_search.rs` 分发(对应 hotSearch/index.ts,去重 + 最多 30 条 + rank): ```rust pub async fn hot_search(source: &str) -> Result> { 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, hotkey: Option } #[derive(Deserialize)] struct TxHotkey { code: Option, data: Option } #[derive(Deserialize)] struct TxHotData { vec_hotkey: Option> } #[derive(Deserialize)] struct TxHotItem { query: Option } pub async fn hot_search() -> Result> { 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/

.ts` 里的常量)。`get_board_songs(source, board_id, page)` 分发到 `get_

_board_songs`。 ```rust pub fn all_boards(source: &str) -> Vec { 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> { 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=&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> { 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> { 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=&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> { ... } pub async fn get_playlist_tags(source: &str) -> Result> { ... } pub async fn get_playlist_detail(source: &str, id: &str, page: u32) -> Result { ... } pub async fn parse_playlist_link(source: &str, raw: &str) -> Result { ... } ``` ### 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 { 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 = 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 = 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 { 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?&signature=` + `special/song_v2`(300/页) | `kg_signature(params,"web","")` | | `decode_gcid` | `POST https://t.kugou.com/v1/songlist/batch_decode?&signature=` | 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/-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> { 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::>() }); 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::(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=&order=hot`),无 tag 走 `getRcmPlayList`(`order=hot&rn=36`)。 - 详情:`GET http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=&pn=&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=&...`,非 0 code 重试 3 次(退避 400/800ms + 抖动)。 ```rust pub async fn get_tx_playlist_detail(id: &str, try_num: u32) -> Result { 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=&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 { // 主接口 let resp = match wy::eapi_post::(&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::("/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=&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 { 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, 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, 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, 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, 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, String> { Ok(crate::platform::charts::all_boards(&source)) } #[tauri::command] async fn get_hot_playlists(source: String, page: u32, tag_id: Option) -> Result, 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, 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 { 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 { 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 { 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) -> Result, 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, 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("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::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` |