mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
feat: v0.3.0 - 流式播放、本地音乐、下载系统、漫游修复
### 新功能 - 流式播放:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载 - 本地音乐页面:支持浏览、播放本地歌曲,横向菜单含「从磁盘删除」 - 下载系统:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长) - 封面补全:本地音乐缺少封面时自动从网易云 API 获取 - 更新信息:接入 Gitea Releases API,查看最新版更新日志 ### 修复 - 修复私人漫游播完一首歌后跳三首的问题(双重触发:audio-ended + startTick) - 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片 - 修复 PlayerBar 无封面歌曲显示破损图片 - 修复下载路径修改后不生效(Rust serde camelCase 映射) - 修复本地音乐始终只显示默认路径歌曲 - 修复下载完成提示弹出 4 次 - 修复播放网络歌曲时进度条先走但无声音(audio-started 事件同步) ### 优化 - PlayerBar 下载状态:未下载显示下载按钮,下载中显示进度,已下载不显示 - audio.rs 新增 manual_stop 标志防止 stop_audio 触发虚假 audio-ended - player.ts 新增 waitForAudioStart() 确保 playing 状态与实际播放同步 - 切歌/停止时立即清除 tickInterval 防止重复触发 next()
This commit is contained in:
@ -1,12 +1,18 @@
|
||||
use ncm_api_rs::{create_client, ApiClient, Query};
|
||||
use serde::Deserialize;
|
||||
use tauri::{Manager, State};
|
||||
use serde_json::json;
|
||||
use tauri::{Manager, State, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::io::Write;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use lofty::file::{AudioFile, TaggedFileExt};
|
||||
use lofty::tag::Accessor;
|
||||
use base64::Engine;
|
||||
|
||||
pub struct ApiController {
|
||||
client: Mutex<ApiClient>,
|
||||
@ -103,21 +109,54 @@ pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, A
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SongUrlQuery { pub id: u64, pub level: Option<String> }
|
||||
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
|
||||
|
||||
/// 获取歌曲播放地址
|
||||
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
||||
#[tauri::command]
|
||||
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
resp.body["data"][0]["url"].as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "暂无播放源".into())
|
||||
|
||||
let resp = if query.fm_mode.unwrap_or(false) {
|
||||
let mut fm_cookie = state.cookie.lock().ok().and_then(|g| g.clone()).unwrap_or_default();
|
||||
if !fm_cookie.contains("os=") {
|
||||
fm_cookie = format!("{}; os=android; appver=8.10.05", fm_cookie);
|
||||
}
|
||||
let data = serde_json::json!({
|
||||
"ids": format!("[{}]", query.id),
|
||||
"level": level,
|
||||
"encodeType": "flac",
|
||||
"feeProcess": "true"
|
||||
});
|
||||
let option = ncm_api_rs::request::RequestOption {
|
||||
crypto: ncm_api_rs::request::CryptoType::default(),
|
||||
cookie: Some(fm_cookie),
|
||||
ua: None,
|
||||
proxy: None,
|
||||
real_ip: None,
|
||||
random_cn_ip: false,
|
||||
e_r: None,
|
||||
domain: None,
|
||||
check_token: false,
|
||||
};
|
||||
client.request(
|
||||
"/api/song/enhance/player/url/v1",
|
||||
data,
|
||||
option,
|
||||
).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
client.song_url_v1(&q).await.map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
if url.is_none() {
|
||||
return Err("暂无播放源".into());
|
||||
}
|
||||
Ok(data.to_string())
|
||||
}
|
||||
|
||||
/// 获取歌词
|
||||
@ -340,3 +379,352 @@ pub async fn exit_app(app_handle: tauri::AppHandle) {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSongInfo {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration: u64,
|
||||
pub cover: Option<String>,
|
||||
pub filename: String,
|
||||
pub file_size: u64,
|
||||
pub path: String,
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadSongQuery {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub duration: Option<u64>,
|
||||
pub cover_url: Option<String>,
|
||||
pub level: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
query: DownloadSongQuery,
|
||||
state: State<'_, ApiController>,
|
||||
) -> Result<String, String> {
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let client = state.client.lock().await;
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
if url.is_none() {
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
return Err("暂无下载源,可能需要 VIP 权限".into());
|
||||
}
|
||||
let url = url.unwrap();
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
let ext = if url.contains(".flac") { "flac" } else { "mp3" };
|
||||
drop(client);
|
||||
|
||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
||||
let _ = fs::create_dir_all(&download_dir);
|
||||
|
||||
let safe_name = sanitize_filename(&query.name);
|
||||
let safe_artist = sanitize_filename(&query.artist);
|
||||
let filename = format!("{} - {}.{}", safe_artist, safe_name, ext);
|
||||
let filepath = download_dir.join(&filename);
|
||||
|
||||
if filepath.exists() {
|
||||
return Err("文件已存在".into());
|
||||
}
|
||||
|
||||
let resp = reqwest::get(url).await.map_err(|e| format!("下载失败: {}", e))?;
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
let temp_path = filepath.with_extension(format!("{}.tmp", ext));
|
||||
let mut file = fs::File::create(&temp_path).map_err(|e| format!("创建文件失败: {}", e))?;
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
let mut chunk_count: u64 = 0;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("读取失败: {}", e))?;
|
||||
file.write_all(&chunk).map_err(|e| format!("写入失败: {}", e))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
chunk_count += 1;
|
||||
if chunk_count % 8 == 0 || downloaded == total_size {
|
||||
let progress = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let _ = app_handle.emit("download-progress", json!({
|
||||
"id": query.id,
|
||||
"progress": progress,
|
||||
"name": query.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
drop(file);
|
||||
|
||||
fs::rename(&temp_path, &filepath).map_err(|e| format!("重命名失败: {}", e))?;
|
||||
|
||||
let meta = json!({
|
||||
"id": query.id,
|
||||
"name": query.name,
|
||||
"artist": query.artist,
|
||||
"album": query.album,
|
||||
"duration": query.duration,
|
||||
"coverUrl": query.cover_url,
|
||||
"filename": filename,
|
||||
});
|
||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||
let mut meta_file = fs::File::create(&meta_path).map_err(|e| format!("创建元数据失败: {}", e))?;
|
||||
meta_file.write_all(serde_json::to_string_pretty(&meta).unwrap().as_bytes())
|
||||
.map_err(|e| format!("写入元数据失败: {}", e))?;
|
||||
|
||||
let _ = app_handle.emit("download-progress", json!({
|
||||
"id": query.id,
|
||||
"progress": 100.0,
|
||||
"name": query.name,
|
||||
}));
|
||||
|
||||
Ok(filename)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
if !download_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"];
|
||||
|
||||
let mut meta_map: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |e| e == "json") {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(filename) = meta["filename"].as_str() {
|
||||
meta_map.insert(filename.to_string(), meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut songs: Vec<LocalSongInfo> = Vec::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if !audio_exts.contains(&ext.to_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path);
|
||||
|
||||
if let Some(meta) = meta_map.get(&filename) {
|
||||
let meta_title = meta["name"].as_str().unwrap_or("");
|
||||
let meta_artist = meta["artist"].as_str().unwrap_or("");
|
||||
let meta_album = meta["album"].as_str().unwrap_or("");
|
||||
let meta_duration = meta["duration"].as_u64().unwrap_or(0);
|
||||
let meta_cover_url = meta["coverUrl"].as_str().unwrap_or("");
|
||||
|
||||
let final_title = if title.is_empty() { meta_title.to_string() } else { title };
|
||||
let final_artist = if artist.is_empty() { meta_artist.to_string() } else { artist };
|
||||
let final_album = if album.is_empty() { meta_album.to_string() } else { album };
|
||||
let final_duration = if duration_ms == 0 { meta_duration } else { duration_ms };
|
||||
let final_cover = cover_b64.or_else(|| {
|
||||
if meta_cover_url.is_empty() { None } else { Some(meta_cover_url.to_string()) }
|
||||
});
|
||||
|
||||
songs.push(LocalSongInfo {
|
||||
id: meta["id"].as_u64().unwrap_or(0),
|
||||
name: final_title,
|
||||
artist: final_artist,
|
||||
album: final_album,
|
||||
duration: final_duration,
|
||||
cover: final_cover,
|
||||
filename,
|
||||
file_size,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
local: true,
|
||||
});
|
||||
} else {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let (parsed_artist, parsed_name) = parse_filename(&stem);
|
||||
let final_title = if title.is_empty() { parsed_name } else { title };
|
||||
let final_artist = if artist.is_empty() { parsed_artist } else { artist };
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
filename.hash(&mut hasher);
|
||||
let hash_id = hasher.finish();
|
||||
|
||||
songs.push(LocalSongInfo {
|
||||
id: hash_id,
|
||||
name: final_title,
|
||||
artist: final_artist,
|
||||
album,
|
||||
duration: duration_ms,
|
||||
cover: cover_b64,
|
||||
filename,
|
||||
file_size,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
|
||||
match lofty::read_from_path(path) {
|
||||
Ok(tagged_file) => {
|
||||
let properties = tagged_file.properties();
|
||||
let duration_ms = properties.duration().as_millis() as u64;
|
||||
|
||||
let tag = tagged_file.primary_tag();
|
||||
let (title, artist, album) = if let Some(t) = tag {
|
||||
let title = t.title().map(|s| s.to_string()).unwrap_or_default();
|
||||
let artist = t.artist().map(|s| s.to_string()).unwrap_or_default();
|
||||
let album = t.album().map(|s| s.to_string()).unwrap_or_default();
|
||||
(title, artist, album)
|
||||
} else {
|
||||
(String::new(), String::new(), String::new())
|
||||
};
|
||||
|
||||
let cover_b64 = if let Some(t) = tag {
|
||||
if let Some(pic) = t.pictures().first() {
|
||||
let data = pic.data();
|
||||
let mime = pic.mime_type().map(|m| m.to_string()).unwrap_or_else(|| "image/jpeg".to_string());
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
Some(format!("data:{};base64,{}", mime, b64))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(title, artist, album, duration_ms, cover_b64)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[api] 读取音频元数据失败 {}: {}", path.display(), e);
|
||||
(String::new(), String::new(), String::new(), 0, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_filename(stem: &str) -> (String, String) {
|
||||
if let Some(pos) = stem.find(" - ") {
|
||||
let artist = &stem[..pos];
|
||||
let name = &stem[pos + 3..];
|
||||
(artist.trim().to_string(), name.trim().to_string())
|
||||
} else if let Some(pos) = stem.find('-') {
|
||||
let artist = &stem[..pos];
|
||||
let name = &stem[pos + 1..];
|
||||
(artist.trim().to_string(), name.trim().to_string())
|
||||
} else {
|
||||
("".to_string(), stem.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteLocalSongQuery {
|
||||
pub id: u64,
|
||||
pub filename: String,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_local_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
query: DeleteLocalSongQuery,
|
||||
) -> Result<(), String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
||||
let file_path = download_dir.join(&query.filename);
|
||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
|
||||
}
|
||||
if meta_path.exists() {
|
||||
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
let meta_path = download_dir.join(format!("{}.json", id));
|
||||
Ok(meta_path.exists())
|
||||
}
|
||||
|
||||
fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = custom_path {
|
||||
if !path.is_empty() {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
get_default_download_dir(app_handle)
|
||||
}
|
||||
|
||||
fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
if let Ok(dir) = app_handle.path().app_data_dir() {
|
||||
let download_dir = dir.join("downloads");
|
||||
return download_dir;
|
||||
}
|
||||
let music_dir = dirs::audio_dir().unwrap_or_else(|| {
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
});
|
||||
music_dir.join("Nekosonic")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
|
||||
get_default_download_dir(&app_handle).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
|
||||
|| c == '"' || c == '<' || c == '>' || c == '|'
|
||||
{
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user