//! 音乐下载模块(musicdl 桥接)。 //! //! 架构:Vue 前端 → Tauri 命令 → [MusicManager] → stdio JSON-Lines → `bridge.py` //! → musicdl(纯 Python 聚合下载器)。 //! //! 目录布局({app_data_dir}/music/): //! - `bridge.py`:桥接脚本(include_str! 内置,运行时写出) //! - `runtime/python/`:便携 Python(python.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, /// python 来源:"system" | "bundled" | "none" pub python_source: String, /// 便携 Python 可执行文件路径(未安装则 None) pub bundled_python: Option, /// musicdl 是否可导入 pub musicdl_installed: bool, /// musicdl 版本 pub musicdl_version: Option, /// FFmpeg 是否可用(部分音源需要,非必需) pub ffmpeg: Option, /// 桥接进程是否在运行 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, 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, /// 下载时同步保存歌词 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, /// 当前激活连接的 id #[serde(default)] pub feiniu_active_id: String, /// 本地曲库扫描目录(默认含音乐下载 savedir) #[serde(default)] pub feiniu_local_dirs: Vec, /// 播放缓存开关 #[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>, /// 待响应请求表:id → oneshot(reader 线程按 id 分发) pending: Arc>>>, /// 请求 id 自增 next_id: AtomicU64, /// 便携运行时安装/下载取消标志 runtime_cancel: Arc, /// 正在执行的 pip/python 子进程 pid(取消时 taskkill) install_pid: Arc, /// 桥接启动互斥锁(防止并发 ensure_bridge 双重 spawn) start_lock: Mutex<()>, /// settings 内存缓存 settings_cache: Mutex>, /// 飞牛音乐(NAS)播放器运行期(连接 + 本地流代理) feiniu: Feiniu, /// AppHandle(桥接 reader 线程据此将事件转发给前端;setup 时设置) app: Mutex>, } 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), } } /// 设置 AppHandle(setup 阶段调用;桥接 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 { 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::(&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 { 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 { detect_bundled_python_at(&self.bundled_python_exe()) } // ---------- 环境状态 ---------- /// 查询环境状态。子进程探测(python/musicdl/ffmpeg,便携 Python 冷启动可达数秒) /// 放入阻塞线程池执行,避免阻塞主线程导致 UI 冻结。 pub async fn env_status(&self) -> Result { 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, python_source: &'static str, bundled_python: Option, musicdl_installed: bool, musicdl_version: Option, ffmpeg: Option, } /// 阻塞式环境探测(串行 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 { 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 { 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 { 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 { text.split_whitespace().find_map(|w| { let mut parts = w.split('.'); let major = parts.next()?.parse::().ok()?; let minor = parts.next()?.parse::().ok()?; if (major, minor) >= (3, 8) { Some(w.to_string()) } else { None } }) } /// 检查指定 Python 能否导入 musicdl(同步子进程调用,仅在设置页触发) fn check_musicdl(exe: &PathBuf) -> (bool, Option) { 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 { 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()) }) }