mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-08-06 11:54:18 +08:00
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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user