调整,音乐模块

This commit is contained in:
zhongluofeng
2026-09-12 11:05:26 +08:00
parent 27ad5d89a5
commit d702ed0d31
71 changed files with 13647 additions and 387 deletions
+588
View File
@@ -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(),
}
}