mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-08-06 03:44:18 +08:00
Compare commits
5
Commits
v0.8.0
...
f514d29f02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f514d29f02 | ||
|
|
f7f0217047 | ||
|
|
83cfae3b10 | ||
|
|
1fddbeab77 | ||
|
|
2993495d05 |
@@ -26,8 +26,13 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
@@ -45,8 +50,8 @@ jobs:
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Extract release notes for version
|
||||
id: release_notes
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
## v0.9.0
|
||||
|
||||
本次版本为音源系统与播放器核心重构版本,新增多音源融合系统、歌曲标签体系、搜索建议、播放器稳定性优化等重大功能。
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
#### 🎵 多音源系统
|
||||
- **可配置多音源**:支持配置多个音源(波点、酷狗、酷我),可独立启用/禁用/排序,支持自定义音源扩展
|
||||
- **音质优化**:酷我音源支持更高音质播放,提供更多音质选择
|
||||
- **多源自动切换**:歌曲播放时自动尝试多个音源,版本一致性匹配(原版/DJ/Live/混音)
|
||||
- **多源融合搜索**:搜索结果聚合多个音源,按歌名+歌手组合去重,提供更多选择
|
||||
|
||||
#### 🏷️ 歌曲标签体系
|
||||
- **智能标签显示**:自动识别并显示歌曲标签(音质/版本/音源),提供清晰的歌曲信息展示
|
||||
- **权限信息聚合**:统一管理歌手、歌单、专辑、每日推荐、收藏页的歌曲权限信息
|
||||
|
||||
#### 🔍 搜索体验优化
|
||||
- **实时搜索建议**:输入时自动显示搜索建议,支持中文拼音输入,120ms 防抖优化
|
||||
- **compositionend 支持**:正确处理中文输入法的组合输入过程,实时响应
|
||||
- **浮层定位优化**:Teleport + fixed 定位,避免被父容器裁剪,显示更完整
|
||||
|
||||
### 🎨 播放器改进
|
||||
|
||||
#### 🎧 核心播放优化
|
||||
- **网络流 seek 支持**:sharedBuffer 下载完成后支持拖动进度条跳转(byte_len 可用)
|
||||
- **切歌流程优化**:停止旧歌曲播放后再加载新歌曲,进度条正确归零,避免音频叠加
|
||||
- **加载状态可视化**:切歌时按钮显示加载状态并禁用,提供清晰的交互反馈
|
||||
- **tick generation 防竞态**:无 tick 时不覆盖新歌曲位置,避免播放位置跳变
|
||||
|
||||
#### 🖥️ 系统媒体控制
|
||||
- **跨平台媒体键支持**:系统媒体控制集成(Windows/Linux/macOS),支持媒体键、通知栏、锁屏控制
|
||||
- **Windows hwnd 就绪检查**:增加窗口句柄就绪检查,避免 panic,提升稳定性
|
||||
|
||||
## v0.8.1
|
||||
|
||||
### 🐛 修复
|
||||
- **关闭到托盘不生效**:点击关闭按钮选择「最小化到托盘」或「直接退出」时,窗口未隐藏/退出。原因是 `capabilities/default.json` 缺少 `core:window:allow-hide` 权限,前端调用 `currentWindow.hide()` 被 Tauri 2 权限系统静默拦截。补齐 `allow-hide` 与 `allow-show` 权限
|
||||
|
||||
## v0.8.0
|
||||
|
||||
本次版本为架构重构与交互升级版本,涵盖皮肤系统、详情页架构、侧边栏交互、播放器稳定性、底层模块拆分等方面的系统性优化。
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nekosonic",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+2039
File diff suppressed because it is too large
Load Diff
Generated
+3
-1
@@ -4,8 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Nekosonic"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
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]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:path:default",
|
||||
"opener:default",
|
||||
"global-shortcut:allow-is-registered",
|
||||
|
||||
+153
-4
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取歌词
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t_local_path, ¤t_audio_buffer) {
|
||||
Some(mss) => mss,
|
||||
None => continue,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// 波点音乐(酷我旗下子品牌)
|
||||
// 参考 SPlayer bodian.ts / UnblockNeteaseMusic bodian.js
|
||||
//
|
||||
// 搜索:复用 kuwo 的 search.kuwo.cn/r.s 接口(无需签名)
|
||||
// URL:bd-api.kuwo.cn/api/play/music/v2/audioUrl,需 sign 签名 + 先调一次广告接口
|
||||
//
|
||||
// sign 算法(来自 bodian.js generateSign):
|
||||
// 1. url 末尾追加 ×tamp={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
|
||||
/// 输出:追加 ×tamp=...&sign=... 的完整 url
|
||||
///
|
||||
/// 算法(参考 UnblockNeteaseMusic bodian.js generateSign):
|
||||
/// 1. str += ×tamp={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!("{}×tamp={}", 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×tamp=...
|
||||
/// → ("http://bd-api.kuwo.cn", "/api/play/music/v2/audioUrl", "&br=320kmp3&musicId=123×tamp=...")
|
||||
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=×tamp=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×tamp=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×tamp=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("×tamp="));
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 酷狗音乐(内置音源)
|
||||
// 搜索:mobilecdn.kugou.com/api/v3/search/song
|
||||
// URL:trackercdn.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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 接口返回的 JSON(bodian 与 kuwo 共用)
|
||||
///
|
||||
/// 该接口返回格式不稳定:
|
||||
/// - 有时返回标准双引号 JSON
|
||||
/// - 有时返回单引号 JSON(key/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nekosonic",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"identifier": "com.atdunbg.Nekosonic",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -40,6 +40,7 @@ export const MusicApi = {
|
||||
likelist: SongApi.likelist,
|
||||
likeSong: SongApi.likeSong,
|
||||
scrobble: SongApi.scrobble,
|
||||
getExternalSongUrl: SongApi.getExternalSongUrl,
|
||||
|
||||
// 歌单
|
||||
userPlaylist: PlaylistApi.userPlaylist,
|
||||
@@ -53,6 +54,7 @@ export const MusicApi = {
|
||||
searchSuggest: SearchApi.searchSuggest,
|
||||
getHotSearch: SearchApi.getHotSearch,
|
||||
cloudsearch: SearchApi.cloudsearch,
|
||||
searchSongsMulti: SearchApi.searchSongsMulti,
|
||||
|
||||
// 专辑
|
||||
albumDetail: AlbumApi.albumDetail,
|
||||
|
||||
+14
-1
@@ -1,4 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { MusicSourceConfig } from '../stores/settings';
|
||||
|
||||
/**
|
||||
* 搜索相关 API
|
||||
@@ -14,8 +15,20 @@ export const SearchApi = {
|
||||
return invoke('get_hot_search');
|
||||
},
|
||||
|
||||
/** 云搜索 */
|
||||
/** 云搜索(网易云) */
|
||||
async cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise<string> {
|
||||
return invoke('cloudsearch', { query });
|
||||
},
|
||||
|
||||
/** 多源聚合搜索:并发请求所有启用音源,合并结果返回
|
||||
* 返回 JSON: ExternalSong[](与后端 ExternalSong 结构对齐)
|
||||
* 用于「网易云搜不到的歌,从其他音源补充搜索结果」场景
|
||||
*/
|
||||
async searchSongsMulti(query: {
|
||||
keyword: string;
|
||||
sources: MusicSourceConfig[];
|
||||
limit?: number;
|
||||
}): Promise<string> {
|
||||
return invoke('search_songs_multi', { query });
|
||||
},
|
||||
};
|
||||
|
||||
+33
-3
@@ -1,4 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { MusicSourceConfig } from '../stores/settings';
|
||||
|
||||
/**
|
||||
* 歌曲相关 API
|
||||
@@ -9,8 +10,15 @@ export const SongApi = {
|
||||
return invoke('get_song_detail', { id });
|
||||
},
|
||||
|
||||
/** 获取歌曲播放 URL */
|
||||
async getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise<string> {
|
||||
/** 获取歌曲播放 URL
|
||||
* sources: 启用的第三方音源配置列表(按优先级排序),网易云无 URL 时按顺序降级
|
||||
*/
|
||||
async getSongUrl(query: {
|
||||
id: number;
|
||||
level: string;
|
||||
fm_mode?: boolean;
|
||||
sources?: MusicSourceConfig[];
|
||||
}): Promise<string> {
|
||||
return invoke('get_song_url', { query });
|
||||
},
|
||||
|
||||
@@ -30,7 +38,29 @@ export const SongApi = {
|
||||
},
|
||||
|
||||
/** 上报听歌记录(scrobble) */
|
||||
async scrobble(query: { id: number; sourceid: string; time: number; alg?: string; source?: string; bitrate?: number }): Promise<void> {
|
||||
async scrobble(query: {
|
||||
id: number;
|
||||
sourceid: string;
|
||||
time: number;
|
||||
alg?: string;
|
||||
source?: string;
|
||||
bitrate?: number;
|
||||
}): Promise<void> {
|
||||
return invoke('scrobble', { query });
|
||||
},
|
||||
|
||||
/** 从指定外部音源获取播放 URL(用于搜索结果里来自外部源的歌曲)
|
||||
* 返回 JSON: { url, source, source_label }
|
||||
* 主源失败时会用 songName/artist/allSources 跨源 fallback
|
||||
*/
|
||||
async getExternalSongUrl(query: {
|
||||
source: MusicSourceConfig;
|
||||
songId: string;
|
||||
quality?: string;
|
||||
songName?: string;
|
||||
artist?: string;
|
||||
allSources?: MusicSourceConfig[];
|
||||
}): Promise<string> {
|
||||
return invoke('get_external_song_url', { query });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,19 +59,26 @@
|
||||
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-1">
|
||||
<div class="flex items-center gap-5 relative">
|
||||
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
|
||||
<button @click="player.prev()" :disabled="player.isFmMode || player.switching" :class="[
|
||||
'transition',
|
||||
player.isFmMode ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
|
||||
(player.isFmMode || player.switching) ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
|
||||
]">
|
||||
<IconSkipBack class="w-5 h-5" />
|
||||
</button>
|
||||
<button @click="player.toggle()"
|
||||
<button @click="player.toggle()" :disabled="player.switching"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full transition"
|
||||
:class="drawerActive ? 'bg-white/15 hover:bg-white/25 border border-white/20' : 'bg-muted hover:bg-emphasis border border-emphasis'">
|
||||
<IconPause v-if="player.playing" class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
:class="[
|
||||
drawerActive ? 'bg-white/15 hover:bg-white/25 border border-white/20' : 'bg-muted hover:bg-emphasis border border-emphasis',
|
||||
player.switching ? 'cursor-not-allowed' : '',
|
||||
]">
|
||||
<IconLoader2 v-if="player.switching" class="w-4 h-4 animate-spin" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
<IconPause v-else-if="player.playing" class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
<IconPlay v-else class="w-4 h-4" :class="drawerActive ? 'text-white' : 'text-content'" />
|
||||
</button>
|
||||
<button @click="player.next()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
|
||||
<button @click="player.next()" :disabled="player.switching" :class="[
|
||||
'transition',
|
||||
player.switching ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
|
||||
]">
|
||||
<IconSkipForward class="w-5 h-5" />
|
||||
</button>
|
||||
<button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" class="absolute left-full ml-5" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐">
|
||||
|
||||
@@ -32,7 +32,16 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" :class="nameClass">{{ song.name }}</p>
|
||||
<p class="text-sm font-medium truncate flex items-center gap-1.5" :class="nameClass">
|
||||
<span class="truncate">{{ song.name }}</span>
|
||||
<span
|
||||
v-for="tag in songTags"
|
||||
:key="tag.kind"
|
||||
class="text-[10px] leading-none px-1 py-0.5 rounded flex-shrink-0 font-medium"
|
||||
:class="tag.class"
|
||||
>{{ tag.label }}</span>
|
||||
<span v-if="song.sourceLabel" class="text-[10px] px-1 py-0.5 rounded bg-muted text-content-2 flex-shrink-0">{{ song.sourceLabel }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-content-2 truncate">
|
||||
<template v-if="song.ar?.length">
|
||||
<template v-for="(a, i) in song.ar" :key="a.id || i">
|
||||
@@ -69,7 +78,7 @@ import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import { getCoverUrl, type Song } from '../utils/song';
|
||||
import { getCoverUrl, getSongTags, type Song } from '../utils/song';
|
||||
import { formatDuration } from '../utils/format';
|
||||
import SongItemMenu from './SongItemMenu.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
@@ -112,4 +121,5 @@ const props = withDefaults(defineProps<{
|
||||
const coverClass = computed(() => props.coverSize);
|
||||
const coverSrc = computed(() => getCoverUrl(props.song, props.coverSizeParam));
|
||||
const nameClass = computed(() => props.isCurrent ? 'text-accent-text' : '');
|
||||
const songTags = computed(() => getSongTags(props.song));
|
||||
</script>
|
||||
|
||||
@@ -217,8 +217,18 @@ function onInputChange() {
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.searchSuggest(keyword.value.trim());
|
||||
const data = JSON.parse(jsonStr);
|
||||
const all = data.result?.allMatch || [];
|
||||
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
|
||||
const result = data.result || {};
|
||||
// 优先用 allMatch(type=mobile 格式)
|
||||
let list: string[] = (result.allMatch || []).map((m: any) => m.keyword);
|
||||
// 兜底:从 songs/artists/albums 提取名称
|
||||
if (list.length === 0) {
|
||||
list = [
|
||||
...(result.songs || []).map((s: any) => s.name),
|
||||
...(result.artists || []).map((a: any) => a.name),
|
||||
...(result.albums || []).map((a: any) => a.name),
|
||||
].filter(Boolean);
|
||||
}
|
||||
suggestions.value = list.slice(0, 8);
|
||||
showPanel.value = true;
|
||||
} catch {
|
||||
suggestions.value = [];
|
||||
|
||||
@@ -11,14 +11,17 @@ export interface UpdateInfo {
|
||||
|
||||
const IGNORED_VERSION_KEY = 'updater_ignored_version'
|
||||
|
||||
// 模块级共享状态:所有 useUpdater() 调用返回同一份 ref
|
||||
// 否则 App.vue 的弹窗和 Settings.vue 的检查按钮会操作不同的实例,弹窗永远不显示
|
||||
const checking = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const updateAvailable = ref(false)
|
||||
const updateInfo = ref<UpdateInfo | null>(null)
|
||||
const currentVersion = ref('')
|
||||
const error = ref('')
|
||||
|
||||
export function useUpdater() {
|
||||
const checking = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const updateAvailable = ref(false)
|
||||
const updateInfo = ref<UpdateInfo | null>(null)
|
||||
const currentVersion = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function getCurrentVersion() {
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
/** 当前 Schema 版本号 */
|
||||
export const CURRENT_SCHEMA_VERSION = 3;
|
||||
export const CURRENT_SCHEMA_VERSION = 7;
|
||||
|
||||
/** 迁移函数按目标版本号索引(data 升级到此版本后即为 vN) */
|
||||
const migrations: Record<number, (data: any) => any> = {
|
||||
@@ -80,6 +80,149 @@ const migrations: Record<number, (data: any) => any> = {
|
||||
delete data.localMusicPaths;
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* v4: 新增第三方音源 fallback 设置
|
||||
*
|
||||
* 老用户没有 musicSourceEnabled / musicSources 字段,
|
||||
* 默认开启总开关,启用 kugou + kuwo(与默认值一致)。
|
||||
*/
|
||||
4: (data) => {
|
||||
if (typeof data.musicSourceEnabled !== 'boolean') {
|
||||
data.musicSourceEnabled = true;
|
||||
}
|
||||
if (!Array.isArray(data.musicSources)) {
|
||||
data.musicSources = ['kugou', 'kuwo'];
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* v5: 音源模型重构
|
||||
*
|
||||
* 旧版 musicSources 是 string[](仅存音源 id),
|
||||
* 新版改为 MusicSourceConfig[](含 id/label/enabled/kind 及自定义 HTTP API 字段)。
|
||||
*
|
||||
* 迁移规则:
|
||||
* - 已是对象数组:保留,补齐缺失字段
|
||||
* - 是字符串数组:转换为对象,旧列表里的视为 enabled,bodain(新增波点)默认启用并置于首位
|
||||
* - 不存在或格式不对:使用默认内置音源列表
|
||||
*/
|
||||
5: (data) => {
|
||||
// 默认内置音源列表(与 settings.ts 的 builtinMusicSourceDefaults 一致)
|
||||
const BUILTIN_DEFAULTS: Array<{ id: string; label: string; enabled: boolean; kind: 'builtin' }> = [
|
||||
{ id: 'bodian', label: '波点音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kugou', label: '酷狗音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kuwo', label: '酷我音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'bilibili', label: 'Bilibili 音频', enabled: false, kind: 'builtin' },
|
||||
];
|
||||
const BUILTIN_LABELS: Record<string, string> = {
|
||||
bodian: '波点音乐',
|
||||
kugou: '酷狗音乐',
|
||||
kuwo: '酷我音乐',
|
||||
bilibili: 'Bilibili 音频',
|
||||
};
|
||||
|
||||
const oldSources = data.musicSources;
|
||||
// 字符串数组 → 对象数组
|
||||
if (Array.isArray(oldSources) && oldSources.length > 0 && typeof oldSources[0] === 'string') {
|
||||
const oldIds = new Set(oldSources.filter((s: any) => typeof s === 'string'));
|
||||
// 波点(bodian)作为新默认首选,老用户没有则补上启用
|
||||
const result: any[] = [];
|
||||
for (const def of BUILTIN_DEFAULTS) {
|
||||
// 老用户已启用的源保持启用;新增的 bodian 默认启用
|
||||
const enabled = oldIds.has(def.id) || def.id === 'bodian' ? true : def.enabled;
|
||||
result.push({ ...def, enabled });
|
||||
}
|
||||
// 老用户列表里可能有自定义源 id(理论上 v4 没有自定义源),保留为 builtin 兜底
|
||||
for (const sid of oldIds) {
|
||||
if (typeof sid !== 'string') continue;
|
||||
if (result.some(r => r.id === sid)) continue;
|
||||
result.push({
|
||||
id: sid,
|
||||
label: BUILTIN_LABELS[sid] || sid,
|
||||
enabled: true,
|
||||
kind: 'builtin',
|
||||
});
|
||||
}
|
||||
data.musicSources = result;
|
||||
} else if (Array.isArray(oldSources) && oldSources.length > 0 && typeof oldSources[0] === 'object') {
|
||||
// 已是对象数组:补齐缺失字段(kind / 默认 label 等)
|
||||
data.musicSources = oldSources.map((s: any) => {
|
||||
if (!s || typeof s !== 'object') return null;
|
||||
const id: string = String(s.id || '');
|
||||
const kind: string = s.kind === 'custom' ? 'custom' : 'builtin';
|
||||
return {
|
||||
id,
|
||||
label: s.label || BUILTIN_LABELS[id] || id,
|
||||
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
|
||||
kind,
|
||||
...(kind === 'custom' ? {
|
||||
searchUrl: s.searchUrl,
|
||||
urlApi: s.urlApi,
|
||||
searchPath: s.searchPath,
|
||||
urlPath: s.urlPath,
|
||||
idField: s.idField,
|
||||
nameField: s.nameField,
|
||||
artistField: s.artistField,
|
||||
albumField: s.albumField,
|
||||
durationField: s.durationField,
|
||||
picField: s.picField,
|
||||
} : {}),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
// 老用户对象数组里可能没有 bodian,补上
|
||||
if (!data.musicSources.some((s: any) => s.id === 'bodian')) {
|
||||
data.musicSources.unshift({ id: 'bodian', label: '波点音乐', enabled: true, kind: 'builtin' });
|
||||
}
|
||||
} else {
|
||||
// 不存在或空:使用默认
|
||||
data.musicSources = BUILTIN_DEFAULTS.map(s => ({ ...s }));
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* v6: 移除已废弃的 bilibili 内置源
|
||||
*
|
||||
* bilibili 接口需 cookie,无 cookie 返回 412,已从内置源列表中删除。
|
||||
* 注意:kuwo 之前因 r.s 接口返回单引号 JSON 解析失败也被移除,
|
||||
* 但现已用 kwDES + mobi.s 接口重写(能绕过 VIP 限制),不再废弃。
|
||||
* 用户如果列表里有 bilibili id,直接过滤掉;自定义源保留。
|
||||
*/
|
||||
6: (data) => {
|
||||
if (Array.isArray(data.musicSources)) {
|
||||
data.musicSources = data.musicSources.filter((s: any) =>
|
||||
s && typeof s === 'object' && !(s.kind === 'builtin' && s.id === 'bilibili')
|
||||
);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* v7: 恢复 kuwo 内置源
|
||||
*
|
||||
* 之前 v6 错误地把 kuwo 也一并移除了。kuwo 现已用 kwDES 加密 + mobi.s 接口重写,
|
||||
* 能绕过 VIP 限制播放完整歌曲(参考 SPlayer 实现),是播放 VIP 歌曲的关键音源。
|
||||
* 对已升级到 v6(kuwo 被删除)的用户,把 kuwo 加回列表末尾(默认启用)。
|
||||
* 已有 kuwo 的用户不受影响(幂等)。
|
||||
*/
|
||||
7: (data) => {
|
||||
if (Array.isArray(data.musicSources)) {
|
||||
const hasKuwo = data.musicSources.some((s: any) =>
|
||||
s && typeof s === 'object' && s.id === 'kuwo' && s.kind === 'builtin'
|
||||
);
|
||||
if (!hasKuwo) {
|
||||
data.musicSources.push({
|
||||
id: 'kuwo',
|
||||
label: '酷我音乐',
|
||||
enabled: true,
|
||||
kind: 'builtin',
|
||||
});
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+115
-36
@@ -36,8 +36,10 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const { addRecent } = recentStore;
|
||||
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let tickGeneration = 0;
|
||||
function clearTick() {
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
tickGeneration++;
|
||||
}
|
||||
function setTick(v: ReturnType<typeof setInterval>) {
|
||||
tickInterval = v;
|
||||
@@ -153,7 +155,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
async function playFmSong(song: Song) {
|
||||
const seq = ++_playSeq;
|
||||
_switchingSong = true;
|
||||
switching.value = true;
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
if (!song.dt || song.dt === 0) {
|
||||
@@ -169,7 +171,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
await AudioApi.stopAudio();
|
||||
if (!(await stopAndWaitStopped(seq))) return;
|
||||
queue.value = [];
|
||||
currentIndex.value = -1;
|
||||
playing.value = false;
|
||||
@@ -177,10 +179,11 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
fmSong.value = song;
|
||||
currentSong.value = song;
|
||||
try {
|
||||
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality, fm_mode: true });
|
||||
const data = await loadSongUrl(song, true);
|
||||
if (seq !== _playSeq) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
// 记录实际播放音源(netease / 波点音乐 / 酷狗音乐 / ...)
|
||||
if (data.source) song.source = data.source;
|
||||
if (!url) {
|
||||
const fee = data.fee;
|
||||
if (fee === 4) {
|
||||
@@ -196,7 +199,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
disableFmMode();
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
switching.value = false;
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
@@ -215,7 +218,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
disableFmMode();
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
switching.value = false;
|
||||
if (fmNextCallback) {
|
||||
fmNextCallback();
|
||||
} else {
|
||||
@@ -230,12 +233,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
duration.value = (song.dt || 0) / 1000;
|
||||
currentTime.value = 0;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
onPlaybackStarted(song, true);
|
||||
} else {
|
||||
playing.value = false;
|
||||
showToast('FM 播放启动超时,仍在尝试加载…', 'info');
|
||||
@@ -253,7 +251,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
} finally {
|
||||
if (seq === _playSeq) {
|
||||
_switchingSong = false;
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,9 +311,30 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const MAX_VIP_SKIP = 10;
|
||||
|
||||
let _playSeq = 0;
|
||||
let _switchingSong = false;
|
||||
const switching = ref(false);
|
||||
|
||||
async function waitForPlaybackStart(timeoutMs: number = 5000): Promise<boolean> {
|
||||
/// 停止后端播放并等待 is_playing 变为 false(最多 1.5s)。
|
||||
/// 替代音源的 loadSongUrl(跨源 fallback)耗时可能数秒,
|
||||
/// 若不先停止旧歌,期间后端 is_playing 仍为 true、shared_position 持续增长,
|
||||
/// 之后 waitForPlaybackStart 会误判旧歌的 is_playing=true 为新歌启动,
|
||||
/// 导致 startTick 后 tick 读取旧歌的 position 覆盖 currentTime(进度条不从零开始)。
|
||||
/// 返回 false 表示 seq 已过期,调用方应立即 return。
|
||||
async function stopAndWaitStopped(seq: number): Promise<boolean> {
|
||||
try { await AudioApi.stopAudio(); } catch { /* 忽略 */ }
|
||||
const deadline = Date.now() + 1500;
|
||||
while (Date.now() < deadline) {
|
||||
if (seq !== _playSeq) return false;
|
||||
try {
|
||||
if (!(await AudioApi.isAudioPlaying())) return true;
|
||||
} catch { return true; }
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 等待新播放启动(is_playing 变为 true)。
|
||||
/// 调用方须先用 stopAndWaitStopped 确保旧播放已停止,否则可能命中旧歌的 true。
|
||||
async function waitForPlaybackStart(timeoutMs: number = 8000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
@@ -328,15 +347,76 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/// 播放启动成功后的统一处理:设置状态、启动 tick、记录最近播放、上报状态
|
||||
function onPlaybackStarted(song: Song, isFm: boolean) {
|
||||
playing.value = true;
|
||||
if (isFm) duration.value = (song.dt || 0) / 1000;
|
||||
currentTime.value = 0;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
emitPlaybackState();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一获取播放 URL:外部音源歌曲走 getExternalSongUrl,网易云走 getSongUrl(含 fallback)
|
||||
* 返回结构兼容网易云 data 对象(url/source/fee/freeTrialInfo)
|
||||
*/
|
||||
async function loadSongUrl(song: Song, fmMode = false): Promise<{ url?: string; source?: string; fee?: number; freeTrialInfo?: any }> {
|
||||
// 外部音源歌曲:直接通过对应源取 URL,失败时后端会跨源 fallback
|
||||
if (song.externalSourceId && song.externalId) {
|
||||
const sourceCfg = settings.musicSources.find(s => s.id === song.externalSourceId);
|
||||
if (!sourceCfg) {
|
||||
console.warn('外部音源配置未找到:', song.externalSourceId);
|
||||
return { url: undefined };
|
||||
}
|
||||
try {
|
||||
const json = await MusicApi.getExternalSongUrl({
|
||||
source: sourceCfg,
|
||||
songId: song.externalId,
|
||||
quality: settings.audioQuality,
|
||||
songName: song.name,
|
||||
artist: song.ar?.map(a => a.name).join(' / ') || '',
|
||||
allSources: settings.enabledMusicSources,
|
||||
});
|
||||
const data = JSON.parse(json);
|
||||
return {
|
||||
url: data.url,
|
||||
source: data.source_label || sourceCfg.label,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('外部音源获取URL失败:', e);
|
||||
return { url: undefined };
|
||||
}
|
||||
}
|
||||
// 网易云流程(含 fallback)
|
||||
const jsonStr = await MusicApi.getSongUrl({
|
||||
id: Number(song.id),
|
||||
level: settings.audioQuality,
|
||||
fm_mode: fmMode,
|
||||
sources: settings.enabledMusicSources,
|
||||
});
|
||||
const data = JSON.parse(jsonStr);
|
||||
return {
|
||||
url: data.url,
|
||||
source: data.source,
|
||||
fee: data.fee,
|
||||
freeTrialInfo: data.freeTrialInfo,
|
||||
};
|
||||
}
|
||||
|
||||
async function playCurrent() {
|
||||
const seq = ++_playSeq;
|
||||
_switchingSong = true;
|
||||
switching.value = true;
|
||||
clearTick();
|
||||
reportScrobble();
|
||||
|
||||
if (!(await stopAndWaitStopped(seq))) return;
|
||||
|
||||
const song = queue.value[currentIndex.value];
|
||||
if (!song?.id) {
|
||||
console.error('无效的歌曲数据', song);
|
||||
_switchingSong = false;
|
||||
switching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -352,10 +432,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
emitPlaybackState();
|
||||
onPlaybackStarted(song, false);
|
||||
} else {
|
||||
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||
watchForLatePlayback(seq, song);
|
||||
@@ -363,10 +440,11 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality });
|
||||
const data = await loadSongUrl(song, false);
|
||||
if (seq !== _playSeq) return;
|
||||
const data = JSON.parse(jsonStr);
|
||||
const url: string | undefined = data.url;
|
||||
// 记录实际播放音源(netease / 波点音乐 / 酷狗音乐 / ...)
|
||||
if (data.source) song.source = data.source;
|
||||
|
||||
if (!url) {
|
||||
// url 为空:可能是数字专辑/付费歌曲,根据 fee 字段判断
|
||||
@@ -383,7 +461,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
vipSkipCount = 0;
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
switching.value = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -397,7 +475,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
vipSkipCount = 0;
|
||||
return;
|
||||
}
|
||||
_switchingSong = false;
|
||||
switching.value = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -407,11 +485,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const started = await waitForPlaybackStart();
|
||||
if (seq !== _playSeq) return;
|
||||
if (started) {
|
||||
playing.value = true;
|
||||
startTick();
|
||||
addRecent(song);
|
||||
vipSkipCount = 0;
|
||||
emitPlaybackState();
|
||||
onPlaybackStarted(song, false);
|
||||
} else {
|
||||
playing.value = false;
|
||||
showToast('播放启动超时,仍在尝试加载…', 'info');
|
||||
@@ -424,7 +498,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
showToast('播放失败,请稍后重试', 'error');
|
||||
} finally {
|
||||
if (seq === _playSeq) {
|
||||
_switchingSong = false;
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,6 +535,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
function startTick() {
|
||||
clearTick();
|
||||
const gen = tickGeneration;
|
||||
let seekGuard = false;
|
||||
onSeekStart = () => { seekGuard = true; };
|
||||
let syncCounter = 1;
|
||||
@@ -468,6 +543,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
let backendFrozen = false;
|
||||
let stateSyncCounter = 0;
|
||||
setTick(setInterval(async () => {
|
||||
if (gen !== tickGeneration) return;
|
||||
if (playing.value && duration.value > 0) {
|
||||
if (seekGuard) return;
|
||||
syncCounter++;
|
||||
@@ -476,6 +552,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
syncCounter = 0;
|
||||
try {
|
||||
const pos = await AudioApi.getAudioPosition();
|
||||
if (gen !== tickGeneration) return;
|
||||
if (pos >= currentTime.value - 0.5) {
|
||||
currentTime.value = pos;
|
||||
}
|
||||
@@ -494,6 +571,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
stateSyncCounter = 0;
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (gen !== tickGeneration) return;
|
||||
if (backendPlaying !== playing.value) {
|
||||
playing.value = backendPlaying;
|
||||
}
|
||||
@@ -515,7 +593,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (_switchingSong) return;
|
||||
if (switching.value) return;
|
||||
try {
|
||||
const backendPlaying = await AudioApi.isAudioPlaying();
|
||||
if (backendPlaying !== playing.value) {
|
||||
@@ -546,7 +624,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
function prev() {
|
||||
if (isFmMode.value) return;
|
||||
if (_switchingSong) return;
|
||||
if (switching.value) return;
|
||||
if (queue.value.length === 0) return;
|
||||
currentIndex.value = (currentIndex.value - 1 + queue.value.length) % queue.value.length;
|
||||
playCurrent();
|
||||
@@ -588,7 +666,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
async function seek(time: number) {
|
||||
if (_switchingSong) return;
|
||||
if (switching.value) return;
|
||||
try {
|
||||
currentTime.value = time;
|
||||
if (onSeekStart) onSeekStart();
|
||||
@@ -620,7 +698,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_switchingSong) return;
|
||||
if (switching.value) return;
|
||||
if (queue.value.length === 0) return;
|
||||
let nextIndex: number;
|
||||
switch (playMode.value) {
|
||||
@@ -708,7 +786,7 @@ async function nextFm() {
|
||||
}
|
||||
|
||||
listen('audio-ended', () => {
|
||||
if (_switchingSong) return;
|
||||
if (switching.value) return;
|
||||
const player = usePlayerStore();
|
||||
player.clearTick();
|
||||
player.reportScrobble();
|
||||
@@ -775,6 +853,7 @@ watch(playing, (val) => {
|
||||
return {
|
||||
currentSong,
|
||||
playing,
|
||||
switching,
|
||||
currentTime,
|
||||
duration,
|
||||
queue,
|
||||
|
||||
+142
-13
@@ -20,6 +20,57 @@ export const closeActionLabels: Record<CloseAction, string> = {
|
||||
exit: '直接退出',
|
||||
};
|
||||
|
||||
/**
|
||||
* 音源类型
|
||||
* - builtin: 内置源(bodian/kugou/kuwo/bilibili),由后端实现
|
||||
* - custom: 自定义 HTTP API 源,由用户配置模板 URL + JSON 路径
|
||||
*/
|
||||
export type MusicSourceKind = 'builtin' | 'custom';
|
||||
|
||||
/**
|
||||
* 音源配置(前端持久化、调用后端命令都用这个结构,与后端 SourceConfig 对齐)
|
||||
*/
|
||||
export interface MusicSourceConfig {
|
||||
/** 唯一 ID(builtin: 'bodian'/'kugou'/...;custom: 'custom-xxxx') */
|
||||
id: string;
|
||||
/** 显示名称 */
|
||||
label: string;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 类型 */
|
||||
kind: MusicSourceKind;
|
||||
|
||||
// ===== 自定义 HTTP API 源字段(kind === 'custom' 时使用)=====
|
||||
/** 搜索 URL 模板,支持 {keyword} {page} {limit} 占位符 */
|
||||
searchUrl?: string;
|
||||
/** 获取播放 URL 接口,支持 {id} {quality} 占位符 */
|
||||
urlApi?: string;
|
||||
/** JSON 路径:搜索结果数组(如 "data.songs") */
|
||||
searchPath?: string;
|
||||
/** JSON 路径:URL 字符串字段(如 "data.url") */
|
||||
urlPath?: string;
|
||||
/** 字段映射(自定义源 JSON 字段名) */
|
||||
idField?: string;
|
||||
nameField?: string;
|
||||
artistField?: string;
|
||||
albumField?: string;
|
||||
durationField?: string;
|
||||
picField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置音源默认配置(顺序即默认优先级,波点为首选,参考 SPlayer)
|
||||
* kuwo 使用 kwDES + mobi.s 接口,能绕过 VIP 限制播放完整歌曲
|
||||
*/
|
||||
export const builtinMusicSourceDefaults: MusicSourceConfig[] = [
|
||||
{ id: 'bodian', label: '波点音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kugou', label: '酷狗音乐', enabled: true, kind: 'builtin' },
|
||||
{ id: 'kuwo', label: '酷我音乐', enabled: true, kind: 'builtin' },
|
||||
];
|
||||
|
||||
/** 内置音源 id 集合,用于判断是否可删除 */
|
||||
export const builtinSourceIds = new Set(builtinMusicSourceDefaults.map(s => s.id));
|
||||
|
||||
export interface ShortcutBinding {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -66,11 +117,15 @@ interface SettingsData {
|
||||
shortcuts: Record<string, ShortcutBinding>;
|
||||
outputDevice: string | null;
|
||||
volume: number;
|
||||
/** 第三方音源总开关 */
|
||||
musicSourceEnabled: boolean;
|
||||
/** 音源列表(有序:搜索聚合顺序 / fallback 优先级;含 builtin + custom) */
|
||||
musicSources: MusicSourceConfig[];
|
||||
schemaVersion: number;
|
||||
}
|
||||
|
||||
function loadSettings(): SettingsData {
|
||||
const defaults: SettingsData = {
|
||||
function defaultSettings(): SettingsData {
|
||||
return {
|
||||
audioQuality: 'standard',
|
||||
downloadPath: '',
|
||||
localMusicFolders: [],
|
||||
@@ -80,15 +135,20 @@ function loadSettings(): SettingsData {
|
||||
shortcuts: { ...defaultShortcuts },
|
||||
outputDevice: null,
|
||||
volume: 100,
|
||||
musicSourceEnabled: true,
|
||||
musicSources: builtinMusicSourceDefaults.map(s => ({ ...s })),
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
function loadSettings(): SettingsData {
|
||||
const defaults = defaultSettings();
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('app_settings');
|
||||
if (!raw) return defaults;
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
// 执行迁移(theme→skin、wallpaper→customSkin、localMusicPaths→localMusicFolders)
|
||||
const migrated = migrateSettings(parsed);
|
||||
|
||||
return {
|
||||
@@ -101,6 +161,8 @@ function loadSettings(): SettingsData {
|
||||
shortcuts: { ...defaultShortcuts, ...(migrated.shortcuts || {}) },
|
||||
outputDevice: migrated.outputDevice ?? null,
|
||||
volume: typeof migrated.volume === 'number' ? migrated.volume : defaults.volume,
|
||||
musicSourceEnabled: typeof migrated.musicSourceEnabled === 'boolean' ? migrated.musicSourceEnabled : defaults.musicSourceEnabled,
|
||||
musicSources: Array.isArray(migrated.musicSources) ? migrated.musicSources : defaults.musicSources,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
} catch { /* 忽略 */ }
|
||||
@@ -119,17 +181,16 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
|
||||
const outputDevice = ref<string | null>(saved.outputDevice);
|
||||
const volume = ref<number>(saved.volume);
|
||||
const musicSourceEnabled = ref<boolean>(saved.musicSourceEnabled);
|
||||
const musicSources = ref<MusicSourceConfig[]>(saved.musicSources);
|
||||
|
||||
/** 当前皮肤是否为预设皮肤 */
|
||||
const isPreset = computed(() => isPresetSkinId(skin.value));
|
||||
|
||||
/** 获取当前自定义皮肤 */
|
||||
const currentCustomSkin = computed(() => {
|
||||
if (isPreset.value) return null;
|
||||
return customSkins.value.find(s => s.id === skin.value) || null;
|
||||
});
|
||||
|
||||
/** 获取当前皮肤的预览色 */
|
||||
const skinPreview = computed(() => {
|
||||
if (isPreset.value) {
|
||||
return getPresetSkin(skin.value)?.preview || '#3b82f6';
|
||||
@@ -137,7 +198,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
return currentCustomSkin.value?.preview || '#3b82f6';
|
||||
});
|
||||
|
||||
/** 获取当前皮肤的完整颜色集(响应式) */
|
||||
const currentColors = computed<SkinColors>(() => {
|
||||
if (isPreset.value) {
|
||||
return getPresetSkin(skin.value)!.colors;
|
||||
@@ -149,7 +209,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
return custom.colors;
|
||||
});
|
||||
|
||||
/** 获取当前皮肤的壁纸信息 */
|
||||
const currentWallpaper = computed(() => {
|
||||
if (isPreset.value) return { path: '', blur: 10, opacity: 0.3 };
|
||||
const custom = currentCustomSkin.value;
|
||||
@@ -173,7 +232,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
customSkins.value = customSkins.value.map(s =>
|
||||
s.id === id ? { ...s, ...updates } : s
|
||||
);
|
||||
// 如果正在使用该皮肤,立即刷新 CSS 变量
|
||||
if (skin.value === id) {
|
||||
applySkin();
|
||||
}
|
||||
@@ -186,7 +244,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用当前皮肤到 DOM(统一通过 JS 设置 CSS 变量) */
|
||||
function applySkin() {
|
||||
let colors: SkinColors;
|
||||
if (isPreset.value) {
|
||||
@@ -195,7 +252,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
} else {
|
||||
const custom = currentCustomSkin.value;
|
||||
if (!custom) {
|
||||
// 找不到自定义皮肤,回退到默认
|
||||
skin.value = 'dark-blue';
|
||||
colors = getPresetSkin('dark-blue')!.colors;
|
||||
} else {
|
||||
@@ -229,7 +285,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
);
|
||||
}
|
||||
|
||||
/** 已启用的扫描路径 */
|
||||
const enabledMusicPaths = computed(() =>
|
||||
localMusicFolders.value.filter(f => f.enabled).map(f => f.path)
|
||||
);
|
||||
@@ -250,6 +305,65 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
outputDevice.value = device;
|
||||
}
|
||||
|
||||
// ===== 音源管理 =====
|
||||
|
||||
function setMusicSourceEnabled(v: boolean) {
|
||||
musicSourceEnabled.value = v;
|
||||
}
|
||||
|
||||
/** 切换某个音源的启用状态(保持原顺序) */
|
||||
function toggleMusicSource(id: string) {
|
||||
musicSources.value = musicSources.value.map(s =>
|
||||
s.id === id ? { ...s, enabled: !s.enabled } : s
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置某个音源的 enabled 为指定值 */
|
||||
function setMusicSourceEnabledById(id: string, enabled: boolean) {
|
||||
musicSources.value = musicSources.value.map(s =>
|
||||
s.id === id ? { ...s, enabled } : s
|
||||
);
|
||||
}
|
||||
|
||||
/** 调整音源顺序(拖拽排序用):把 from 移到 to 位置 */
|
||||
function moveMusicSource(from: number, to: number) {
|
||||
if (from < 0 || to < 0 || from >= musicSources.value.length) return;
|
||||
const arr = [...musicSources.value];
|
||||
const [item] = arr.splice(from, 1);
|
||||
arr.splice(Math.min(to, arr.length), 0, item);
|
||||
musicSources.value = arr;
|
||||
}
|
||||
|
||||
/** 添加自定义音源 */
|
||||
function addCustomMusicSource(cfg: MusicSourceConfig) {
|
||||
if (musicSources.value.some(s => s.id === cfg.id)) return;
|
||||
musicSources.value = [...musicSources.value, cfg];
|
||||
}
|
||||
|
||||
/** 更新自定义音源配置 */
|
||||
function updateMusicSource(id: string, updates: Partial<MusicSourceConfig>) {
|
||||
musicSources.value = musicSources.value.map(s =>
|
||||
s.id === id ? { ...s, ...updates, id: s.id, kind: s.kind } : s
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除音源(内置源不可删除,只能禁用) */
|
||||
function removeMusicSource(id: string) {
|
||||
if (builtinSourceIds.has(id)) return;
|
||||
musicSources.value = musicSources.value.filter(s => s.id !== id);
|
||||
}
|
||||
|
||||
/** 恢复默认音源列表(保留用户已有的自定义源) */
|
||||
function resetMusicSourcesToDefault() {
|
||||
const customs = musicSources.value.filter(s => s.kind === 'custom');
|
||||
musicSources.value = [...builtinMusicSourceDefaults.map(s => ({ ...s })), ...customs];
|
||||
}
|
||||
|
||||
/** 当前已启用的音源列表(含总开关检查,传给后端搜索/降级用) */
|
||||
const enabledMusicSources = computed(() =>
|
||||
musicSourceEnabled.value ? musicSources.value.filter(s => s.enabled) : []
|
||||
);
|
||||
|
||||
function resetAll() {
|
||||
audioQuality.value = 'standard';
|
||||
downloadPath.value = '';
|
||||
@@ -260,9 +374,11 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
shortcuts.value = { ...defaultShortcuts };
|
||||
outputDevice.value = null;
|
||||
volume.value = 100;
|
||||
musicSourceEnabled.value = true;
|
||||
musicSources.value = builtinMusicSourceDefaults.map(s => ({ ...s }));
|
||||
}
|
||||
|
||||
watch([audioQuality, downloadPath, localMusicFolders, skin, customSkins, closeAction, shortcuts, outputDevice, volume], () => {
|
||||
watch([audioQuality, downloadPath, localMusicFolders, skin, customSkins, closeAction, shortcuts, outputDevice, volume, musicSourceEnabled, musicSources], () => {
|
||||
const data: SettingsData = {
|
||||
audioQuality: audioQuality.value,
|
||||
downloadPath: downloadPath.value,
|
||||
@@ -273,6 +389,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
shortcuts: shortcuts.value,
|
||||
outputDevice: outputDevice.value,
|
||||
volume: volume.value,
|
||||
musicSourceEnabled: musicSourceEnabled.value,
|
||||
musicSources: musicSources.value,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
localStorage.setItem('app_settings', JSON.stringify(data));
|
||||
@@ -294,6 +412,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
shortcuts,
|
||||
outputDevice,
|
||||
volume,
|
||||
musicSourceEnabled,
|
||||
musicSources,
|
||||
enabledMusicSources,
|
||||
setSkin,
|
||||
addCustomSkin,
|
||||
updateCustomSkin,
|
||||
@@ -306,6 +427,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
toggleLocalMusicFolder,
|
||||
setCloseAction,
|
||||
setOutputDevice,
|
||||
setMusicSourceEnabled,
|
||||
toggleMusicSource,
|
||||
setMusicSourceEnabledById,
|
||||
moveMusicSource,
|
||||
addCustomMusicSource,
|
||||
updateMusicSource,
|
||||
removeMusicSource,
|
||||
resetMusicSourcesToDefault,
|
||||
setShortcut,
|
||||
resetShortcuts,
|
||||
resetAll,
|
||||
|
||||
+14
-2
@@ -6,7 +6,7 @@
|
||||
* 歌曲信息
|
||||
*/
|
||||
export interface Song {
|
||||
/** 歌曲 ID */
|
||||
/** 歌曲 ID(网易云歌曲 ID;外部音源歌曲用占位负数) */
|
||||
id: number;
|
||||
/** 歌曲名称 */
|
||||
name: string;
|
||||
@@ -20,8 +20,20 @@ export interface Song {
|
||||
localPath?: string;
|
||||
/** 推荐算法标识(私人 FM 推荐) */
|
||||
alg?: string;
|
||||
/** 音频码率 */
|
||||
/** 音频码率(bps,如 320000 表示 320kbps) */
|
||||
br?: number;
|
||||
/** 网易云付费标志:0=免费 1=VIP 4=数字专辑 8=低音质免费试听 */
|
||||
fee?: number;
|
||||
/** 当前可播放的最高码率(bps),来自 privilege.maxbr */
|
||||
maxbr?: number;
|
||||
/** 实际播放音源显示名(如 "netease"、"波点音乐"),由后端 fallback 返回 */
|
||||
source?: string;
|
||||
/** 外部音源歌曲专用:源内 ID(字符串,如 kuwo rid / kugou hash) */
|
||||
externalId?: string;
|
||||
/** 外部音源歌曲专用:源 ID("bodian" / "kugou" / "kuwo" / "bilibili" / "custom-xxx") */
|
||||
externalSourceId?: string;
|
||||
/** 外部音源歌曲专用:来源显示名("波点音乐" / ...) */
|
||||
sourceLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,9 @@ export function normalizeSong(song: any): Song {
|
||||
const ar = rawAr.filter((a: any) => a.name);
|
||||
let dt = song.dt || song.duration || 0;
|
||||
if (dt < 100 || dt > 7200000) dt = 0;
|
||||
// fee / maxbr 可能直接在 song 上,也可能在 song.privilege 里
|
||||
// (/cloudsearch 附在 song 上,/artist/songs / /playlist/detail 等可能在单独 privileges 数组)
|
||||
const priv = song.privilege || {};
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
@@ -21,9 +24,107 @@ export function normalizeSong(song: any): Song {
|
||||
localPath: song.localPath,
|
||||
alg: song.alg || undefined,
|
||||
br: song.br || undefined,
|
||||
fee: typeof song.fee === 'number' ? song.fee : (typeof priv.fee === 'number' ? priv.fee : undefined),
|
||||
maxbr: priv.maxbr || song.maxbr || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将网易云单独的 privileges 数组合并到 songs 中(给每个 song 附上对应 privilege)
|
||||
* 用于 /artist/songs / /playlist/detail 等接口,privilege 不在 song 上而是单独数组
|
||||
*/
|
||||
export function attachPrivileges(songs: any[], privileges?: any[]): any[] {
|
||||
if (!Array.isArray(privileges) || !privileges.length) return songs;
|
||||
const map = new Map<number, any>();
|
||||
for (const p of privileges) {
|
||||
if (p?.id != null) map.set(p.id, p);
|
||||
}
|
||||
return songs.map(s => (s && !s.privilege && s.id != null && map.has(s.id))
|
||||
? { ...s, privilege: map.get(s.id) }
|
||||
: s);
|
||||
}
|
||||
|
||||
/** 将网易云单独的 privileges 数组合并到 songs 并 normalize(5 个详情页共用) */
|
||||
export function normalizeSongsWithPrivileges(songs: any[], privileges?: any[]): Song[] {
|
||||
return attachPrivileges(songs, privileges).map(normalizeSong);
|
||||
}
|
||||
|
||||
/** 歌曲标签类型 */
|
||||
export type SongTagKind = 'vip' | 'album' | 'hires' | 'sq' | 'hq' | 'original' | 'cover' | 'live' | 'accompaniment' | 'dj';
|
||||
|
||||
export interface SongTag {
|
||||
/** 标签文案 */
|
||||
label: string;
|
||||
/** 标签种类,便于按需样式覆盖 */
|
||||
kind: SongTagKind;
|
||||
/** Tailwind class 字符串(背景 + 文字色) */
|
||||
class: string;
|
||||
}
|
||||
|
||||
// getSongTags 版本标签正则(模块级常量,避免每次调用重建)
|
||||
// 注意:JS 正则的 \b 只对 ASCII 字符生效,所以中文字符周围不能用 \b
|
||||
const RE_COVER = /翻唱|\bcover\b/i;
|
||||
const RE_ORIGINAL = /原唱/;
|
||||
const RE_LIVE = /\blive\b|现场|演唱会|巡演|concert/i;
|
||||
const ACCOMPANIMENT_RE = /伴奏|karaoke|\binst\b/i;
|
||||
const REMIX_RE = /\b(?:dj|remix)\b|混音|电音|dj版|混音版/i;
|
||||
|
||||
/**
|
||||
* 计算歌曲应显示的标签
|
||||
* 顺序:付费 → 音质 → 版本(原唱/翻唱/Live/伴奏/DJ)
|
||||
* 参考 SPlayer 的标签展示
|
||||
*/
|
||||
export function getSongTags(song: Song): SongTag[] {
|
||||
const tags: SongTag[] = [];
|
||||
const name = song.name || '';
|
||||
const lower = name.toLowerCase();
|
||||
|
||||
// 1) 付费标签(网易云 fee 字段)
|
||||
// 注意:fee=8 实际表示「免费低音质版」,大量免费歌曲都是 8,不能当试听
|
||||
// - fee=1: VIP 歌曲
|
||||
// - fee=4: 数字专辑
|
||||
// - fee=0/8: 免费歌曲,不显示付费标签
|
||||
if (song.fee === 1) {
|
||||
tags.push({ label: 'VIP', kind: 'vip', class: 'bg-danger/15 text-danger' });
|
||||
} else if (song.fee === 4) {
|
||||
tags.push({ label: '专辑', kind: 'album', class: 'bg-warning/15 text-warning' });
|
||||
}
|
||||
|
||||
// 2) 音质标签:优先用 maxbr(最大可达码率),fallback 到 br(实际码率)
|
||||
const qualityBr = song.maxbr || song.br || 0;
|
||||
if (qualityBr >= 1999000) {
|
||||
tags.push({ label: 'Hi-Res', kind: 'hires', class: 'bg-info/15 text-info' });
|
||||
} else if (qualityBr >= 999000) {
|
||||
tags.push({ label: 'SQ', kind: 'sq', class: 'bg-warning/15 text-warning' });
|
||||
} else if (qualityBr >= 320000) {
|
||||
tags.push({ label: 'HQ', kind: 'hq', class: 'bg-accent/15 text-accent-text' });
|
||||
}
|
||||
|
||||
// 3) 版本标签(从歌名推断,互斥:原唱/翻唱 二选一)
|
||||
const isCover = RE_COVER.test(lower);
|
||||
const isOriginal = RE_ORIGINAL.test(name);
|
||||
const isLive = RE_LIVE.test(lower);
|
||||
const isAccompaniment = ACCOMPANIMENT_RE.test(lower);
|
||||
const isDj = REMIX_RE.test(lower);
|
||||
|
||||
if (isCover) {
|
||||
tags.push({ label: '翻唱', kind: 'cover', class: 'bg-info/15 text-info' });
|
||||
} else if (isOriginal) {
|
||||
tags.push({ label: '原唱', kind: 'original', class: 'bg-accent/15 text-accent-text' });
|
||||
}
|
||||
if (isLive) {
|
||||
tags.push({ label: 'Live', kind: 'live', class: 'bg-accent/15 text-accent-text' });
|
||||
}
|
||||
if (isAccompaniment) {
|
||||
tags.push({ label: '伴奏', kind: 'accompaniment', class: 'bg-muted text-content-2' });
|
||||
}
|
||||
if (isDj) {
|
||||
tags.push({ label: 'DJ', kind: 'dj', class: 'bg-warning/15 text-warning' });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getCoverUrl(song: Song | null, sizeParam = ''): string {
|
||||
if (!song) return '';
|
||||
const raw = song.al?.picUrl || '';
|
||||
|
||||
@@ -186,7 +186,7 @@ import { ref, computed, onMounted, watch, onActivated } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
@@ -274,7 +274,7 @@ async function fetchAlbum(id: number, force = false) {
|
||||
const data = JSON.parse(jsonStr);
|
||||
album.value = data.album;
|
||||
albumLoading.value = false;
|
||||
songs.value = (data.songs || []).map(normalizeSong);
|
||||
songs.value = normalizeSongsWithPrivileges(data.songs || [], data.privileges);
|
||||
songsLoading.value = false;
|
||||
pageCacheSet(cacheKey, { album: album.value, songs: songs.value });
|
||||
} catch (e) {
|
||||
|
||||
@@ -177,7 +177,7 @@ import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { checkOverflow } from '../utils/dom';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
@@ -284,7 +284,8 @@ async function fetchArtist(id: number, force = false) {
|
||||
try {
|
||||
const jsonStr = await MusicApi.artistSongs({ id, order: 'hot', limit: 50, offset: 0 });
|
||||
const data = JSON.parse(jsonStr);
|
||||
songs.value = (data.songs || []).map(normalizeSong);
|
||||
// /artist/songs 返回的 privilege 在单独的 privileges 数组里,需合并到 song 上
|
||||
songs.value = normalizeSongsWithPrivileges(data.songs || [], data.privileges);
|
||||
} catch { /* 忽略 */ }
|
||||
finally { songsLoading.value = false; }
|
||||
};
|
||||
|
||||
@@ -45,11 +45,13 @@ import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
|
||||
defineOptions({ name: 'DailySongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
@@ -69,7 +71,7 @@ async function loadData(force = false) {
|
||||
loadError.value = false;
|
||||
const jsonStr: string = await MusicApi.recommendSongs();
|
||||
const data = JSON.parse(jsonStr);
|
||||
songs.value = (data.data?.dailySongs || []).map(normalizeSong);
|
||||
songs.value = normalizeSongsWithPrivileges(data.data?.dailySongs || [], data.data?.privileges);
|
||||
pageCacheSet('dailySongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error('获取每日推荐失败', e);
|
||||
@@ -89,7 +91,7 @@ onActivated(() => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => navigator.onLine, (val, old) => {
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && songs.value.length === 0) {
|
||||
pageCacheInvalidate('dailySongs');
|
||||
loadData();
|
||||
|
||||
+74
-152
@@ -1,56 +1,5 @@
|
||||
<template>
|
||||
<div class="p-8 text-content" @click="showSuggestions = false">
|
||||
<div class="relative mb-6" @click.stop>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<IconSearch class="absolute left-3.5 top-1/2 -translate-y-1/2 text-content-3 w-[18px] h-[18px]" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="keyword"
|
||||
@input="onInputChange"
|
||||
@keydown.enter="handleSearch"
|
||||
@focus="onInputFocus"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
class="w-full rounded-xl bg-muted pl-10 pr-10 py-3 text-content placeholder-content-3 outline-none focus:bg-subtle focus:ring-1 focus:ring-accent/30 transition"
|
||||
/>
|
||||
<button v-if="keyword" @click="clearSearch" class="absolute right-3 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSuggestions && !hasSearched"
|
||||
class="absolute z-30 left-0 right-0 top-full mt-2 bg-surface border border-line-2 rounded-xl shadow-xl overflow-hidden max-h-[60vh] overflow-y-auto">
|
||||
<div v-if="suggestions.length" class="p-2">
|
||||
<p class="text-xs text-content-3 px-3 py-1.5">搜索建议</p>
|
||||
<button v-for="s in suggestions" :key="s" @click="searchTag(s)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconSearch style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ s }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchHistory.length && !suggestions.length" class="p-2">
|
||||
<div class="flex items-center justify-between px-3 py-1.5">
|
||||
<p class="text-xs text-content-3">搜索历史</p>
|
||||
<button @click.stop="clearHistory" class="text-xs text-content-3 hover:text-danger transition">清空</button>
|
||||
</div>
|
||||
<button v-for="h in searchHistory" :key="h" @click="searchTag(h)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconHistory style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ h }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="hotTags.length && !suggestions.length && !searchHistory.length" class="p-2">
|
||||
<p class="text-xs text-content-3 px-3 py-1.5">热门搜索</p>
|
||||
<button v-for="tag in hotTags" :key="tag.searchWord" @click="searchTag(tag.searchWord)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
|
||||
<IconClock style="font-size: 14px" class="text-content-3 flex-shrink-0" />
|
||||
<span>{{ tag.searchWord }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8 text-content">
|
||||
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -143,34 +92,48 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DiscoverView' });
|
||||
|
||||
import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue';
|
||||
import { ref, computed, onMounted, onActivated, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { MusicApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconSearch from '~icons/lucide/search';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconHistory from '~icons/lucide/history';
|
||||
import IconClock from '~icons/lucide/clock';
|
||||
import IconUserRound from '~icons/lucide/user-round';
|
||||
import IconDisc from '~icons/lucide/disc';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const settings = useSettingsStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const keyword = ref('');
|
||||
/** 外部音源歌曲 ID 计数器(生成唯一负数占位 ID) */
|
||||
let externalIdCounter = -1;
|
||||
/** 将后端 ExternalSong 转为前端 Song 结构 */
|
||||
function externalSongToSong(ext: any): Song {
|
||||
const artistStr: string = ext.artist || '';
|
||||
const ar = artistStr
|
||||
? artistStr.split(/[\/,;&|]/).map((name: string) => ({ name: name.trim() })).filter((a: any) => a.name)
|
||||
: [];
|
||||
return {
|
||||
id: externalIdCounter--,
|
||||
name: ext.name || '未知歌曲',
|
||||
ar,
|
||||
al: { picUrl: ext.pic_url || '', name: ext.album || undefined },
|
||||
dt: ext.duration || 0,
|
||||
externalId: String(ext.id || ''),
|
||||
externalSourceId: ext.source || '',
|
||||
sourceLabel: ext.source_label || '',
|
||||
};
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const hotTags = ref<any[]>([]);
|
||||
const suggestions = ref<string[]>([]);
|
||||
const showSuggestions = ref(false);
|
||||
const activeTab = ref(1);
|
||||
const cacheError = ref(false);
|
||||
|
||||
@@ -194,60 +157,6 @@ const tabs = [
|
||||
{ type: 10, label: '专辑' },
|
||||
];
|
||||
|
||||
const HISTORY_KEY = 'search_history';
|
||||
const MAX_HISTORY = 15;
|
||||
|
||||
function loadSearchHistory(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* 忽略 */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveSearchHistory(q: string) {
|
||||
let history = loadSearchHistory();
|
||||
history = history.filter(h => h !== q);
|
||||
history.unshift(q);
|
||||
if (history.length > MAX_HISTORY) history = history.slice(0, MAX_HISTORY);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
const searchHistory = ref<string[]>(loadSearchHistory());
|
||||
|
||||
function clearHistory() {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onInputChange() {
|
||||
if (suggestTimer) clearTimeout(suggestTimer);
|
||||
if (!keyword.value.trim()) {
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = true;
|
||||
return;
|
||||
}
|
||||
suggestTimer = setTimeout(async () => {
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.searchSuggest(keyword.value.trim());
|
||||
const data = JSON.parse(jsonStr);
|
||||
const all = data.result?.allMatch || [];
|
||||
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
|
||||
showSuggestions.value = true;
|
||||
} catch {
|
||||
suggestions.value = [];
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onInputFocus() {
|
||||
if (!hasSearched.value) {
|
||||
showSuggestions.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotTags() {
|
||||
const cached = pageCacheGet('discover_hotTags');
|
||||
if (cached) {
|
||||
@@ -264,24 +173,19 @@ async function loadHotTags() {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHotTags();
|
||||
const q = route.query.q as string;
|
||||
if (q) {
|
||||
keyword.value = q;
|
||||
await handleSearch();
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
|
||||
const q = route.query.q as string;
|
||||
if (q && q !== lastSearchKeyword.value) {
|
||||
keyword.value = q;
|
||||
await handleSearch();
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
});
|
||||
|
||||
// 监听路由搜索参数变化(immediate 处理首次加载,_t 时间戳确保相同关键词也能重复搜索)
|
||||
watch(() => [route.query.q, route.query._t], async ([q]) => {
|
||||
if (q && typeof q === 'string') {
|
||||
await handleSearch(q);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && hotTags.value.length === 0) {
|
||||
pageCacheInvalidate('discover_hotTags');
|
||||
@@ -289,18 +193,12 @@ watch(isOnline, (val, old) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
const q = keyword.value.trim();
|
||||
if (!q) return;
|
||||
showSuggestions.value = false;
|
||||
async function handleSearch(q: string) {
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
hasSearched.value = true;
|
||||
cacheError.value = false;
|
||||
saveSearchHistory(q);
|
||||
searchHistory.value = loadSearchHistory();
|
||||
|
||||
if (q === lastSearchKeyword.value && resultCache.value.size > 0) return;
|
||||
|
||||
lastSearchKeyword.value = q;
|
||||
lastSearchKeyword.value = query;
|
||||
resultCache.value.clear();
|
||||
|
||||
await Promise.all([
|
||||
@@ -311,21 +209,56 @@ async function handleSearch() {
|
||||
}
|
||||
|
||||
async function fetchTabResults(type: number) {
|
||||
const dedupeKey = (s: Song) => `${s.name.toLowerCase()}|${(s.ar?.[0]?.name || '').toLowerCase()}`;
|
||||
const entry = resultCache.value.get(type);
|
||||
if (entry && !entry.dirty) return;
|
||||
|
||||
loading.value = true;
|
||||
cacheError.value = false;
|
||||
try {
|
||||
const jsonStr: string = await MusicApi.cloudsearch({
|
||||
// 网易云搜索
|
||||
const cloudsearchPromise = MusicApi.cloudsearch({
|
||||
keyword: lastSearchKeyword.value, searchType: type, limit: 30
|
||||
});
|
||||
|
||||
// 多源聚合搜索(仅歌曲 tab,且音源补充开启且有启用源时)
|
||||
const canMultiSearch = type === 1
|
||||
&& settings.musicSourceEnabled
|
||||
&& settings.enabledMusicSources.length > 0;
|
||||
const multiSearchPromise = canMultiSearch
|
||||
? MusicApi.searchSongsMulti({
|
||||
keyword: lastSearchKeyword.value,
|
||||
sources: settings.enabledMusicSources,
|
||||
limit: 20,
|
||||
}).catch(() => '[]')
|
||||
: Promise.resolve('[]');
|
||||
|
||||
const [jsonStr, externalJsonStr] = await Promise.all([cloudsearchPromise, multiSearchPromise]);
|
||||
|
||||
const data = JSON.parse(jsonStr);
|
||||
const result = data.result || {};
|
||||
|
||||
let items: any[] = [];
|
||||
if (type === 1) {
|
||||
items = (result.songs || []).map(normalizeSong);
|
||||
const neteaseSongs: Song[] = (result.songs || []).map(normalizeSong);
|
||||
// 外部音源结果(如 QQ 音乐独占歌曲,通过 bodian/kugou/kuwo 等源搜到)
|
||||
const externalSongs: Song[] = (JSON.parse(externalJsonStr) || []).map(externalSongToSong);
|
||||
// 去重1:外部源中与网易云「歌名+歌手」都匹配的歌曲跳过,保留网易云版本
|
||||
// 只按歌名匹配会误杀——例如网易云有别的「定玄」,会把酷我的原版也过滤掉
|
||||
const neteaseKeys = new Set(neteaseSongs.map(dedupeKey));
|
||||
const externalFiltered = externalSongs.filter(s => {
|
||||
const key = dedupeKey(s);
|
||||
return !neteaseKeys.has(key);
|
||||
});
|
||||
// 去重2:外部源之间也可能搜到同一首(如 kuwo 和 bodian 都有),保留第一个
|
||||
const seenExternal = new Set<string>();
|
||||
const dedupedExternal = externalFiltered.filter(s => {
|
||||
const key = dedupeKey(s);
|
||||
if (seenExternal.has(key)) return false;
|
||||
seenExternal.add(key);
|
||||
return true;
|
||||
});
|
||||
items = [...neteaseSongs, ...dedupedExternal];
|
||||
} else if (type === 100) {
|
||||
items = result.artists || [];
|
||||
} else if (type === 10) {
|
||||
@@ -353,18 +286,7 @@ async function switchTab(type: number) {
|
||||
}
|
||||
|
||||
function searchTag(tag: string) {
|
||||
keyword.value = tag;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
keyword.value = '';
|
||||
hasSearched.value = false;
|
||||
resultCache.value.clear();
|
||||
lastSearchKeyword.value = '';
|
||||
cacheError.value = false;
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = true;
|
||||
nextTick(() => searchInput.value?.focus());
|
||||
// 通过路由跳转触发搜索(带时间戳确保相同关键词也能重复搜索)
|
||||
router.push({ name: 'discover', query: { q: tag, _t: String(Date.now()) } });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,14 +50,16 @@ import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
defineOptions({ name: 'FavoriteSongsView' });
|
||||
|
||||
const player = usePlayerStore();
|
||||
const userStore = useUserStore();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
@@ -89,7 +91,7 @@ async function loadData(force = false) {
|
||||
const likePlaylistId = created[0].id;
|
||||
const trackJson: string = await MusicApi.playlistTrackAll(likePlaylistId);
|
||||
const trackData = JSON.parse(trackJson);
|
||||
songs.value = (trackData.songs || []).map(normalizeSong);
|
||||
songs.value = normalizeSongsWithPrivileges(trackData.songs || [], trackData.privileges);
|
||||
pageCacheSet('favoriteSongs', songs.value);
|
||||
} catch (e) {
|
||||
console.error('加载我喜欢的音乐失败', e);
|
||||
@@ -109,7 +111,7 @@ onActivated(() => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => navigator.onLine, (val, old) => {
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
|
||||
pageCacheInvalidate('favoriteSongs');
|
||||
loadData();
|
||||
|
||||
+10
-2
@@ -128,7 +128,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate text-content">{{ song.name }}</p>
|
||||
<p class="text-sm font-medium truncate text-content flex items-center gap-1">
|
||||
<span class="truncate">{{ song.name }}</span>
|
||||
<span
|
||||
v-for="tag in getSongTags(song)"
|
||||
:key="tag.kind"
|
||||
class="text-[10px] leading-none px-1 py-0.5 rounded flex-shrink-0 font-medium"
|
||||
:class="tag.class"
|
||||
>{{ tag.label }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-content-2 truncate">{{ getArtistDisplay(song) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -216,7 +224,7 @@ import { usePlayerStore } from '../stores/player';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
import { getCoverUrl, getArtistDisplay, normalizeSong, type Song } from '../utils/song';
|
||||
import { getCoverUrl, getArtistDisplay, getSongTags, normalizeSong, type Song } from '../utils/song';
|
||||
import PlaylistCard, { type PlaylistCardData } from '../components/Card/PlaylistCard.vue';
|
||||
import ArtistCard, { type ArtistCardData } from '../components/Card/ArtistCard.vue';
|
||||
import AlbumCard, { type AlbumCardData } from '../components/Card/AlbumCard.vue';
|
||||
|
||||
@@ -166,7 +166,7 @@ import { useUserStore } from '../stores/user';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { formatPlayCount } from '../utils/format';
|
||||
import { checkOverflow } from '../utils/dom';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { normalizeSongsWithPrivileges, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
@@ -236,7 +236,7 @@ async function fetchPlaylist(id: number, force = false) {
|
||||
const data = JSON.parse(jsonStr);
|
||||
playlist.value = data.playlist;
|
||||
playlistLoading.value = false;
|
||||
songs.value = (data.playlist.tracks || []).map(normalizeSong);
|
||||
songs.value = normalizeSongsWithPrivileges(data.playlist.tracks || [], data.privileges);
|
||||
songsLoading.value = false;
|
||||
subscribed.value = data.playlist.subscribed || false;
|
||||
pageCacheSet(cacheKey, { playlist: playlist.value, songs: songs.value, subscribed: subscribed.value });
|
||||
|
||||
@@ -25,6 +25,165 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">音源</h2>
|
||||
<div class="space-y-5">
|
||||
<!-- 总开关 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">音源补充</p>
|
||||
<p class="text-xs text-content-3 mt-0.5">网易云无版权 / VIP 歌曲自动从其他音源搜索播放</p>
|
||||
</div>
|
||||
<button
|
||||
@click="settings.setMusicSourceEnabled(!settings.musicSourceEnabled)"
|
||||
class="relative w-11 h-6 rounded-full transition-colors duration-200"
|
||||
:class="settings.musicSourceEnabled ? 'bg-accent' : 'bg-muted'"
|
||||
:aria-pressed="settings.musicSourceEnabled"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform duration-200"
|
||||
:class="settings.musicSourceEnabled ? 'translate-x-5' : ''"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 音源列表 -->
|
||||
<div v-if="settings.musicSourceEnabled" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-content-3">音源列表(拖拽或上下按钮调整优先级,自定义源可编辑/删除)</p>
|
||||
<button @click="settings.resetMusicSourcesToDefault()" class="text-xs text-content-3 hover:text-accent-text transition">恢复默认</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(src, index) in settings.musicSources"
|
||||
:key="src.id"
|
||||
draggable="true"
|
||||
@dragstart="onSourceDragStart(index)"
|
||||
@dragover.prevent
|
||||
@drop="onSourceDrop(index)"
|
||||
@dragend="onSourceDragEnd"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-subtle border border-line transition"
|
||||
:class="draggedSourceIndex === index ? 'opacity-50 border-accent/40' : 'hover:border-line-2'"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<IconGripVertical class="w-4 h-4 text-content-4 cursor-grab flex-shrink-0" />
|
||||
<span class="text-xs text-content-3 w-5 flex-shrink-0">#{{ index + 1 }}</span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm truncate">{{ src.label }}</span>
|
||||
<span v-if="src.kind === 'custom'" class="text-[10px] px-1.5 py-0.5 rounded bg-accent/15 text-accent-text flex-shrink-0">自定义</span>
|
||||
<span v-if="!src.enabled" class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-content-3 flex-shrink-0">已禁用</span>
|
||||
</div>
|
||||
<p v-if="src.kind === 'custom' && src.searchUrl" class="text-[10px] text-content-4 truncate mt-0.5">{{ src.searchUrl }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<button @click="moveSourceUp(index)" :disabled="index === 0" class="w-6 h-6 flex items-center justify-center rounded text-content-3 hover:text-content hover:bg-muted transition disabled:opacity-30 disabled:cursor-not-allowed" title="上移">
|
||||
<IconChevronUp style="font-size: 14px" />
|
||||
</button>
|
||||
<button @click="moveSourceDown(index)" :disabled="index === settings.musicSources.length - 1" class="w-6 h-6 flex items-center justify-center rounded text-content-3 hover:text-content hover:bg-muted transition disabled:opacity-30 disabled:cursor-not-allowed" title="下移">
|
||||
<IconChevronDown style="font-size: 14px" />
|
||||
</button>
|
||||
<button v-if="src.kind === 'custom'" @click="openSourceEditor(src.id)" class="w-6 h-6 flex items-center justify-center rounded text-content-3 hover:text-accent-text hover:bg-accent/10 transition" title="编辑">
|
||||
<IconPencil style="font-size: 12px" />
|
||||
</button>
|
||||
<button v-if="src.kind === 'custom'" @click="deleteSource(src.id)" class="w-6 h-6 flex items-center justify-center rounded text-content-3 hover:text-danger hover:bg-danger/10 transition" title="删除">
|
||||
<IconTrash2 style="font-size: 12px" />
|
||||
</button>
|
||||
<button
|
||||
@click="settings.toggleMusicSource(src.id)"
|
||||
class="relative w-9 h-5 rounded-full transition-colors duration-200 ml-1"
|
||||
:class="src.enabled ? 'bg-accent' : 'bg-muted'"
|
||||
:aria-pressed="src.enabled"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-200"
|
||||
:class="src.enabled ? 'translate-x-4' : ''"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加自定义音源按钮 -->
|
||||
<button
|
||||
@click="openSourceEditor()"
|
||||
class="w-full flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-line hover:border-accent/40 hover:bg-accent/5 transition text-content-3 hover:text-accent-text"
|
||||
>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
<span class="text-sm">添加自定义音源(HTTP API)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 自定义音源编辑器 -->
|
||||
<div v-if="showSourceEditor" class="p-4 rounded-lg bg-subtle border border-line-2 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium">{{ editingSourceId ? '编辑自定义音源' : '添加自定义音源' }}</p>
|
||||
<button @click="showSourceEditor = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX style="font-size: 16px" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-content-3">支持 {keyword} {page} {limit} {id} {quality} 占位符;JSON 路径用点分隔(如 data.songs)</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-content-3 block mb-1">名称 *</label>
|
||||
<input v-model="sourceForm.label" placeholder="我的音源" class="w-full bg-muted border border-line rounded-lg px-3 py-2 text-sm outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-content-3 block mb-1">搜索 URL</label>
|
||||
<input v-model="sourceForm.searchUrl" placeholder="https://api.example.com/search?kw={keyword}&p={page}&n={limit}" class="w-full bg-muted border border-line rounded-lg px-3 py-2 text-sm outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-content-3 block mb-1">URL 接口</label>
|
||||
<input v-model="sourceForm.urlApi" placeholder="https://api.example.com/url?id={id}&q={quality}" class="w-full bg-muted border border-line rounded-lg px-3 py-2 text-sm outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-content-3 block mb-1">搜索结果路径</label>
|
||||
<input v-model="sourceForm.searchPath" placeholder="data.songs" class="w-full bg-muted border border-line rounded-lg px-3 py-2 text-sm outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-content-3 block mb-1">URL 字段路径</label>
|
||||
<input v-model="sourceForm.urlPath" placeholder="data.url" class="w-full bg-muted border border-line rounded-lg px-3 py-2 text-sm outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="text-xs">
|
||||
<summary class="cursor-pointer text-content-3 hover:text-content transition">字段映射(高级)</summary>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2">
|
||||
<div>
|
||||
<label class="text-content-3 block mb-1">ID 字段</label>
|
||||
<input v-model="sourceForm.idField" placeholder="id" class="w-full bg-muted border border-line rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-content-3 block mb-1">歌名字段</label>
|
||||
<input v-model="sourceForm.nameField" placeholder="name" class="w-full bg-muted border border-line rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-content-3 block mb-1">歌手字段</label>
|
||||
<input v-model="sourceForm.artistField" placeholder="artist" class="w-full bg-muted border border-line rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-content-3 block mb-1">专辑字段</label>
|
||||
<input v-model="sourceForm.albumField" placeholder="album" class="w-full bg-muted border border-line rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-content-3 block mb-1">时长字段</label>
|
||||
<input v-model="sourceForm.durationField" placeholder="duration" class="w-full bg-muted border border-line rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-content-3 block mb-1">封面字段</label>
|
||||
<input v-model="sourceForm.picField" placeholder="pic" class="w-full bg-muted border border-line rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent/40" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="showSourceEditor = false" class="px-4 py-2 rounded-lg text-sm bg-muted hover:bg-emphasis text-content-2 transition">取消</button>
|
||||
<button @click="saveSource" :disabled="!sourceForm.label.trim()" class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:opacity-90 transition disabled:opacity-50">{{ editingSourceId ? '保存' : '添加' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">皮肤</h2>
|
||||
<div class="space-y-4">
|
||||
@@ -668,11 +827,127 @@ import IconPalette from '~icons/lucide/palette';
|
||||
import IconSun from '~icons/lucide/sun';
|
||||
import IconMoon from '~icons/lucide/moon';
|
||||
import IconImage from '~icons/lucide/image';
|
||||
import IconGripVertical from '~icons/lucide/grip-vertical';
|
||||
import IconChevronUp from '~icons/lucide/chevron-up';
|
||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||
import IconPencil from '~icons/lucide/pencil';
|
||||
import IconTrash2 from '~icons/lucide/trash-2';
|
||||
import IconPlus from '~icons/lucide/plus';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
const updater = useUpdater();
|
||||
|
||||
// ===== 音源管理 =====
|
||||
const showSourceEditor = ref(false);
|
||||
const editingSourceId = ref<string | null>(null);
|
||||
const sourceForm = reactive({
|
||||
label: '',
|
||||
searchUrl: '',
|
||||
urlApi: '',
|
||||
searchPath: 'data.songs',
|
||||
urlPath: 'data.url',
|
||||
idField: 'id',
|
||||
nameField: 'name',
|
||||
artistField: 'artist',
|
||||
albumField: 'album',
|
||||
durationField: 'duration',
|
||||
picField: 'pic',
|
||||
});
|
||||
|
||||
function openSourceEditor(id?: string) {
|
||||
if (id) {
|
||||
const existing = settings.musicSources.find(s => s.id === id);
|
||||
if (!existing) return;
|
||||
editingSourceId.value = id;
|
||||
sourceForm.label = existing.label;
|
||||
sourceForm.searchUrl = existing.searchUrl || '';
|
||||
sourceForm.urlApi = existing.urlApi || '';
|
||||
sourceForm.searchPath = existing.searchPath || 'data.songs';
|
||||
sourceForm.urlPath = existing.urlPath || 'data.url';
|
||||
sourceForm.idField = existing.idField || 'id';
|
||||
sourceForm.nameField = existing.nameField || 'name';
|
||||
sourceForm.artistField = existing.artistField || 'artist';
|
||||
sourceForm.albumField = existing.albumField || 'album';
|
||||
sourceForm.durationField = existing.durationField || 'duration';
|
||||
sourceForm.picField = existing.picField || 'pic';
|
||||
} else {
|
||||
editingSourceId.value = null;
|
||||
sourceForm.label = '';
|
||||
sourceForm.searchUrl = '';
|
||||
sourceForm.urlApi = '';
|
||||
sourceForm.searchPath = 'data.songs';
|
||||
sourceForm.urlPath = 'data.url';
|
||||
sourceForm.idField = 'id';
|
||||
sourceForm.nameField = 'name';
|
||||
sourceForm.artistField = 'artist';
|
||||
sourceForm.albumField = 'album';
|
||||
sourceForm.durationField = 'duration';
|
||||
sourceForm.picField = 'pic';
|
||||
}
|
||||
showSourceEditor.value = true;
|
||||
}
|
||||
|
||||
function saveSource() {
|
||||
if (!sourceForm.label.trim()) return;
|
||||
const payload = {
|
||||
label: sourceForm.label.trim(),
|
||||
searchUrl: sourceForm.searchUrl.trim() || undefined,
|
||||
urlApi: sourceForm.urlApi.trim() || undefined,
|
||||
searchPath: sourceForm.searchPath.trim() || undefined,
|
||||
urlPath: sourceForm.urlPath.trim() || undefined,
|
||||
idField: sourceForm.idField.trim() || undefined,
|
||||
nameField: sourceForm.nameField.trim() || undefined,
|
||||
artistField: sourceForm.artistField.trim() || undefined,
|
||||
albumField: sourceForm.albumField.trim() || undefined,
|
||||
durationField: sourceForm.durationField.trim() || undefined,
|
||||
picField: sourceForm.picField.trim() || undefined,
|
||||
};
|
||||
if (editingSourceId.value) {
|
||||
settings.updateMusicSource(editingSourceId.value, payload);
|
||||
showToast('音源已更新', 'success');
|
||||
} else {
|
||||
const id = `custom-${Date.now()}`;
|
||||
settings.addCustomMusicSource({
|
||||
id,
|
||||
enabled: true,
|
||||
kind: 'custom',
|
||||
...payload,
|
||||
});
|
||||
showToast('音源已添加', 'success');
|
||||
}
|
||||
showSourceEditor.value = false;
|
||||
}
|
||||
|
||||
function deleteSource(id: string) {
|
||||
settings.removeMusicSource(id);
|
||||
showToast('已删除自定义音源', 'success');
|
||||
}
|
||||
|
||||
function moveSourceUp(index: number) {
|
||||
if (index === 0) return;
|
||||
settings.moveMusicSource(index, index - 1);
|
||||
}
|
||||
|
||||
function moveSourceDown(index: number) {
|
||||
if (index >= settings.musicSources.length - 1) return;
|
||||
settings.moveMusicSource(index, index + 1);
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
const draggedSourceIndex = ref<number | null>(null);
|
||||
function onSourceDragStart(index: number) {
|
||||
draggedSourceIndex.value = index;
|
||||
}
|
||||
function onSourceDrop(index: number) {
|
||||
if (draggedSourceIndex.value === null || draggedSourceIndex.value === index) return;
|
||||
settings.moveMusicSource(draggedSourceIndex.value, index);
|
||||
draggedSourceIndex.value = null;
|
||||
}
|
||||
function onSourceDragEnd() {
|
||||
draggedSourceIndex.value = null;
|
||||
}
|
||||
|
||||
// 主题色选项(7色,不分深浅)
|
||||
const themeColorOptions = [
|
||||
{ id: 'blue', name: '蓝', color: '#3b82f6' },
|
||||
|
||||
Reference in New Issue
Block a user