Files
Thing/src-tauri/src/music/feiniu/mod.rs
T
2026-09-15 17:17:16 +08:00

743 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 飞牛音乐(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> 或裸 id
#[serde(default)]
pub fn_id: String,
/// 是否经由 FnConnect 中继链路(`<fnId>.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<Option<ClientSlot>>,
/// 流代理专用 client(无总超时)
stream_client: Mutex<Option<ClientSlot>>,
conn: Mutex<Conn>,
proxy: Mutex<Option<(u16, ProxyShared)>>,
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<Option<ClientSlot>>, 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<String> {
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<Value, String> {
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=<guid>`。
pub async fn lyric(&self, guid: &str) -> Result<String, String> {
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:<port>/feiniu`。首次调用惰性启动代理。
pub async fn media_prefix(&self) -> Result<String, String> {
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<Option<String>, 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<u16, String> {
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<Value, String> {
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<Value> = 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<String, Value> = 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<String, Value>,
path: &str,
size: u64,
mtim: u64|
-> Option<Value> {
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<Value> = Vec::new();
// 本次扫描命中的音频路径:用于修剪缓存(见函数末尾)
let mut seen: std::collections::HashSet<String> = 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(),
}
}