From 07d36496bc224fa70f0f749c12c803e596abc51e Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Fri, 14 Aug 2026 16:24:47 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20AGENTS.md=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8F=90=E4=BE=9B=E9=A1=B9=E7=9B=AE=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=8C=87=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 AGENTS.md 文件,为 Codex AI 提供项目开发指南,包含: - 项目概述(Tauri 2 + Vue 3 + TypeScript 桌面音乐播放器) - 开发命令(安装依赖、运行开发服务器、构建等) - 架构说明(前端 Vue 3、后端 Tauri/Rust、自动导入配置) - API 请求模式和状态管理说明 - 关键文件列表和 TypeScript 配置 - 前端变更测试指导 --- AGENTS.md | 100 ++ docs/music-platform-apis-rust.md | 1666 +++++++++++++++++++++++++++ docs/music-platform-apis.md | 413 +++++++ package.json | 9 + pnpm-lock.yaml | 98 ++ src-tauri/capabilities/default.json | 5 + src-tauri/src/lib.rs | 9 + src-tauri/tauri.conf.json | 8 +- src/App.vue | 185 ++- src/components/FullscreenPlayer.vue | 11 +- src/layouts/MainLayout.vue | 442 +++---- src/router/index.ts | 40 +- src/styles/global.css | 8 + src/types/auto-imports.d.ts | 79 -- src/types/components.d.ts | 8 +- src/views/DiscoverView.vue | 16 +- src/views/PlayMusic.vue | 18 - src/views/SearchView.vue | 4 +- src/views/SettingsView.vue | 224 ++-- src/views/setting/SourceSetting.vue | 167 +++ 20 files changed, 2987 insertions(+), 523 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/music-platform-apis-rust.md create mode 100644 docs/music-platform-apis.md delete mode 100644 src/views/PlayMusic.vue create mode 100644 src/views/setting/SourceSetting.vue diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8db61ee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +YwYMusic is a desktop music player built with Tauri 2 + Vue 3 + TypeScript. The app uses Tauri's Rust backend for native OS integration while the UI is built with Vue 3 and Naive UI components. + +## Development Commands + +```bash +# Install dependencies (uses pnpm workspaces) +pnpm install + +# Run frontend dev server only (Vite on port 1420) +pnpm dev + +# Run Tauri desktop app in dev mode (recommended) +pnpm tauri dev + +# Build frontend (with TypeScript checking) +pnpm build + +# Build Tauri desktop app for production +pnpm tauri build + +# Preview production build +pnpm preview +``` + +## Architecture + +### Frontend (Vue 3 + TypeScript) + +- **Router**: Uses `createWebHashHistory` (required for Tauri file:// protocol) +- **State**: Lightweight reactive store pattern in `/src/stores/player.ts` (not Pinia) +- **UI Library**: Naive UI with auto-import resolver +- **Path Alias**: `@` maps to `./src` + +### Backend (Tauri/Rust) + +- Minimal Rust code in `/src-tauri/src/` +- Tauri handles native OS integration, window management, and system APIs + +### Auto-Import Setup + +**Critical**: This project uses unplugin-auto-import and unplugin-vue-components: + +- Vue APIs (`ref`, `reactive`, `computed`, etc.) are auto-imported—no need to import from "vue" +- Naive UI hooks (`useMessage`, `useDialog`, etc.) are auto-imported +- Naive UI components are auto-imported—no need to register or import them +- TypeScript declarations are auto-generated in `src/types/auto-imports.d.ts` and `src/types/components.d.ts` + +When writing Vue components, do not add manual imports for these APIs/components unless the auto-import fails. + +### API Request Pattern + +Centralized in `src/utils/request.ts`: + +- Base URL from `VITE_BASE_API_URL` environment variable (see `.env`) +- Token stored in localStorage with key `"token"` +- Request interceptor adds `Authorization: Bearer ` header +- Response interceptor handles success (200) and unauthorized (401) codes +- Use exported functions: `get()`, `post()`, `put()`, `del()`, or generic `request()` +- All responses follow `ApiResponse` type: `{ code: number, msg: string, data: T }` + +### State Management + +Player state lives in `src/stores/player.ts` using Vue's `reactive()`: + +```typescript +playerState.current // Current song +playerState.queue // Play queue +playerState.isPlaying // Playing state +playerState.currentTime // Current playback position +playerState.volume // Volume level (0-100) +``` + +Use `playSong(song, queue?)` to update current song and queue. + +## Key Files + +- `vite.config.ts` - Vite configuration with auto-import plugins +- `src/main.ts` - Vue app entry point +- `src/router/index.ts` - Route definitions +- `src/utils/request.ts` - Axios instance with interceptors +- `src/types/global.ts` - Global TypeScript types +- `src-tauri/tauri.conf.json` - Tauri app configuration +- `src-tauri/src/lib.rs` - Tauri backend entry point + +## TypeScript + +- Base config: `tsconfig.json` +- Node config: `tsconfig.node.json` +- Auto-generated types in `src/types/` should not be manually edited +- Use type imports from `@/types/global.ts` for API responses + +## Testing Frontend Changes + +Always run `pnpm tauri dev` to test in the actual desktop environment, as Tauri apps behave differently from web apps (file protocol, window APIs, etc.). diff --git a/docs/music-platform-apis-rust.md b/docs/music-platform-apis-rust.md new file mode 100644 index 0000000..9ad8aba --- /dev/null +++ b/docs/music-platform-apis-rust.md @@ -0,0 +1,1666 @@ +# 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` | diff --git a/docs/music-platform-apis.md b/docs/music-platform-apis.md new file mode 100644 index 0000000..3f55711 --- /dev/null +++ b/docs/music-platform-apis.md @@ -0,0 +1,413 @@ +# Museek 音乐平台内容获取实现解析 + +本文档解析 Museek 如何通过各音乐平台(网易云 wy、酷狗 kg、酷我 kw、QQ 音乐 tx、咪咕 mg)的公开接口获取**搜索、歌单、专辑、排行榜、热搜**等内容。所有实现均移植自 lx-music-desktop 的 `musicSdk//`,代码位于 `src/lib/` 下按功能分目录组织。 + +> 平台缩写对照:`wy` = 网易云音乐,`kg` = 酷狗音乐,`kw` = 酷我音乐,`tx` = QQ 音乐(腾讯),`mg` = 咪咕音乐。 + +--- + +## 目录 + +1. [通用基础设施](#1-通用基础设施) +2. [平台 SDK 桶与签名算法](#2-平台-sdk-桶与签名算法) +3. [歌曲搜索(5 平台)](#3-歌曲搜索) +4. [热搜关键词(5 平台)](#4-热搜关键词) +5. [排行榜(5 平台)](#5-排行榜) +6. [歌单(5 平台)](#6-歌单) +7. [歌单搜索](#7-歌单搜索) +8. [歌单链接解析](#8-歌单链接解析) +9. [专辑(5 平台)](#9-专辑) +10. [专辑搜索](#10-专辑搜索) +11. [附:播放 URL / 歌词获取](#11-附播放-url--歌词获取) + +--- + +## 1. 通用基础设施 + +### 1.1 HTTP 层 — `src/lib/http.ts` + +所有第三方请求统一经过 `httpFetch(input, init)`: + +- **Tauri 环境**(`__TAURI_INTERNALS__ in window`):走 `@tauri-apps/plugin-http` 原生 HTTP,绕过浏览器 CORS 限制。 +- **浏览器/预览环境**:路由到 Vite 开发代理 `/__proxy?target=`,并将浏览器禁止 JS 设置的请求头(`user-agent`、`referer`、`origin`、`cookie`、`host`)通过 `x-pxy-*` 前缀中继给代理还原。 + +### 1.2 缓存层 — `src/lib/cache.ts` + +`createAsyncCache(ttlMs, max)` 提供进程内 TTL 缓存 + Promise 级去重(并发相同请求共享一次网络调用),失败不缓存。各模块用它包裹只读接口。 + +| 模块 | 缓存键 | TTL | +|------|--------|-----| +| 歌曲搜索 | `platform:query:page` | 3 min | +| 歌单搜索 | `source:query:page` | 3 min | +| 专辑搜索 | `v4:source:query:page:limit` | 3 min | +| 歌单(热门/标签/详情) | `source:page:tag` / `source:tags` / `source:id:page` | 5 min | +| 专辑(热门/标签/详情) | `v2:source:page:tag` 等 | 5–10 min | +| 排行榜歌曲 | `source:boardId:page` | 5 min | +| 热搜 | `source` | 10 min | +| 歌词 | `source:songId:version` | 30 min | + +### 1.3 统一数据模型 — `src/types/music.ts` + +所有平台返回统一归一化为 `MusicInfo`: + +```ts +interface MusicInfo { + id: string; // "_",如 "wy_12345" + name: string; + singer: string; + source: Source; // "kw" | "kg" | "tx" | "wy" | "mg" | "local" + interval: string; // 时长 "m:ss" + albumName: string; + meta: MusicInfoMeta; // 平台特有字段 + 音质列表 +} +``` + +- `MusicInfoMeta.songId` 是平台原始歌曲 ID(网易云是数字 id,QQ 是 songmid,酷狗是 album_audio_id 等)。 +- `MusicInfoMeta.qualitys` / `_qualitys` 由 `src/lib/quality.ts` 的 `indexQualitySizes` 归一化,音质枚举:`128k` / `320k` / `flac` / `flac24bit`。 +- 各平台特有字段:`hash`(酷狗 FileHash)、`strMediaMid`(QQ media_mid)、`copyrightId`(咪咕)。 + +--- + +## 2. 平台 SDK 桶与签名算法 + +`src/lib/platforms/index.ts` 是各平台的 SDK 聚合桶(`export * as wy/kg/tx/mg/kw`),各平台 index 仅做 re-export,签名/加密逻辑集中在各自文件内。 + +### 2.1 网易云 eapi 签名 — `src/lib/platforms/wy/eapi.ts` + +```ts +eapi(url, object) → { params: string } +eapiParams(url, object) → string +``` + +算法(移植自 wy/utils/crypto.js): +1. `text = JSON.stringify(object)`; +2. `message = "nobody" + url + "use" + text + "md5forencrypt"`; +3. `digest = md5(message)`; +4. `data = url + "-36cd479b6b5-" + text + "-36cd479b6b5-" + digest`; +5. 用密钥 `e82ckenh8dichen8` 做 **AES-128-ECB + PKCS7** 加密,输出大写十六进制。 + +请求体为 `application/x-www-form-urlencoded` 的 `params=...`,POST 到 `http://interface.music.163.com/eapi/batch`(或用 `eapi` 直连网关)。 + +### 2.2 QQ 音乐 zzcSign 签名 — `src/lib/search/txDesktop.ts` + +用于 `musics.fcg` 桌面搜索(search_type 0/2/3): + +1. 对请求体 JSON 求 **SHA-1**; +2. 按 `PART_1_INDEXES=[23,14,6,36,16,40,7,19]`、`PART_2_INDEXES=[16,1,32,12,19,27,8,5]` 抽取散列字符; +3. `SCRAMBLE_VALUES`(20 个固定字节)与散列逐字节异或后 Base64(去掉 `/+`); +4. 签名 = `zzc + part1 + b64 + part2`(小写); +5. URL 形如 `https://u.y.qq.com/cgi-bin/musics.fcg?sign=`,body 为 `comm` + `music.search.SearchCgiService.DoSearchForQQMusicDesktop` 协议包,含随机 `searchid`(32 位 hex + 5 位数字)。 + +### 2.3 QQ 音乐 musicu.fcg(无 zzcSign) + +排行榜/热搜/歌单/专辑使用的 `u.y.qq.com/cgi-bin/musicu.fcg` 使用传统 `comm` 块(`uin/format/ct/cv`),**不需要** zzcSign。请求体 `data` 参数为 `encodeURIComponent(JSON.stringify(body))`,或 POST JSON。 + +### 2.4 酷我 wbdCrypto — `src/lib/charts/kw.ts` + +排行榜请求签名: +- AES key 固定 16 字节 `[112,87,39,61,199,250,41,191,57,68,45,114,221,94,140,228]`; +- `appId = "y67sprxhhpws"`; +- `encodeData = base64(AES-128-ECB(JSON))`;`sign = MD5(appId + encodeData + time).toUpperCase()`; +- 最终 query:`data=&time=