Files
Thing/src-tauri/src/music/mod.rs
T
2026-09-12 18:38:30 +08:00

596 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 音乐下载模块(musicdl 桥接)。
//!
//! 架构:Vue 前端 → Tauri 命令 → [MusicManager] → stdio JSON-Lines → `bridge.py`
//! → musicdl(纯 Python 聚合下载器)。
//!
//! 目录布局({app_data_dir}/music/):
//! - `bridge.py`:桥接脚本(include_str! 内置,运行时写出)
//! - `runtime/python/`:便携 Pythonpython.org embeddable,含 pip
//! - `runtime/get-pip.py`pip 引导脚本(下载后删除)
//! - `runtime/*.zip`:下载过程中的临时安装包(完成后删除)
//! - `outputs/`:默认音乐下载目录
//!
//! 子模块:
//! - [`runtime`]Python 运行时探测与便携版安装
//! - [`bridge`]:桥接进程生命周期(spawn / JSON 协议 / ping / 停止)
//! - [`commands`]Tauri 命令层
mod bridge;
mod commands;
mod feiniu;
mod runtime;
pub use feiniu::{extract_fn_id, normalize_base_url, resolve_base_url, Feiniu, FeiniuConnection};
pub use commands::{
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
feiniu_cover_data, 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};
use specta::Type;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::{Child, ChildStdin};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::AppHandle;
/// 便携 Python 版本(python.org embeddable,含 pip 引导)
pub const BUNDLED_PY_VERSION: &str = "3.12.10";
/// musicdl 锁定版本(其 API 每周都在变,必须锁版本并定期升级)
pub const MUSICDL_VERSION: &str = "2.13.11";
/// pip 镜像源(国内网络直连 PyPI 较慢,默认用清华镜像,可改回官方)
pub const PIP_INDEX_URL: &str = "https://pypi.tuna.tsinghua.edu.cn/simple";
/// 默认搜索源(网易云 / QQ音乐 / 酷狗)
pub const DEFAULT_SOURCES: [&str; 3] = [
"NeteaseMusicClient",
"QQMusicClient",
"KugouMusicClient",
];
/// 桥接进程条目(自管 stdio,不走 ProcessManager
pub(crate) struct BridgeEntry {
pub child: Child,
pub stdin: ChildStdin,
}
/// 解析出的 Python 运行时信息(内部使用,不序列化)
pub(crate) struct PythonEnv {
pub source: &'static str, // "system" | "bundled"
pub exe: PathBuf,
pub version: String,
}
/// 环境状态(返回前端,设置页「环境检查」面板展示)
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct MusicEnvStatus {
/// 系统 Python 版本(如 "3.14.5"),无则 None
pub python: Option<String>,
/// python 来源:"system" | "bundled" | "none"
pub python_source: String,
/// 便携 Python 可执行文件路径(未安装则 None)
pub bundled_python: Option<String>,
/// musicdl 是否可导入
pub musicdl_installed: bool,
/// musicdl 版本
pub musicdl_version: Option<String>,
/// FFmpeg 是否可用(部分音源需要,非必需)
pub ffmpeg: Option<String>,
/// 桥接进程是否在运行
pub bridge_running: bool,
/// 运行时目录({app_data_dir}/music
pub runtime_dir: String,
}
/// 运行时安装进度事件负载(对应 events::MUSIC_RUNTIME_INSTALL_PROGRESS
#[derive(Serialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct MusicInstallProgress {
pub stage: String,
pub percent: u32,
pub downloaded_bytes: u64,
pub total_bytes: Option<u64>,
pub message: String,
}
/// 音乐模块设置(settings.json 持久化;变更即时生效)
#[derive(Serialize, Deserialize, Clone, Type)]
#[serde(rename_all = "camelCase")]
pub struct MusicSettings {
/// 下载保存目录
pub savedir: String,
/// 搜索源(musicdl 客户端名,如 NeteaseMusicClient
pub sources: Vec<String>,
/// 下载时同步保存歌词
pub lyric_download: bool,
/// 下载时同步保存封面
pub cover_download: bool,
/// 搜索/下载请求是否走代理模块(mihomo mixed 端口)
pub use_proxy: bool,
/// 最大并发下载数
pub max_concurrent: u32,
/// 下载引擎:"musicdl" | "rust"P2 生效)
pub download_engine: String,
/// 下载时是否弹窗选择音质(默认关;开启后点下载弹出所选歌曲档位并集选择)
pub select_quality_on_download: bool,
/// 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K"
pub default_download_quality: String,
/// 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置)
#[serde(default)]
pub feiniu_base_url: String,
/// 飞牛音乐登录 token(登录成功后保存)
#[serde(default)]
pub feiniu_token: String,
/// 飞牛音乐登录账号(展示 + 重新登录回填用)
#[serde(default)]
pub feiniu_username: String,
/// 飞牛音乐设备 ID(32 位 hex,登录签名用,一次生成复用)
#[serde(default)]
pub feiniu_device_id: String,
/// 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空)
#[serde(default)]
pub feiniu_access_code: String,
/// 飞牛音乐连接列表(多连接:本地 / frp / 预留 fnconnect
#[serde(default)]
pub feiniu_connections: Vec<FeiniuConnection>,
/// 当前激活连接的 id
#[serde(default)]
pub feiniu_active_id: String,
/// 本地曲库扫描目录(默认含音乐下载 savedir)
#[serde(default)]
pub feiniu_local_dirs: Vec<String>,
/// 播放缓存开关
#[serde(default)]
pub feiniu_cache_enabled: bool,
/// 缓存上限(GB
#[serde(default)]
pub feiniu_cache_max_gb: u32,
/// 播放模式:"stream" 直连流式 | "cache" 缓存后播放
#[serde(default)]
pub feiniu_play_mode: String,
/// 飞牛曲库目标目录(NAS 绝对路径,如 vol1/1000/Music;上传到飞牛用)
#[serde(default)]
pub feiniu_library_nas_path: String,
/// 下载完成后自动上传到飞牛曲库
#[serde(default)]
pub feiniu_auto_upload: bool,
}
impl MusicSettings {
/// 取激活连接:优先按 active_id,否则回退到第一条。
pub fn feiniu_active(&self) -> Option<&FeiniuConnection> {
self.feiniu_connections
.iter()
.find(|c| c.id == self.feiniu_active_id)
.or_else(|| self.feiniu_connections.first())
}
/// 兼容旧版单连接字段:若连接列表为空且存在旧 feiniu_* 字段,则迁移为一条默认连接。
pub fn migrate_feiniu(&mut self) {
if self.feiniu_connections.is_empty() {
if !self.feiniu_base_url.trim().is_empty() {
let base = self.feiniu_base_url.clone();
self.feiniu_connections.push(FeiniuConnection {
id: "default".to_string(),
name: base.clone(),
kind: "lan".to_string(),
base_url: base,
username: self.feiniu_username.clone(),
token: self.feiniu_token.clone(),
device_id: self.feiniu_device_id.clone(),
access_code: self.feiniu_access_code.clone(),
insecure: false,
fn_id: String::new(),
});
self.feiniu_active_id = "default".to_string();
}
} else if self.feiniu_active_id.is_empty()
|| !self.feiniu_connections.iter().any(|c| c.id == self.feiniu_active_id)
{
if let Some(c) = self.feiniu_connections.first() {
self.feiniu_active_id = c.id.clone();
}
}
}
}
/// settings 内存缓存条目(短时复用,避免高频调用反复读盘)
struct SettingsCacheEntry {
read_at: Instant,
settings: MusicSettings,
}
/// 音乐模块管理器
pub struct MusicManager {
root: PathBuf,
client: reqwest::Client,
/// 桥接进程(stdio 自管)
bridge: Mutex<Option<BridgeEntry>>,
/// 待响应请求表:id → oneshotreader 线程按 id 分发)
pending: Arc<Mutex<HashMap<u64, tokio::sync::oneshot::Sender<serde_json::Value>>>>,
/// 请求 id 自增
next_id: AtomicU64,
/// 便携运行时安装/下载取消标志
runtime_cancel: Arc<AtomicBool>,
/// 正在执行的 pip/python 子进程 pid(取消时 taskkill
install_pid: Arc<AtomicU64>,
/// 桥接启动互斥锁(防止并发 ensure_bridge 双重 spawn
start_lock: Mutex<()>,
/// settings 内存缓存
settings_cache: Mutex<Option<SettingsCacheEntry>>,
/// 飞牛音乐(NAS)播放器运行期(连接 + 本地流代理)
feiniu: Feiniu,
/// AppHandle(桥接 reader 线程据此将事件转发给前端;setup 时设置)
app: Mutex<Option<AppHandle>>,
}
impl MusicManager {
pub fn new(app_data_dir: PathBuf) -> Self {
let root = app_data_dir.join("music");
for d in ["runtime", "outputs"] {
fs::create_dir_all(root.join(d)).ok();
}
let mut feiniu = Feiniu::default();
feiniu.set_cache_root(&app_data_dir);
Self {
root,
client: reqwest::Client::builder()
// 默认 30s 兜底超时;流式下载按块推进,不受此限制
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
bridge: Mutex::new(None),
pending: Arc::new(Mutex::new(HashMap::new())),
next_id: AtomicU64::new(1),
runtime_cancel: Arc::new(AtomicBool::new(false)),
install_pid: Arc::new(AtomicU64::new(0)),
start_lock: Mutex::new(()),
settings_cache: Mutex::new(None),
feiniu,
app: Mutex::new(None),
}
}
/// 设置 AppHandlesetup 阶段调用;桥接 reader 线程据此转发事件到前端)
pub fn set_app(&self, app: AppHandle) {
if let Ok(mut guard) = self.app.lock() {
*guard = Some(app);
}
}
pub(crate) fn app_handle(&self) -> Option<AppHandle> {
self.app.lock().ok().and_then(|g| g.clone())
}
// ---------- 设置 ----------
/// 默认保存目录:系统「下载」目录(不可用时退回 {root}/outputs
fn default_savedir(&self) -> String {
dirs::download_dir()
.unwrap_or_else(|| self.outputs_dir())
.to_string_lossy()
.to_string()
}
fn default_settings(&self) -> MusicSettings {
MusicSettings {
savedir: self.default_savedir(),
sources: DEFAULT_SOURCES.iter().map(|s| s.to_string()).collect(),
lyric_download: true,
cover_download: true,
use_proxy: false,
max_concurrent: 4,
download_engine: "musicdl".to_string(),
select_quality_on_download: false,
default_download_quality: "最高".to_string(), // 默认下载最高音质
feiniu_base_url: String::new(),
feiniu_token: String::new(),
feiniu_username: String::new(),
feiniu_device_id: String::new(),
feiniu_access_code: String::new(),
feiniu_connections: Vec::new(),
feiniu_active_id: String::new(),
feiniu_local_dirs: Vec::new(),
feiniu_cache_enabled: false,
feiniu_cache_max_gb: 5,
feiniu_play_mode: "stream".to_string(),
feiniu_library_nas_path: String::new(),
feiniu_auto_upload: false,
}
}
fn settings_path(&self) -> PathBuf {
self.root.join("settings.json")
}
/// 读取设置(500ms 内存缓存;文件缺失/损坏时回退默认值)
pub fn load_settings(&self) -> MusicSettings {
if let Ok(cache) = self.settings_cache.lock() {
if let Some(entry) = cache.as_ref() {
if entry.read_at.elapsed() < Duration::from_millis(500) {
return entry.settings.clone();
}
}
}
let defaults = self.default_settings();
let settings = fs::read_to_string(self.settings_path())
.ok()
.and_then(|s| serde_json::from_str::<MusicSettings>(&s).ok())
.unwrap_or_else(|| defaults.clone());
// 自愈:保存目录为空 / 源为空时补默认值
let mut settings = settings;
if settings.savedir.trim().is_empty() {
settings.savedir = defaults.savedir;
}
// 迁移:旧默认保存目录({root}/outputs)→ 系统下载目录
if settings.savedir == self.outputs_dir().to_string_lossy() {
settings.savedir = self.default_savedir();
}
if settings.sources.is_empty() {
settings.sources = defaults.sources;
}
// 飞牛音乐多连接迁移:旧单连接字段 → 连接列表
settings.migrate_feiniu();
if let Ok(mut cache) = self.settings_cache.lock() {
*cache = Some(SettingsCacheEntry {
read_at: Instant::now(),
settings: settings.clone(),
});
}
settings
}
/// 保存设置并更新缓存
pub fn save_settings(&self, settings: &MusicSettings) -> Result<(), String> {
let json = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?;
fs::write(self.settings_path(), json).map_err(|e| format!("写入设置失败: {}", e))?;
if let Ok(mut cache) = self.settings_cache.lock() {
*cache = Some(SettingsCacheEntry {
read_at: Instant::now(),
settings: settings.clone(),
});
}
Ok(())
}
// ---------- 目录 ----------
pub fn runtime_dir(&self) -> PathBuf {
self.root.join("runtime")
}
/// 默认音乐下载目录
pub fn outputs_dir(&self) -> PathBuf {
self.root.join("outputs")
}
pub fn bridge_script_path(&self) -> PathBuf {
self.root.join("bridge.py")
}
pub fn bundled_python_exe(&self) -> PathBuf {
self.runtime_dir().join("python").join("python.exe")
}
// ---------- Python 探测 ----------
/// 解析桥接要用的 Python 运行时。
/// 便携版优先:musicdl 只装入便携运行时,系统 Python 无法保证装有 musicdl
/// 无便携版时退回系统 Python(此时 search 会报 musicdl 未安装,引导用户装便携版)。
pub fn resolve_python(&self) -> Result<PythonEnv, String> {
if let Some(env) = self.detect_bundled_python() {
return Ok(env);
}
if let Some(env) = detect_system_python() {
return Ok(env);
}
Err("未找到 Python 运行时:系统未安装 Python,且便携版未安装。请点击「安装便携版」".into())
}
/// 便携 Python 是否可用(exe 存在且能跑 --version
pub fn detect_bundled_python(&self) -> Option<PythonEnv> {
detect_bundled_python_at(&self.bundled_python_exe())
}
// ---------- 环境状态 ----------
/// 查询环境状态。子进程探测(python/musicdl/ffmpeg,便携 Python 冷启动可达数秒)
/// 放入阻塞线程池执行,避免阻塞主线程导致 UI 冻结。
pub async fn env_status(&self) -> Result<MusicEnvStatus, String> {
let bundled_exe = self.bundled_python_exe();
let bridge_running = self.bridge.lock().map(|b| b.is_some()).unwrap_or(false);
let runtime_dir = self.root.to_string_lossy().to_string();
let probe =
tauri::async_runtime::spawn_blocking(move || probe_env_blocking(bundled_exe))
.await
.map_err(|e| format!("环境探测任务失败: {}", e))?;
Ok(MusicEnvStatus {
python: probe.python,
python_source: probe.python_source.to_string(),
bundled_python: probe.bundled_python,
musicdl_installed: probe.musicdl_installed,
musicdl_version: probe.musicdl_version,
ffmpeg: probe.ffmpeg,
bridge_running,
runtime_dir,
})
}
// ---------- 退出清理 ----------
/// 应用退出时停止桥接进程(stdin/stdout 随 child drop 关闭,reader 线程读到 EOF 自行退出)
pub fn cleanup_on_exit(&self) {
self.stop_bridge();
}
// ---------- 内部工具 ----------
pub(crate) fn cancel(&self) {
self.runtime_cancel.store(true, Ordering::SeqCst);
let pid = self.install_pid.load(Ordering::SeqCst);
if pid != 0 {
// 杀掉正在执行的 pip/python 子进程,避免安装流程挂住
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
pub(crate) fn is_cancelled(&self) -> bool {
self.runtime_cancel.load(Ordering::SeqCst)
}
pub(crate) fn start_lock(&self) -> &Mutex<()> {
&self.start_lock
}
}
/// 环境探测结果(spawn_blocking 跨线程返回)
struct EnvProbe {
python: Option<String>,
python_source: &'static str,
bundled_python: Option<String>,
musicdl_installed: bool,
musicdl_version: Option<String>,
ffmpeg: Option<String>,
}
/// 阻塞式环境探测(串行 spawn 多个子进程,必须在阻塞线程池执行,禁止占用主线程)
fn probe_env_blocking(bundled_exe: PathBuf) -> EnvProbe {
let bundled = detect_bundled_python_at(&bundled_exe);
let sys_py = detect_system_python();
// 便携版优先(与 resolve_python 一致):musicdl 只装入便携运行时,
// 若按系统优先检查,装好便携版后 UI 仍会误报 musicdl 未安装
let python = bundled.as_ref().or(sys_py.as_ref());
let (musicdl_installed, musicdl_version) = match python {
Some(env) => check_musicdl(&env.exe),
None => (false, None),
};
EnvProbe {
python: python.map(|e| e.version.clone()),
python_source: match python {
Some(e) => e.source,
None => "none",
},
bundled_python: bundled.map(|e| e.exe.to_string_lossy().to_string()),
musicdl_installed,
musicdl_version,
ffmpeg: check_ffmpeg(),
}
}
/// 指定路径的便携 Python 是否可用(exe 存在且能跑 --version
fn detect_bundled_python_at(exe: &PathBuf) -> Option<PythonEnv> {
if !exe.exists() {
return None;
}
let version = run_python_version(exe)?;
Some(PythonEnv {
source: "bundled",
exe: exe.clone(),
version,
})
}
/// 探测系统 Python:依次尝试 python / py / python3,解析 `--version` 输出。
/// 注意:Windows 的「应用商店别名」python 会在无安装时打印提示并以非零码退出,会被自然过滤。
fn detect_system_python() -> Option<PythonEnv> {
for candidate in ["python", "py", "python3"] {
let mut cmd = std::process::Command::new(candidate);
cmd.arg("--version");
crate::process_manager::setup_creation_flags(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.stdin(std::process::Stdio::null());
let out = cmd.output().ok()?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if let Some(version) = parse_python_version(&text) {
return Some(PythonEnv {
source: "system",
exe: PathBuf::from(candidate),
version,
});
}
}
None
}
/// 运行 `python --version` 并解析版本号
fn run_python_version(exe: &PathBuf) -> Option<String> {
let mut cmd = std::process::Command::new(exe);
cmd.arg("--version");
crate::process_manager::setup_creation_flags(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.stdin(std::process::Stdio::null());
let out = cmd.output().ok()?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
parse_python_version(&text)
}
/// 从 "Python 3.14.5" 文本中提取 "3.14.5"
fn parse_python_version(text: &str) -> Option<String> {
text.split_whitespace().find_map(|w| {
let mut parts = w.split('.');
let major = parts.next()?.parse::<u32>().ok()?;
let minor = parts.next()?.parse::<u32>().ok()?;
if (major, minor) >= (3, 8) {
Some(w.to_string())
} else {
None
}
})
}
/// 检查指定 Python 能否导入 musicdl(同步子进程调用,仅在设置页触发)
fn check_musicdl(exe: &PathBuf) -> (bool, Option<String>) {
let mut cmd = std::process::Command::new(exe);
cmd.args([
"-c",
"import musicdl; print(getattr(musicdl, '__version__', 'unknown'))",
]);
crate::process_manager::setup_creation_flags(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null());
match cmd.output() {
Ok(out) if out.status.success() => {
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
(true, Some(version))
}
_ => (false, None),
}
}
/// 检查 FFmpeg 是否可用(部分海外音源需要,非必需)
fn check_ffmpeg() -> Option<String> {
let mut cmd = std::process::Command::new("ffmpeg");
cmd.arg("-version");
crate::process_manager::setup_creation_flags(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null());
cmd.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| {
s.lines()
.next()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
})
}