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
+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())
}
}