调整,音乐模块
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
//! 飞牛音乐(NAS)原生接口客户端 + 本地流代理的运行期。
|
||||
//!
|
||||
//! 支持多连接(本地 / frp / 预留 FnConnect),当前以"激活连接"为准。
|
||||
//! 原生接口路径/认证(`music-token` Cookie、`x-access-code` 安全码、`/user/password-login` 登录)
|
||||
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
|
||||
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
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;
|
||||
mod fnos;
|
||||
pub mod proxy;
|
||||
|
||||
pub use cache::CacheManager;
|
||||
pub use fnconnect::{extract_fn_id, resolve_base_url};
|
||||
pub use fnos::FnOsSession;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前激活连接信息(运行期副本,与 `MusicSettings` 的激活连接一致)。
|
||||
struct Conn {
|
||||
base_url: String,
|
||||
token: String,
|
||||
username: String,
|
||||
device_id: String,
|
||||
access_code: String,
|
||||
insecure: bool,
|
||||
}
|
||||
|
||||
/// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。
|
||||
struct ClientSlot {
|
||||
insecure: bool,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub struct Feiniu {
|
||||
client: Mutex<Option<ClientSlot>>,
|
||||
conn: Mutex<Conn>,
|
||||
proxy: Mutex<Option<(u16, ProxyShared)>>,
|
||||
cache: CacheManager,
|
||||
/// fnOS 文件服务会话:connection id → 会话
|
||||
fnos_sessions: Mutex<HashMap<String, Arc<FnOsSession>>>,
|
||||
}
|
||||
|
||||
impl Default for Feiniu {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
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,
|
||||
}),
|
||||
proxy: Mutex::new(None),
|
||||
cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建
|
||||
fnos_sessions: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Feiniu {
|
||||
/// 设置缓存根目录({app_data}/music/cache),应用启动时调用一次。
|
||||
pub fn set_cache_root(&mut self, app_data_dir: &Path) {
|
||||
self.cache = CacheManager::new(app_data_dir);
|
||||
}
|
||||
/// 取(并惰性构建)对应 insecure 的 reqwest client。
|
||||
fn client(&self, insecure: bool) -> reqwest::Client {
|
||||
let mut g = self.client.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: reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(insecure)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
});
|
||||
}
|
||||
g.as_ref().unwrap().client.clone()
|
||||
}
|
||||
|
||||
/// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。
|
||||
pub fn sync_with_settings(&self, s: &MusicSettings) {
|
||||
let (base_url, token, username, device_id, access_code, insecure) =
|
||||
match s.feiniu_active() {
|
||||
Some(c) => (
|
||||
c.base_url.clone(),
|
||||
c.token.clone(),
|
||||
c.username.clone(),
|
||||
c.device_id.clone(),
|
||||
c.access_code.clone(),
|
||||
c.insecure,
|
||||
),
|
||||
None => (
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
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;
|
||||
}
|
||||
// 确保 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) {
|
||||
let (_, client) = self.conn_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 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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_triple(&self) -> (String, String, String) {
|
||||
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(c.base_url.clone(), c.token.clone(), c.access_code.clone())
|
||||
}
|
||||
|
||||
/// 对某个 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 resp = self
|
||||
.client(insecure)
|
||||
.post(format!("{base}/music/api/v1/user/password-login"))
|
||||
.json(&body)
|
||||
.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 scan_local(&self, dirs: &[String]) -> Value {
|
||||
const EXTS: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "ape"];
|
||||
let mut items: Vec<Value> = Vec::new();
|
||||
for dir in dirs {
|
||||
let p = Path::new(dir);
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
if !EXTS.contains(&ext.as_str()) {
|
||||
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(),
|
||||
"title": name,
|
||||
"size": size,
|
||||
"mtim": mtim,
|
||||
"dir": dir,
|
||||
}));
|
||||
}
|
||||
}
|
||||
json!({ "items": items })
|
||||
}
|
||||
|
||||
/// 缓存状态。
|
||||
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) = self.auth_triple();
|
||||
if base.is_empty() || token.is_empty() {
|
||||
return Err("未登录".into());
|
||||
}
|
||||
let (_, client) = self.conn_client();
|
||||
let mut rb = client.get(format!("{base}/music/api/v1/track/stream?guid={guid}"));
|
||||
rb = rb.header("cookie", format!("music-token={token}"));
|
||||
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))
|
||||
}
|
||||
|
||||
// ===== fnOS 文件服务(P6:上传/删除) =====
|
||||
|
||||
/// fnOS 登录:为指定连接建立文件服务会话。
|
||||
pub async fn fnos_login(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
let session = Arc::new(FnOsSession::connect(base_url, username, password).await?);
|
||||
if let Ok(mut m) = self.fnos_sessions.lock() {
|
||||
m.insert(connection_id.to_string(), session);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fnos_logout(&self, connection_id: &str) {
|
||||
if let Ok(mut m) = self.fnos_sessions.lock() {
|
||||
m.remove(connection_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fnos_logged_in(&self, connection_id: &str) -> bool {
|
||||
self.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.contains_key(connection_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 上传本地文件到 NAS(走 fnOS 会话;会话缺失返回 Err)。
|
||||
pub async fn fnos_upload(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
local_path: &std::path::Path,
|
||||
nas_path: &str,
|
||||
) -> Result<String, String> {
|
||||
let session = self
|
||||
.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.get(connection_id).cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "请先登录 NAS 文件服务(设置 → 连接 → fnOS 登录)")?;
|
||||
fnos::upload_file(&session, local_path, nas_path, 2).await
|
||||
}
|
||||
|
||||
/// 删除 NAS 文件。
|
||||
pub async fn fnos_delete(&self, connection_id: &str, nas_path: &str) -> Result<(), String> {
|
||||
let session = self
|
||||
.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.get(connection_id).cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "请先登录 NAS 文件服务")?;
|
||||
session.delete_file(nas_path).await
|
||||
}
|
||||
|
||||
/// 列出 NAS 目录。
|
||||
pub async fn fnos_list(&self, connection_id: &str, path: &str) -> Result<Value, String> {
|
||||
let session = self
|
||||
.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.get(connection_id).cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "请先登录 NAS 文件服务")?;
|
||||
session.list(path).await
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
let (_, client) = self.conn_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) = 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);
|
||||
rb = rb.header("cookie", format!("music-token={token}"));
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
/// 尽力从 /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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user