音乐模块调整
This commit is contained in:
@@ -5,11 +5,14 @@
|
||||
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
|
||||
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
|
||||
|
||||
use std::path::Path;
|
||||
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};
|
||||
@@ -48,6 +51,10 @@ pub struct FeiniuConnection {
|
||||
/// 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 {
|
||||
@@ -63,6 +70,7 @@ impl Default for FeiniuConnection {
|
||||
access_code: String::new(),
|
||||
insecure: false,
|
||||
fn_id: String::new(),
|
||||
relay: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +83,8 @@ struct Conn {
|
||||
device_id: String,
|
||||
access_code: String,
|
||||
insecure: bool,
|
||||
/// FnConnect 中继链路标记
|
||||
relay: bool,
|
||||
}
|
||||
|
||||
/// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。
|
||||
@@ -83,8 +93,26 @@ struct ClientSlot {
|
||||
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,
|
||||
@@ -94,6 +122,7 @@ 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(),
|
||||
@@ -101,6 +130,7 @@ impl Default for Feiniu {
|
||||
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 重建
|
||||
@@ -108,40 +138,56 @@ impl Default for Feiniu {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 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);
|
||||
}
|
||||
/// 取(并惰性构建)对应 insecure 的 reqwest client。
|
||||
/// 本地曲库标签缓存文件路径({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 {
|
||||
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()
|
||||
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) =
|
||||
let (base_url, token, username, device_id, access_code, insecure, relay) =
|
||||
match s.feiniu_active() {
|
||||
Some(c) => (
|
||||
c.base_url.clone(),
|
||||
c.token.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(),
|
||||
@@ -150,6 +196,7 @@ impl Feiniu {
|
||||
String::new(),
|
||||
String::new(),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
};
|
||||
if let Ok(mut c) = self.conn.lock() {
|
||||
@@ -159,6 +206,7 @@ impl Feiniu {
|
||||
c.device_id = device_id;
|
||||
c.access_code = access_code;
|
||||
c.insecure = insecure;
|
||||
c.relay = relay;
|
||||
}
|
||||
// 确保 client 构建到位(insecure 变化时重建)
|
||||
self.client(insecure);
|
||||
@@ -178,7 +226,8 @@ impl Feiniu {
|
||||
}
|
||||
|
||||
fn sync_proxy_client(&self) {
|
||||
let (_, client) = self.conn_client();
|
||||
// 代理必须用流式 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;
|
||||
@@ -191,18 +240,44 @@ impl Feiniu {
|
||||
(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,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_triple(&self) -> (String, String, String) {
|
||||
/// 构造鉴权 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.base_url.clone(),
|
||||
c.token.clone(),
|
||||
c.access_code.clone(),
|
||||
c.relay,
|
||||
)
|
||||
}
|
||||
|
||||
/// 对某个 base_url 执行登录(探测/登录连接共用)。
|
||||
@@ -228,10 +303,20 @@ impl Feiniu {
|
||||
"password": sha256_hex(password),
|
||||
"deviceId": device_id,
|
||||
});
|
||||
let resp = self
|
||||
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)
|
||||
.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| {
|
||||
@@ -321,53 +406,6 @@ impl Feiniu {
|
||||
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 {
|
||||
@@ -385,13 +423,16 @@ impl Feiniu {
|
||||
if let Some(hit) = self.cache.hit(guid) {
|
||||
return Ok(Some(hit));
|
||||
}
|
||||
let (base, token, access_code) = self.auth_triple();
|
||||
let (base, token, access_code, relay) = self.auth_triple();
|
||||
if base.is_empty() || token.is_empty() {
|
||||
return Err("未登录".into());
|
||||
}
|
||||
let (_, client) = self.conn_client();
|
||||
// 整首拉取写入缓存:同样用无总超时的 client,否则大文件会被 10s 超时截断
|
||||
let (_, client) = self.conn_stream_client();
|
||||
let mut rb = client.get(format!("{base}/music/api/v1/track/stream?guid={guid}"));
|
||||
rb = rb.header("cookie", format!("music-token={token}"));
|
||||
if let Some(cookie) = Self::auth_cookie(&token, relay) {
|
||||
rb = rb.header("cookie", cookie);
|
||||
}
|
||||
if !access_code.is_empty() {
|
||||
use base64::Engine;
|
||||
rb = rb
|
||||
@@ -439,7 +480,8 @@ impl Feiniu {
|
||||
return Ok(*port);
|
||||
}
|
||||
}
|
||||
let (_, client) = self.conn_client();
|
||||
// 流代理用无总超时的 client(见 build_client)
|
||||
let (_, client) = self.conn_stream_client();
|
||||
let shared = ProxyShared {
|
||||
client,
|
||||
cfg: Arc::new(Mutex::new(self.current_cfg())),
|
||||
@@ -452,14 +494,16 @@ impl Feiniu {
|
||||
}
|
||||
|
||||
async fn authed_get(&self, path: &str, query: Vec<(String, String)>) -> Result<Value, String> {
|
||||
let (base, token, access_code) = self.auth_triple();
|
||||
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);
|
||||
rb = rb.header("cookie", format!("music-token={token}"));
|
||||
if let Some(cookie) = Self::auth_cookie(&token, relay) {
|
||||
rb = rb.header("cookie", cookie);
|
||||
}
|
||||
if !access_code.is_empty() {
|
||||
use base64::Engine;
|
||||
rb = rb
|
||||
@@ -490,6 +534,190 @@ impl Feiniu {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地曲库 / 下载目录里认可的音频扩展名。
|
||||
/// 扫描(递归)与单目录列举共用同一份,避免两处判定不一致。
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user