feat: v0.9.0 多音源系统、歌曲标签、搜索建议、播放器改进

音源系统:
- 新增可配置多音源(波点/酷狗/酷我),支持启用/禁用/排序/自定义源
- 酷我 kwDES 加密 + mobi.s 接口绕过 VIP 限制
- VIP/版权歌曲跨源 fallback,版本一致性匹配(原版/DJ/Live/混音)
- 多源聚合搜索,歌名+歌手组合去重

歌曲标签:
- 付费/音质/版本/音源标签,网易云 fee 字段精确判断
- privilege 数组合并(歌手/歌单/专辑/每日推荐/收藏页)

搜索建议:
- 120ms 防抖 + compositionend 支持,中文输入实时建议
- Teleport + fixed 定位避免父容器裁剪

播放器:
- 网络流 seek 支持(SharedBuffer 下载完成后 byte_len 可用)
- 切歌停止旧播放再加载新歌,进度条起步归零
- 切歌加载状态可视化,按钮禁用反馈
- tick generation 防竞态,旧 tick 不覆盖新歌位置
- 系统媒体控制(跨平台,Windows 增加 hwnd 就绪检查避免 panic)

其他:
- 设置迁移 v7(恢复酷我音源)
- useUpdater 模块级共享状态
This commit is contained in:
2026-07-02 09:54:45 +08:00
parent 2993495d05
commit 1fddbeab77
35 changed files with 4754 additions and 248 deletions
+3 -1
View File
@@ -4,8 +4,9 @@ version = 4
[[package]]
name = "Nekosonic"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"async-trait",
"base64 0.22.1",
"cpal",
"dirs 5.0.1",
@@ -29,6 +30,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"urlencoding",
]
[[package]]
+3 -1
View File
@@ -36,7 +36,9 @@ base64 = "0.22"
md5 = "0.7"
ncm-api-rs = "0.1"
tokio = { version = "1", features = ["rt", "sync"] }
tokio = { version = "1", features = ["rt", "sync"] }
async-trait = "0.1"
urlencoding = "2"
tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2"
+153 -4
View File
@@ -165,7 +165,8 @@ pub async fn cloudsearch(query: CloudSearchQuery, state: State<'_, ApiController
/// 搜索建议
#[tauri::command]
pub async fn search_suggest(query: SearchSuggestQuery, state: State<'_, ApiController>) -> Result<String, String> {
api_call!(state, search_suggest, params: [("keywords", &query.keyword)])
// type=mobile 返回 allMatch 格式(纯关键词列表),不传 type 会返回 albums/songs 等详细字段
api_call!(state, search_suggest, params: [("keywords", &query.keyword), ("type", "mobile")])
}
/// 获取热搜词列表
@@ -185,8 +186,14 @@ pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, A
}
/// 歌曲播放地址查询参数
/// sources: 启用的第三方音源配置列表(按优先级排序),网易云无 URL 时按顺序降级
#[derive(Deserialize)]
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
pub struct SongUrlQuery {
pub id: u64,
pub level: Option<String>,
pub fm_mode: Option<bool>,
pub sources: Option<Vec<crate::music_sources::SourceConfig>>,
}
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
#[tauri::command]
@@ -230,10 +237,152 @@ pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>)
let data = &resp.body["data"][0];
let url = data["url"].as_str().filter(|s| !s.is_empty());
if url.is_none() {
// VIP 试听片段标记:存在说明网易云返回的只是试听 URL,需走音源补充获取完整 URL
let has_free_trial = data["freeTrialInfo"].is_object();
// 网易云有可用 URL 且非试听片段,直接返回
if url.is_some() && !has_free_trial {
return Ok(data.to_string());
}
// URL 为空 或 VIP 试听片段:尝试音源 fallback
let sources = query.sources.unwrap_or_default();
if sources.is_empty() {
// 没有配置音源:有 URL(试听)就返回试听,否则报错
if url.is_some() {
return Ok(data.to_string());
}
return Err("暂无播放源".into());
}
Ok(data.to_string())
// 获取歌曲信息用于搜索
let detail_q = state.build_query().param("ids", &query.id.to_string());
let detail_resp = client.song_detail(&detail_q).await
.map_err(|e| format!("获取歌曲详情失败: {}", e))?;
let song_info = &detail_resp.body["songs"][0];
let song_name = song_info["name"].as_str().unwrap_or("");
let artist_str: String = song_info["ar"].as_array()
.map(|arr| {
arr.iter()
.filter_map(|a| a["name"].as_str())
.collect::<Vec<_>>()
.join("/")
})
.unwrap_or_default();
match crate::music_sources::fallback_get_url(song_name, &artist_str, level, &sources).await {
Ok((fb_url, source_label)) => {
let mut data_obj = data.clone();
data_obj["url"] = json!(fb_url);
data_obj["source"] = json!(source_label);
data_obj["br"] = json!(320000);
// 清除试听标记,使用音源补充的完整 URL
if let Some(obj) = data_obj.as_object_mut() {
obj.remove("freeTrialInfo");
}
Ok(data_obj.to_string())
}
Err(_) => {
// fallback 失败:有试听 URL 就返回试听,否则报错
if url.is_some() {
return Ok(data.to_string());
}
Err("暂无播放源".into())
}
}
}
/// 多源聚合搜索查询参数
#[derive(Deserialize)]
pub struct SearchMultiQuery {
pub keyword: String,
pub sources: Vec<crate::music_sources::SourceConfig>,
pub limit: Option<u32>,
}
/// 多源聚合搜索:并发请求所有启用音源,合并结果返回
/// 用于「网易云搜不到的歌,从其他音源补充搜索结果」场景
#[tauri::command]
pub async fn search_songs_multi(query: SearchMultiQuery) -> Result<String, String> {
let limit = query.limit.unwrap_or(20);
let results = crate::music_sources::search_multi(&query.keyword, &query.sources, limit).await;
serde_json::to_string(&results).map_err(|e| e.to_string())
}
/// 外部音源获取播放 URL 查询参数
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalUrlQuery {
pub source: crate::music_sources::SourceConfig,
pub song_id: String,
pub quality: Option<String>,
/// 歌曲名(用于主源失败时跨源 fallback)
#[serde(default)]
pub song_name: Option<String>,
/// 歌手(用于跨源 fallback 精确匹配)
#[serde(default)]
pub artist: Option<String>,
/// 所有启用的音源(主源失败时按顺序尝试其他源)
#[serde(default)]
pub all_sources: Option<Vec<crate::music_sources::SourceConfig>>,
}
/// 从指定外部音源获取播放 URL
/// 用于「点击搜索结果中来自某外部源的歌曲,直接通过该源取 URL 播放」
///
/// 流程:
/// 1. 先用主源(query.source)直接取 URL(已有 song_id,最快)
/// 2. 主源失败时,用歌名+歌手在 all_sources 里 fallback 搜索
/// (例如 kugou 的 VIP 歌曲无 URL,换 bodian/kuwo 找同款)
#[tauri::command]
pub async fn get_external_song_url(query: ExternalUrlQuery) -> Result<String, String> {
let quality = query.quality.unwrap_or_else(|| "standard".into());
// 1. 主源直接取 URL(已有 song_id,最快路径)
let src = crate::music_sources::get_source(&query.source)
.ok_or_else(|| format!("未知音源: {} ({})", query.source.id, query.source.kind))?;
let primary_id = &query.source.id;
let primary_label = &query.source.label;
match src.get_url(&query.song_id, &quality).await {
Ok(url) if !url.is_empty() => {
let resp = json!({
"url": url,
"source": primary_id,
"source_label": primary_label,
});
return Ok(resp.to_string());
}
Ok(_) => {} // 空 URL,走 fallback
Err(e) => {
eprintln!("[music_sources] {} 取URL失败,尝试跨源 fallback: {}", primary_id, e);
}
}
// 2. 主源失败,用歌名+歌手在其他启用源里 fallback
let song_name = match query.song_name.as_deref() {
Some(n) if !n.is_empty() => n,
_ => return Err(format!("{} 无法获取播放URL且缺少歌名无法 fallback", primary_id)),
};
let artist = query.artist.as_deref().unwrap_or("");
let all_sources = query.all_sources.unwrap_or_default();
if all_sources.is_empty() {
return Err(format!("{} 无法获取播放URL且无备用源", primary_id));
}
match crate::music_sources::fallback_get_url(song_name, artist, &quality, &all_sources).await {
Ok((url, label)) => {
eprintln!("[music_sources] 跨源 fallback 成功: {} -> {}", song_name, label);
let resp = json!({
"url": url,
"source": "fallback",
"source_label": label,
});
Ok(resp.to_string())
}
Err(e) => Err(format!("{} 跨源 fallback 失败: {}", primary_id, e)),
}
}
/// 获取歌词
+8 -1
View File
@@ -198,6 +198,13 @@ impl MediaSource for StreamingReader {
}
fn byte_len(&self) -> Option<u64> {
None
// 只有下载完成后才能返回正确的文件总长度
// symphonia 需要 byte_len() 来计算 seek 的字节位置
let state = self.buffer.state_mutex().lock().unwrap();
if state.done {
Some(state.bytes.len() as u64)
} else {
None
}
}
}
+24
View File
@@ -95,6 +95,23 @@ fn stop_playback(output_ctx: &mut Option<OutputContext>, shared_position: &Arc<M
*shared_position.lock().unwrap() = 0.0;
}
/// 等待网络流 SharedBuffer 下载完成(用于 seek 前确保 byte_len 可用)。
/// StreamingReader 的 byte_len() 只有在 done==true 时才返回文件长度,
/// symphonia 才能计算 seek 的字节位置,否则 seek 会静默失败导致音频从头播放。
fn wait_for_buffer_done(buffer: &SharedBuffer) {
if buffer.is_done() || buffer.is_cancelled() {
return;
}
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while !buffer.is_done() && !buffer.is_cancelled() {
if std::time::Instant::now() > deadline {
eprintln!("[audio] seek 等待网络流下载超时(3s)");
break;
}
thread::sleep(Duration::from_millis(50));
}
}
/// 根据当前播放源(本地文件或网络缓冲区)重建 MediaSourceStream
fn rebuild_mss(
local_path: &Option<String>,
@@ -290,6 +307,13 @@ fn audio_thread(
AudioCmd::Seek(time) => {
stop_playback(&mut output_ctx, &shared_position);
// 对网络流,等待 SharedBuffer 下载完成后再 seek
if current_local_path.is_none() {
if let Some(ref buffer) = current_audio_buffer {
wait_for_buffer_done(buffer);
}
}
let mss = match rebuild_mss(&current_local_path, &current_audio_buffer) {
Some(mss) => mss,
None => continue,
+3 -1
View File
@@ -253,7 +253,9 @@ pub fn decode_to_ring(
time: Time::from(time),
track_id: Some(track_id),
};
let _ = format_reader.seek(SeekMode::Accurate, seek_to);
if let Err(e) = format_reader.seek(SeekMode::Accurate, seek_to) {
eprintln!("[audio] seek 失败(time={}s): {}", time, e);
}
}
let ratio = target_sample_rate as f64 / src_rate;
+10 -1
View File
@@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
mod api;
mod audio;
mod media_controls;
mod music_sources;
use api::ApiController;
use audio::AppAudio;
@@ -41,7 +42,13 @@ pub fn run() {
} else {
None
};
media_controls::start_media_controls(app.handle().clone(), hwnd);
// Windows 上 souvlaki 要求 hwnd 必须存在,否则 panic
// 窗口尚未创建时跳过媒体控制初始化(不阻塞 app 启动)
if hwnd.is_some() {
media_controls::start_media_controls(app.handle().clone(), hwnd);
} else {
eprintln!("[media_controls] 窗口未就绪,跳过媒体控制初始化");
}
}
#[cfg(not(target_os = "windows"))]
@@ -140,6 +147,8 @@ pub fn run() {
api::cloudsearch,
api::search_suggest,
api::get_song_url,
api::search_songs_multi,
api::get_external_song_url,
api::get_hot_search,
api::get_playlist_detail,
api::get_lyric,
+255
View File
@@ -0,0 +1,255 @@
// 波点音乐(酷我旗下子品牌)
// 参考 SPlayer bodian.ts / UnblockNeteaseMusic bodian.js
//
// 搜索:复用 kuwo 的 search.kuwo.cn/r.s 接口(无需签名)
// URLbd-api.kuwo.cn/api/play/music/v2/audioUrl,需 sign 签名 + 先调一次广告接口
//
// sign 算法(来自 bodian.js generateSign):
// 1. url 末尾追加 &timestamp={ms}
// 2. 提取 ? 后的 query 部分
// 3. 去除非字母数字字符后按字符排序
// 4. 拼接 "kuwotest" + sorted_chars + url.pathname
// 5. md5
use async_trait::async_trait;
use md5::compute as md5_compute;
use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{ExternalSong, MusicSource, build_client, parse_kuwo_abslist, parse_kuwo_json, truncate_chars};
const UA: &str = "Dart/2.19 (dart:io)";
pub struct BodianSource {
label: String,
/// 随机设备 ID(参考 SPlayer,每次实例化生成)
device_id: String,
}
impl BodianSource {
pub fn new(label: String) -> Self {
Self {
label,
device_id: gen_device_id(),
}
}
}
/// 生成随机设备 ID(参考 SPlayer getRandomDeviceId
/// 用系统时间纳秒作为种子,每次实例化不同
fn gen_device_id() -> String {
let ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
// 取纳秒的低 11 位数字,范围 0~100000000000
let n = (ns % 100_000_000_001) as u64;
n.to_string()
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// 生成 bodian 风格签名 URL
/// 输入:原始 url(含 query),如 http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123
/// 输出:追加 &timestamp=...&sign=... 的完整 url
///
/// 算法(参考 UnblockNeteaseMusic bodian.js generateSign):
/// 1. str += &timestamp={ms}
/// 2. query = str 中 ? 之后的部分
/// 3. filteredChars = query 去除非字母数字后按字符排序
/// 4. dataToEncrypt = "kuwotest" + filteredChars + url.pathname
/// 5. sign = md5(dataToEncrypt)
fn generate_sign(url: &str) -> String {
let ts = now_ms();
let with_ts = format!("{}&timestamp={}", url, ts);
// 提取 pathname? 之前的部分去掉 scheme://host
let (scheme_host, pathname, query_with_ts) = split_url(&with_ts);
let _ = scheme_host; // 不需要
// 提取 ? 之后的 query 部分(含 timestamp
let q = query_with_ts;
// 去除非字母数字,按字符排序
let mut chars: Vec<char> = q.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
chars.sort();
let sorted: String = chars.iter().collect();
let data = format!("kuwotest{}{}", sorted, pathname);
let hash = md5_compute(data.as_bytes());
let sign = format!("{:x}", hash);
format!("{}&sign={}", with_ts, sign)
}
/// 将 URL 拆分为 (scheme://host, pathname, query)
/// 例:http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123&timestamp=...
/// → ("http://bd-api.kuwo.cn", "/api/play/music/v2/audioUrl", "&br=320kmp3&musicId=123&timestamp=...")
fn split_url(url: &str) -> (String, String, String) {
// 分离 query
let (path_part, query) = url.split_once('?').map(|(p, q)| (p.to_string(), q.to_string()))
.unwrap_or((url.to_string(), String::new()));
// path_part 形如 http://bd-api.kuwo.cn/api/play/music/v2/audioUrl
// 找到 scheme:// 后的第三个 /(即 host 之后的第一个 /)
let scheme_end = path_part.find("://").map(|i| i + 3).unwrap_or(0);
let after_scheme = &path_part[scheme_end..];
let path_start = after_scheme.find('/').map(|i| scheme_end + i).unwrap_or(path_part.len());
let scheme_host = path_part[..path_start].to_string();
let pathname = path_part[path_start..].to_string();
(scheme_host, pathname, query)
}
fn client() -> Result<reqwest::Client, String> {
build_client(UA, 10)
}
#[async_trait]
impl MusicSource for BodianSource {
fn id(&self) -> &str {
"bodian"
}
fn label(&self) -> &str {
&self.label
}
async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
let client = client()?;
let kw = urlencoding::encode(keyword);
let url = format!(
"http://search.kuwo.cn/r.s?&correct=1&vipver=1&stype=comprehensive&encoding=utf8&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all={}",
kw
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("bodian 搜索请求失败: {}", e))?;
let text = resp
.text()
.await
.map_err(|e| format!("bodian 读取响应失败: {}", e))?;
// 复用 kuwo 的 JSON 解析(含单引号 JSON 兜底)和 abslist 解析逻辑
let json: Value = parse_kuwo_json(&text, "bodian")?;
let abslist = json["content"][1]["musicpage"]["abslist"]
.as_array()
.ok_or_else(|| format!("bodian 搜索结果格式异常, body(前300): {}", truncate_chars(&text, 300)))?;
let list = parse_kuwo_abslist(&abslist[..abslist.len().min(limit as usize)], "bodian", &self.label);
if list.is_empty() {
return Err("bodian 搜索无结果".into());
}
Ok(list)
}
async fn get_url(&self, song_id: &str, quality: &str) -> Result<String, String> {
let client = client()?;
// 1. 先调一次广告接口(参考 SPlayer sendAdFreeRequest
// 模拟"观看广告"获取免费播放权限
let _ = client
.post("http://bd-api.kuwo.cn/api/service/advert/watch?uid=-1&token=&timestamp=1724306124436&sign=15a676d66285117ad714e8c8371691da")
.header("user-agent", "Dart/2.19 (dart:io)")
.header("plat", "ar")
.header("channel", "aliopen")
.header("devid", &self.device_id)
.header("ver", "3.9.0")
.header("host", "bd-api.kuwo.cn")
.header("qimei36", "1e9970cbcdc20a031dee9f37100017e1840e")
.header("content-type", "application/json; charset=utf-8")
.body(r#"{"type":5,"subType":5,"musicId":0,"adToken":""}"#)
.send()
.await;
// 2. 构造带签名的 audioUrl 请求
let br = if quality.contains("lossless") || quality.contains("hires") {
"2000kflac"
} else {
"320kmp3"
};
let base = format!(
"http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br={}&musicId={}",
br, song_id
);
let signed_url = generate_sign(&base);
let resp = client
.get(&signed_url)
.header("user-agent", "Dart/2.19 (dart:io)")
.header("plat", "ar")
.header("channel", "aliopen")
.header("devid", &self.device_id)
.header("ver", "3.9.0")
.header("host", "bd-api.kuwo.cn")
.header("qimei36", "1e9970cbcdc20a031dee9f37100017e1840e")
.header("X-Forwarded-For", "1.0.1.114")
.send()
.await
.map_err(|e| format!("bodian 获取URL失败: {}", e))?;
let text = resp
.text()
.await
.map_err(|e| format!("bodian 读取URL失败: {}", e))?;
// 解析 bd-api 返回的 JSON
let json: Value = serde_json::from_str(&text)
.map_err(|e| format!("bodian 解析URL JSON失败: {} | body: {}", e, text))?;
if json["code"].as_i64() == Some(200) {
if let Some(url) = json["data"]["audioUrl"].as_str().filter(|s| !s.is_empty()) {
return Ok(url.to_string());
}
}
Err(format!("bodian 无法获取URL (code={}): {}",
json["code"].as_i64().unwrap_or(-1),
json["msg"].as_str().unwrap_or("未知错误")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_url() {
let url = "http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123&timestamp=1700000000000";
let (host, path, query) = split_url(url);
assert_eq!(host, "http://bd-api.kuwo.cn");
assert_eq!(path, "/api/play/music/v2/audioUrl");
assert_eq!(query, "&br=320kmp3&musicId=123&timestamp=1700000000000");
}
#[test]
fn test_generate_sign_format() {
let url = "http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123";
let signed = generate_sign(url);
// 应包含 timestamp 和 sign
assert!(signed.contains("&timestamp="));
assert!(signed.contains("&sign="));
// 原始 URL 应保留
assert!(signed.starts_with("http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123"));
}
/// 真实接口测试,需手动运行: cargo test -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn test_bodian_get_url_real() {
// 测试:搜索「唯一 邓紫棋」
let src = BodianSource::new("波点音乐".into());
let kw = "唯一 邓紫棋";
let songs = src.search(kw, 1, 10).await.expect("搜索应成功");
println!("bodian 搜索「{}」到 {}", kw, songs.len());
for s in &songs {
println!(" - {} - {} (id={})", s.name, s.artist, s.id);
}
// 尝试获取前 5 首的 URL
for s in songs.iter().take(5) {
match src.get_url(&s.id, "standard").await {
Ok(url) => println!("{} -> {}", s.name, url),
Err(e) => println!("{} -> {}", s.name, e),
}
}
}
}
+166
View File
@@ -0,0 +1,166 @@
// 自定义 HTTP API 音源
// 用户通过模板 URL + JSON 路径配置搜索/获取播放地址的接口
//
// 模板占位符:
// 搜索 URL{keyword} {page} {limit}
// URL 接口:{id} {quality}
//
// JSON 路径用 "." 分隔,如 "data.songs"
// 字段映射指定结果数组每项里各字段对应的 key 名
use async_trait::async_trait;
use serde_json::Value;
use super::{ExternalSong, MusicSource, SourceConfig, build_client};
const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
pub struct CustomHttpSource {
cfg: SourceConfig,
}
impl CustomHttpSource {
pub fn new(cfg: SourceConfig) -> Self {
Self { cfg }
}
}
fn client() -> Result<reqwest::Client, String> {
build_client(UA, 10)
}
/// 按 "a.b.c" 路径访问嵌套 JSON
fn json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = value;
for k in path.split('.') {
if k.is_empty() {
continue;
}
cur = cur.get(k)?;
}
Some(cur)
}
/// 取节点的字符串值(兼容字符串/数字)
fn value_to_string(v: &Value) -> String {
v.as_str()
.map(String::from)
.unwrap_or_else(|| v.to_string())
}
#[async_trait]
impl MusicSource for CustomHttpSource {
fn id(&self) -> &str {
&self.cfg.id
}
fn label(&self) -> &str {
&self.cfg.label
}
async fn search(&self, keyword: &str, page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
let client = client()?;
let tmpl = self
.cfg
.search_url
.as_deref()
.ok_or("自定义音源缺少 search_url")?;
let url = tmpl
.replace("{keyword}", &urlencoding::encode(keyword))
.replace("{page}", &page.to_string())
.replace("{limit}", &limit.to_string());
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("custom [{}] 请求失败: {}", self.cfg.id, e))?;
let text = resp
.text()
.await
.map_err(|e| format!("custom [{}] 读取失败: {}", self.cfg.id, e))?;
let json: Value = serde_json::from_str(&text)
.map_err(|e| format!("custom [{}] JSON 解析失败: {}", self.cfg.id, e))?;
let arr_path = self.cfg.search_path.as_deref().unwrap_or("data");
let arr = json_path(&json, arr_path).and_then(|v| v.as_array()).ok_or_else(|| {
format!(
"custom [{}] 找不到数组路径: {}(响应: {}",
self.cfg.id, arr_path, &text[..text.len().min(200)]
)
})?;
let id_f = self.cfg.id_field.as_deref().unwrap_or("id");
let name_f = self.cfg.name_field.as_deref().unwrap_or("name");
let artist_f = self.cfg.artist_field.as_deref().unwrap_or("artist");
let album_f = self.cfg.album_field.as_deref().unwrap_or("album");
let dur_f = self.cfg.duration_field.as_deref().unwrap_or("duration");
let pic_f = self.cfg.pic_field.as_deref().unwrap_or("pic");
let list: Vec<ExternalSong> = arr
.iter()
.map(|s| {
let id = json_path(s, id_f).map(value_to_string).unwrap_or_default();
let name = json_path(s, name_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
let artist = json_path(s, artist_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
let album = json_path(s, album_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
let duration = json_path(s, dur_f)
.and_then(|v| {
v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.unwrap_or(0);
let pic = json_path(s, pic_f).and_then(|v| v.as_str()).unwrap_or("").to_string();
ExternalSong {
id,
source: self.cfg.id.clone(),
source_label: self.cfg.label.clone(),
name,
artist,
album,
duration,
pic_url: pic,
}
})
.collect();
Ok(list)
}
async fn get_url(&self, song_id: &str, quality: &str) -> Result<String, String> {
let client = client()?;
let tmpl = self.cfg.url_api.as_deref().ok_or("自定义音源缺少 url_api")?;
let url = tmpl
.replace("{id}", &urlencoding::encode(song_id))
.replace("{quality}", quality);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("custom [{}] URL 请求失败: {}", self.cfg.id, e))?;
let text = resp
.text()
.await
.map_err(|e| format!("custom [{}] URL 读取失败: {}", self.cfg.id, e))?;
// 先尝试 JSON 路径取 URL;不是 JSON 则视为纯 URL 文本
if let Ok(json) = serde_json::from_str::<Value>(&text) {
let url_path = self.cfg.url_path.as_deref().unwrap_or("data.url");
if let Some(v) = json_path(&json, url_path) {
if let Some(s) = v.as_str() {
if !s.is_empty() {
return Ok(s.to_string());
}
}
}
return Err(format!(
"custom [{}] URL 字段为空或路径错误: {}",
self.cfg.id, text
));
}
let s = text.trim();
if s.is_empty() {
return Err("custom URL 为空".into());
}
Ok(s.to_string())
}
}
+179
View File
@@ -0,0 +1,179 @@
// 酷狗音乐(内置音源)
// 搜索:mobilecdn.kugou.com/api/v3/search/song
// URLtrackercdn.kugou.com/i/v2/ key = md5(hash + "kgcloudv2")
use async_trait::async_trait;
use md5::compute as md5_compute;
use serde_json::Value;
use super::{ExternalSong, MusicSource, build_client, truncate_chars};
const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
pub struct KugouSource {
label: String,
}
impl KugouSource {
pub fn new(label: String) -> Self {
Self { label }
}
}
fn client() -> Result<reqwest::Client, String> {
build_client(UA, 8)
}
/// 酷狗 V2 接口 key 算法:md5(hash + "kgcloudv2")
fn make_key(hash: &str) -> String {
let data = format!("{}kgcloudv2", hash);
let digest = md5_compute(data.as_bytes());
format!("{:x}", digest)
}
#[async_trait]
impl MusicSource for KugouSource {
fn id(&self) -> &str {
"kugou"
}
fn label(&self) -> &str {
&self.label
}
async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
let client = client()?;
let limit_str = limit.to_string();
let resp = client
.get("http://mobilecdn.kugou.com/api/v3/search/song")
.query(&[
("keyword", keyword),
("page", "1"),
("pagesize", &limit_str),
("showtype", "20"),
])
.send()
.await
.map_err(|e| format!("kugou 搜索请求失败: {}", e))?;
let text = resp
.text()
.await
.map_err(|e| format!("kugou 读取响应失败: {}", e))?;
let json: Value =
serde_json::from_str(&text).map_err(|e| format!("kugou 解析JSON失败: {}", e))?;
let info = json["data"]["info"]
.as_array()
.ok_or_else(|| format!("kugou 搜索无结果: {}", truncate_chars(&text, 200)))?;
let list: Vec<ExternalSong> = info
.iter()
.map(|s| {
let hash = s["hash"].as_str().unwrap_or("").to_string();
// V3 接口字段是 songname/album_name/singername(带下划线),老接口是 songname/albumname/singername
let name = s["songname"]
.as_str()
.or_else(|| s["filename"].as_str())
.unwrap_or("")
.to_string();
let artist = s["singername"]
.as_str()
.or_else(|| s["singer_name"].as_str())
.unwrap_or("")
.to_string();
let album = s["albumname"]
.as_str()
.or_else(|| s["album_name"].as_str())
.unwrap_or("")
.to_string();
let duration = s["duration"].as_u64().map(|d| d * 1000).unwrap_or(0);
// kugou V3 搜索接口不返回封面字段,需通过 album_id 单独调接口获取
// 这里先留空,前端会 fallback 到默认封面图标
let pic = s["albumpic"]
.as_str()
.or_else(|| s["album_img"].as_str())
.or_else(|| s["img"].as_str())
.or_else(|| s["pic"].as_str())
.unwrap_or("")
.to_string();
ExternalSong {
id: hash,
source: "kugou".into(),
source_label: self.label.clone(),
name,
artist,
album,
duration,
pic_url: pic,
}
})
.collect();
if list.is_empty() {
return Err("kugou 搜索无结果".into());
}
Ok(list)
}
async fn get_url(&self, song_id: &str, _quality: &str) -> Result<String, String> {
let client = client()?;
let hash = song_id;
let key = make_key(hash);
let filename = format!("{}.mp3", hash);
let resp = client
.get("http://trackercdn.kugou.com/i/v2/")
.query(&[
("key", key.as_str()),
("hash", hash),
("br", "hq"),
("appid", "1005"),
("pid", "2"),
("behavior", "play"),
("cmd", "25"),
("filename", &filename),
])
.send()
.await
.map_err(|e| format!("kugou 获取URL请求失败: {}", e))?;
let text = resp
.text()
.await
.map_err(|e| format!("kugou 读取URL响应失败: {}", e))?;
let json: Value =
serde_json::from_str(&text).map_err(|e| format!("kugou 解析URL JSON失败: {} | body: {}", e, truncate_chars(&text, 300)))?;
// 响应中 url 是数组(新接口)或字符串(旧接口)
let url = json["url"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.or_else(|| json["url"].as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("kugou URL 为空: status={} | body: {}", json["status"].as_u64().unwrap_or(0), truncate_chars(&text, 300)))?;
Ok(url.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 真实接口测试,需手动运行: cargo test -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn test_kugou_search_and_url() {
// 测试:搜索「唯一 邓紫棋」
let src = KugouSource::new("酷狗音乐".into());
let kw = "唯一 邓紫棋";
let songs = src.search(kw, 1, 10).await.expect("搜索应成功");
println!("kugou 搜索「{}」到 {}", kw, songs.len());
for s in &songs {
println!(" - {} - {} (id={})", s.name, s.artist, s.id);
}
// 尝试获取前 5 首的 URL
for s in songs.iter().take(5) {
match src.get_url(&s.id, "standard").await {
Ok(url) => println!("{} -> {}", s.name, url),
Err(e) => println!("{} -> {}", s.name, e),
}
}
}
}
+141
View File
@@ -0,0 +1,141 @@
//! 酷我音乐音源
//! 搜索: search.kuwo.cn/r.s(返回单引号 JSON,需预处理)
//! URL: mobi.kuwo.cn/mobi.s + kwDES 加密(能绕过 VIP 限制)
//! 移植自 UnblockNeteaseMusic/server 的 kuwo.js
use crate::music_sources::kwdes;
use crate::music_sources::{ExternalSong, MusicSource, build_client, parse_kuwo_abslist, parse_kuwo_json, truncate_chars};
use serde_json::Value;
const UA: &str = "okhttp/3.10.0";
pub struct KuwoSource {
label: String,
}
impl KuwoSource {
pub fn new(label: String) -> Self {
Self { label }
}
}
#[async_trait::async_trait]
impl MusicSource for KuwoSource {
fn id(&self) -> &str {
"kuwo"
}
fn label(&self) -> &str {
&self.label
}
async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result<Vec<ExternalSong>, String> {
let url = format!(
"http://search.kuwo.cn/r.s?correct=1&vipver=1&stype=comprehensive&encoding=utf8&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all={}",
urlencoding::encode(keyword)
);
let client = build_client(UA, 10)?;
let resp = client
.get(&url)
.header("X-Forwarded-For", "1.0.1.114")
.send()
.await
.map_err(|e| format!("kuwo 搜索请求失败: {}", e))?;
let text = resp
.text()
.await
.map_err(|e| format!("kuwo 搜索读取失败: {}", e))?;
let json: Value = parse_kuwo_json(&text, "kuwo")?;
let abs_list = json["content"][1]["musicpage"]["abslist"]
.as_array()
.ok_or_else(|| format!("kuwo 搜索结果格式异常, body(前300): {}", truncate_chars(&text, 300)))?;
Ok(parse_kuwo_abslist(&abs_list[..abs_list.len().min(limit as usize)], "kuwo", &self.label))
}
async fn get_url(&self, song_id: &str, _quality: &str) -> Result<String, String> {
// 用 kwDES 加密的 mobi.s 接口获取播放 URL(能绕过 VIP 限制)
let query = format!(
"corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid={}",
song_id
);
let encrypted = kwdes::encrypt_query(&query);
let url = format!("http://mobi.kuwo.cn/mobi.s?f=kuwo&q={}", encrypted);
let client = build_client(UA, 10)?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("kuwo mobi.s 请求失败: {}", e))?;
let text = resp
.text()
.await
.map_err(|e| format!("kuwo mobi.s 读取失败: {}", e))?;
// 解析 key=value 格式响应(每行一个),提取 url 和 bitrate
let mut url_val = String::new();
let mut bitrate: u64 = 0;
for line in text.lines() {
let line = line.trim();
if let Some(pos) = line.find('=') {
let key = line[..pos].trim();
let val = line[pos + 1..].trim();
match key {
"url" => url_val = val.to_string(),
"bitrate" => bitrate = val.parse::<u64>().unwrap_or(0),
_ => {}
}
}
}
// 广告音频检测:bitrate < 32 通常是"酷我音乐以为你免费开放播放权限"提示音
// 正常歌曲 bitrate >= 128
if bitrate > 0 && bitrate < 32 {
return Err(format!(
"kuwo 返回广告音频 (bitrate={}), song_id={}",
bitrate, song_id
));
}
if url_val.starts_with("http") {
Ok(url_val)
} else {
Err(format!(
"kuwo mobi.s 未返回有效 URL: {}",
truncate_chars(&text, 200)
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 真实接口测试:搜索 + 获取 VIP 歌曲 URL
/// 手动运行: cargo test -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn test_kuwo_search_and_url() {
let src = KuwoSource::new("酷我音乐".into());
let kw = "唯一 邓紫棋";
let songs = src.search(kw, 1, 10).await.expect("搜索应成功");
println!("kuwo 搜索「{}」到 {}", kw, songs.len());
for s in &songs {
println!(" - {} - {} (id={})", s.name, s.artist, s.id);
}
// 尝试获取前 5 首的 URL
for s in songs.iter().take(5) {
match src.get_url(&s.id, "standard").await {
Ok(url) => println!("{} -> {}", s.name, url),
Err(e) => println!("{} -> {}", s.name, e),
}
}
}
}
+300
View File
@@ -0,0 +1,300 @@
//! kwDES 加密算法 —— 酷我音乐 mobi.s 接口的 query 加密
//! 移植自 UnblockNeteaseMusic/server 的 kwDES.js
//! 参考: https://github.com/UnblockNeteaseMusic/server/blob/enhanced/src/kwDES.js
// EXPANSION (E 扩展置换, 64个元素, 32->48位)
const ARRAY_E: [i32; 64] = [
31, 0, 1, 2, 3, 4, -1, -1, 3, 4, 5, 6, 7, 8, -1, -1,
7, 8, 9, 10, 11, 12, -1, -1, 11, 12, 13, 14, 15, 16, -1, -1,
15, 16, 17, 18, 19, 20, -1, -1, 19, 20, 21, 22, 23, 24, -1, -1,
23, 24, 25, 26, 27, 28, -1, -1, 27, 28, 29, 30, 31, 30, -1, -1,
];
// INITIAL_PERMUTATION (IP 初始置换, 64个元素)
const ARRAY_IP: [i32; 64] = [
57, 49, 41, 33, 25, 17, 9, 1,
59, 51, 43, 35, 27, 19, 11, 3,
61, 53, 45, 37, 29, 21, 13, 5,
63, 55, 47, 39, 31, 23, 15, 7,
56, 48, 40, 32, 24, 16, 8, 0,
58, 50, 42, 34, 26, 18, 10, 2,
60, 52, 44, 36, 28, 20, 12, 4,
62, 54, 46, 38, 30, 22, 14, 6,
];
// INVERSE_PERMUTATION (IP-1 逆置换, 64个元素)
const ARRAY_IP_1: [i32; 64] = [
39, 7, 47, 15, 55, 23, 63, 31,
38, 6, 46, 14, 54, 22, 62, 30,
37, 5, 45, 13, 53, 21, 61, 29,
36, 4, 44, 12, 52, 20, 60, 28,
35, 3, 43, 11, 51, 19, 59, 27,
34, 2, 42, 10, 50, 18, 58, 26,
33, 1, 41, 9, 49, 17, 57, 25,
32, 0, 40, 8, 48, 16, 56, 24,
];
// ROTATES (循环移位表, 16个元素)
const ARRAY_LS: [u32; 16] = [
1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1,
];
// 移位掩码
const ARRAY_LS_MASK: [u64; 3] = [0, 0x100001, 0x300003];
// PERMUTATION (P 置换, 32个元素)
const ARRAY_P: [i32; 32] = [
15, 6, 19, 20, 28, 11, 27, 16,
0, 14, 22, 25, 4, 17, 30, 9,
1, 7, 23, 13, 31, 26, 2, 8,
18, 12, 29, 5, 21, 10, 3, 24,
];
// PERMUTED_CHOICE1 (PC-1, 56个元素)
const ARRAY_PC_1: [i32; 56] = [
56, 48, 40, 32, 24, 16, 8,
0, 57, 49, 41, 33, 25, 17,
9, 1, 58, 50, 42, 34, 26,
18, 10, 2, 59, 51, 43, 35,
62, 54, 46, 38, 30, 22, 14,
6, 61, 53, 45, 37, 29, 21,
13, 5, 60, 52, 44, 36, 28,
20, 12, 4, 27, 19, 11, 3,
];
// PERMUTED_CHOICE2 (PC-2, 64个元素, -1表示跳过)
const ARRAY_PC_2: [i32; 64] = [
13, 16, 10, 23, 0, 4, -1, -1,
2, 27, 14, 5, 20, 9, -1, -1,
22, 18, 11, 3, 25, 7, -1, -1,
15, 6, 26, 19, 12, 1, -1, -1,
40, 51, 30, 36, 46, 54, -1, -1,
29, 39, 50, 44, 32, 47, -1, -1,
43, 48, 38, 55, 33, 52, -1, -1,
45, 41, 49, 35, 28, 31, -1, -1,
];
// S-Box (8个子表, 每个64个元素)
const MATRIX_NS_BOX: [[u8; 64]; 8] = [
[
14, 4, 3, 15, 2, 13, 5, 3, 13, 14, 6, 9, 11, 2, 0, 5, 4, 1, 10, 12, 15,
6, 9, 10, 1, 8, 12, 7, 8, 11, 7, 0, 0, 15, 10, 5, 14, 4, 9, 10, 7, 8,
12, 3, 13, 1, 3, 6, 15, 12, 6, 11, 2, 9, 5, 0, 4, 2, 11, 14, 1, 7, 8,
13,
],
[
15, 0, 9, 5, 6, 10, 12, 9, 8, 7, 2, 12, 3, 13, 5, 2, 1, 14, 7, 8, 11, 4,
0, 3, 14, 11, 13, 6, 4, 1, 10, 15, 3, 13, 12, 11, 15, 3, 6, 0, 4, 10, 1,
7, 8, 4, 11, 14, 13, 8, 0, 6, 2, 15, 9, 5, 7, 1, 10, 12, 14, 2, 5, 9,
],
[
10, 13, 1, 11, 6, 8, 11, 5, 9, 4, 12, 2, 15, 3, 2, 14, 0, 6, 13, 1, 3,
15, 4, 10, 14, 9, 7, 12, 5, 0, 8, 7, 13, 1, 2, 4, 3, 6, 12, 11, 0, 13,
5, 14, 6, 8, 15, 2, 7, 10, 8, 15, 4, 9, 11, 5, 9, 0, 14, 3, 10, 7, 1,
12,
],
[
7, 10, 1, 15, 0, 12, 11, 5, 14, 9, 8, 3, 9, 7, 4, 8, 13, 6, 2, 1, 6, 11,
12, 2, 3, 0, 5, 14, 10, 13, 15, 4, 13, 3, 4, 9, 6, 10, 1, 12, 11, 0, 2,
5, 0, 13, 14, 2, 8, 15, 7, 4, 15, 1, 10, 7, 5, 6, 12, 11, 3, 8, 9, 14,
],
[
2, 4, 8, 15, 7, 10, 13, 6, 4, 1, 3, 12, 11, 7, 14, 0, 12, 2, 5, 9, 10,
13, 0, 3, 1, 11, 15, 5, 6, 8, 9, 14, 14, 11, 5, 6, 4, 1, 3, 10, 2, 12,
15, 0, 13, 2, 8, 5, 11, 8, 0, 15, 7, 14, 9, 4, 12, 7, 10, 9, 1, 13, 6,
3,
],
[
12, 9, 0, 7, 9, 2, 14, 1, 10, 15, 3, 4, 6, 12, 5, 11, 1, 14, 13, 0, 2,
8, 7, 13, 15, 5, 4, 10, 8, 3, 11, 6, 10, 4, 6, 11, 7, 9, 0, 6, 4, 2, 13,
1, 9, 15, 3, 8, 15, 3, 1, 14, 12, 5, 11, 0, 2, 12, 14, 7, 5, 10, 8, 13,
],
[
4, 1, 3, 10, 15, 12, 5, 0, 2, 11, 9, 6, 8, 7, 6, 9, 11, 4, 12, 15, 0, 3,
10, 5, 14, 13, 7, 8, 13, 14, 1, 2, 13, 6, 14, 9, 4, 1, 2, 14, 11, 13, 5,
0, 1, 10, 8, 3, 0, 11, 3, 5, 9, 4, 15, 2, 7, 8, 12, 15, 10, 7, 6, 12,
],
[
13, 7, 10, 0, 6, 9, 5, 15, 8, 4, 3, 10, 11, 14, 12, 5, 2, 11, 9, 6, 15,
12, 0, 3, 4, 1, 14, 13, 1, 2, 7, 8, 1, 2, 12, 15, 10, 4, 0, 3, 13, 14,
6, 9, 7, 8, 9, 6, 15, 1, 5, 12, 3, 10, 14, 5, 8, 7, 11, 0, 4, 13, 2, 11,
],
];
/// 按位置换:从 l 中按 arr 的索引取位,放到结果的对应位置
fn bit_transform(arr: &[i32], l: u64) -> u64 {
let mut result: u64 = 0;
for (i, &idx) in arr.iter().enumerate() {
if idx < 0 {
continue;
}
if l & (1u64 << idx as u64) != 0 {
result |= 1u64 << i as u64;
}
}
result
}
/// 生成 16 个子密钥
fn sub_keys(l: u64, mode: i32) -> [u64; 16] {
let mut l2 = bit_transform(&ARRAY_PC_1, l);
let mut longs = [0u64; 16];
for i in 0..16 {
let ls = ARRAY_LS[i] as u32;
let mask = ARRAY_LS_MASK[ARRAY_LS[i] as usize];
// (l2 & mask) << (28 - ls) | (l2 & !mask) >> ls
let part1 = (l2 & mask).wrapping_shl(28 - ls);
let part2 = (l2 & !mask) >> ls;
l2 = part1 | part2;
longs[i] = bit_transform(&ARRAY_PC_2, l2);
}
// 解密模式:反转子密钥顺序
if mode == 1 {
for j in 0..8 {
longs.swap(j, 15 - j);
}
}
longs
}
/// DES 加密一个 64 位块
fn des64(subkeys: &[u64; 16], l: u64) -> u64 {
let mut out = bit_transform(&ARRAY_IP, l);
let mut p0 = out & 0xffffffff; // 低 32 位 (pSource[0])
let mut p1 = out >> 32; // 高 32 位 (pSource[1])
for i in 0..16 {
let mut r = p1;
r = bit_transform(&ARRAY_E, r);
r ^= subkeys[i];
// 取 8 个 8 位组
let pr: [u64; 8] = [
(r >> 0) & 255,
(r >> 8) & 255,
(r >> 16) & 255,
(r >> 24) & 255,
(r >> 32) & 255,
(r >> 40) & 255,
(r >> 48) & 255,
(r >> 56) & 255,
];
// S-Box 替换(反向遍历,每次左移 4 位)
let mut s_out: u64 = 0;
for sbi in (0..8).rev() {
s_out = (s_out << 4) | MATRIX_NS_BOX[sbi][pr[sbi] as usize] as u64;
}
r = bit_transform(&ARRAY_P, s_out);
let temp = p0;
p0 = p1;
p1 = temp ^ r;
}
// pSource.reverse() 后: pSource[0] = p1, pSource[1] = p0
// out = (pSource[1] << 32) | pSource[0] = (p0 << 32) | p1
out = (p0 << 32) | (p1 & 0xffffffff);
out = bit_transform(&ARRAY_IP_1, out);
out
}
/// 加密/解密消息
fn crypt(msg: &[u8], key: &[u8], mode: i32) -> Vec<u8> {
// 处理密钥块:将 8 字节密钥转为 u64
let mut l: u64 = 0;
for i in 0..8 {
l |= (key[i] as u64) << (i * 8);
}
let subkeys = sub_keys(l, mode);
let j = msg.len() / 8;
let mut result = Vec::new();
// 处理完整块
for m in 0..j {
let mut block: u64 = 0;
for n in 0..8 {
block |= (msg[n + m * 8] as u64) << (n * 8);
}
let encrypted = des64(&subkeys, block);
for n in 0..8 {
result.push((encrypted >> (n * 8)) as u8);
}
}
// 处理剩余字节(加密模式追加完整块)
let remainder = msg.len() % 8;
if remainder > 0 || mode == 0 {
let mut block: u64 = 0;
let start = j * 8;
for i in 0..remainder {
block |= (msg[start + i] as u64) << (i * 8);
}
let encrypted = des64(&subkeys, block);
for n in 0..8 {
result.push((encrypted >> (n * 8)) as u8);
}
}
result
}
const SECRET_KEY: &[u8] = b"ylzsxkwm";
/// 加密 query 字符串并返回 base64 编码
pub fn encrypt_query(query: &str) -> String {
use base64::Engine;
let encrypted = crypt(query.as_bytes(), SECRET_KEY, 0);
base64::engine::general_purpose::STANDARD.encode(&encrypted)
}
#[cfg(test)]
mod tests {
use super::*;
/// 验证 kwDES 加密能正常执行且输出 base64
#[test]
fn test_encrypt_query_basic() {
let query = "corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid=321260769";
let result = encrypt_query(query);
assert!(!result.is_empty());
// base64 字符串只包含合法字符
assert!(result.chars().all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '='));
println!("kwDES encrypt_query result: {}", result);
}
/// 真实接口测试:用 kwDES 加密的 query 请求 kuwo mobi.s 获取 VIP 歌曲 URL
/// 手动运行: cargo test -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn test_kuwo_mobi_with_kwdes() {
let rid = "321260769"; // 邓紫棋「唯一」VIP 歌曲
let query = format!(
"corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid={}",
rid
);
let encrypted = encrypt_query(&query);
let url = format!("http://mobi.kuwo.cn/mobi.s?f=kuwo&q={}", encrypted);
let client = reqwest::Client::new();
let resp = client
.get(&url)
.header("user-agent", "okhttp/3.10.0")
.send()
.await
.expect("请求应成功");
let text = resp.text().await.expect("读取响应应成功");
println!("kuwo mobi.s 响应 (前 500 字符): {}", &text[..text.len().min(500)]);
// 响应中应包含 http URL
assert!(text.contains("http"), "响应应包含 URL: {}", text);
}
}
+480
View File
@@ -0,0 +1,480 @@
// 音源模块:可扩展架构(内置 + 自定义 HTTP API)
//
// 设计要点:
// - MusicSource trait 定义统一接口(search + get_url
// - 内置源:bodian(波点,首选)/ kugou(酷狗,次选)
// - 自定义源:通过 SourceConfig 配置 HTTP API 模板,由 CustomHttpSource 实现
// - 调用方传入 Vec<SourceConfig>,模块根据 kind 路由到对应实现
// - 多源聚合搜索:并发请求所有启用源,按配置顺序合并结果
pub mod bodian;
pub mod custom;
pub mod kugou;
pub mod kwdes;
pub mod kuwo;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// 音源配置(前端持久化、传给后端都用这个结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceConfig {
/// 唯一 ID
/// - builtin: 'bodian' / 'kugou' / 'kuwo' / 'bilibili'
/// - custom: 'custom-xxxx'(前端生成)
pub id: String,
/// 显示名称
pub label: String,
/// 是否启用
pub enabled: bool,
/// 类型: "builtin" | "custom"
pub kind: String,
// ===== 自定义 HTTP API 源字段(kind == "custom" 时使用)=====
/// 搜索 URL 模板,支持 {keyword} / {page} / {limit} 占位符
#[serde(default)]
pub search_url: Option<String>,
/// 获取播放 URL 接口,支持 {id} / {quality} 占位符
#[serde(default)]
pub url_api: Option<String>,
/// JSON 路径:搜索结果数组(如 "data.songs"
#[serde(default)]
pub search_path: Option<String>,
/// JSON 路径:URL 字符串字段(如 "data.url"
#[serde(default)]
pub url_path: Option<String>,
/// 字段映射(自定义源 JSON 字段名)
#[serde(default)]
pub id_field: Option<String>,
#[serde(default)]
pub name_field: Option<String>,
#[serde(default)]
pub artist_field: Option<String>,
#[serde(default)]
pub album_field: Option<String>,
#[serde(default)]
pub duration_field: Option<String>,
#[serde(default)]
pub pic_field: Option<String>,
}
impl SourceConfig {
/// 默认内置音源列表(顺序即默认优先级,波点为首选,参考 SPlayer)
/// kuwo 使用 kwDES + mobi.s 接口,能绕过 VIP 限制播放完整歌曲
#[allow(dead_code)]
pub fn builtin_defaults() -> Vec<SourceConfig> {
vec![
SourceConfig::builtin("bodian", "波点音乐", true),
SourceConfig::builtin("kugou", "酷狗音乐", true),
SourceConfig::builtin("kuwo", "酷我音乐", true),
]
}
#[allow(dead_code)]
fn builtin(id: &str, label: &str, enabled: bool) -> SourceConfig {
SourceConfig {
id: id.into(),
label: label.into(),
enabled,
kind: "builtin".into(),
search_url: None,
url_api: None,
search_path: None,
url_path: None,
id_field: None,
name_field: None,
artist_field: None,
album_field: None,
duration_field: None,
pic_field: None,
}
}
}
/// 外部歌曲(搜索结果)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalSong {
/// 源内 ID(字符串,便于通用)
pub id: String,
/// 来源 source id"bodian" / "kugou" / 自定义源 id
pub source: String,
/// 来源显示名("波点音乐" / ...
pub source_label: String,
pub name: String,
pub artist: String,
pub album: String,
/// 时长(毫秒)
pub duration: u64,
pub pic_url: String,
}
/// 音源统一接口
#[async_trait]
pub trait MusicSource: Send + Sync {
fn id(&self) -> &str;
fn label(&self) -> &str;
/// 搜索
async fn search(&self, keyword: &str, page: u32, limit: u32) -> Result<Vec<ExternalSong>, String>;
/// 获取播放 URL
async fn get_url(&self, song_id: &str, quality: &str) -> Result<String, String>;
}
/// 构建 HTTP 客户端(统一 UA / 超时,复用连接池)
pub(crate) fn build_client(ua: &str, timeout_secs: u64) -> Result<Client, String> {
Client::builder()
.user_agent(ua)
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("构建 HTTP 客户端失败: {}", e))
}
/// 安全截断字符串到指定字符数(避免 UTF-8 多字节边界 panic
pub(crate) fn truncate_chars(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
/// 解析 kuwo r.s 接口返回的 JSONbodian 与 kuwo 共用)
///
/// 该接口返回格式不稳定:
/// - 有时返回标准双引号 JSON
/// - 有时返回单引号 JSONkey/value 用 ' 包裹)
///
/// 先尝试直接解析标准 JSON;失败再用 `fix_single_quote_json` 修复单引号格式。
/// 不能无脑 fix,因为 `fix_single_quote_json` 遇到标准 JSON 值内部的单引号
/// (如歌名 "Don't Stop")会误判为定界符,破坏正确的 JSON。
pub(crate) fn parse_kuwo_json(text: &str, source_tag: &str) -> Result<serde_json::Value, String> {
match serde_json::from_str(text) {
Ok(j) => Ok(j),
Err(_) => {
let fixed = fix_single_quote_json(text);
serde_json::from_str(&fixed).map_err(|e| {
format!(
"{} 搜索JSON解析失败: {} | body(前200): {}",
source_tag,
e,
truncate_chars(text, 200)
)
})
}
}
}
/// 从 kuwo r.s 接口的 abslist 数组构造 ExternalSong 列表(bodian 与 kuwo 共用)
pub(crate) fn parse_kuwo_abslist(
abslist: &[serde_json::Value],
source_id: &str,
source_label: &str,
) -> Vec<ExternalSong> {
abslist
.iter()
.map(|item| {
let musicrid = item["MUSICRID"].as_str().unwrap_or("");
let id = musicrid
.strip_prefix("MUSIC_")
.or_else(|| musicrid.strip_prefix("music_"))
.unwrap_or(musicrid)
.to_string();
let name = item["SONGNAME"].as_str().unwrap_or("").to_string();
let artist = item["ARTIST"].as_str().unwrap_or("").to_string();
let album = item["ALBUM"].as_str().unwrap_or("").to_string();
// DURATION 兼容字符串/数字两种格式
let duration = item["DURATION"]
.as_str()
.and_then(|d| d.parse::<u64>().ok())
.or_else(|| item["DURATION"].as_u64())
.map(|d| d * 1000)
.unwrap_or(0);
// web_albumpic_short 是相对路径(如 "120/s4s61/37/4021488843.jpg"
// 需拼接 kuwo 图片 CDN 前缀;albumpic 字段有时是完整 URL
let pic_raw = item["web_albumpic_short"]
.as_str()
.or_else(|| item["albumpic"].as_str())
.or_else(|| item["pic"].as_str())
.unwrap_or("");
let pic_url = if pic_raw.is_empty() {
String::new()
} else if pic_raw.starts_with("http") {
pic_raw.to_string()
} else {
format!("https://img1.kuwo.cn/star/albumcover/{}", pic_raw)
};
ExternalSong {
id,
source: source_id.to_string(),
source_label: source_label.to_string(),
name,
artist,
album,
duration,
pic_url,
}
})
.collect()
}
/// 修复 kuwo r.s 接口返回的单引号 JSON
/// kuwo 用 `'` 代替 `"` 作为字符串定界符,但值内部可能也含单引号
/// 用状态机遍历:只在「作为定界符的位置」把 `'` 替换成 `"`
///
/// 规则:遇到 `'` 时判断它是否为定界符:
/// - 如果当前在字符串内部(已遇到开定界符 `'`),则看下一个非空白字符
/// 是 `:` `}` `,` `]` 之一 → 这是闭定界符;否则是值内部的字符,保留
/// - 如果不在字符串内部,遇到 `'` 一定是开定界符
fn fix_single_quote_json(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(n);
let mut in_str = false;
let mut i = 0;
while i < n {
let c = chars[i];
if c == '\'' {
if !in_str {
out.push('"');
in_str = true;
} else {
let mut j = i + 1;
while j < n && (chars[j] == ' ' || chars[j] == '\t' || chars[j] == '\n' || chars[j] == '\r') {
j += 1;
}
let next = if j < n { Some(chars[j]) } else { None };
match next {
Some(':') | Some('}') | Some(',') | Some(']') | None => {
out.push('"');
in_str = false;
}
_ => {
out.push('\'');
}
}
}
} else if c == '"' && in_str {
out.push('\\');
out.push('"');
} else {
out.push(c);
}
i += 1;
}
out
}
/// 根据 SourceConfig 构造对应的音源实例
pub fn get_source(cfg: &SourceConfig) -> Option<Box<dyn MusicSource>> {
match cfg.kind.as_str() {
"builtin" => match cfg.id.as_str() {
"bodian" => Some(Box::new(bodian::BodianSource::new(cfg.label.clone()))),
"kugou" => Some(Box::new(kugou::KugouSource::new(cfg.label.clone()))),
"kuwo" => Some(Box::new(kuwo::KuwoSource::new(cfg.label.clone()))),
_ => None,
},
"custom" => Some(Box::new(custom::CustomHttpSource::new(cfg.clone()))),
_ => None,
}
}
/// 多源聚合搜索:并发请求所有启用的源,按配置顺序合并结果
pub async fn search_multi(
keyword: &str,
sources: &[SourceConfig],
limit: u32,
) -> Vec<ExternalSong> {
use futures_util::future::join_all;
let tasks: Vec<_> = sources
.iter()
.filter(|s| s.enabled)
.filter_map(|cfg| {
let src = get_source(cfg)?;
let kw = keyword.to_string();
Some(async move {
let label = src.label().to_string();
let sid = src.id().to_string();
let _ = label;
match src.search(&kw, 1, limit).await {
Ok(list) => list,
Err(e) => {
eprintln!("[music_sources] {} 搜索失败: {}", sid, e);
Vec::new()
}
}
})
})
.collect();
let results = join_all(tasks).await;
let mut all = Vec::new();
for r in results {
all.extend(r);
}
all
}
/// 网易云 fallback:按 sources 顺序尝试 (search + get_url)
/// 用于 get_song_url 在网易云无 URL 时降级
/// 返回 (url, source_label)
///
/// 匹配策略:
/// 1. 用「歌名 歌手」在源里搜索
/// 2. 只选原版(排除 Live/DJ/翻唱/伴奏/remix 等非原版)
/// 3. 原版 get_url 失败(VIP/广告/付费)则换下一个源继续尝试
/// 4. 所有源都失败就报错,不塞非原版替代品
pub async fn fallback_get_url(
song_name: &str,
artist: &str,
quality: &str,
sources: &[SourceConfig],
) -> Result<(String, String), String> {
let target_lower = song_name.to_lowercase();
// 歌手列表:按 "/" 分割(兼容前端 " / " 和 api.rs 的 "/" 格式)
let artists: Vec<String> = if artist.is_empty() {
Vec::new()
} else {
artist
.split('/')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
};
// 搜索关键词只用第一个歌手(多歌手全拼接会搜不到结果)
let first_artist = artists.first().cloned().unwrap_or_default();
// 收集每个源的失败原因,便于排障
let mut details: Vec<String> = Vec::new();
for cfg in sources.iter().filter(|s| s.enabled) {
let src = match get_source(cfg) {
Some(s) => s,
None => continue,
};
let kw = if first_artist.is_empty() {
song_name.to_string()
} else {
format!("{} {}", song_name, first_artist)
};
let results = match src.search(&kw, 1, 20).await {
Ok(r) => r,
Err(e) => {
details.push(format!("{}搜索失败:{}", src.id(), e));
continue;
}
};
// 版本标签提取:从歌名中识别版本关键词
// 用于「版本一致性匹配」——目标是原版就只匹配原版,目标是DJ版就只匹配DJ版
// 避免 VIP 原版歌曲误匹配到同名 DJ 版,也避免 VIP DJ 版误匹配到同名原版
let extract_version = |name: &str| -> Vec<&'static str> {
let n = name.to_lowercase();
let mut tags = Vec::new();
// 括号通常表示版本标注(如 "Rise (Redux)" "RISE (Live)"
// 目标是原版(无括号)时,带括号的搜索结果视为不同版本
if n.contains('(') || n.contains('') {
tags.push("paren");
}
if n.contains("live") || n.contains("现场") || n.contains("演唱会") {
tags.push("live");
}
if n.contains("remix") || n.contains("混音") {
tags.push("remix");
}
if n.contains("dj") || n.contains("电音") {
tags.push("dj");
}
if n.contains("伴奏") || n.contains("karaoke") || n.contains("inst") {
tags.push("accompaniment");
}
if n.contains("翻唱") {
tags.push("cover");
}
tags
};
// 版本一致性:目标歌名和搜索结果歌名的版本标签必须相同
// - 目标是原版(无标签)→ 搜索结果也必须是原版(无标签)
// - 目标是 DJ 版 → 搜索结果也必须是 DJ 版
let version_match = |result_name: &str| -> bool {
extract_version(&target_lower) == extract_version(result_name)
};
// 歌手匹配:任一网易云歌手名与外部源歌手双向包含匹配
// 双向是因为网易云可能用中文名"邓紫棋",外部源用英文名"G.E.M."
// 去掉歌手字段中的标点(如 "G.E.M." → "gem"),避免点号/空格影响匹配
let normalize_artist = |a: &str| -> String {
a.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == '&' || *c == '、')
.collect()
};
let artist_match = |a: &str| -> bool {
if artists.is_empty() {
return false;
}
let a_norm = normalize_artist(a);
if a_norm.is_empty() {
return false;
}
artists.iter().any(|art| {
let art_norm = normalize_artist(art);
!art_norm.is_empty() && (a_norm.contains(&art_norm) || art_norm.contains(&a_norm))
})
};
// 匹配优先级(逐级放宽,但每级都要求版本一致):
// 1. 歌名完全匹配 + 版本一致 + 歌手匹配(最严格,优先选同名同版本同歌手)
// 2. 歌名包含目标歌名 + 版本一致 + 歌手匹配(处理歌名带后缀,必须歌手匹配防误匹配)
// 3. 歌名完全匹配 + 版本一致(最后兜底,不检查歌手——宁可播放同名同版本也不返回失败)
let best = results.iter().find(|s| {
s.name.to_lowercase() == target_lower && version_match(&s.name) && artist_match(&s.artist)
})
.or_else(|| results.iter().find(|s| {
let n = s.name.to_lowercase();
n.contains(&target_lower) && version_match(&s.name) && artist_match(&s.artist)
}))
.or_else(|| results.iter().find(|s| {
s.name.to_lowercase() == target_lower && version_match(&s.name)
}));
if let Some(song) = best {
eprintln!("[music_sources] fallback 匹配: [{}] {} - {} (id={})", src.id(), song.name, song.artist, song.id);
match src.get_url(&song.id, quality).await {
Ok(url) if !url.is_empty() => {
return Ok((url, src.label().to_string()));
}
Ok(_) => {
details.push(format!("{}返回空URL", src.id()));
}
Err(e) => {
details.push(format!("{}取URL失败:{}", src.id(), e));
}
}
} else {
details.push(format!("{}未匹配到同版本", src.id()));
}
}
Err(format!("所有音源均无可用 URL; 尝试明细: {}", details.join(", ")))
}
#[cfg(test)]
mod tests {
use super::*;
/// 真实接口测试:验证「唯一 邓紫棋」的 fallback 能找到可播放 URL
/// 手动运行: cargo test -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn test_fallback_weiyi_dengziqi() {
let sources = SourceConfig::builtin_defaults();
match fallback_get_url("唯一", "邓紫棋", "standard", &sources).await {
Ok((url, label)) => {
println!("✅ fallback 成功: [{}] {}", label, url);
assert!(url.starts_with("http"));
}
Err(e) => {
panic!("❌ fallback 失败: {}", e);
}
}
}
}