mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-21 16:48:48 +08:00
新增: - 音乐云盘页面(列表/详情弹窗/删除/存储空间, NOS multipart上传+LBS区域查询+进度事件) - 下载音乐页面(独立于本地音乐, 只显示应用下载的歌曲) - PageHeader粘性头部组件(IntersectionObserver控制显隐, 渐变模糊背景) - useLocalMusic composable(LocalSong类型/formatFileSize/localSongToSong/fetchMissingCovers) - 云盘上传完整流程(cloud_upload命令: check->token->LBS->NOS分块上传->info->publish) - 云盘API(user_cloud/user_cloud_detail/user_cloud_del) - 歌手关注/取关(artist_sub/artist_sublist命令, ArtistDetail关注按钮+artistSublist查询状态) - 本地音乐多文件夹扫描(scan_local_folders命令, settings.localMusicPaths, 模态框管理) - 侧边栏下载音乐和云盘导航项, 路由新增downloaded-music和cloud-music - md5 crate依赖 改进: - 路由全部改为懒加载 - keep-alive缓存管理重写(30s TTL+导航栈保护+FavoriteSongs常驻+10s定时清理) - 播放器状态同步改为轮询isAudioPlaying(替代audio-started事件), 超时后watchForLatePlayback继续监听 - audio.rs新增is_playing原子状态+is_audio_playing命令 - 同步命令改async+spawn_blocking(list_local_songs/delete_local_song/check_local_song/get_default_download_path) - scan_dir_for_songs抽取为公共函数, 新增downloaded_only参数 - RoamDrawer tab状态从组件本地ref移至store(roamTab替换roamInitialTab) - App.vue onMounted改为非阻塞 - 多页面添加骨架屏加载态和加载失败重试 - 多页面使用PageHeader替代手动返回按钮 - PlaylistDetail/ArtistDetail添加简介弹窗(溢出时显示查看完整介绍) - Home推荐/排行榜拆分为独立fetch函数支持分别重试 - Toast去重(3s窗口)+数量限制(最多3条) - LocalMusic移除删除功能改文件夹模态框, ArtistDetail头像改圆形简介内嵌 - README重写 修复: - 播放超时后后端实际开始播放但UI显示暂停(watchForLatePlayback+tick定期同步isAudioPlaying) - FM播放缺少playSeq竞态保护 - scrobble离线时仍发送(添加navigator.onLine检查) - RoamDrawer已打开时点击评论按钮无法切换(roamTab移至store) - 关闭RoamDrawer后再打开永远显示评论(closeRoamDrawer重置roamTab) - 歌手详情页关注状态离开后丢失(artist_detail不返回followed, 改用artistSublist查询) - audio-ended事件在切歌时误触发(新增_switchingSong标志拦截) - 路由beforeEach中localStorage key从user改为user_profile - toggle播放前先同步后端状态
1325 lines
47 KiB
Rust
1325 lines
47 KiB
Rust
use ncm_api_rs::{create_client, ApiClient, Query};
|
||
use serde::Deserialize;
|
||
use serde_json::json;
|
||
use tauri::{Manager, State, Emitter};
|
||
use tokio::sync::Mutex as AsyncMutex;
|
||
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;
|
||
|
||
/// 统一的 API 调用宏,封装了「获取客户端 → 构建请求 → 发送 → 提取响应体」的完整流程。
|
||
///
|
||
/// 消除了每个 Tauri 命令中重复的 `client.lock().unwrap().clone()` + `build_query()` + `.map(|r| r.body.to_string())` 样板代码。
|
||
///
|
||
/// 提供三种调用方式:
|
||
///
|
||
/// 1. 无额外参数 — 仅使用 cookie 中的默认参数构建请求
|
||
/// ```
|
||
/// api_call!(state, get_playlist_detail)
|
||
/// // 等价于:
|
||
/// // let client = state.client.lock().unwrap().clone();
|
||
/// // let q = state.build_query();
|
||
/// // client.get_playlist_detail(&q).await.map(|r| r.body.to_string()).map_err(|e| e.to_string())
|
||
/// ```
|
||
///
|
||
/// 2. 附加参数 — 在默认参数基础上追加键值对
|
||
/// ```
|
||
/// api_call!(state, song_url_v1, params: [("id", id), ("level", "standard")])
|
||
/// // 等价于:
|
||
/// // let q = state.build_query().param("id", id).param("level", "standard");
|
||
/// // client.song_url_v1(&q).await...
|
||
/// ```
|
||
///
|
||
/// 3. 预构建查询 — 直接传入已构建好的 Query 对象,跳过 build_query()
|
||
/// ```
|
||
/// api_call!(state, playlist_track_all, query: my_query)
|
||
/// // 等价于:
|
||
/// // client.playlist_track_all(&my_query).await...
|
||
/// ```
|
||
macro_rules! api_call {
|
||
($state:expr, $method:ident) => {{
|
||
let client = $state.client.lock().await.clone();
|
||
let q = $state.build_query();
|
||
client.$method(&q).await
|
||
.map(|r| r.body.to_string())
|
||
.map_err(|e| e.to_string())
|
||
}};
|
||
($state:expr, $method:ident, params: [$(($key:expr, $val:expr)),* $(,)?]) => {{
|
||
let client = $state.client.lock().await.clone();
|
||
let mut q = $state.build_query();
|
||
$(q = q.param($key, $val);)*
|
||
client.$method(&q).await
|
||
.map(|r| r.body.to_string())
|
||
.map_err(|e| e.to_string())
|
||
}};
|
||
($state:expr, $method:ident, query: $q:expr) => {{
|
||
let client = $state.client.lock().await.clone();
|
||
client.$method(&$q).await
|
||
.map(|r| r.body.to_string())
|
||
.map_err(|e| e.to_string())
|
||
}};
|
||
}
|
||
|
||
pub struct ApiController {
|
||
client: AsyncMutex<ApiClient>,
|
||
cookie: StdMutex<Option<String>>,
|
||
cookie_path: PathBuf,
|
||
}
|
||
|
||
/// 将 Cookie 字符串列表转换为 `key=value; key=value` 格式
|
||
fn cookies_to_key_values(cookies: &[String]) -> String {
|
||
cookies
|
||
.iter()
|
||
.filter_map(|c| c.split(';').next())
|
||
.map(|s| s.trim().to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("; ")
|
||
}
|
||
|
||
impl ApiController {
|
||
|
||
/// 创建新的 API 控制器,从本地文件恢复已保存的 Cookie
|
||
pub fn new(app_data_dir: PathBuf) -> Self {
|
||
let _ = fs::create_dir_all(&app_data_dir);
|
||
let cookie_path = app_data_dir.join("netease_cookies.json");
|
||
let saved_cookie = fs::read_to_string(&cookie_path)
|
||
.map(|s| s.trim().to_string())
|
||
.ok();
|
||
|
||
let client = create_client(None);
|
||
ApiController {
|
||
client: AsyncMutex::new(client),
|
||
cookie: StdMutex::new(saved_cookie),
|
||
cookie_path,
|
||
}
|
||
}
|
||
|
||
/// 构建带当前 Cookie 的 API 查询对象
|
||
fn build_query(&self) -> Query {
|
||
let mut query = Query::new();
|
||
if let Ok(cookie_guard) = self.cookie.lock() {
|
||
if let Some(c) = cookie_guard.as_ref() {
|
||
query = query.cookie(c);
|
||
}
|
||
}
|
||
query
|
||
}
|
||
/// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端
|
||
async fn save_cookie(&self, cookie_str: &str) {
|
||
let _ = fs::write(&self.cookie_path, cookie_str);
|
||
let mut client = self.client.lock().await;
|
||
client.set_cookie(cookie_str.to_string());
|
||
}
|
||
}
|
||
|
||
/// 搜索查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct SearchQuery { pub keyword: String }
|
||
|
||
/// 多类型搜索查询参数
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CloudSearchQuery {
|
||
pub keyword: String,
|
||
pub search_type: Option<i64>,
|
||
pub limit: Option<i64>,
|
||
pub offset: Option<i64>,
|
||
}
|
||
|
||
/// 搜索建议查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct SearchSuggestQuery { pub keyword: String }
|
||
|
||
/// 手机号登录查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct LoginQuery { pub phone: String, pub password: String }
|
||
|
||
/// 二维码登录密钥查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct QrKeyQuery { pub key: String }
|
||
|
||
/// 搜索歌曲
|
||
#[tauri::command]
|
||
pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, cloudsearch, params: [("keywords", &query.keyword), ("type", "1"), ("limit", "30")])
|
||
}
|
||
|
||
/// 多类型搜索(歌曲/歌手/专辑)
|
||
#[tauri::command]
|
||
pub async fn cloudsearch(query: CloudSearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, cloudsearch, params: [
|
||
("keywords", &query.keyword),
|
||
("type", &query.search_type.unwrap_or(1).to_string()),
|
||
("limit", &query.limit.unwrap_or(30).to_string()),
|
||
("offset", &query.offset.unwrap_or(0).to_string())
|
||
])
|
||
}
|
||
|
||
/// 搜索建议
|
||
#[tauri::command]
|
||
pub async fn search_suggest(query: SearchSuggestQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, search_suggest, params: [("keywords", &query.keyword)])
|
||
}
|
||
|
||
/// 获取热搜词列表
|
||
#[tauri::command]
|
||
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, search_hot_detail)
|
||
}
|
||
|
||
/// 歌单全部曲目查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option<i64>, pub offset: Option<i64> }
|
||
|
||
/// 获取歌单全部歌曲
|
||
#[tauri::command]
|
||
pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, playlist_track_all, params: [("id", &query.id.to_string()), ("limit", &query.limit.unwrap_or(1000).to_string()), ("offset", &query.offset.unwrap_or(0).to_string())])
|
||
}
|
||
|
||
/// 歌曲播放地址查询参数
|
||
#[derive(Deserialize)]
|
||
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.clone();
|
||
let level = query.level.as_deref().unwrap_or("standard");
|
||
|
||
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())
|
||
}
|
||
|
||
/// 获取歌词
|
||
#[tauri::command]
|
||
pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, lyric, params: [("id", &id.to_string())])
|
||
}
|
||
|
||
/// 获取歌单详情
|
||
#[tauri::command]
|
||
pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, playlist_detail, params: [("id", &id.to_string())])
|
||
}
|
||
|
||
/// 手机号密码登录
|
||
#[tauri::command]
|
||
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let client = state.client.lock().await.clone();
|
||
let q = Query::new()
|
||
.param("phone", &query.phone)
|
||
.param("password", &query.password);
|
||
let resp = client.login_cellphone(&q).await.map_err(|e| e.to_string())?;
|
||
|
||
if !resp.cookie.is_empty() {
|
||
let cookie_str = cookies_to_key_values(&resp.cookie);
|
||
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
|
||
state.save_cookie(&cookie_str).await;
|
||
}
|
||
|
||
Ok(resp.body.to_string())
|
||
}
|
||
|
||
/// 退出登录
|
||
#[tauri::command]
|
||
pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
|
||
*state.cookie.lock().map_err(|e| e.to_string())? = None;
|
||
let _ = fs::remove_file(&state.cookie_path);
|
||
Ok(())
|
||
}
|
||
|
||
/// 获取二维码登录密钥
|
||
#[tauri::command]
|
||
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
|
||
let client = state.client.lock().await.clone();
|
||
let q = state.build_query();
|
||
let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?;
|
||
resp.body["unikey"]
|
||
.as_str()
|
||
.map(|s| s.to_string())
|
||
.ok_or_else(|| "缺少 unikey".into())
|
||
}
|
||
|
||
/// 生成二维码图片
|
||
#[tauri::command]
|
||
pub async fn create_qr(
|
||
query: QrKeyQuery,
|
||
state: State<'_, ApiController>,
|
||
) -> Result<String, String> {
|
||
let client = state.client.lock().await.clone();
|
||
let q = state
|
||
.build_query()
|
||
.param("key", &query.key)
|
||
.param("qrimg", "true");
|
||
let resp = client.login_qr_create(&q).await.map_err(|e| e.to_string())?;
|
||
let qrurl = resp.body["data"]["qrurl"]
|
||
.as_str()
|
||
.ok_or("未获取到二维码链接")?
|
||
.to_string();
|
||
Ok(qrurl)
|
||
}
|
||
|
||
/// 检查二维码扫码状态
|
||
#[tauri::command]
|
||
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let client = state.client.lock().await.clone();
|
||
let q = state.build_query().param("key", &query.key);
|
||
let resp = client.login_qr_check(&q).await.map_err(|e| e.to_string())?;
|
||
if resp.body["code"].as_u64() == Some(803) && !resp.cookie.is_empty() {
|
||
let cookie_str = cookies_to_key_values(&resp.cookie);
|
||
*state.cookie.lock().map_err(|e| e.to_string())? = Some(cookie_str.clone());
|
||
state.save_cookie(&cookie_str).await;
|
||
}
|
||
Ok(resp.body.to_string())
|
||
}
|
||
|
||
/// 获取当前登录状态
|
||
#[tauri::command]
|
||
pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, user_account)
|
||
}
|
||
|
||
/// 获取用户歌单列表
|
||
#[tauri::command]
|
||
pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, user_playlist, params: [("uid", &uid.to_string())])
|
||
}
|
||
|
||
/// 获取每日推荐歌曲
|
||
#[tauri::command]
|
||
pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, recommend_songs)
|
||
}
|
||
|
||
/// 获取推荐歌单
|
||
#[tauri::command]
|
||
pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, recommend_resource)
|
||
}
|
||
|
||
/// 私人漫游模式查询参数
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PersonalFmModeQuery {
|
||
pub mode: Option<String>,
|
||
pub sub_mode: Option<String>,
|
||
pub limit: Option<i64>,
|
||
}
|
||
|
||
/// 私人漫游(带模式)
|
||
#[tauri::command]
|
||
pub async fn personal_fm_mode(query: PersonalFmModeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, personal_fm_mode, params: [
|
||
("mode", query.mode.as_deref().unwrap_or("DEFAULT")),
|
||
("submode", query.sub_mode.as_deref().unwrap_or("")),
|
||
("limit", &query.limit.unwrap_or(3).to_string())
|
||
])
|
||
}
|
||
|
||
/// FM 不喜欢查询参数
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct FmTrashQuery {
|
||
pub id: u64,
|
||
pub time: Option<i64>,
|
||
}
|
||
|
||
/// FM 不喜欢(减少推荐)
|
||
#[tauri::command]
|
||
pub async fn fm_trash(query: FmTrashQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, fm_trash, params: [
|
||
("id", &query.id.to_string()),
|
||
("time", &query.time.unwrap_or(25).to_string())
|
||
])
|
||
}
|
||
|
||
/// 获取私人漫游歌曲
|
||
#[tauri::command]
|
||
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, personal_fm)
|
||
}
|
||
|
||
/// 听歌打卡查询参数
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ScrobbleQuery {
|
||
pub id: u64,
|
||
pub sourceid: Option<String>,
|
||
pub time: u64,
|
||
}
|
||
|
||
/// 听歌打卡
|
||
#[tauri::command]
|
||
pub async fn scrobble(query: ScrobbleQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, scrobble, params: [("id", &query.id.to_string()), ("sourceid", query.sourceid.as_deref().unwrap_or("")), ("time", &query.time.to_string())])
|
||
}
|
||
|
||
/// 获取歌曲详情
|
||
#[tauri::command]
|
||
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, song_detail, params: [("ids", &id)])
|
||
}
|
||
|
||
/// 用户播放记录查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct UserRecordQuery { pub uid: u64, pub r#type: String }
|
||
|
||
/// 喜欢/取消喜欢歌曲查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct LikeSongQuery { pub id: u64, pub like: String }
|
||
|
||
/// 获取喜欢的歌曲ID列表
|
||
#[tauri::command]
|
||
pub async fn likelist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, likelist, params: [("uid", &uid.to_string())])
|
||
}
|
||
|
||
/// 获取用户播放记录
|
||
#[tauri::command]
|
||
pub async fn user_record(query: UserRecordQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, user_record, params: [("uid", &query.uid.to_string()), ("type", &query.r#type)])
|
||
}
|
||
|
||
/// 喜欢/取消喜欢歌曲
|
||
#[tauri::command]
|
||
pub async fn like_song(query: LikeSongQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, like, params: [("id", &query.id.to_string()), ("like", &query.like)])
|
||
}
|
||
|
||
/// 上报最近播放歌曲
|
||
#[tauri::command]
|
||
pub async fn record_recent_song(limit: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, record_recent_song, params: [("limit", &limit.to_string())])
|
||
}
|
||
|
||
/// 歌单收藏/取消收藏查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct PlaylistSubscribeQuery { pub id: u64, pub subscribe: Option<bool> }
|
||
|
||
/// 收藏/取消收藏歌单
|
||
#[tauri::command]
|
||
pub async fn playlist_subscribe(query: PlaylistSubscribeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let t = if query.subscribe.unwrap_or(true) { "1" } else { "0" };
|
||
api_call!(state, playlist_subscribe, params: [("id", &query.id.to_string()), ("t", t)])
|
||
}
|
||
|
||
/// 退出应用
|
||
#[tauri::command]
|
||
pub async fn exit_app(app_handle: tauri::AppHandle) {
|
||
crate::ALLOW_EXIT.store(true, Ordering::SeqCst);
|
||
if let Some(window) = app_handle.get_webview_window("main") {
|
||
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.clone();
|
||
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());
|
||
let is_vip = data.get("freeTrialInfo").is_some_and(|v| !v.is_null());
|
||
if is_vip {
|
||
return Err("VIP歌曲无法下载".into());
|
||
}
|
||
if url.is_none() {
|
||
return Err("暂无下载源,可能需要 VIP 权限".into());
|
||
}
|
||
let url = url.unwrap();
|
||
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)
|
||
}
|
||
|
||
/// 扫描指定目录下的音频文件,优先使用元数据文件补充信息
|
||
/// `downloaded_only` 为 true 时,只返回有对应 .json 元数据的文件(即通过应用下载的)
|
||
fn scan_dir_for_songs(dir: &PathBuf, downloaded_only: bool) -> Result<Vec<LocalSongInfo>, String> {
|
||
if !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(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(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);
|
||
|
||
// 下载音乐模式:只显示有 .json 元数据的文件
|
||
if downloaded_only && !meta_map.contains_key(&filename) {
|
||
continue;
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// 列出本地已下载的歌曲(下载目录)
|
||
#[tauri::command]
|
||
pub async 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());
|
||
tokio::task::spawn_blocking(move || {
|
||
scan_dir_for_songs(&download_dir, true) // 只显示下载的歌曲
|
||
}).await.map_err(|e| format!("扫描任务失败: {}", e))?
|
||
}
|
||
|
||
/// 扫描多个本地文件夹中的音频文件
|
||
#[tauri::command]
|
||
pub async fn scan_local_folders(paths: Vec<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||
tokio::task::spawn_blocking(move || {
|
||
let mut all_songs: Vec<LocalSongInfo> = Vec::new();
|
||
let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||
|
||
for p in &paths {
|
||
let dir = PathBuf::from(p);
|
||
let songs = scan_dir_for_songs(&dir, false)?; // 本地音乐:显示所有音频
|
||
for song in songs {
|
||
if seen_paths.insert(song.path.clone()) {
|
||
all_songs.push(song);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(all_songs)
|
||
}).await.map_err(|e| format!("扫描任务失败: {}", e))?
|
||
}
|
||
|
||
/// 读取音频文件的元数据(标题、艺术家、专辑、时长、封面)
|
||
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 async 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());
|
||
tokio::task::spawn_blocking(move || {
|
||
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(())
|
||
}).await.map_err(|e| format!("删除任务失败: {}", e))?
|
||
}
|
||
|
||
/// 检查指定歌曲是否已下载到本地
|
||
#[tauri::command]
|
||
pub async 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());
|
||
tokio::task::spawn_blocking(move || {
|
||
let meta_path = download_dir.join(format!("{}.json", id));
|
||
Ok(meta_path.exists())
|
||
}).await.map_err(|e| format!("检查任务失败: {}", e))?
|
||
}
|
||
|
||
/// 解析下载目录,优先使用自定义路径,否则使用默认目录
|
||
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)
|
||
}
|
||
|
||
/// 获取默认下载目录,优先使用应用数据目录下的 downloads 子目录
|
||
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 async 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()
|
||
}
|
||
|
||
/// 获取歌手详情
|
||
#[tauri::command]
|
||
pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, artist_detail, params: [("id", &id.to_string())])
|
||
}
|
||
|
||
/// 获取歌手歌曲列表
|
||
#[tauri::command]
|
||
pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let mut q = state.build_query().param("id", &query.id.to_string());
|
||
if let Some(ref order) = query.order {
|
||
q = q.param("order", order);
|
||
}
|
||
if let Some(limit) = query.limit {
|
||
q = q.param("limit", &limit.to_string());
|
||
}
|
||
if let Some(offset) = query.offset {
|
||
q = q.param("offset", &offset.to_string());
|
||
}
|
||
api_call!(state, artist_songs, query: q)
|
||
}
|
||
|
||
/// 歌手歌曲查询参数
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ArtistSongsQuery {
|
||
pub id: u64,
|
||
pub order: Option<String>,
|
||
pub limit: Option<u32>,
|
||
pub offset: Option<u32>,
|
||
}
|
||
|
||
/// 获取歌手专辑列表
|
||
#[tauri::command]
|
||
pub async fn artist_album(id: u64, limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let mut q = state.build_query().param("id", &id.to_string());
|
||
if let Some(limit) = limit {
|
||
q = q.param("limit", &limit.to_string());
|
||
}
|
||
if let Some(offset) = offset {
|
||
q = q.param("offset", &offset.to_string());
|
||
}
|
||
api_call!(state, artist_album, query: q)
|
||
}
|
||
|
||
/// 获取歌手简介
|
||
#[tauri::command]
|
||
pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, artist_desc, params: [("id", &id.to_string())])
|
||
}
|
||
|
||
/// 关注/取消关注歌手
|
||
#[derive(Deserialize)]
|
||
pub struct ArtistSubQuery { pub id: u64, pub sub: Option<bool> }
|
||
|
||
#[tauri::command]
|
||
pub async fn artist_sub(query: ArtistSubQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let t = if query.sub.unwrap_or(true) { "1" } else { "0" };
|
||
api_call!(state, artist_sub, params: [("id", &query.id.to_string()), ("t", t)])
|
||
}
|
||
|
||
/// 获取已关注的歌手列表
|
||
#[derive(Deserialize)]
|
||
pub struct ArtistSublistQuery { pub limit: Option<u32>, pub offset: Option<u32> }
|
||
|
||
#[tauri::command]
|
||
pub async fn artist_sublist(query: ArtistSublistQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let q = state.build_query()
|
||
.param("limit", &query.limit.unwrap_or(100).to_string())
|
||
.param("offset", &query.offset.unwrap_or(0).to_string());
|
||
api_call!(state, artist_sublist, query: &q)
|
||
}
|
||
|
||
/// 获取专辑详情
|
||
#[tauri::command]
|
||
pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, album, params: [("id", &id.to_string())])
|
||
}
|
||
|
||
/// 获取最新评论
|
||
#[tauri::command]
|
||
pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let mut q = state.build_query()
|
||
.param("type", &query.r#type.to_string())
|
||
.param("id", &query.id.to_string());
|
||
if let Some(sort_type) = query.sort_type {
|
||
q = q.param("sortType", &sort_type.to_string());
|
||
}
|
||
if let Some(page_no) = query.page_no {
|
||
q = q.param("pageNo", &page_no.to_string());
|
||
}
|
||
if let Some(page_size) = query.page_size {
|
||
q = q.param("pageSize", &page_size.to_string());
|
||
}
|
||
if let Some(cursor) = query.cursor {
|
||
q = q.param("cursor", &cursor.to_string());
|
||
}
|
||
api_call!(state, comment_new, query: q)
|
||
}
|
||
|
||
/// 最新评论查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct CommentNewQuery {
|
||
pub r#type: u8,
|
||
pub id: u64,
|
||
#[serde(rename = "sortType")]
|
||
pub sort_type: Option<u8>,
|
||
#[serde(rename = "pageNo")]
|
||
pub page_no: Option<u32>,
|
||
#[serde(rename = "pageSize")]
|
||
pub page_size: Option<u32>,
|
||
pub cursor: Option<u64>,
|
||
}
|
||
|
||
/// 获取热门评论
|
||
#[tauri::command]
|
||
pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let mut q = state.build_query()
|
||
.param("type", &query.r#type.to_string())
|
||
.param("id", &query.id.to_string());
|
||
if let Some(limit) = query.limit {
|
||
q = q.param("limit", &limit.to_string());
|
||
}
|
||
if let Some(offset) = query.offset {
|
||
q = q.param("offset", &offset.to_string());
|
||
}
|
||
if let Some(before) = query.before {
|
||
q = q.param("before", &before.to_string());
|
||
}
|
||
api_call!(state, comment_hot, query: q)
|
||
}
|
||
|
||
/// 热门评论查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct CommentHotQuery {
|
||
pub r#type: u8,
|
||
pub id: u64,
|
||
pub limit: Option<u32>,
|
||
pub offset: Option<u32>,
|
||
pub before: Option<u64>,
|
||
}
|
||
|
||
/// 获取评论楼层(子评论)
|
||
#[tauri::command]
|
||
pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let mut q = state.build_query()
|
||
.param("parentCommentId", &query.parent_comment_id.to_string())
|
||
.param("type", &query.r#type.to_string())
|
||
.param("id", &query.id.to_string());
|
||
if let Some(limit) = query.limit {
|
||
q = q.param("limit", &limit.to_string());
|
||
}
|
||
if let Some(time) = query.time {
|
||
q = q.param("time", &time.to_string());
|
||
}
|
||
api_call!(state, comment_floor, query: q)
|
||
}
|
||
|
||
/// 评论楼层查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct CommentFloorQuery {
|
||
#[serde(rename = "parentCommentId")]
|
||
pub parent_comment_id: u64,
|
||
pub r#type: u8,
|
||
pub id: u64,
|
||
pub limit: Option<u32>,
|
||
pub time: Option<u64>,
|
||
}
|
||
|
||
/// 点赞/取消点赞评论
|
||
#[tauri::command]
|
||
pub async fn comment_like(query: CommentLikeQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, comment_like, params: [("t", &query.t.to_string()), ("type", &query.r#type.to_string()), ("id", &query.id.to_string()), ("cid", &query.cid.to_string())])
|
||
}
|
||
|
||
/// 评论点赞查询参数
|
||
#[derive(Deserialize)]
|
||
pub struct CommentLikeQuery {
|
||
pub t: u8,
|
||
pub r#type: u8,
|
||
pub id: u64,
|
||
pub cid: u64,
|
||
}
|
||
|
||
// ==================== 云盘 ====================
|
||
|
||
/// 获取云盘列表
|
||
#[tauri::command]
|
||
pub async fn user_cloud(limit: Option<u32>, offset: Option<u32>, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, user_cloud, params: [("limit", &limit.unwrap_or(30).to_string()), ("offset", &offset.unwrap_or(0).to_string())])
|
||
}
|
||
|
||
/// 获取云盘歌曲详情
|
||
#[tauri::command]
|
||
pub async fn user_cloud_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, user_cloud_detail, params: [("id", &id)])
|
||
}
|
||
|
||
/// 删除云盘歌曲
|
||
#[tauri::command]
|
||
pub async fn user_cloud_del(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
|
||
api_call!(state, user_cloud_del, params: [("id", &id.to_string())])
|
||
}
|
||
|
||
/// 查询 NOS LBS 获取上传节点域名
|
||
///
|
||
/// 通过 `http://wannos.127.net/lbs` 查询指定 bucket 的上传节点,
|
||
/// 从返回的 `nosup-<region><n>.127.net` 中提取区域标识,
|
||
/// 构造 multipart upload 所需的 `<bucket>.nos-<region>.163yun.com` 域名。
|
||
async fn query_nos_upload_host(bucket: &str) -> Result<String, String> {
|
||
let lbs_url = format!("http://wannos.127.net/lbs?version=1.0&bucketname={}", bucket);
|
||
let http = reqwest::Client::new();
|
||
let resp = http.get(&lbs_url).send().await
|
||
.map_err(|e| format!("LBS查询请求失败: {}", e))?;
|
||
|
||
let lbs_data: serde_json::Value = resp.json().await
|
||
.map_err(|e| format!("LBS响应解析失败: {}", e))?;
|
||
|
||
let nosup_url = lbs_data["upload"][0].as_str()
|
||
.ok_or_else(|| format!("LBS响应缺少upload字段: {}", lbs_data))?;
|
||
|
||
// 从 "nosup-jd1.127.net" 中提取区域 "jd"
|
||
let region = extract_nos_region(nosup_url)?;
|
||
Ok(format!("https://{}.nos-{}.163yun.com", bucket, region))
|
||
}
|
||
|
||
/// 从 nosup 上传节点 URL 中提取 NOS 区域标识
|
||
///
|
||
/// 例如: `http://nosup-jd1.127.net` → `jd`
|
||
/// `http://nosup-hz1.127.net` → `hz`
|
||
fn extract_nos_region(nosup_url: &str) -> Result<String, String> {
|
||
let start = nosup_url.find("nosup-")
|
||
.ok_or_else(|| format!("无法从LBS响应中解析区域: {}", nosup_url))?;
|
||
let after = &nosup_url[start + 6..];
|
||
let dot = after.find('.')
|
||
.ok_or_else(|| format!("无法从LBS响应中解析区域: {}", nosup_url))?;
|
||
let region_with_num = &after[..dot];
|
||
// 去掉末尾数字: "jd1" → "jd"
|
||
let region: String = region_with_num
|
||
.chars()
|
||
.take_while(|c| !c.is_ascii_digit())
|
||
.collect();
|
||
if region.is_empty() {
|
||
return Err(format!("区域标识为空: {}", nosup_url));
|
||
}
|
||
Ok(region)
|
||
}
|
||
|
||
/// 云盘上传:完整流程(检查 → [获取Token → LBS查询 → NOS上传] → 提交信息 → 发布)
|
||
///
|
||
/// 关键发现:upload check 返回的 songId 是十六进制字符串(如 MD5 摘要),不是数字 ID。
|
||
/// needUpload=false 表示文件已在 NOS 上,无需重复上传,直接走 info+pub 即可。
|
||
/// 参考 ydq/netease-cloud-disk-music-upload 实现。
|
||
#[tauri::command]
|
||
pub async fn cloud_upload(file_path: String, app_handle: tauri::AppHandle, state: State<'_, ApiController>) -> Result<String, String> {
|
||
let path = PathBuf::from(&file_path);
|
||
if !path.exists() {
|
||
return Err("文件不存在".to_string());
|
||
}
|
||
|
||
let file_bytes = fs::read(&path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||
let file_size = file_bytes.len() as i64;
|
||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||
|
||
// 计算 MD5
|
||
let md5_hex = format!("{:x}", md5::compute(&file_bytes));
|
||
|
||
// 读取音频元数据
|
||
let (song_name, artist, album, _duration, _cover) = read_audio_metadata(&path);
|
||
let bitrate = {
|
||
match lofty::read_from_path(&path) {
|
||
Ok(tf) => {
|
||
let br = tf.properties().audio_bitrate().unwrap_or(0) * 1000;
|
||
if br > 0 { br.to_string() } else { "999000".to_string() }
|
||
}
|
||
Err(_) => "999000".to_string()
|
||
}
|
||
};
|
||
|
||
// 文件扩展名
|
||
let ext = if filename.contains('.') {
|
||
filename.rsplit('.').next().unwrap_or("mp3").to_string()
|
||
} else {
|
||
"mp3".to_string()
|
||
};
|
||
|
||
let client = state.client.lock().await.clone();
|
||
|
||
// Step 1: 上传检查
|
||
let check_q = state.build_query()
|
||
.param("bitrate", &bitrate)
|
||
.param("length", &file_size.to_string())
|
||
.param("md5", &md5_hex)
|
||
.param("ext", &ext)
|
||
.param("songId", "0");
|
||
|
||
let check_res = client.cloud_upload_check(&check_q).await
|
||
.map_err(|e| format!("上传检查失败: {}", e))?;
|
||
let check_data = &check_res.body;
|
||
|
||
// songId 是十六进制字符串(非数字),需要保持字符串传递
|
||
let song_id = check_data["songId"].as_str().unwrap_or("0").to_string();
|
||
let need_upload = check_data["needUpload"].as_bool().unwrap_or(true);
|
||
|
||
let mut resource_id = String::new();
|
||
|
||
// Step 2-4: 仅在 needUpload=true 时执行 NOS 上传
|
||
if need_upload {
|
||
// Step 2: 获取 NOS 上传 Token
|
||
let token_q = state.build_query()
|
||
.param("filename", &filename)
|
||
.param("md5", &md5_hex);
|
||
|
||
let token_res = client.cloud_upload_token_alloc(&token_q).await
|
||
.map_err(|e| format!("获取上传Token失败: {}", e))?;
|
||
let token_data = &token_res.body;
|
||
|
||
resource_id = token_data["result"]["resourceId"].as_str().unwrap_or("").to_string();
|
||
let object_key_raw = token_data["result"]["objectKey"].as_str().unwrap_or("").to_string();
|
||
let object_key = object_key_raw.replace('/', "%2F");
|
||
let token_str = token_data["result"]["token"].as_str().unwrap_or("").to_string();
|
||
|
||
if token_str.is_empty() {
|
||
return Err(format!("获取上传Token为空, 响应: {}", token_data));
|
||
}
|
||
|
||
// Step 3: 查询 LBS 获取正确的 NOS 上传节点
|
||
let bucket = "jd-musicrep-privatecloud-audio-public";
|
||
let nos_host = query_nos_upload_host(bucket).await
|
||
.unwrap_or_else(|_| format!("https://{}.nos-jd.163yun.com", bucket));
|
||
|
||
let content_type = match ext.as_str() {
|
||
"flac" => "audio/flac",
|
||
"wav" => "audio/wav",
|
||
"ogg" => "audio/ogg",
|
||
"aac" | "m4a" => "audio/aac",
|
||
_ => "audio/mpeg",
|
||
};
|
||
|
||
// Step 4: 上传文件到 NOS(multipart upload)
|
||
let http_client = reqwest::Client::new();
|
||
|
||
// 4a: 初始化 multipart upload
|
||
let init_url = format!("{}/{}?uploads", nos_host, object_key);
|
||
let init_res = http_client.post(&init_url)
|
||
.header("x-nos-token", &token_str)
|
||
.header("X-Nos-Meta-Content-Type", content_type)
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("初始化NOS上传失败: {}", e))?;
|
||
|
||
let init_status = init_res.status();
|
||
let init_xml = init_res.text().await.map_err(|e| format!("读取NOS响应失败: {}", e))?;
|
||
|
||
if !init_status.is_success() {
|
||
return Err(format!("初始化NOS上传失败: HTTP {} 响应: {}", init_status, init_xml));
|
||
}
|
||
|
||
// 解析 UploadId
|
||
let upload_id = init_xml
|
||
.split("<UploadId>")
|
||
.nth(1)
|
||
.and_then(|s| s.split("</UploadId>").next())
|
||
.unwrap_or_default()
|
||
.to_string();
|
||
|
||
if upload_id.is_empty() {
|
||
return Err(format!("获取UploadId失败, NOS响应: {}", init_xml));
|
||
}
|
||
|
||
// 4b: 分块上传(每块 10MB)
|
||
let block_size = 10 * 1024 * 1024;
|
||
let file_size_usize = file_bytes.len();
|
||
let mut offset = 0usize;
|
||
let mut block_index = 1u32;
|
||
let mut etags = Vec::new();
|
||
let total_blocks = ((file_size_usize + block_size - 1) / block_size).max(1);
|
||
|
||
while offset < file_size_usize {
|
||
let end = (offset + block_size).min(file_size_usize);
|
||
let chunk = file_bytes[offset..end].to_vec();
|
||
|
||
let part_url = format!(
|
||
"{}/{}?partNumber={}&uploadId={}",
|
||
nos_host, object_key, block_index, upload_id
|
||
);
|
||
|
||
let part_res = http_client.put(&part_url)
|
||
.header("x-nos-token", &token_str)
|
||
.header("Content-Type", content_type)
|
||
.body(chunk)
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("上传分块{}失败: {}", block_index, e))?;
|
||
|
||
if let Some(etag) = part_res.headers().get("etag") {
|
||
etags.push(etag.to_str().unwrap_or_default().to_string());
|
||
}
|
||
|
||
// 发送上传进度
|
||
let progress = (block_index as f64 / total_blocks as f64 * 100.0).min(100.0);
|
||
let _ = app_handle.emit("cloud-upload-progress", json!({
|
||
"filename": filename,
|
||
"progress": progress,
|
||
"uploaded": end,
|
||
"total": file_size_usize,
|
||
}));
|
||
|
||
offset = end;
|
||
block_index += 1;
|
||
}
|
||
|
||
// 4c: 完成 multipart upload
|
||
let mut complete_xml = String::from("<CompleteMultipartUpload>");
|
||
for (i, etag) in etags.iter().enumerate() {
|
||
complete_xml.push_str(&format!(
|
||
"<Part><PartNumber>{}</PartNumber><ETag>{}</ETag></Part>",
|
||
i + 1, etag
|
||
));
|
||
}
|
||
complete_xml.push_str("</CompleteMultipartUpload>");
|
||
|
||
let complete_url = format!("{}/{}?uploadId={}", nos_host, object_key, upload_id);
|
||
let complete_res = http_client.post(&complete_url)
|
||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||
.header("X-Nos-Meta-Content-Type", content_type)
|
||
.header("x-nos-token", &token_str)
|
||
.body(complete_xml)
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("完成NOS上传失败: {}", e))?;
|
||
|
||
if !complete_res.status().is_success() {
|
||
let status = complete_res.status();
|
||
let body = complete_res.text().await.unwrap_or_default();
|
||
return Err(format!("完成NOS上传失败: HTTP {} 响应: {}", status, body));
|
||
}
|
||
}
|
||
|
||
// Step 5: 提交歌曲信息(songId 使用 check 返回的字符串值)
|
||
let info_q = state.build_query()
|
||
.param("md5", &md5_hex)
|
||
.param("songId", &song_id)
|
||
.param("filename", &filename)
|
||
.param("song", &song_name)
|
||
.param("album", &album)
|
||
.param("artist", &artist)
|
||
.param("bitrate", &bitrate)
|
||
.param("resourceId", &resource_id);
|
||
|
||
let info_res = client.cloud_upload_info(&info_q).await
|
||
.map_err(|e| format!("提交歌曲信息失败: {}", e))?;
|
||
let info_data = &info_res.body;
|
||
|
||
// info 可能返回新的 songId,优先使用
|
||
let final_song_id = info_data["songId"].as_str()
|
||
.filter(|s| s != &"0" && !s.is_empty())
|
||
.unwrap_or(&song_id)
|
||
.to_string();
|
||
|
||
// Step 6: 发布
|
||
let pub_q = state.build_query().param("songId", &final_song_id);
|
||
let pub_res = client.cloud_publish(&pub_q).await
|
||
.map_err(|e| format!("发布失败: {}", e))?;
|
||
|
||
let _ = app_handle.emit("cloud-upload-complete", &filename);
|
||
Ok(pub_res.body.to_string())
|
||
}
|