//! 飞牛音乐(NAS)原生接口客户端 + 本地流代理的运行期。 //! //! 支持多连接(本地 / frp / 预留 FnConnect),当前以"激活连接"为准。 //! 原生接口路径/认证(`music-token` Cookie、`x-access-code` 安全码、`/user/password-login` 登录) //! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。 //! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。 use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use axum::http::StatusCode; use lofty::file::{AudioFile, TaggedFileExt}; use lofty::tag::Accessor; use serde::{Deserialize, Serialize}; use specta::Type; use serde_json::{json, Value}; use super::MusicSettings; pub use conn::normalize_base_url; mod cache; mod conn; mod fnconnect; pub mod proxy; pub mod webdav; pub use cache::CacheManager; pub use fnconnect::{extract_fn_id, resolve_base_url}; use conn::{generate_device_id, sha256_hex}; use proxy::{ProxyCfg, ProxyShared}; /// 一条飞牛音乐连接(持久化在 `MusicSettings`)。 #[derive(Serialize, Deserialize, Clone, Type)] #[serde(rename_all = "camelCase")] pub struct FeiniuConnection { pub id: String, pub name: String, /// "lan" | "frp" | "fnconnect"(fnconnect 预留) pub kind: String, /// 服务器地址(http://192.168.x.x:5666 或 https://域名) pub base_url: String, pub username: String, pub token: String, pub device_id: String, pub access_code: String, /// https 遇到自签证书时忽略校验 pub insecure: bool, /// fnconnect 连接的 fnId(如 fnos.net/ 或裸 id) #[serde(default)] pub fn_id: String, /// 是否经由 FnConnect 中继链路(`.fnos.net`)。 /// 中继要求所有请求携带 `Cookie: mode=relay`,否则网关 302 回登录页。 #[serde(default)] pub relay: bool, } impl Default for FeiniuConnection { fn default() -> Self { Self { id: String::new(), name: String::new(), kind: "lan".to_string(), base_url: String::new(), username: String::new(), token: String::new(), device_id: String::new(), access_code: String::new(), insecure: false, fn_id: String::new(), relay: false, } } } /// 当前激活连接信息(运行期副本,与 `MusicSettings` 的激活连接一致)。 struct Conn { base_url: String, token: String, username: String, device_id: String, access_code: String, insecure: bool, /// FnConnect 中继链路标记 relay: bool, } /// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。 struct ClientSlot { insecure: bool, client: reqwest::Client, } /// 构建飞牛请求 client。 /// /// `streaming = true` 时不设**总超时**:reqwest 的 `timeout` 覆盖到响应体读完为止, /// 用来取整首音频会把流式中途掐断(大文件/慢链路下播放器会一直缓冲)。 /// 流式只限制建连时间。 fn build_client(insecure: bool, streaming: bool) -> reqwest::Client { let mut b = reqwest::Client::builder() .no_proxy() .connect_timeout(Duration::from_secs(10)) .danger_accept_invalid_certs(insecure); if !streaming { b = b.timeout(Duration::from_secs(10)); } b.build().unwrap_or_else(|_| reqwest::Client::new()) } pub struct Feiniu { client: Mutex>, /// 流代理专用 client(无总超时) stream_client: Mutex>, conn: Mutex, proxy: Mutex>, cache: CacheManager, } impl Default for Feiniu { fn default() -> Self { Self { client: Mutex::new(None), stream_client: Mutex::new(None), conn: Mutex::new(Conn { base_url: String::new(), token: String::new(), username: String::new(), device_id: String::new(), access_code: String::new(), insecure: false, relay: false, }), proxy: Mutex::new(None), cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建 } } } /// 按 insecure 惰性构建 client(`streaming` 决定是否免总超时)。 fn slot_client(slot: &Mutex>, insecure: bool, streaming: bool) -> reqwest::Client { let mut g = slot.lock().unwrap_or_else(|e| e.into_inner()); let hit = g.as_ref().map(|s| s.insecure == insecure).unwrap_or(false); if !hit { *g = Some(ClientSlot { insecure, client: build_client(insecure, streaming), }); } g.as_ref().unwrap().client.clone() } impl Feiniu { /// 设置缓存根目录({app_data}/music/cache),应用启动时调用一次。 pub fn set_cache_root(&mut self, app_data_dir: &Path) { self.cache = CacheManager::new(app_data_dir); } /// 本地曲库标签缓存文件路径({cache_root}/local-tags.json)。 pub fn local_tags_path(&self) -> PathBuf { self.cache.local_tags_path() } /// 取(并惰性构建)对应 insecure 的 reqwest client(普通请求,10s 总超时)。 fn client(&self, insecure: bool) -> reqwest::Client { slot_client(&self.client, insecure, false) } /// 取流式请求专用 client(无总超时)。 fn stream_client(&self, insecure: bool) -> reqwest::Client { slot_client(&self.stream_client, insecure, true) } /// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。 pub fn sync_with_settings(&self, s: &MusicSettings) { let (base_url, token, username, device_id, access_code, insecure, relay) = match s.feiniu_active() { Some(c) => ( c.base_url.clone(), // token 常态存放在系统凭据管理器(见 crate::music::secrets), // 结构体字段为空;只有凭据库不可用(降级)时才回落到明文。 if c.token.is_empty() { super::secrets::read_feiniu_token(&c.id) } else { c.token.clone() }, c.username.clone(), c.device_id.clone(), c.access_code.clone(), c.insecure, c.relay, ), None => ( String::new(), String::new(), String::new(), String::new(), String::new(), false, false, ), }; if let Ok(mut c) = self.conn.lock() { c.base_url = base_url; c.token = token; c.username = username; c.device_id = device_id; c.access_code = access_code; c.insecure = insecure; c.relay = relay; } // 确保 client 构建到位(insecure 变化时重建) self.client(insecure); self.sync_proxy_cfg(); self.sync_proxy_client(); } fn sync_proxy_cfg(&self) { if let Ok(g) = self.proxy.lock() { if let Some((_, shared)) = g.as_ref() { let cfg = self.current_cfg(); if let Ok(mut c) = shared.cfg.lock() { *c = cfg; } } } } fn sync_proxy_client(&self) { // 代理必须用流式 client:普通 client 的 10s 总超时会把长音频流掐断 let (_, client) = self.conn_stream_client(); if let Ok(mut g) = self.proxy.lock() { if let Some((_, shared)) = g.as_mut() { shared.client = client; } } } fn conn_client(&self) -> (bool, reqwest::Client) { let insecure = self.conn.lock().unwrap_or_else(|e| e.into_inner()).insecure; (insecure, self.client(insecure)) } fn conn_stream_client(&self) -> (bool, reqwest::Client) { let insecure = self.conn.lock().unwrap_or_else(|e| e.into_inner()).insecure; (insecure, self.stream_client(insecure)) } fn current_cfg(&self) -> ProxyCfg { let c = self.conn.lock().unwrap_or_else(|e| e.into_inner()); ProxyCfg { base_url: c.base_url.clone(), token: c.token.clone(), access_code: c.access_code.clone(), relay: c.relay, } } /// 构造鉴权 Cookie 头。 /// /// 中继链路需把 `mode=relay` 与 `music-token` 合并进**同一个** Cookie 头 /// (拆成两个 Cookie 头会互相覆盖);未登录的中继请求只带 `mode=relay`。 /// 无 token 且非中继时返回 None,表示无需携带 Cookie。 fn auth_cookie(token: &str, relay: bool) -> Option { match (token.is_empty(), relay) { (false, true) => Some(format!("music-token={token}; mode=relay")), (false, false) => Some(format!("music-token={token}")), (true, true) => Some("mode=relay".to_string()), (true, false) => None, } } /// 鉴权上下文:`(base_url, token, access_code, relay)`。 fn auth_triple(&self) -> (String, String, String, bool) { let c = self.conn.lock().unwrap_or_else(|e| e.into_inner()); ( c.base_url.clone(), c.token.clone(), c.access_code.clone(), c.relay, ) } /// 对某个 base_url 执行登录(探测/登录连接共用)。 /// 成功返回 `(userToken, device_id)` 并更新运行期 conn;命令层负责落回对应连接持久化。 pub async fn login( &self, base_url: &str, username: &str, password: &str, ) -> Result<(String, String), String> { let base = normalize_base_url(base_url); let (device_id, insecure) = { let c = self.conn.lock().unwrap_or_else(|e| e.into_inner()); let d = if c.device_id.is_empty() { generate_device_id() } else { c.device_id.clone() }; (d, c.insecure) }; let body = json!({ "username": username, "password": sha256_hex(password), "deviceId": device_id, }); let relay = self .conn .lock() .unwrap_or_else(|e| e.into_inner()) .relay; let mut rb = self .client(insecure) .post(format!("{base}/music/api/v1/user/password-login")) .json(&body); // 中继链路:登录请求也需带 mode=relay,否则网关 302 回登录页 if let Some(cookie) = Self::auth_cookie("", relay) { rb = rb.header("cookie", cookie); } let resp = rb .send() .await .map_err(|e| { let kind = if e.is_timeout() { "连接超时(NAS 不可达?)" } else { "连接失败" }; format!("{kind}: {e}") })?; let status = resp.status(); let b: Value = resp .json() .await .unwrap_or_else(|_| json!({ "code": status.as_u16() })); let code = b["code"].as_i64().unwrap_or(i64::from(status.as_u16())); if code != 0 { if code == 120001 { return Err("用户名或密码错误".into()); } let msg = b["msg"] .as_str() .map(|s| s.to_string()) .unwrap_or_else(|| format!("HTTP {status}")); return Err(msg); } let token = b["data"]["userToken"] .as_str() .ok_or_else(|| "登录失败:未返回 token".to_string())? .to_string(); if let Ok(mut c) = self.conn.lock() { c.base_url = base; c.token = token.clone(); c.username = username.to_string(); c.device_id = device_id.clone(); } self.sync_proxy_cfg(); Ok((token, device_id)) } /// 登出:仅清运行期 token(保留连接信息与账号)。 pub fn logout(&self) { if let Ok(mut c) = self.conn.lock() { c.token.clear(); } self.sync_proxy_cfg(); } /// 当前激活连接配置(前端状态展示 + 是否已登录)。 pub fn config(&self) -> Value { let c = self.conn.lock().unwrap_or_else(|e| e.into_inner()); json!({ "baseUrl": c.base_url, "username": c.username, "loggedIn": !c.token.is_empty(), }) } /// 分页拉取曲目列表:`GET /music/api/v1/track/list`(可选关键词)。 pub async fn list_tracks(&self, page: u32, size: u32, keyword: Option<&str>) -> Result { let mut query = vec![ ("page".to_string(), page.to_string()), ("size".to_string(), size.to_string()), ]; let kw = keyword.unwrap_or("").trim().to_string(); if !kw.is_empty() { query.push(("keyword".to_string(), kw)); } self.authed_get("/music/api/v1/track/list", query).await } /// 歌词:`GET /music/api/v1/lyric/list?trackGUID=`。 pub async fn lyric(&self, guid: &str) -> Result { let v = self .authed_get( "/music/api/v1/lyric/list", vec![("trackGUID".to_string(), guid.to_string())], ) .await?; Ok(extract_lyric_text(&v)) } /// 本地媒体地址前缀:`http://127.0.0.1:/feiniu`。首次调用惰性启动代理。 pub async fn media_prefix(&self) -> Result { let port = self.ensure_proxy().await?; Ok(format!("http://127.0.0.1:{port}/feiniu")) } /// 缓存状态。 pub fn cache_status(&self) -> Value { self.cache.status() } /// 清空缓存。 pub fn cache_clear(&self) { self.cache.clear(); } /// 命中缓存直接返回文件路径;未命中则从 NAS 流式拉取写入缓存后返回。 /// 返回缓存文件路径。失败返回 Err。 pub async fn cache_fetch(&self, guid: &str, max_gb: u32) -> Result, String> { if let Some(hit) = self.cache.hit(guid) { return Ok(Some(hit)); } let (base, token, access_code, relay) = self.auth_triple(); if base.is_empty() || token.is_empty() { return Err("未登录".into()); } // 整首拉取写入缓存:同样用无总超时的 client,否则大文件会被 10s 超时截断 let (_, client) = self.conn_stream_client(); let mut rb = client.get(format!("{base}/music/api/v1/track/stream?guid={guid}")); if let Some(cookie) = Self::auth_cookie(&token, relay) { rb = rb.header("cookie", cookie); } if !access_code.is_empty() { use base64::Engine; rb = rb .header( "x-access-code", base64::engine::general_purpose::STANDARD.encode(access_code.as_bytes()), ) .header("x-access-source", "app"); } let resp = rb.send().await.map_err(|e| format!("拉取失败: {e}"))?; if !resp.status().is_success() { return Err(format!("拉取失败(HTTP {})", resp.status().as_u16())); } let ct = resp .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) .unwrap_or_default(); let ext = match ct.split('/').last() { Some("flac") => "flac", Some("mpeg") => "mp3", Some("wav") => "wav", Some("ogg") => "ogg", Some("mp4") => "m4a", Some("aac") => "aac", _ => "bin", }; let stream = resp.bytes_stream(); let path = self .cache .put(guid, ext, max_gb, stream) .await .map_err(|e| e)?; let _ = path; Ok(self.cache.hit(guid)) } // ===== WebDAV 传输(下载到飞牛 / 曲库增删)===== // 无状态:配置由前端每次调用传入,实现在 webdav 模块,命令层直接调用。 async fn ensure_proxy(&self) -> Result { if let Ok(g) = self.proxy.lock() { if let Some((port, _)) = g.as_ref() { return Ok(*port); } } // 流代理用无总超时的 client(见 build_client) let (_, client) = self.conn_stream_client(); let shared = ProxyShared { client, cfg: Arc::new(Mutex::new(self.current_cfg())), }; let port = proxy::start(shared.clone()).await?; if let Ok(mut g) = self.proxy.lock() { *g = Some((port, shared)); } Ok(port) } async fn authed_get(&self, path: &str, query: Vec<(String, String)>) -> Result { let (base, token, access_code, relay) = self.auth_triple(); if base.is_empty() || token.is_empty() { return Err("未登录".into()); } let (_, client) = self.conn_client(); let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); let mut rb = client.get(format!("{base}{path}")).query(&qrefs); if let Some(cookie) = Self::auth_cookie(&token, relay) { rb = rb.header("cookie", cookie); } if !access_code.is_empty() { use base64::Engine; rb = rb .header( "x-access-code", base64::engine::general_purpose::STANDARD.encode(access_code.as_bytes()), ) .header("x-access-source", "app"); } let resp = rb.send().await.map_err(|e| format!("请求失败: {e}"))?; let status = resp.status(); let body: Value = resp .json() .await .unwrap_or_else(|_| json!({ "code": status.as_u16() })); let code = body["code"].as_i64().unwrap_or(i64::from(status.as_u16())); if code != 0 { if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { return Err("登录已过期,请重新登录".into()); } let msg = body["msg"] .as_str() .map(|s| s.to_string()) .unwrap_or_else(|| format!("请求失败(HTTP {status})")); return Err(msg); } Ok(body["data"].clone()) } } /// 本地曲库 / 下载目录里认可的音频扩展名。 /// 扫描(递归)与单目录列举共用同一份,避免两处判定不一致。 pub const AUDIO_EXTS: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "ape"]; /// 该路径是否为认可的音频文件(只看扩展名,不校验存在性)。 pub fn is_audio_path(path: &Path) -> bool { path.extension() .and_then(|e| e.to_str()) .map(|e| AUDIO_EXTS.contains(&e.to_lowercase().as_str())) .unwrap_or(false) } /// 列出一个**目录**(非递归)下的音频文件:`{ items: [{path,name,size,mtim}] }`。 /// /// 与 [`scan_local_dirs`] 的分工:那个是「曲库全量扫描 + 标签解析」, /// 用于曲库视图;这个是「只看一层目录、不读标签」的轻量列举, /// 用于上传编排(只需在下载目录里找到刚落盘的文件)。 /// 为一次上传去递归遍历整个曲库目录是纯浪费。 pub fn list_audio_files(dir: &str) -> Value { let mut items: Vec = Vec::new(); let p = Path::new(dir); if !p.is_dir() { return json!({ "items": items }); } let Ok(entries) = fs::read_dir(p) else { return json!({ "items": items }); }; for entry in entries.flatten() { let path = entry.path(); if !path.is_file() || !is_audio_path(&path) { continue; } let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let mtim = entry .metadata() .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let name = path .file_stem() .and_then(|n| n.to_str()) .unwrap_or("未知") .to_string(); items.push(json!({ "path": path.to_string_lossy(), "name": name, "size": size, "mtim": mtim, })); } json!({ "items": items }) } /// 递归扫描本地曲库目录中的音频文件,返回轻量条目。 /// /// 元数据(标题/歌手/专辑/时长/是否有内嵌封面)用 lofty 解析音频标签; /// 结果按 path 缓存(size+mtim 未变即复用),避免万级曲库每次全量重解析。 /// `tags_cache_path` 为标签缓存文件路径(由命令层传入 CacheManager)。 /// /// **纯函数(不依赖 &self)**:全量解析是重活,命令层必须把它放进 /// `spawn_blocking`(Tauri 的同步命令在主线程执行,会冻结 UI)。 pub fn scan_local_dirs(dirs: &[String], tags_cache_path: Option<&Path>) -> Value { // ---- 标签缓存 ---- let mut tag_cache: serde_json::Map = tags_cache_path .and_then(|p| fs::read_to_string(p).ok()) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let cache_hit = |cache: &serde_json::Map, path: &str, size: u64, mtim: u64| -> Option { cache .get(path) .and_then(|v| v.as_object()) .filter(|e| { e.get("size").and_then(|x| x.as_u64()) == Some(size) && e.get("mtim").and_then(|x| x.as_u64()) == Some(mtim) }) .map(|e| Value::Object(e.clone())) }; let mut items: Vec = Vec::new(); // 本次扫描命中的音频路径:用于修剪缓存(见函数末尾) let mut seen: std::collections::HashSet = std::collections::HashSet::new(); // 至少有一个目录可访问才允许修剪,避免目录临时不可用(外接盘未挂载)时清空整个缓存 let mut scanned_any_dir = false; for dir in dirs { let p = Path::new(dir); if !p.is_dir() { continue; } scanned_any_dir = true; for entry in walkdir::WalkDir::new(p).follow_links(false) { let Ok(entry) = entry else { continue }; if !entry.file_type().is_file() { continue; } let path = entry.path(); if !is_audio_path(path) { continue; } let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let mtim = entry .metadata() .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let path_str = path.to_string_lossy().to_string(); let name = path .file_stem() .and_then(|n| n.to_str()) .unwrap_or("未知") .to_string(); seen.insert(path_str.clone()); // 标签:命中缓存直接复用;否则 lofty 解析并写回缓存 let tags = match cache_hit(&tag_cache, &path_str, size, mtim) { Some(hit) => hit, None => { let mut t = json!({ "size": size, "mtim": mtim, "title": name, "artist": "", "album": "", "durationS": 0, "cover": false, }); if let Ok(tagged) = lofty::read_from_path(path) { let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); if let Some(tag) = tag { if let Some(v) = tag.title().filter(|s| !s.trim().is_empty()) { t["title"] = json!(v.trim()); } if let Some(v) = tag.artist().filter(|s| !s.trim().is_empty()) { t["artist"] = json!(v.trim()); } if let Some(v) = tag.album().filter(|s| !s.trim().is_empty()) { t["album"] = json!(v.trim()); } t["cover"] = json!(!tag.pictures().is_empty()); } let secs = tagged.properties().duration().as_secs(); if secs > 0 { t["durationS"] = json!(secs); } } tag_cache.insert(path_str.clone(), t.clone()); t } }; items.push(json!({ "path": path_str, "name": name, "title": tags.get("title").cloned().unwrap_or(json!(name)), "artist": tags.get("artist").cloned().unwrap_or(json!("")), "album": tags.get("album").cloned().unwrap_or(json!("")), "durationS": tags.get("durationS").cloned().unwrap_or(json!(0)), "cover": tags.get("cover").cloned().unwrap_or(json!(false)), "size": size, "mtim": mtim, "dir": dir, })); } } // 修剪:删掉本次扫描中未再出现的缓存条目(文件已删除/改名/移出曲库目录)。 // 只增不减的缓存会随使用时间无限膨胀,且每次扫描都要整份读写。 if scanned_any_dir { tag_cache.retain(|k, _| seen.contains(k)); } if let Some(p) = tags_cache_path { if let Ok(s) = serde_json::to_string(&tag_cache) { fs::write(p, s).ok(); } } json!({ "items": items }) } /// 尽力从 /lyric/list 响应中取第一段歌词文本(响应结构未文档化,做宽松映射)。 fn extract_lyric_text(v: &Value) -> String { match v { Value::Array(arr) => arr .first() .map(|it| { it["content"] .as_str() .or_else(|| it["lyric"].as_str()) .or_else(|| it["text"].as_str()) .unwrap_or("") .to_string() }) .unwrap_or_default(), Value::Object(_) => v["content"] .as_str() .or_else(|| v["lyric"].as_str()) .or_else(|| v["text"].as_str()) .unwrap_or("") .to_string(), _ => String::new(), } }