调整,音乐模块
This commit is contained in:
@@ -0,0 +1,595 @@
|
||||
//! 音乐下载模块(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_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,
|
||||
};
|
||||
|
||||
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 → oneshot(reader 线程按 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 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<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())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user