743 lines
27 KiB
Rust
743 lines
27 KiB
Rust
//! 飞牛音乐(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(),
|
||
}
|
||
} |