音乐模块调整

This commit is contained in:
2026-09-12 15:47:16 +08:00
parent d702ed0d31
commit ffa410b399
33 changed files with 5452 additions and 2237 deletions
+109 -79
View File
@@ -437,102 +437,132 @@ fn new_conn_id() -> String {
format!("c{n:x}")
}
// ============ P6:下载到飞牛 + 曲库增删(fnOS 文件服务 ============
// ============ P6:下载到飞牛 + 曲库增删(WebDAV,无状态 ============
/// fnOS 登录:为某连接建立 NAS 文件服务会话(WS + RSA/AES)。成功后不持久化凭据
/// 测试 WebDAV 连接与目标目录是否可达
#[tauri::command]
pub async fn feiniu_fnos_login(
state: State<'_, MusicManager>,
connection_id: String,
username: String,
password: String,
#[specta::specta]
pub async fn webdav_test(
config: crate::music::feiniu::webdav::WebDavConfig,
dir: String,
) -> Result<serde_json::Value, String> {
let settings = state.load_settings();
let conn = settings
.feiniu_connections
.iter()
.find(|c| c.id == connection_id)
.cloned()
.ok_or_else(|| "连接不存在".to_string())?;
state
.feiniu
.fnos_login(&connection_id, &conn.base_url, &username, &password)
.await?;
Ok(json!({ "ok": true }))
let msg = crate::music::feiniu::webdav::test(&config, &dir).await?;
Ok(json!({ "ok": true, "message": msg }))
}
/// 登出 fnOS 文件服务会话
/// 上传本地文件到 WebDAV 目标目录(流式,不整载内存)
#[tauri::command]
pub fn feiniu_fnos_logout(
state: State<'_, MusicManager>,
connection_id: String,
) -> Result<serde_json::Value, String> {
state.feiniu.fnos_logout(&connection_id);
Ok(json!({ "ok": true }))
}
/// fnOS 文件服务登录状态:{ loggedIn }(按连接)。
#[tauri::command]
pub fn feiniu_fnos_status(
state: State<'_, MusicManager>,
connection_id: String,
) -> Result<serde_json::Value, String> {
Ok(json!({ "loggedIn": state.feiniu.fnos_logged_in(&connection_id) }))
}
/// 上传本地文件到 NAS(激活连接的 fnOS 会话)。返回上传文件名。
#[tauri::command]
pub async fn feiniu_fnos_upload(
state: State<'_, MusicManager>,
#[specta::specta]
pub async fn webdav_upload(
config: crate::music::feiniu::webdav::WebDavConfig,
local_path: String,
nas_path: String,
remote_path: String,
) -> Result<serde_json::Value, String> {
let settings = state.load_settings();
let conn = settings
.feiniu_connections
.iter()
.find(|c| c.id == settings.feiniu_active_id)
.cloned()
.ok_or_else(|| "没有激活连接".to_string())?;
let name = state
.feiniu
.fnos_upload(&conn.id, std::path::Path::new(&local_path), &nas_path)
.await?;
let name = crate::music::feiniu::webdav::upload_file(
&config,
std::path::Path::new(&local_path),
&remote_path,
)
.await?;
Ok(json!({ "ok": true, "name": name }))
}
/// 删除 NAS 文件(激活连接的 fnOS 会话)。
/// 删除 WebDAV 远程文件(404 视为已删除)。
#[tauri::command]
pub async fn feiniu_fnos_delete(
state: State<'_, MusicManager>,
nas_path: String,
#[specta::specta]
pub async fn webdav_delete(
config: crate::music::feiniu::webdav::WebDavConfig,
remote_path: String,
) -> Result<serde_json::Value, String> {
let settings = state.load_settings();
let conn = settings
.feiniu_connections
.iter()
.find(|c| c.id == settings.feiniu_active_id)
.cloned()
.ok_or_else(|| "没有激活连接".to_string())?;
state.feiniu.fnos_delete(&conn.id, &nas_path).await?;
crate::music::feiniu::webdav::delete(&config, &remote_path).await?;
Ok(json!({ "ok": true }))
}
/// 列出 NAS 目录(激活连接的 fnOS 会话)。
// ============ WebDAV 凭据加密存储(Windows 凭据管理器,DPAPI 保护) ============
/// 凭据以 JSON blob 存入系统凭据管理器:{"username","password"}。
/// 明文仅存在于内存与系统凭据库,绝不写回 localStorage / 配置文件。
#[cfg(windows)]
const WEBDAV_SECRET_SERVICE: &str = "Thing";
#[cfg(windows)]
const WEBDAV_SECRET_USER: &str = "webdav-credentials";
#[cfg(windows)]
fn webdav_secret_entry() -> Result<keyring::Entry, String> {
keyring::Entry::new(WEBDAV_SECRET_SERVICE, WEBDAV_SECRET_USER)
.map_err(|e| format!("无法访问系统凭据管理器: {e}"))
}
#[cfg(windows)]
fn webdav_secret_read() -> Result<Option<serde_json::Value>, String> {
let entry = webdav_secret_entry()?;
match entry.get_password() {
Ok(blob) => serde_json::from_str(&blob)
.map(Some)
.map_err(|e| format!("凭据数据损坏: {e}")),
// 未配置过凭据不算错误
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("读取凭据失败: {e}")),
}
}
#[cfg(windows)]
fn webdav_secret_write(username: &str, password: &str) -> Result<(), String> {
let blob = json!({ "username": username, "password": password }).to_string();
let entry = webdav_secret_entry()?;
entry.set_password(&blob).map_err(|e| format!("保存凭据失败: {e}"))
}
/// 读取 WebDAV 凭据。未配置时 username/password 为 null。
#[tauri::command]
pub async fn feiniu_fnos_list(
state: State<'_, MusicManager>,
path: String,
) -> Result<serde_json::Value, String> {
let settings = state.load_settings();
let conn = settings
.feiniu_connections
.iter()
.find(|c| c.id == settings.feiniu_active_id)
.cloned()
.ok_or_else(|| "没有激活连接".to_string())?;
let v = state.feiniu.fnos_list(&conn.id, &path).await?;
Ok(v)
pub fn webdav_get_secret() -> Result<serde_json::Value, String> {
#[cfg(windows)]
{
Ok(webdav_secret_read()?.unwrap_or_else(|| json!({ "username": null, "password": null })))
}
#[cfg(not(windows))]
{
Ok(json!({ "username": null, "password": null }))
}
}
/// 保存 WebDAV 凭据(账号 + 密码整体覆盖)。
#[tauri::command]
pub fn webdav_save_secret(username: String, password: String) -> Result<serde_json::Value, String> {
#[cfg(windows)]
{
webdav_secret_write(&username, &password)?;
}
#[cfg(not(windows))]
{
let _ = (&username, &password);
}
Ok(json!({ "ok": true }))
}
/// 删除本地媒体文件(「下载到飞牛」落地即传流程的收尾)。
/// 仅允许音频 / 歌词 / 封面扩展名,且拒绝目录——防止前端误删任意文件。
#[tauri::command]
#[specta::specta]
pub fn feiniu_delete_local(path: String) -> Result<serde_json::Value, String> {
const ALLOWED: [&str; 13] = [
"mp3", "flac", "wav", "m4a", "aac", "ogg", "ape", "wma", "lrc", "jpg", "jpeg", "png", "webp",
];
let p = std::path::Path::new(&path);
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
.map(|e| ALLOWED.contains(&e.to_lowercase().as_str()))
.unwrap_or(false);
if !ext_ok {
return Err(format!("拒绝删除非媒体文件:{path}"));
}
let meta = std::fs::metadata(p).map_err(|e| format!("文件不存在: {e}"))?;
if meta.is_dir() {
return Err("拒绝删除目录".to_string());
}
std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))?;
Ok(json!({ "ok": true }))
}
// ============ P7FnConnect 远程连接解析 ============
-410
View File
@@ -1,410 +0,0 @@
//! fnOS 文件服务客户端(WebSocket 协议 + HTTP 上传)。
//!
//! 协议参照 `FNOSP/fnnas-api`
//! 1. 连接 `ws://{host}:{port}/websocket?type=main`
//! 2. `util.crypto.getRSAPub` 取 RSA 公钥与 si
//! 3. `user.login`:随机 AES key/ivAES-CBC 加密登录体 + RSA 加密 key,发 `{"req":"encrypted",...}`
//! 4. 之后每个请求 `{base64(HMAC-SHA256(json))}{json}` 签名
//! 5. 上传:WS `file.checkUpload` → HTTP `POST /upload`Trim-Token/Trim-Path/Trim-Sign
//! 6. 删除:WS `file.rm`
//!
//! 仅支持 http://ws://)直连;https/frp 的文件上传留待后续。
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
use aes::Aes256;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use hmac::{Hmac, Mac};
use rand::RngCore;
use rsa::pkcs8::DecodePublicKey;
use rsa::{Pkcs1v15Encrypt, RsaPublicKey};
use serde_json::{json, Value};
use sha2::Sha256;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
type HmacSha256 = Hmac<Sha256>;
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
const KEY_CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
fn random_key() -> String {
let mut rng = rand::thread_rng();
(0..32)
.map(|_| {
let i = rng.next_u64() as usize % KEY_CHARS.len();
KEY_CHARS[i] as char
})
.collect()
}
fn random_iv() -> [u8; 16] {
let mut iv = [0u8; 16];
rand::thread_rng().fill_bytes(&mut iv);
iv
}
/// 手动 AES-256-CBC 加密(避免 cbc crate trait 兼容问题),PKCS7 填充,返回 base64。
fn aes_cbc_encrypt_b64(data: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> String {
let cipher = Aes256::new(key.into());
let mut padded = data.to_vec();
let pad_len = 16 - (padded.len() % 16);
padded.extend(std::iter::repeat(pad_len as u8).take(pad_len));
let mut out = Vec::with_capacity(padded.len());
let mut prev = *iv;
for chunk in padded.chunks(16) {
let mut block = [0u8; 16];
for i in 0..16 {
block[i] = chunk[i] ^ prev[i];
}
cipher.encrypt_block((&mut block).into());
out.extend_from_slice(&block);
prev = block;
}
base64::engine::general_purpose::STANDARD.encode(out)
}
/// AES-256-CBC 解密 → 返回 base64(明文)(对齐 fnnas-api 的 aes_decrypt)。
fn aes_cbc_decrypt_b64(ciphertext_b64: &str, key: &[u8; 32], iv: &[u8; 16]) -> Result<String, String> {
let ct = base64::engine::general_purpose::STANDARD
.decode(ciphertext_b64)
.map_err(|e| format!("AES 密文解码失败: {e}"))?;
let cipher = Aes256::new(key.into());
let mut prev = *iv;
let mut plain = Vec::with_capacity(ct.len());
for chunk in ct.chunks(16) {
let mut block = [0u8; 16];
block.copy_from_slice(chunk);
let enc = block;
cipher.decrypt_block((&mut block).into());
for i in 0..16 {
plain.push(block[i] ^ prev[i]);
}
prev = enc;
}
// 去 PKCS7 填充
if let Some(&last) = plain.last() {
let n = last as usize;
if n > 0 && n <= 16 && plain.len() >= n && plain[plain.len() - n..].iter().all(|&b| b == last) {
plain.truncate(plain.len() - n);
}
}
Ok(base64::engine::general_purpose::STANDARD.encode(plain))
}
fn rsa_encrypt_b64(pub_pem: &str, data: &str) -> Result<String, String> {
let key = RsaPublicKey::from_public_key_pem(pub_pem)
.map_err(|e| format!("解析 RSA 公钥失败: {e}"))?;
let mut rng = rand::thread_rng();
let ct = key
.encrypt(&mut rng, Pkcs1v15Encrypt, data.as_bytes())
.map_err(|e| format!("RSA 加密失败: {e}"))?;
Ok(base64::engine::general_purpose::STANDARD.encode(ct))
}
fn hmac_sha256_b64(key: &[u8], data: &str) -> String {
let mut mac = <HmacSha256 as Mac>::new_from_slice(key).expect("hmac key");
mac.update(data.as_bytes());
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())
}
fn reqid() -> String {
let n = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("0000000000000000{n:x}")
}
fn ws_url(base_url: &str) -> String {
let u = base_url.trim_end_matches('/');
u.replacen("http://", "ws://", 1) + "/websocket?type=main"
}
/// 一次 fnOS 会话(登录后复用;命令层按 connection 存于 Feiniu)。
pub struct FnOsSession {
pub base_url: String,
token: Mutex<String>,
sign_key: Mutex<Vec<u8>>,
ws: Arc<tokio::sync::Mutex<Option<WsStream>>>,
pending: Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<Value>>>>,
reader: Arc<AtomicBool>,
}
impl FnOsSession {
/// 建立会话(新 WS 连接 + 登录)。
pub async fn connect(base_url: &str, username: &str, password: &str) -> Result<Self, String> {
let url = ws_url(base_url);
let (ws, _) = connect_async(&url)
.await
.map_err(|e| format!("连接 fnOS WebSocket 失败: {e}"))?;
let session = Self {
base_url: base_url.trim_end_matches('/').to_string(),
token: Mutex::new(String::new()),
sign_key: Mutex::new(Vec::new()),
ws: Arc::new(tokio::sync::Mutex::new(Some(ws))),
pending: Arc::new(Mutex::new(HashMap::new())),
reader: Arc::new(AtomicBool::new(false)),
};
session.login(username, password).await?;
Ok(session)
}
pub fn token(&self) -> String {
self.token.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
fn ensure_reader(&self) {
if self.reader.swap(true, Ordering::SeqCst) {
return;
}
let ws = self.ws.clone();
let pending = self.pending.clone();
tokio::spawn(async move {
loop {
let msg = {
let mut guard = ws.lock().await;
let Some(stream) = guard.as_mut() else { break };
match stream.next().await {
Some(Ok(m)) => m,
_ => break,
}
};
if let Message::Text(text) = msg {
if let Ok(v) = serde_json::from_str::<Value>(&text) {
if let Some(rid) = v["reqid"].as_str() {
if let Some(sender) =
pending.lock().unwrap_or_else(|e| e.into_inner()).remove(rid)
{
let _ = sender.send(v);
}
}
}
}
}
let pendings: Vec<_> = pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain()
.map(|(_, s)| s)
.collect();
for s in pendings {
let _ = s.send(Value::Null);
}
});
}
/// 发送签名请求并等待响应。
async fn request(&self, req: &str, data: Value) -> Result<Value, String> {
self.ensure_reader();
let rid = reqid();
let mut body = json!({ "req": req, "reqid": rid });
if let Value::Object(map) = data {
for (k, v) in map {
body[k] = v;
}
}
let json_str = serde_json::to_string(&body).map_err(|e| e.to_string())?;
let sign_key = self.sign_key.lock().unwrap_or_else(|e| e.into_inner()).clone();
let message = if sign_key.is_empty() {
json_str.clone()
} else {
hmac_sha256_b64(&sign_key, &json_str) + &json_str
};
let (tx, rx) = tokio::sync::oneshot::channel::<Value>();
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(rid.clone(), tx);
{
let mut guard = self.ws.lock().await;
let stream = guard
.as_mut()
.ok_or_else(|| "fnOS 连接已断开".to_string())?;
stream
.send(Message::Text(message.into()))
.await
.map_err(|e| format!("发送请求失败: {e}"))?;
}
let resp = tokio::time::timeout(Duration::from_secs(10), rx)
.await
.map_err(|_| "fnOS 请求超时".to_string())?
.map_err(|_| "fnOS 请求通道关闭".to_string())?;
if resp.is_null() {
return Err("fnOS 连接已断开".to_string());
}
if let Some(errno) = resp["errno"].as_i64() {
if errno != 0 {
return Err(format!("fnOS 请求失败(errno {errno}"));
}
}
Ok(resp)
}
async fn login(&self, username: &str, password: &str) -> Result<(), String> {
let resp = self.request("util.crypto.getRSAPub", Value::Null).await?;
let pub_pem = resp["pub"].as_str().ok_or("getRSAPub 未返回 pub")?.to_string();
let si = resp["si"].as_str().unwrap_or("").to_string();
let key = random_key();
let iv = random_iv();
let login_body = json!({
"user": username,
"password": password,
"deviceType": "Browser",
"deviceName": "Thing Client",
"stay": false,
"si": si,
});
let json_str = serde_json::to_string(&login_body).map_err(|e| e.to_string())?;
let key_bytes: [u8; 32] = key
.as_bytes()
.try_into()
.map_err(|_| "AES 密钥长度错误".to_string())?;
let aes_b64 = aes_cbc_encrypt_b64(json_str.as_bytes(), &key_bytes, &iv);
let rsa_b64 = rsa_encrypt_b64(&pub_pem, &key)?;
let enc = json!({
"req": "encrypted",
"iv": base64::engine::general_purpose::STANDARD.encode(iv),
"rsa": rsa_b64,
"aes": aes_b64,
});
let resp = self.request("encrypted", enc).await?;
let token = resp["token"].as_str().ok_or("fnOS 登录未返回 token")?.to_string();
let secret = resp["secret"].as_str().ok_or("fnOS 登录未返回 secret")?.to_string();
let secret_dec = aes_cbc_decrypt_b64(&secret, &key_bytes, &iv)?;
let sign_key = base64::engine::general_purpose::STANDARD
.decode(&secret_dec)
.map_err(|e| format!("sign_key base64 解码失败: {e}"))?;
*self.token.lock().unwrap_or_else(|e| e.into_inner()) = token;
*self.sign_key.lock().unwrap_or_else(|e| e.into_inner()) = sign_key;
Ok(())
}
/// `file.checkUpload`:返回 uploadName。
pub async fn check_upload(&self, nas_path: &str, size: u64, overwrite: u32) -> Result<String, String> {
let resp = self
.request(
"file.checkUpload",
json!({ "size": size, "path": nas_path, "overwrite": overwrite }),
)
.await?;
resp["uploadName"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "checkUpload 未返回 uploadName".to_string())
}
/// `file.rm`:删除 NAS 文件(移入回收站)。
pub async fn delete_file(&self, nas_path: &str) -> Result<(), String> {
let name = nas_path.rsplit('/').next().unwrap_or(nas_path).to_string();
self.request(
"file.rm",
json!({
"files": [nas_path],
"moveToTrashbin": true,
"details": { "name": name, "count": 1, "dir": 0 },
}),
)
.await?;
Ok(())
}
/// `file.ls`:列出目录。
pub async fn list(&self, path: &str) -> Result<Value, String> {
self.request("file.ls", json!({ "path": path })).await
}
}
/// HTTP 上传到 NAS(需要已登录的 session)。返回上传后的文件名。
pub async fn upload_file(
session: &FnOsSession,
local_path: &std::path::Path,
nas_path: &str,
overwrite: u32,
) -> Result<String, String> {
let size = std::fs::metadata(local_path)
.map_err(|e| format!("读取本地文件失败: {e}"))?
.len();
let upload_name = session.check_upload(nas_path, size, overwrite).await?;
let parent = nas_path.rsplit('/').nth(1).unwrap_or("").to_string();
let trim_path = if parent.is_empty() {
upload_name.clone()
} else {
format!("{parent}/{upload_name}")
};
let mtim = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.to_string();
let sign_key = session.sign_key.lock().unwrap_or_else(|e| e.into_inner()).clone();
let trim_sign = hmac_sha256_b64(&sign_key, &trim_path);
let token = session.token();
let file_name = local_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("music.bin")
.to_string();
let bytes = std::fs::read(local_path).map_err(|e| format!("读取本地文件失败: {e}"))?;
let mime = guess_mime(local_path);
let form = reqwest::multipart::Form::new().part(
"trim-upload-file",
reqwest::multipart::Part::bytes(bytes)
.file_name(file_name)
.mime_str(mime)
.map_err(|e| e.to_string())?,
);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(300))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.post(format!("{}/upload", session.base_url))
.header("Trim-Token", token)
.header("Trim-Path", trim_path)
.header("Trim-Sign", trim_sign)
.header("Trim-Mtim", mtim)
.header("Trim-Overwrite", overwrite.to_string())
.header("Referer", format!("{}/", session.base_url))
.header("User-Agent", "Thing/1.0")
.header("Accept", "application/json, text/plain, */*")
.multipart(form)
.send()
.await
.map_err(|e| format!("上传请求失败: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("上传失败(HTTP {status}: {body}"));
}
Ok(upload_name)
}
fn guess_mime(p: &std::path::Path) -> &'static str {
match p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref()
{
Some("mp3") => "audio/mpeg",
Some("flac") => "audio/flac",
Some("wav") => "audio/wav",
Some("m4a") => "audio/mp4",
Some("aac") => "audio/aac",
Some("ogg") => "audio/ogg",
Some("ape") => "audio/x-ape",
_ => "application/octet-stream",
}
}
+3 -76
View File
@@ -5,7 +5,6 @@
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -21,12 +20,11 @@ pub use conn::normalize_base_url;
mod cache;
mod conn;
mod fnconnect;
mod fnos;
pub mod proxy;
pub mod webdav;
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};
@@ -90,8 +88,6 @@ pub struct Feiniu {
conn: Mutex<Conn>,
proxy: Mutex<Option<(u16, ProxyShared)>>,
cache: CacheManager,
/// fnOS 文件服务会话:connection id → 会话
fnos_sessions: Mutex<HashMap<String, Arc<FnOsSession>>>,
}
impl Default for Feiniu {
@@ -108,7 +104,6 @@ impl Default for Feiniu {
}),
proxy: Mutex::new(None),
cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建
fnos_sessions: Mutex::new(HashMap::new()),
}
}
}
@@ -435,76 +430,8 @@ impl Feiniu {
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
}
// ===== WebDAV 传输(下载到飞牛 / 曲库增删)=====
// 无状态:配置由前端每次调用传入,实现在 webdav 模块,命令层直接调用。
async fn ensure_proxy(&self) -> Result<u16, String> {
if let Ok(g) = self.proxy.lock() {
+217
View File
@@ -0,0 +1,217 @@
//! WebDAV 客户端(无状态)——用于把音乐传输到 fnOS NAS 的曲库目录。
//!
//! 选择 WebDAV 而非 SMB/FTP/NFS 的原因:
//! - 纯 HTTP 语义,`reqwest` 即可实现,无原生依赖(SMB 在 Rust 侧生态差);
//! - **无会话**:Basic 认证随每个请求携带,没有"重启后要重新登录"的问题;
//! - fnOS 原生支持:系统设置 → 文件服务 → WebDAVHTTP 5005 / HTTPS 5006)。
//!
//! fnOS 侧需要的设置(作为 UI 提示展示):
//! 1. 开启 WebDAV 服务并记下端口;
//! 2. 目标文件夹必须在「可见文件夹范围」内(团队文件夹需勾选
//! 「允许通过文件共享协议(SMB、WebDAV、FTP)挂载到其他设备上」);
//! 3. 账号对该文件夹有读写权限。
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// WebDAV 连接配置(由前端每次调用时传入,后端不持有状态)。
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
pub struct WebDavConfig {
/// 服务地址,如 `http://192.168.110.100:5005`
pub url: String,
pub username: String,
pub password: String,
}
/// WebDAV 操作结果状态码相关的可读错误。
fn status_err(action: &str, status: u16, url: &str) -> String {
match status {
401 => format!("WebDAV 认证失败(401):请检查账号与密码"),
403 => format!("WebDAV 拒绝访问(403):账号可能没有该目录的写入权限"),
404 => format!("WebDAV 目标不存在(404):{url}(目录需要先在 NAS 上创建,且在 WebDAV 可见范围内)"),
409 => format!("WebDAV 父目录不存在(409):{url}"),
_ => format!("WebDAV {action}失败(HTTP {status}):{url}"),
}
}
fn dav_client(_cfg: &WebDavConfig) -> Result<reqwest::Client, String> {
// NAS 通常在内网,默认禁用系统代理(走代理会连不上);Basic 认证在请求级设置
reqwest::Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(10))
.no_proxy()
.build()
.map_err(|e| e.to_string())
}
/// 拼接 WebDAV 完整 URL:base + 远程路径(逐段百分号编码,保留 `/`)。
fn dav_url(cfg: &WebDavConfig, remote_path: &str) -> String {
let base = cfg.url.trim_end_matches('/');
let path = remote_path.trim_start_matches('/');
let encoded: Vec<String> = path
.split('/')
.filter(|s| !s.is_empty())
.map(|seg| {
let mut out = String::new();
for b in seg.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
})
.collect();
format!("{base}/{}", encoded.join("/"))
}
fn with_auth(mut rb: reqwest::RequestBuilder, cfg: &WebDavConfig) -> reqwest::RequestBuilder {
if !cfg.username.is_empty() {
rb = rb.basic_auth(&cfg.username, Some(&cfg.password));
}
rb
}
/// 逐级 MKCOL 创建远程目录(已存在会被忽略)。
async fn ensure_dirs(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> {
let path = remote_path.trim_start_matches('/');
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
// 最后一段是文件名,只建目录部分
let dirs: Vec<String> = segs
.iter()
.take(segs.len().saturating_sub(1))
.fold(Vec::new(), |mut acc, s| {
let prev = acc.last().cloned().unwrap_or_default();
acc.push(if prev.is_empty() {
s.to_string()
} else {
format!("{prev}/{s}")
});
acc
});
let client = dav_client(cfg)?;
for d in dirs {
let url = dav_url(cfg, &d);
let method = reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL 是合法的 HTTP 方法");
let resp = with_auth(client.request(method, &url), cfg)
.send()
.await
.map_err(|e| format!("WebDAV MKCOL 失败: {e}"))?;
// 201 创建成功;405 已存在;其余只记不阻断(权限问题会在 PUT 时暴露)
let code = resp.status().as_u16();
if code != 201 && code != 405 && code != 301 && code != 409 && code != 200 {
return Err(status_err("创建目录", code, &url));
}
}
Ok(())
}
/// 测试连接:PROPFIND 目标目录(Depth 0)。成功返回目录确认信息。
pub async fn test(cfg: &WebDavConfig, dir: &str) -> Result<String, String> {
let url = dav_url(cfg, dir);
let client = dav_client(cfg)?;
let resp = with_auth(
client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
.header("Depth", "0")
.body(String::new()),
cfg,
)
.send()
.await
.map_err(|e| format!("无法连接 WebDAV 服务:{e}(请确认地址与端口)"))?;
let code = resp.status().as_u16();
match code {
207 | 200 => Ok(format!("连接成功:{url}")),
404 => Err(format!(
"WebDAV 服务可达,但目标目录不存在(404):{url}\n请先在 NAS 上创建该目录,并确认它在 WebDAV「可见文件夹范围」内"
)),
401 => Err("WebDAV 认证失败(401):请检查账号与密码".to_string()),
403 => Err("WebDAV 拒绝访问(403):账号可能没有该目录的读取权限".to_string()),
_ => Err(status_err("连接", code, &url)),
}
}
/// 上传本地文件(流式读取,不整载内存)。返回远程路径。
pub async fn upload_file(
cfg: &WebDavConfig,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<String, String> {
ensure_dirs(cfg, remote_path).await?;
let file = tokio::fs::File::open(local_path)
.await
.map_err(|e| format!("读取本地文件失败: {e}"))?;
// 128KB 分块流:用 unfold 把 AsyncRead 转 Stream,避免引入 tokio-util
let stream = futures_util::stream::unfold(file, |mut f| async move {
use tokio::io::AsyncReadExt;
let mut buf = vec![0u8; 128 * 1024];
match f.read(&mut buf).await {
Ok(0) => None,
Ok(n) => Some((Ok::<Bytes, std::io::Error>(Bytes::copy_from_slice(&buf[..n])), f)),
Err(e) => Some((Err(e), f)),
}
});
let url = dav_url(cfg, remote_path);
let client = dav_client(cfg)?;
let resp = with_auth(client.put(&url), cfg)
.header("Content-Type", mime_of(remote_path))
.body(reqwest::Body::wrap_stream(stream))
.send()
.await
.map_err(|e| format!("WebDAV 上传失败: {e}"))?;
let code = resp.status().as_u16();
if !(200..300).contains(&code) {
return Err(status_err("上传", code, &url));
}
Ok(remote_path.to_string())
}
/// 删除远程文件(404 视为已删除)。
pub async fn delete(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> {
let url = dav_url(cfg, remote_path);
let client = dav_client(cfg)?;
let resp = with_auth(client.delete(&url), cfg)
.send()
.await
.map_err(|e| format!("WebDAV 删除失败: {e}"))?;
let code = resp.status().as_u16();
if code == 404 {
return Ok(());
}
if !(200..300).contains(&code) {
return Err(status_err("删除", code, &url));
}
Ok(())
}
pub fn mime_of(name: &str) -> &'static str {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"mp3" => "audio/mpeg",
"flac" => "audio/flac",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"aac" => "audio/aac",
"ogg" => "audio/ogg",
"ape" => "audio/x-ape",
_ => "application/octet-stream",
}
}
/// 保留给调用方校验配置完整性。
pub fn validate(cfg: &WebDavConfig, dir: &str) -> Result<(), String> {
if cfg.url.trim().is_empty() {
return Err("请先填写 WebDAV 服务地址(如 http://192.168.110.100:5005".into());
}
if cfg.username.trim().is_empty() {
return Err("请先填写 WebDAV 账号".into());
}
if dir.trim().is_empty() {
return Err("请先填写 WebDAV 曲库目标目录".into());
}
Ok(())
}
+7 -8
View File
@@ -24,14 +24,13 @@ pub use feiniu::{extract_fn_id, normalize_base_url, resolve_base_url, Feiniu, Fe
pub use commands::{
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
feiniu_delete_connection, feiniu_fnconnect_resolve, feiniu_fnos_delete, feiniu_fnos_list,
feiniu_fnos_login, feiniu_fnos_logout, feiniu_fnos_status, feiniu_fnos_upload,
feiniu_get_config, feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout,
feiniu_lyric, feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local,
feiniu_test_connection, music_cancel_runtime_install, music_download, music_download_cancel,
music_env_status, music_get_settings, music_get_sources, music_install_runtime,
music_parse_playlist, music_ping, music_resolve, music_save_settings, music_search,
music_stop_bridge,
feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config,
feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric,
feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection,
music_cancel_runtime_install, music_download, music_download_cancel, music_env_status,
music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping,
music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete,
webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload,
};
use serde::{Deserialize, Serialize};