调整,音乐模块
This commit is contained in:
Generated
+232
-2
@@ -515,6 +515,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -566,6 +572,15 @@ dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -748,6 +763,15 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.67"
|
||||
@@ -908,6 +932,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const_panic"
|
||||
version = "0.2.17"
|
||||
@@ -1192,6 +1222,17 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1240,6 +1281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
@@ -2619,6 +2661,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
@@ -2847,6 +2890,9 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leaky-bucket"
|
||||
@@ -2908,6 +2954,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.18"
|
||||
@@ -3324,6 +3376,16 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -3570,6 +3632,22 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.8",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.2.4"
|
||||
@@ -3623,6 +3701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3989,6 +4068,15 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -4065,6 +4153,27 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -4346,13 +4455,24 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
@@ -4367,6 +4487,16 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
@@ -4377,6 +4507,15 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -4547,6 +4686,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -4666,6 +4806,26 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
@@ -5167,6 +5327,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -5327,6 +5497,12 @@ dependencies = [
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
@@ -5336,6 +5512,16 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -5951,24 +6137,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thing"
|
||||
version = "26.8.5"
|
||||
version = "26.9.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"image",
|
||||
"librqbit",
|
||||
"md-5",
|
||||
"notify",
|
||||
"rand 0.8.8",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"sysinfo",
|
||||
@@ -5982,6 +6175,7 @@ dependencies = [
|
||||
"tauri-plugin-snap-layout",
|
||||
"tauri-specta",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"walkdir",
|
||||
"windows 0.52.0",
|
||||
@@ -6156,6 +6350,18 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -6395,6 +6601,24 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.8",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -6526,6 +6750,12 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
||||
+17
-10
File diff suppressed because one or more lines are too long
@@ -63,6 +63,10 @@ pub mod events {
|
||||
pub const SCROLL_CANCELLED: &str = "screenshot-scroll-cancelled";
|
||||
// 内核安装进度
|
||||
pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress";
|
||||
// 音乐模块:Python 便携运行时安装进度
|
||||
pub const MUSIC_RUNTIME_INSTALL_PROGRESS: &str = "music-runtime-install-progress";
|
||||
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event)
|
||||
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
|
||||
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
|
||||
pub const PROXY_AUTO_SWITCH: &str = "proxy-auto-switch";
|
||||
// 应用更新进度
|
||||
|
||||
+58
-2
@@ -6,6 +6,7 @@ mod download_engine;
|
||||
mod logger;
|
||||
mod mihomo_manager;
|
||||
mod monitor_kernel;
|
||||
mod music;
|
||||
mod network_monitor;
|
||||
mod osd_window;
|
||||
mod process_manager;
|
||||
@@ -41,6 +42,17 @@ use monitor_kernel::{
|
||||
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status, monitor_stop,
|
||||
MonitorKernel,
|
||||
};
|
||||
use music::{
|
||||
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, MusicManager,
|
||||
};
|
||||
use network_monitor::network_status;
|
||||
use osd_window::{
|
||||
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
|
||||
@@ -61,7 +73,7 @@ use screenshot::commands::{
|
||||
screenshot_register_shortcut,
|
||||
screenshot_save_cache, screenshot_save_png, screenshot_scroll_capture,
|
||||
screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start,
|
||||
screenshot_show_overlay, screenshot_take_editor_image_raw,
|
||||
screenshot_set_scroll_hole, screenshot_show_overlay, screenshot_take_editor_image_raw,
|
||||
screenshot_unregister_pin_shortcut, screenshot_unregister_shortcut,
|
||||
};
|
||||
use clipboard::{
|
||||
@@ -149,7 +161,10 @@ fn export_bindings() {
|
||||
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
|
||||
downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
|
||||
downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files,
|
||||
// screenshot(22,豁免 3:get_fullscreen_bmp / take_editor_image_raw 返回 ipc::Response、
|
||||
// music(6,豁免 3:music_ping / music_get_sources / music_search 返回 serde_json::Value)
|
||||
music_env_status, music_install_runtime, music_cancel_runtime_install,
|
||||
music_stop_bridge, music_get_settings, music_save_settings,
|
||||
// screenshot(23,豁免 3:get_fullscreen_bmp / take_editor_image_raw 返回 ipc::Response、
|
||||
// compose_copy / compose_png 接收 ipc::Request)
|
||||
screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut,
|
||||
screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
|
||||
@@ -158,6 +173,7 @@ fn export_bindings() {
|
||||
screenshot_crop_copy_stored, screenshot_pick_list, screenshot_cursor_pos,
|
||||
screenshot_enum_windows, screenshot_capture_window, screenshot_scroll_capture,
|
||||
screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start,
|
||||
screenshot_set_scroll_hole,
|
||||
screenshot_copy_image, screenshot_save_png,
|
||||
screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache,
|
||||
])
|
||||
@@ -243,6 +259,41 @@ pub fn run() {
|
||||
monitor_set_auto_start,
|
||||
monitor_get_hardware_config,
|
||||
monitor_set_hardware_config,
|
||||
music_env_status,
|
||||
music_install_runtime,
|
||||
music_cancel_runtime_install,
|
||||
music_ping,
|
||||
music_stop_bridge,
|
||||
music_get_sources,
|
||||
music_search,
|
||||
music_parse_playlist,
|
||||
music_resolve,
|
||||
music_get_settings,
|
||||
music_save_settings,
|
||||
music_download,
|
||||
music_download_cancel,
|
||||
feiniu_list_connections,
|
||||
feiniu_save_connection,
|
||||
feiniu_delete_connection,
|
||||
feiniu_activate_connection,
|
||||
feiniu_test_connection,
|
||||
feiniu_login,
|
||||
feiniu_logout,
|
||||
feiniu_get_config,
|
||||
feiniu_list_tracks,
|
||||
feiniu_lyric,
|
||||
feiniu_media_prefix,
|
||||
feiniu_scan_local,
|
||||
feiniu_cache_status,
|
||||
feiniu_cache_clear,
|
||||
feiniu_cache_fetch,
|
||||
feiniu_fnos_login,
|
||||
feiniu_fnos_logout,
|
||||
feiniu_fnos_status,
|
||||
feiniu_fnos_upload,
|
||||
feiniu_fnos_delete,
|
||||
feiniu_fnos_list,
|
||||
feiniu_fnconnect_resolve,
|
||||
network_status,
|
||||
osd_apply_overlay_style,
|
||||
osd_begin_drag,
|
||||
@@ -344,6 +395,7 @@ pub fn run() {
|
||||
screenshot_scroll_cancel,
|
||||
screenshot_scroll_finish,
|
||||
screenshot_scroll_start,
|
||||
screenshot_set_scroll_hole,
|
||||
screenshot_take_editor_image_raw,
|
||||
screenshot_copy_image,
|
||||
screenshot_save_png,
|
||||
@@ -395,6 +447,10 @@ pub fn run() {
|
||||
});
|
||||
let _ = rx.recv_timeout(std::time::Duration::from_secs(3));
|
||||
}
|
||||
if let Some(music) = app.try_state::<MusicManager>() {
|
||||
// 停止音乐桥接进程(kill 快速返回,wait 在后台线程完成)
|
||||
music.cleanup_on_exit();
|
||||
}
|
||||
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
||||
clip.stop();
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
||||
//! 桥接进程生命周期:spawn(stdio JSON-Lines 协议)→ 请求分发 → 事件转发 → 停止。
|
||||
//! 子模块通过 `impl super::MusicManager` 追加方法。
|
||||
//!
|
||||
//! 协议(与 bridge.py 对应):
|
||||
//! 请求 `{"id":1,"method":"ping","params":{}}`
|
||||
//! 响应 `{"id":1,"ok":true,"result":{...}}` 或 `{"id":1,"ok":false,"error":"..."}`
|
||||
//! 无 id 的事件行 `{"event":"download","type":"progress",...}` 由 reader 线程
|
||||
//! 原样转发为 Tauri 事件 `music-download-event`(见 constants::events)。
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use tauri::Emitter;
|
||||
|
||||
use super::{BridgeEntry, MusicManager, PythonEnv};
|
||||
|
||||
/// 桥接脚本源码(内置,运行时写出到 {root}/bridge.py,避免资源目录配置)
|
||||
const BRIDGE_SCRIPT: &str = include_str!("bridge.py");
|
||||
|
||||
/// 桥接请求错误分类:决定是否允许重启桥接进程重试。
|
||||
/// 应用层错误与超时绝不能触发重启——重启会杀掉正在进行的下载任务。
|
||||
pub(crate) enum BridgeError {
|
||||
/// 传输层错误(进程退出/管道损坏/写入失败/通道关闭)→ 可重启重试
|
||||
Transport(String),
|
||||
/// 应用层错误(桥接正常响应 ok:false)→ 不重启
|
||||
App(String),
|
||||
/// 响应超时 → 不重启(进程可能只是忙,如正在执行长耗时搜索)
|
||||
Timeout(String),
|
||||
}
|
||||
|
||||
impl BridgeError {
|
||||
fn into_message(self) -> String {
|
||||
match self {
|
||||
BridgeError::Transport(m) | BridgeError::App(m) | BridgeError::Timeout(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicManager {
|
||||
/// 桥接进程是否在运行(存在且未退出)
|
||||
pub fn bridge_running(&self) -> bool {
|
||||
let mut guard = match self.bridge.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match guard.as_mut() {
|
||||
Some(entry) => entry.child.try_wait().ok().map(|w| w.is_none()).unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保桥接进程已启动(已启动则直接返回;并发调用由 start_lock 串行化)
|
||||
pub fn ensure_bridge(&self) -> Result<(), String> {
|
||||
if self.bridge_running() {
|
||||
return Ok(());
|
||||
}
|
||||
let _guard = self.start_lock().lock().map_err(|e| e.to_string())?;
|
||||
// 二次检查(等待锁期间可能已被其他调用方启动)
|
||||
if self.bridge_running() {
|
||||
return Ok(());
|
||||
}
|
||||
// 清理可能残留的旧条目
|
||||
self.bridge.lock().map_err(|e| e.to_string())?.take();
|
||||
let python = self.resolve_python()?;
|
||||
self.spawn_bridge(&python)
|
||||
}
|
||||
|
||||
/// 启动桥接进程:stdin/stdout 管道直连,stderr 写入 runtime/bridge_stderr.log
|
||||
fn spawn_bridge(&self, python: &PythonEnv) -> Result<(), String> {
|
||||
let script = self.bridge_script_path();
|
||||
// 每次启动前重写脚本,保证与当前版本一致(内容固定,成本极低)
|
||||
std::fs::write(&script, BRIDGE_SCRIPT).map_err(|e| format!("写出桥接脚本失败: {}", e))?;
|
||||
|
||||
let stderr_log = self.runtime_dir().join("bridge_stderr.log");
|
||||
let stderr_file = std::fs::File::create(&stderr_log).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut cmd = Command::new(&python.exe);
|
||||
cmd.arg(&script);
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
// cwd 统一设为模块根目录:musicdl 会在 cwd 落 search_results.pkl 等缓存文件,
|
||||
// 不设置时(系统 Python)会污染应用工作目录(开发期为仓库根目录)
|
||||
if let Some(parent) = script.parent() {
|
||||
cmd.current_dir(parent);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动桥接进程失败 (python: {}): {}", python.exe.display(), e))?;
|
||||
crate::process_manager::assign_to_job(&child);
|
||||
|
||||
let stdin = child.stdin.take().ok_or_else(|| "无法获取桥接 stdin".to_string())?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| "无法获取桥接 stdout".to_string())?;
|
||||
|
||||
*self.bridge.lock().map_err(|e| e.to_string())? = Some(BridgeEntry { child, stdin });
|
||||
|
||||
// 启动 stdout reader 线程:按行读取,按 id 分发到 pending;无 id 的事件行转发到前端
|
||||
let pending = self.pending.clone();
|
||||
let app = self.app_handle();
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||
crate::logger::log_warn("music-bridge", &format!("无法解析 stdout: {}", line));
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = v.get("id").and_then(|i| i.as_u64()) {
|
||||
if let Some(tx) = pending.lock().ok().and_then(|mut m| m.remove(&id)) {
|
||||
let _ = tx.send(v);
|
||||
}
|
||||
} else if v.get("event").is_some() {
|
||||
// 事件行(下载进度等):原样转发给前端
|
||||
if let Some(app) = app.as_ref() {
|
||||
let _ = app.emit(crate::constants::events::MUSIC_DOWNLOAD_EVENT, &v);
|
||||
}
|
||||
} else {
|
||||
// 其他无 id 行仅记日志
|
||||
crate::logger::log_info("music-bridge", &line);
|
||||
}
|
||||
}
|
||||
crate::logger::log_info("music-bridge", "stdout 已关闭,reader 线程退出");
|
||||
});
|
||||
|
||||
crate::logger::log_info(
|
||||
"music-bridge",
|
||||
&format!("桥接进程已启动 (python: {})", python.exe.display()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止桥接进程:kill 快速返回,wait 移到后台线程;所有 pending 请求置为失败
|
||||
pub fn stop_bridge(&self) {
|
||||
let entry = self.bridge.lock().ok().and_then(|mut b| b.take());
|
||||
if let Some(mut entry) = entry {
|
||||
let _ = entry.child.kill();
|
||||
// drop stdin/stdout 关闭管道端,reader 线程读到 EOF 退出
|
||||
drop(entry.stdin);
|
||||
std::thread::spawn(move || {
|
||||
let _ = entry.child.wait();
|
||||
});
|
||||
crate::logger::log_info("music-bridge", "桥接进程已停止");
|
||||
}
|
||||
// 通知前端:活动中的下载任务应标记为中断
|
||||
if let Some(app) = self.app_handle() {
|
||||
let _ = app.emit(
|
||||
crate::constants::events::MUSIC_DOWNLOAD_EVENT,
|
||||
serde_json::json!({ "event": "download", "type": "bridge-stopped" }),
|
||||
);
|
||||
}
|
||||
// 唤醒所有等待中的请求(以 Null 表示已中止)
|
||||
if let Ok(mut map) = self.pending.lock() {
|
||||
for (_, tx) in map.drain() {
|
||||
let _ = tx.send(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送一条请求并等待响应(默认 5s 超时)。传输层错误时自动重启重试一次。
|
||||
pub async fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
self.request_with_timeout(method, params, std::time::Duration::from_secs(5))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 发送一条请求并等待响应(自定义超时,供 search 等长耗时操作使用)。
|
||||
/// 仅传输层错误(进程退出/管道损坏)会重启桥接并重试一次;
|
||||
/// 应用层错误(桥接返回 ok:false)与超时不重启——重启会误杀正在下载的任务。
|
||||
pub async fn request_with_timeout(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
self.ensure_bridge()?;
|
||||
match self.request_inner(method, params.clone(), timeout).await {
|
||||
Ok(v) => Ok(v),
|
||||
Err(BridgeError::Transport(_)) => {
|
||||
// 一次重启机会(进程可能已退出/管道损坏)
|
||||
self.stop_bridge();
|
||||
self.ensure_bridge()?;
|
||||
self.request_inner(method, params, timeout)
|
||||
.await
|
||||
.map_err(BridgeError::into_message)
|
||||
}
|
||||
Err(e) => Err(e.into_message()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_inner(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<serde_json::Value, BridgeError> {
|
||||
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<serde_json::Value>();
|
||||
self.pending
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::Transport(e.to_string()))?
|
||||
.insert(id, tx);
|
||||
|
||||
// 写入 stdin(同步函数,MutexGuard 在返回时释放,避免跨 await 持有非 Send 值)
|
||||
self.write_request(id, method, ¶ms)
|
||||
.map_err(BridgeError::Transport)?;
|
||||
|
||||
let v = tokio::time::timeout(timeout, rx)
|
||||
.await
|
||||
.map_err(|_| BridgeError::Timeout("桥接响应超时".to_string()))?
|
||||
.map_err(|_| BridgeError::Transport("桥接响应通道已关闭".to_string()))?;
|
||||
|
||||
// 停止桥接时发送 Null 表示中止
|
||||
if v.is_null() {
|
||||
return Err(BridgeError::Transport("桥接进程已停止".into()));
|
||||
}
|
||||
if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||
Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null))
|
||||
} else {
|
||||
Err(BridgeError::App(
|
||||
v.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("桥接返回未知错误")
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入一条请求到桥接 stdin(同步;进程已退出 / 管道损坏时返回错误)
|
||||
fn write_request(&self, id: u64, method: &str, params: &serde_json::Value) -> Result<(), String> {
|
||||
let line = format!(
|
||||
"{{\"id\":{},\"method\":{},\"params\":{}}}\n",
|
||||
id,
|
||||
serde_json::to_string(method).map_err(|e| e.to_string())?,
|
||||
params
|
||||
);
|
||||
let mut guard = self.bridge.lock().map_err(|e| e.to_string())?;
|
||||
let Some(entry) = guard.as_mut() else {
|
||||
return Err("桥接进程未启动".into());
|
||||
};
|
||||
// 进程已退出 → 立即失败,交给外层重启
|
||||
if entry.child.try_wait().map_err(|e| e.to_string())?.is_some() {
|
||||
return Err("桥接进程已退出".into());
|
||||
}
|
||||
entry
|
||||
.stdin
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("写入桥接 stdin 失败: {}", e))?;
|
||||
entry.stdin.flush().map_err(|e| format!("刷新桥接 stdin 失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ping 桥接进程(P0 环境层连通性验证)
|
||||
pub async fn ping(&self) -> Result<serde_json::Value, String> {
|
||||
let v = self.request("ping", serde_json::Value::Null).await?;
|
||||
crate::logger::log_info("music-bridge", &format!("ping 成功: {}", v));
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! 音乐模块 Tauri 命令层。
|
||||
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::{
|
||||
normalize_base_url, resolve_base_url, extract_fn_id, FeiniuConnection, MusicEnvStatus,
|
||||
MusicManager, MusicSettings,
|
||||
};
|
||||
|
||||
/// 查询环境状态(Python / musicdl / FFmpeg / 桥接进程),设置页「环境检查」面板调用。
|
||||
/// 异步命令:子进程探测在阻塞线程池执行,避免冻结主线程/UI。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn music_env_status(state: State<'_, MusicManager>) -> Result<MusicEnvStatus, String> {
|
||||
state.env_status().await
|
||||
}
|
||||
|
||||
/// 安装便携 Python + musicdl(幂等),全程推送 music-runtime-install-progress 事件
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn music_install_runtime(
|
||||
state: State<'_, MusicManager>,
|
||||
app: AppHandle,
|
||||
) -> Result<MusicEnvStatus, String> {
|
||||
state.install_runtime(&app).await?;
|
||||
Ok(state.env_status().await?)
|
||||
}
|
||||
|
||||
/// 取消便携运行时安装/下载
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_cancel_runtime_install(state: State<'_, MusicManager>) -> Result<(), String> {
|
||||
state.cancel();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ping 桥接进程(未启动则自动拉起),返回 {"version","python"};
|
||||
/// 返回 Value 且未标注 specta:前端直接按 JSON 使用
|
||||
#[tauri::command]
|
||||
pub async fn music_ping(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
state.ping().await
|
||||
}
|
||||
|
||||
/// 停止桥接进程
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_stop_bridge(state: State<'_, MusicManager>) -> Result<(), String> {
|
||||
state.stop_bridge();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出 musicdl 已注册的全部搜索源(客户端名);返回 Value,未标注 specta
|
||||
#[tauri::command]
|
||||
pub async fn music_get_sources(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
state.request("get_sources", serde_json::Value::Null).await
|
||||
}
|
||||
|
||||
/// 多源搜索(最长 90s)。sources 为空时桥接使用默认 3 个大陆源;返回 Value,未标注 specta
|
||||
#[tauri::command]
|
||||
pub async fn music_search(
|
||||
state: State<'_, MusicManager>,
|
||||
keyword: String,
|
||||
sources: Option<Vec<String>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let params = serde_json::json!({ "keyword": keyword, "sources": sources.unwrap_or_default() });
|
||||
state
|
||||
.request_with_timeout("search", params, std::time::Duration::from_secs(90))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 解析歌单链接(网易云/QQ 等),返回歌曲列表;返回 Value,未标注 specta
|
||||
#[tauri::command]
|
||||
pub async fn music_parse_playlist(
|
||||
state: State<'_, MusicManager>,
|
||||
url: String,
|
||||
sources: Option<Vec<String>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let params = serde_json::json!({ "url": url, "sources": sources.unwrap_or_default() });
|
||||
state
|
||||
.request_with_timeout("parse_playlist", params, std::time::Duration::from_secs(90))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 读取音乐模块设置
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_get_settings(state: State<'_, MusicManager>) -> MusicSettings {
|
||||
state.load_settings()
|
||||
}
|
||||
|
||||
/// 保存音乐模块设置(立即生效)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn music_save_settings(
|
||||
state: State<'_, MusicManager>,
|
||||
settings: MusicSettings,
|
||||
) -> Result<(), String> {
|
||||
state.save_settings(&settings)
|
||||
}
|
||||
|
||||
/// 解析歌曲真实下载链接(懒解析:搜索只取元数据,试听/下载前调用)。
|
||||
/// song(单曲)或 songs(批量)二选一;返回 Value,未标注 specta。
|
||||
#[tauri::command]
|
||||
pub async fn music_resolve(
|
||||
state: State<'_, MusicManager>,
|
||||
song: Option<serde_json::Value>,
|
||||
songs: Option<Vec<serde_json::Value>>,
|
||||
quality: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut list: Vec<serde_json::Value> = songs.unwrap_or_default();
|
||||
if let Some(s) = song {
|
||||
list.insert(0, s);
|
||||
}
|
||||
if list.is_empty() {
|
||||
return Err("未提供歌曲".into());
|
||||
}
|
||||
let params = serde_json::json!({ "songs": list, "quality": quality.unwrap_or_default() });
|
||||
state
|
||||
.request_with_timeout("resolve", params, std::time::Duration::from_secs(180))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 启动下载任务(桥接后台工作池执行,立即返回 taskId;进度经 music-download-event 推送)。
|
||||
/// songs 为搜索结果的歌曲 dict(桥接端用 SongInfo.fromdict 重建)。
|
||||
/// 返回 Value,未标注 specta。
|
||||
#[tauri::command]
|
||||
pub async fn music_download(
|
||||
state: State<'_, MusicManager>,
|
||||
task_id: String,
|
||||
songs: Vec<serde_json::Value>,
|
||||
savedir: String,
|
||||
lyric: Option<bool>,
|
||||
cover: Option<bool>,
|
||||
proxy_url: Option<String>,
|
||||
max_concurrent: Option<u32>,
|
||||
quality: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if songs.is_empty() {
|
||||
return Err("未选择任何歌曲".into());
|
||||
}
|
||||
let params = serde_json::json!({
|
||||
"taskId": task_id,
|
||||
"songs": songs,
|
||||
"savedir": savedir,
|
||||
"lyric": lyric.unwrap_or(true),
|
||||
"cover": cover.unwrap_or(true),
|
||||
"proxy": proxy_url.unwrap_or_default(),
|
||||
"maxConcurrent": max_concurrent.unwrap_or(1).clamp(1, 16),
|
||||
"quality": quality.unwrap_or_default(),
|
||||
});
|
||||
state
|
||||
.request_with_timeout("download", params, std::time::Duration::from_secs(15))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 取消下载任务(队列级:正在下载的歌曲会完成,其余标记取消)
|
||||
#[tauri::command]
|
||||
pub async fn music_download_cancel(
|
||||
state: State<'_, MusicManager>,
|
||||
task_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state
|
||||
.request("cancel", serde_json::json!({ "taskId": task_id }))
|
||||
.await
|
||||
}
|
||||
|
||||
// ============ 飞牛音乐(NAS)客户端(多连接) ============
|
||||
// 全部命令返回 serde_json::Value、不加 specta:前端用裸 invoke,映射在 feiniuStore。
|
||||
|
||||
/// 连接列表 + 激活 id。返回 `{ activeId, list: [{id,name,kind,baseUrl,username,loggedIn,accessCode,insecure}] }`。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
let s = state.load_settings();
|
||||
let list: Vec<_> = s
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"kind": c.kind,
|
||||
"baseUrl": c.base_url,
|
||||
"username": c.username,
|
||||
"loggedIn": !c.token.is_empty(),
|
||||
"accessCode": c.access_code,
|
||||
"insecure": c.insecure,
|
||||
"fnId": c.fn_id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "activeId": s.feiniu_active_id, "list": list }))
|
||||
}
|
||||
|
||||
/// 新增/更新一条连接(不触碰已登录的 token;改地址后需重新登录)。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_save_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
connection: FeiniuConnection,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
let mut conn = connection;
|
||||
conn.base_url = normalize_base_url(&conn.base_url);
|
||||
if conn.id.is_empty() {
|
||||
conn.id = new_conn_id();
|
||||
}
|
||||
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == conn.id) {
|
||||
conn.token = existing.token.clone(); // 保留既有 token
|
||||
*existing = conn;
|
||||
} else {
|
||||
settings.feiniu_connections.push(conn);
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 删除一条连接;若删的是激活连接,自动切换激活到第一条。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_delete_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
settings.feiniu_connections.retain(|c| c.id != id);
|
||||
if settings.feiniu_active_id == id {
|
||||
settings.feiniu_active_id = settings
|
||||
.feiniu_connections
|
||||
.first()
|
||||
.map(|c| c.id.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 设某连接为激活连接。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_activate_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
if !settings.feiniu_connections.iter().any(|c| c.id == id) {
|
||||
return Err("连接不存在".into());
|
||||
}
|
||||
settings.feiniu_active_id = id;
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 登录激活/某连接:校验通过后写回该连接的 token/device_id 并启动本地流代理。
|
||||
/// fnconnect 连接会先用 fnId 解析出可达 base_url 再登录。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_login(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
let mut conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "连接不存在,请先保存连接".to_string())?;
|
||||
// fnconnect:用 fnId 解析 base_url
|
||||
if conn.kind == "fnconnect" {
|
||||
let fid = extract_fn_id(&conn.fn_id).ok_or_else(|| "FnConnect 连接缺少有效 fnId".to_string())?;
|
||||
let (url, _relay) = resolve_base_url(&fid).await?;
|
||||
conn.base_url = url;
|
||||
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
c.base_url = conn.base_url.clone();
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
}
|
||||
// 用该连接配置装备运行期
|
||||
let mut tmp = settings.clone();
|
||||
tmp.feiniu_active_id = conn.id.clone();
|
||||
state.feiniu.sync_with_settings(&tmp);
|
||||
|
||||
let (token, device_id) = state.feiniu.login(&conn.base_url, &username, &password).await?;
|
||||
|
||||
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
existing.token = token.clone();
|
||||
existing.device_id = device_id;
|
||||
existing.username = username;
|
||||
}
|
||||
settings.feiniu_active_id = connection_id.clone();
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
let prefix = state.feiniu.media_prefix().await?;
|
||||
Ok(json!({ "ok": true, "userToken": token, "mediaPrefix": prefix }))
|
||||
}
|
||||
|
||||
/// 登出某连接(清 token,保留地址/账号),代理 Cookie 同步失效。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_logout(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut settings = state.load_settings();
|
||||
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
c.token.clear();
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
state.feiniu.logout();
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 测试某连接是否能登录(不持久化 token),探测后恢复原激活连接的运行期状态。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_test_connection(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
let conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "连接不存在".to_string())?;
|
||||
let mut tmp = settings.clone();
|
||||
tmp.feiniu_active_id = conn.id.clone();
|
||||
state.feiniu.sync_with_settings(&tmp);
|
||||
let r = state.feiniu.login(&conn.base_url, &username, &password).await;
|
||||
// 探测可能污染运行期:恢复为持久化的激活连接
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
match r {
|
||||
Ok(_) => Ok(json!({ "ok": true })),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询当前激活连接配置:{ activeId, baseUrl, username, loggedIn }。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_get_config(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
let active_id = settings.feiniu_active_id;
|
||||
let mut cfg = state.feiniu.config();
|
||||
cfg["activeId"] = json!(active_id);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// 分页拉取激活连接曲目列表:{ page, size, keyword? } → NAS 原始 data。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_list_tracks(
|
||||
state: State<'_, MusicManager>,
|
||||
page: Option<u32>,
|
||||
size: Option<u32>,
|
||||
keyword: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
state
|
||||
.feiniu
|
||||
.list_tracks(
|
||||
page.unwrap_or(1).max(1),
|
||||
size.unwrap_or(50).clamp(1, 100),
|
||||
keyword.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 获取某曲目歌词:{ lyric }(无则空字符串)。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_lyric(
|
||||
state: State<'_, MusicManager>,
|
||||
guid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
let text = state.feiniu.lyric(&guid).await?;
|
||||
Ok(json!({ "lyric": text }))
|
||||
}
|
||||
|
||||
/// 本地媒体地址前缀:{ mediaPrefix }(首次调用惰性启动本地流代理)。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_media_prefix(
|
||||
state: State<'_, MusicManager>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
let prefix = state.feiniu.media_prefix().await?;
|
||||
Ok(json!({ "mediaPrefix": prefix }))
|
||||
}
|
||||
|
||||
/// 扫描本地曲库目录中的音频文件:{ items }(目录 = 下载 savedir + 用户自定义 dirs)。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
let s = state.load_settings();
|
||||
let mut dirs: Vec<String> = vec![s.savedir.clone()];
|
||||
for d in &s.feiniu_local_dirs {
|
||||
if !dirs.iter().any(|x| x == d) {
|
||||
dirs.push(d.clone());
|
||||
}
|
||||
}
|
||||
Ok(state.feiniu.scan_local(&dirs))
|
||||
}
|
||||
|
||||
/// 播放缓存状态:{ count, usedBytes, usedMb }。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_cache_status(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
Ok(state.feiniu.cache_status())
|
||||
}
|
||||
|
||||
/// 清空播放缓存。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_cache_clear(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.cache_clear();
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 缓存一首歌(命中则直接返回):{ path } 或 { cached }。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_cache_fetch(
|
||||
state: State<'_, MusicManager>,
|
||||
guid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.sync_with_settings(&state.load_settings());
|
||||
let max_gb = state.load_settings().feiniu_cache_max_gb;
|
||||
let hit = state.feiniu.cache_fetch(&guid, max_gb).await?;
|
||||
Ok(json!({ "path": hit, "cached": hit.is_some() }))
|
||||
}
|
||||
|
||||
/// 生成一条新连接的 id(时间戳 + 进程号,避免引 rand)。
|
||||
fn new_conn_id() -> String {
|
||||
let n = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("c{n:x}")
|
||||
}
|
||||
|
||||
// ============ P6:下载到飞牛 + 曲库增删(fnOS 文件服务) ============
|
||||
|
||||
/// fnOS 登录:为某连接建立 NAS 文件服务会话(WS + RSA/AES)。成功后不持久化凭据。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_fnos_login(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
let conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "连接不存在".to_string())?;
|
||||
state
|
||||
.feiniu
|
||||
.fnos_login(&connection_id, &conn.base_url, &username, &password)
|
||||
.await?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 登出 fnOS 文件服务会话。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_fnos_logout(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
state.feiniu.fnos_logout(&connection_id);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// fnOS 文件服务登录状态:{ loggedIn }(按连接)。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_fnos_status(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
Ok(json!({ "loggedIn": state.feiniu.fnos_logged_in(&connection_id) }))
|
||||
}
|
||||
|
||||
/// 上传本地文件到 NAS(激活连接的 fnOS 会话)。返回上传文件名。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_fnos_upload(
|
||||
state: State<'_, MusicManager>,
|
||||
local_path: String,
|
||||
nas_path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
let conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == settings.feiniu_active_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有激活连接".to_string())?;
|
||||
let name = state
|
||||
.feiniu
|
||||
.fnos_upload(&conn.id, std::path::Path::new(&local_path), &nas_path)
|
||||
.await?;
|
||||
Ok(json!({ "ok": true, "name": name }))
|
||||
}
|
||||
|
||||
/// 删除 NAS 文件(激活连接的 fnOS 会话)。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_fnos_delete(
|
||||
state: State<'_, MusicManager>,
|
||||
nas_path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
let conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == settings.feiniu_active_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有激活连接".to_string())?;
|
||||
state.feiniu.fnos_delete(&conn.id, &nas_path).await?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// 列出 NAS 目录(激活连接的 fnOS 会话)。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_fnos_list(
|
||||
state: State<'_, MusicManager>,
|
||||
path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let settings = state.load_settings();
|
||||
let conn = settings
|
||||
.feiniu_connections
|
||||
.iter()
|
||||
.find(|c| c.id == settings.feiniu_active_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有激活连接".to_string())?;
|
||||
let v = state.feiniu.fnos_list(&conn.id, &path).await?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
// ============ P7:FnConnect 远程连接解析 ============
|
||||
|
||||
/// 解析 fnId → 可达 base_url(探测后返回)。命令层在 fnconnect 连接登录前调用。
|
||||
#[tauri::command]
|
||||
pub async fn feiniu_fnconnect_resolve(
|
||||
state: State<'_, MusicManager>,
|
||||
fn_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let _ = &state;
|
||||
let (url, relay) = resolve_base_url(&fn_id).await?;
|
||||
Ok(json!({ "baseUrl": url, "relay": relay }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 飞牛音乐播放缓存(容量限制 + LRU 逐出)。
|
||||
//!
|
||||
//! 缓存目录:`{app_data}/music/cache`,文件 `guid.<ext>`(ext 缺省记 `bin`)。
|
||||
//! 元数据:`index.json` → `{ "guid": { "size", "lastUsed", "file" } }`。
|
||||
//! 超上限按 lastUsed 升序逐出,直到总占用低于上限。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
/// 单条缓存元数据
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CacheEntry {
|
||||
pub size: u64,
|
||||
pub last_used: u64,
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
pub struct CacheManager {
|
||||
root: PathBuf,
|
||||
index_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CacheManager {
|
||||
pub fn new(app_data_dir: &Path) -> Self {
|
||||
let root = app_data_dir.join("music").join("cache");
|
||||
fs::create_dir_all(&root).ok();
|
||||
let index_path = root.join("index.json");
|
||||
Self { root, index_path }
|
||||
}
|
||||
|
||||
fn load_index(&self) -> HashMap<String, CacheEntry> {
|
||||
fs::read_to_string(&self.index_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_index(&self, idx: &HashMap<String, CacheEntry>) {
|
||||
if let Ok(s) = serde_json::to_string(idx) {
|
||||
fs::write(&self.index_path, s).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 命中缓存:更新 lastUsed 并返回文件路径。
|
||||
pub fn hit(&self, guid: &str) -> Option<String> {
|
||||
let mut idx = self.load_index();
|
||||
if let Some(e) = idx.get_mut(guid) {
|
||||
let path = self.root.join(&e.file);
|
||||
if path.exists() {
|
||||
e.last_used = self.now_ms();
|
||||
self.save_index(&idx);
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
idx.remove(guid);
|
||||
self.save_index(&idx);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 写入缓存(流式 chunk);按上限(GB)逐出。返回写入总字节数。
|
||||
pub async fn put<E: std::fmt::Display>(
|
||||
&self,
|
||||
guid: &str,
|
||||
ext: &str,
|
||||
max_gb: u32,
|
||||
mut stream: impl futures_util::Stream<Item = Result<bytes::Bytes, E>> + Unpin,
|
||||
) -> Result<u64, String> {
|
||||
let safe_guid = sanitize(guid);
|
||||
let ext = if ext.is_empty() { "bin" } else { ext };
|
||||
let file = format!("{safe_guid}.{ext}");
|
||||
let path = self.root.join(&file);
|
||||
let mut total: u64 = 0;
|
||||
let mut f = fs::File::create(&path).map_err(|e| format!("创建缓存文件失败: {e}"))?;
|
||||
while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await {
|
||||
let chunk = chunk.map_err(|e| format!("读取流失败: {e}"))?;
|
||||
total += chunk.len() as u64;
|
||||
f.write_all(chunk.as_ref()).map_err(|e| format!("写入缓存失败: {e}"))?;
|
||||
}
|
||||
f.flush().ok();
|
||||
|
||||
let mut idx = self.load_index();
|
||||
idx.insert(
|
||||
guid.to_string(),
|
||||
CacheEntry {
|
||||
size: total,
|
||||
last_used: self.now_ms(),
|
||||
file,
|
||||
},
|
||||
);
|
||||
self.save_index(&idx);
|
||||
self.evict(max_gb);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 按上限(GB)逐出。max_gb==0 视为全部清空。
|
||||
fn evict(&self, max_gb: u32) {
|
||||
if max_gb == 0 {
|
||||
self.clear();
|
||||
return;
|
||||
}
|
||||
let max_bytes = max_gb as u64 * 1024 * 1024 * 1024;
|
||||
let mut idx = self.load_index();
|
||||
let mut total: u64 = idx.values().map(|e| e.size).sum();
|
||||
let mut order: Vec<(String, u64)> = idx.iter().map(|(g, e)| (g.clone(), e.last_used)).collect();
|
||||
order.sort_by_key(|(_, t)| *t);
|
||||
for (guid, _) in order {
|
||||
if total <= max_bytes {
|
||||
break;
|
||||
}
|
||||
if let Some(e) = idx.remove(&guid) {
|
||||
let _ = fs::remove_file(self.root.join(&e.file));
|
||||
total = total.saturating_sub(e.size);
|
||||
}
|
||||
}
|
||||
self.save_index(&idx);
|
||||
}
|
||||
|
||||
/// 当前占用与条目数。
|
||||
pub fn status(&self) -> serde_json::Value {
|
||||
let idx = self.load_index();
|
||||
let total: u64 = idx.values().map(|e| e.size).sum();
|
||||
json!({
|
||||
"count": idx.len(),
|
||||
"usedBytes": total,
|
||||
"usedMb": (total as f64 / 1024.0 / 1024.0 * 10.0).round() / 10.0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
let idx = self.load_index();
|
||||
for e in idx.values() {
|
||||
let _ = fs::remove_file(self.root.join(&e.file));
|
||||
}
|
||||
fs::remove_file(&self.index_path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 飞牛音乐原生接口纯函数工具(登录签名、地址规范化、设备 ID)。
|
||||
//! 接口路径/认证方式对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方实现。
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// 规范化服务器地址:去首尾空白、去尾部各层斜杠、去误粘贴的 `/music/api/v1` 后缀。
|
||||
pub fn normalize_base_url(input: &str) -> String {
|
||||
let mut u = input.trim().trim_end_matches('/').to_string();
|
||||
let lower = u.to_lowercase();
|
||||
if lower.ends_with("/music/api/v1") {
|
||||
u = u[..u.len() - "/music/api/v1".len()].to_string();
|
||||
}
|
||||
u.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// SHA-256 十六进制(登录时密码签名,对齐原生客户端 `sha256Hex(password)`)。
|
||||
pub fn sha256_hex(input: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// 32 位 hex 设备 ID(首次生成后落 settings 复用;不依赖 rand,用时间戳+进程号哈希)。
|
||||
pub fn generate_device_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
sha256_hex(&format!("{}-{}", nanos, std::process::id()))[..32].to_string()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! FnConnect 远程连接解析(参考 feiniu-car-music `fn-api.js`)。
|
||||
//!
|
||||
//! fnId → 网关 `https://5ddd.com/api/v1/fn/con`(authx md5 签名)→ 内网/公网/中继候选 →
|
||||
//! 探测可达性 → 得到可用的 base_url(含 mode=relay 的中继地址)。
|
||||
|
||||
use md5::Md5;
|
||||
use rand::RngCore;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// 网关地址与签名常量(对齐 feiniu-car-music)。
|
||||
const FN_CONNECT_URL: &str = "https://5ddd.com/api/v1/fn/con";
|
||||
const FN_AUTHX_PREFIX: &str = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh";
|
||||
const FN_API_KEY: &str = "zIGtkc3dqZnJpd29qZXJqa2w7c";
|
||||
|
||||
fn md5_hex(input: &str) -> String {
|
||||
let mut h = Md5::new();
|
||||
h.update(input.as_bytes());
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(input.as_bytes());
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
|
||||
/// 从输入识别 fnId:`fnos.net/<id>`、`<id>.5ddd.com`、或裸 fnId。
|
||||
pub fn extract_fn_id(input: &str) -> Option<String> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(id) = s.split_once("fnos.net/").map(|(_, r)| r.split('/').next().unwrap_or("")) {
|
||||
if !id.is_empty() {
|
||||
return Some(id.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(rest) = s.rsplit_once("/") {
|
||||
let last = rest.1;
|
||||
if last.ends_with(".5ddd.com") {
|
||||
return Some(last.trim_end_matches(".5ddd.com").to_string());
|
||||
}
|
||||
}
|
||||
if s.ends_with(".5ddd.com") {
|
||||
return Some(s.trim_end_matches(".5ddd.com").to_string());
|
||||
}
|
||||
// 裸 fnId
|
||||
if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && !s.starts_with("http") {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 计算网关 authx 签名。
|
||||
fn fn_authx(method: &str, url: &str, data: &Value) -> String {
|
||||
let body = if method.eq_ignore_ascii_case("get") {
|
||||
String::new()
|
||||
} else {
|
||||
serde_json::to_string(data).unwrap_or_default()
|
||||
};
|
||||
let mut nonce = String::new();
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..6 {
|
||||
nonce.push(char::from(b'0' + (rng.next_u32() % 10) as u8));
|
||||
}
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().to_string())
|
||||
.unwrap_or_default();
|
||||
let raw = format!(
|
||||
"{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}__{FN_API_KEY}",
|
||||
md5_hex(&body)
|
||||
);
|
||||
format!("nonce={nonce}×tamp={timestamp}&sign={}", md5_hex(&raw))
|
||||
}
|
||||
|
||||
/// 从网关查询 fnId 的连接参数。
|
||||
pub async fn query_fn_connect(fn_id: &str) -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let body = json!({ "fnId": fn_id });
|
||||
let resp = client
|
||||
.post(FN_CONNECT_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("authx", fn_authx("post", "/api/v1/fn/con", &body))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("FnConnect 网关不可达: {e}"))?;
|
||||
let b: Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
if b["code"].as_i64().unwrap_or(-1) != 0 {
|
||||
return Err(b["msg"].as_str().unwrap_or("FnConnect 网关返回错误").to_string());
|
||||
}
|
||||
Ok(b["data"].clone())
|
||||
}
|
||||
|
||||
/// 构建候选 base_url 列表。返回 (url, is_relay)。
|
||||
pub fn build_candidates(data: &Value) -> Vec<(String, bool)> {
|
||||
let mut out: Vec<(String, bool)> = Vec::new();
|
||||
let port = &data["port"];
|
||||
let http = port["httpPort"].as_u64().unwrap_or(5666);
|
||||
let https = port["httpsPort"].as_u64().unwrap_or(5667);
|
||||
let empty = vec![];
|
||||
for ip in data["ipv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
||||
out.push((format!("http://{ip}:{http}"), false));
|
||||
out.push((format!("https://{ip}:{https}"), false));
|
||||
}
|
||||
for ip in data["publicIpv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
||||
out.push((format!("http://{ip}:{http}"), false));
|
||||
out.push((format!("https://{ip}:{https}"), false));
|
||||
}
|
||||
for ip in data["publicIpv6"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
||||
out.push((format!("http://[{ip}]:{http}"), false));
|
||||
out.push((format!("https://[{ip}]:{https}"), false));
|
||||
}
|
||||
let relays = data["fn"].as_array().unwrap_or(&empty);
|
||||
let relay_addrs: Vec<String> = if relays.is_empty() {
|
||||
vec!["5ddd.com".to_string()]
|
||||
} else {
|
||||
relays
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
};
|
||||
for addr in relay_addrs {
|
||||
let domain = addr.split(':').next().unwrap_or(&addr).to_string();
|
||||
out.push((format!("https://{domain}"), true));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 探测某个 base_url 是否可用。
|
||||
async fn probe(url: &str) -> bool {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(6))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let full = format!("{}/music/api/v1/track/list?page=1&size=1", url.trim_end_matches('/'));
|
||||
match client.get(&full).send().await {
|
||||
Ok(resp) => resp.status().as_u16() < 500,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 fnId → 第一个可达的 base_url;返回 (base_url, is_relay)。
|
||||
pub async fn resolve_base_url(fn_id: &str) -> Result<(String, bool), String> {
|
||||
let data = query_fn_connect(fn_id).await?;
|
||||
let candidates = build_candidates(&data);
|
||||
if candidates.is_empty() {
|
||||
return Err("FnConnect 未返回可用地址".into());
|
||||
}
|
||||
for (url, relay) in &candidates {
|
||||
if probe(url).await {
|
||||
return Ok((url.clone(), *relay));
|
||||
}
|
||||
}
|
||||
Err(format!("FnConnect 候选均不可达({} 个)", candidates.len()))
|
||||
}
|
||||
|
||||
/// sha256 hex(登录用)。
|
||||
pub fn sha256_hex_pub(input: &str) -> String {
|
||||
sha256_hex(input)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! fnOS 文件服务客户端(WebSocket 协议 + HTTP 上传)。
|
||||
//!
|
||||
//! 协议参照 `FNOSP/fnnas-api`:
|
||||
//! 1. 连接 `ws://{host}:{port}/websocket?type=main`
|
||||
//! 2. `util.crypto.getRSAPub` 取 RSA 公钥与 si
|
||||
//! 3. `user.login`:随机 AES key/iv,AES-CBC 加密登录体 + RSA 加密 key,发 `{"req":"encrypted",...}`
|
||||
//! 4. 之后每个请求 `{base64(HMAC-SHA256(json))}{json}` 签名
|
||||
//! 5. 上传:WS `file.checkUpload` → HTTP `POST /upload`(Trim-Token/Trim-Path/Trim-Sign)
|
||||
//! 6. 删除:WS `file.rm`
|
||||
//!
|
||||
//! 仅支持 http://(ws://)直连;https/frp 的文件上传留待后续。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use aes::Aes256;
|
||||
use base64::Engine;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use rsa::pkcs8::DecodePublicKey;
|
||||
use rsa::{Pkcs1v15Encrypt, RsaPublicKey};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
const KEY_CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
fn random_key() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..32)
|
||||
.map(|_| {
|
||||
let i = rng.next_u64() as usize % KEY_CHARS.len();
|
||||
KEY_CHARS[i] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn random_iv() -> [u8; 16] {
|
||||
let mut iv = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut iv);
|
||||
iv
|
||||
}
|
||||
|
||||
/// 手动 AES-256-CBC 加密(避免 cbc crate trait 兼容问题),PKCS7 填充,返回 base64。
|
||||
fn aes_cbc_encrypt_b64(data: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> String {
|
||||
let cipher = Aes256::new(key.into());
|
||||
let mut padded = data.to_vec();
|
||||
let pad_len = 16 - (padded.len() % 16);
|
||||
padded.extend(std::iter::repeat(pad_len as u8).take(pad_len));
|
||||
let mut out = Vec::with_capacity(padded.len());
|
||||
let mut prev = *iv;
|
||||
for chunk in padded.chunks(16) {
|
||||
let mut block = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
block[i] = chunk[i] ^ prev[i];
|
||||
}
|
||||
cipher.encrypt_block((&mut block).into());
|
||||
out.extend_from_slice(&block);
|
||||
prev = block;
|
||||
}
|
||||
base64::engine::general_purpose::STANDARD.encode(out)
|
||||
}
|
||||
|
||||
/// AES-256-CBC 解密 → 返回 base64(明文)(对齐 fnnas-api 的 aes_decrypt)。
|
||||
fn aes_cbc_decrypt_b64(ciphertext_b64: &str, key: &[u8; 32], iv: &[u8; 16]) -> Result<String, String> {
|
||||
let ct = base64::engine::general_purpose::STANDARD
|
||||
.decode(ciphertext_b64)
|
||||
.map_err(|e| format!("AES 密文解码失败: {e}"))?;
|
||||
let cipher = Aes256::new(key.into());
|
||||
let mut prev = *iv;
|
||||
let mut plain = Vec::with_capacity(ct.len());
|
||||
for chunk in ct.chunks(16) {
|
||||
let mut block = [0u8; 16];
|
||||
block.copy_from_slice(chunk);
|
||||
let enc = block;
|
||||
cipher.decrypt_block((&mut block).into());
|
||||
for i in 0..16 {
|
||||
plain.push(block[i] ^ prev[i]);
|
||||
}
|
||||
prev = enc;
|
||||
}
|
||||
// 去 PKCS7 填充
|
||||
if let Some(&last) = plain.last() {
|
||||
let n = last as usize;
|
||||
if n > 0 && n <= 16 && plain.len() >= n && plain[plain.len() - n..].iter().all(|&b| b == last) {
|
||||
plain.truncate(plain.len() - n);
|
||||
}
|
||||
}
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(plain))
|
||||
}
|
||||
|
||||
fn rsa_encrypt_b64(pub_pem: &str, data: &str) -> Result<String, String> {
|
||||
let key = RsaPublicKey::from_public_key_pem(pub_pem)
|
||||
.map_err(|e| format!("解析 RSA 公钥失败: {e}"))?;
|
||||
let mut rng = rand::thread_rng();
|
||||
let ct = key
|
||||
.encrypt(&mut rng, Pkcs1v15Encrypt, data.as_bytes())
|
||||
.map_err(|e| format!("RSA 加密失败: {e}"))?;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(ct))
|
||||
}
|
||||
|
||||
fn hmac_sha256_b64(key: &[u8], data: &str) -> String {
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(key).expect("hmac key");
|
||||
mac.update(data.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
fn reqid() -> String {
|
||||
let n = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("0000000000000000{n:x}")
|
||||
}
|
||||
|
||||
fn ws_url(base_url: &str) -> String {
|
||||
let u = base_url.trim_end_matches('/');
|
||||
u.replacen("http://", "ws://", 1) + "/websocket?type=main"
|
||||
}
|
||||
|
||||
/// 一次 fnOS 会话(登录后复用;命令层按 connection 存于 Feiniu)。
|
||||
pub struct FnOsSession {
|
||||
pub base_url: String,
|
||||
token: Mutex<String>,
|
||||
sign_key: Mutex<Vec<u8>>,
|
||||
ws: Arc<tokio::sync::Mutex<Option<WsStream>>>,
|
||||
pending: Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<Value>>>>,
|
||||
reader: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl FnOsSession {
|
||||
/// 建立会话(新 WS 连接 + 登录)。
|
||||
pub async fn connect(base_url: &str, username: &str, password: &str) -> Result<Self, String> {
|
||||
let url = ws_url(base_url);
|
||||
let (ws, _) = connect_async(&url)
|
||||
.await
|
||||
.map_err(|e| format!("连接 fnOS WebSocket 失败: {e}"))?;
|
||||
let session = Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token: Mutex::new(String::new()),
|
||||
sign_key: Mutex::new(Vec::new()),
|
||||
ws: Arc::new(tokio::sync::Mutex::new(Some(ws))),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
reader: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
session.login(username, password).await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn token(&self) -> String {
|
||||
self.token.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
fn ensure_reader(&self) {
|
||||
if self.reader.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let ws = self.ws.clone();
|
||||
let pending = self.pending.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let msg = {
|
||||
let mut guard = ws.lock().await;
|
||||
let Some(stream) = guard.as_mut() else { break };
|
||||
match stream.next().await {
|
||||
Some(Ok(m)) => m,
|
||||
_ => break,
|
||||
}
|
||||
};
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&text) {
|
||||
if let Some(rid) = v["reqid"].as_str() {
|
||||
if let Some(sender) =
|
||||
pending.lock().unwrap_or_else(|e| e.into_inner()).remove(rid)
|
||||
{
|
||||
let _ = sender.send(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let pendings: Vec<_> = pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.drain()
|
||||
.map(|(_, s)| s)
|
||||
.collect();
|
||||
for s in pendings {
|
||||
let _ = s.send(Value::Null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 发送签名请求并等待响应。
|
||||
async fn request(&self, req: &str, data: Value) -> Result<Value, String> {
|
||||
self.ensure_reader();
|
||||
let rid = reqid();
|
||||
let mut body = json!({ "req": req, "reqid": rid });
|
||||
if let Value::Object(map) = data {
|
||||
for (k, v) in map {
|
||||
body[k] = v;
|
||||
}
|
||||
}
|
||||
let json_str = serde_json::to_string(&body).map_err(|e| e.to_string())?;
|
||||
let sign_key = self.sign_key.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let message = if sign_key.is_empty() {
|
||||
json_str.clone()
|
||||
} else {
|
||||
hmac_sha256_b64(&sign_key, &json_str) + &json_str
|
||||
};
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Value>();
|
||||
self.pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(rid.clone(), tx);
|
||||
{
|
||||
let mut guard = self.ws.lock().await;
|
||||
let stream = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| "fnOS 连接已断开".to_string())?;
|
||||
stream
|
||||
.send(Message::Text(message.into()))
|
||||
.await
|
||||
.map_err(|e| format!("发送请求失败: {e}"))?;
|
||||
}
|
||||
|
||||
let resp = tokio::time::timeout(Duration::from_secs(10), rx)
|
||||
.await
|
||||
.map_err(|_| "fnOS 请求超时".to_string())?
|
||||
.map_err(|_| "fnOS 请求通道关闭".to_string())?;
|
||||
if resp.is_null() {
|
||||
return Err("fnOS 连接已断开".to_string());
|
||||
}
|
||||
if let Some(errno) = resp["errno"].as_i64() {
|
||||
if errno != 0 {
|
||||
return Err(format!("fnOS 请求失败(errno {errno})"));
|
||||
}
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn login(&self, username: &str, password: &str) -> Result<(), String> {
|
||||
let resp = self.request("util.crypto.getRSAPub", Value::Null).await?;
|
||||
let pub_pem = resp["pub"].as_str().ok_or("getRSAPub 未返回 pub")?.to_string();
|
||||
let si = resp["si"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let key = random_key();
|
||||
let iv = random_iv();
|
||||
let login_body = json!({
|
||||
"user": username,
|
||||
"password": password,
|
||||
"deviceType": "Browser",
|
||||
"deviceName": "Thing Client",
|
||||
"stay": false,
|
||||
"si": si,
|
||||
});
|
||||
let json_str = serde_json::to_string(&login_body).map_err(|e| e.to_string())?;
|
||||
let key_bytes: [u8; 32] = key
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| "AES 密钥长度错误".to_string())?;
|
||||
let aes_b64 = aes_cbc_encrypt_b64(json_str.as_bytes(), &key_bytes, &iv);
|
||||
let rsa_b64 = rsa_encrypt_b64(&pub_pem, &key)?;
|
||||
let enc = json!({
|
||||
"req": "encrypted",
|
||||
"iv": base64::engine::general_purpose::STANDARD.encode(iv),
|
||||
"rsa": rsa_b64,
|
||||
"aes": aes_b64,
|
||||
});
|
||||
let resp = self.request("encrypted", enc).await?;
|
||||
let token = resp["token"].as_str().ok_or("fnOS 登录未返回 token")?.to_string();
|
||||
let secret = resp["secret"].as_str().ok_or("fnOS 登录未返回 secret")?.to_string();
|
||||
let secret_dec = aes_cbc_decrypt_b64(&secret, &key_bytes, &iv)?;
|
||||
let sign_key = base64::engine::general_purpose::STANDARD
|
||||
.decode(&secret_dec)
|
||||
.map_err(|e| format!("sign_key base64 解码失败: {e}"))?;
|
||||
*self.token.lock().unwrap_or_else(|e| e.into_inner()) = token;
|
||||
*self.sign_key.lock().unwrap_or_else(|e| e.into_inner()) = sign_key;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `file.checkUpload`:返回 uploadName。
|
||||
pub async fn check_upload(&self, nas_path: &str, size: u64, overwrite: u32) -> Result<String, String> {
|
||||
let resp = self
|
||||
.request(
|
||||
"file.checkUpload",
|
||||
json!({ "size": size, "path": nas_path, "overwrite": overwrite }),
|
||||
)
|
||||
.await?;
|
||||
resp["uploadName"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "checkUpload 未返回 uploadName".to_string())
|
||||
}
|
||||
|
||||
/// `file.rm`:删除 NAS 文件(移入回收站)。
|
||||
pub async fn delete_file(&self, nas_path: &str) -> Result<(), String> {
|
||||
let name = nas_path.rsplit('/').next().unwrap_or(nas_path).to_string();
|
||||
self.request(
|
||||
"file.rm",
|
||||
json!({
|
||||
"files": [nas_path],
|
||||
"moveToTrashbin": true,
|
||||
"details": { "name": name, "count": 1, "dir": 0 },
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `file.ls`:列出目录。
|
||||
pub async fn list(&self, path: &str) -> Result<Value, String> {
|
||||
self.request("file.ls", json!({ "path": path })).await
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP 上传到 NAS(需要已登录的 session)。返回上传后的文件名。
|
||||
pub async fn upload_file(
|
||||
session: &FnOsSession,
|
||||
local_path: &std::path::Path,
|
||||
nas_path: &str,
|
||||
overwrite: u32,
|
||||
) -> Result<String, String> {
|
||||
let size = std::fs::metadata(local_path)
|
||||
.map_err(|e| format!("读取本地文件失败: {e}"))?
|
||||
.len();
|
||||
let upload_name = session.check_upload(nas_path, size, overwrite).await?;
|
||||
let parent = nas_path.rsplit('/').nth(1).unwrap_or("").to_string();
|
||||
let trim_path = if parent.is_empty() {
|
||||
upload_name.clone()
|
||||
} else {
|
||||
format!("{parent}/{upload_name}")
|
||||
};
|
||||
let mtim = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
.to_string();
|
||||
let sign_key = session.sign_key.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let trim_sign = hmac_sha256_b64(&sign_key, &trim_path);
|
||||
let token = session.token();
|
||||
|
||||
let file_name = local_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("music.bin")
|
||||
.to_string();
|
||||
let bytes = std::fs::read(local_path).map_err(|e| format!("读取本地文件失败: {e}"))?;
|
||||
let mime = guess_mime(local_path);
|
||||
let form = reqwest::multipart::Form::new().part(
|
||||
"trim-upload-file",
|
||||
reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name(file_name)
|
||||
.mime_str(mime)
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.post(format!("{}/upload", session.base_url))
|
||||
.header("Trim-Token", token)
|
||||
.header("Trim-Path", trim_path)
|
||||
.header("Trim-Sign", trim_sign)
|
||||
.header("Trim-Mtim", mtim)
|
||||
.header("Trim-Overwrite", overwrite.to_string())
|
||||
.header("Referer", format!("{}/", session.base_url))
|
||||
.header("User-Agent", "Thing/1.0")
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("上传请求失败: {e}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("上传失败(HTTP {status}): {body}"));
|
||||
}
|
||||
Ok(upload_name)
|
||||
}
|
||||
|
||||
fn guess_mime(p: &std::path::Path) -> &'static str {
|
||||
match p
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("mp3") => "audio/mpeg",
|
||||
Some("flac") => "audio/flac",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("m4a") => "audio/mp4",
|
||||
Some("aac") => "audio/aac",
|
||||
Some("ogg") => "audio/ogg",
|
||||
Some("ape") => "audio/x-ape",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
//! 飞牛音乐(NAS)原生接口客户端 + 本地流代理的运行期。
|
||||
//!
|
||||
//! 支持多连接(本地 / frp / 预留 FnConnect),当前以"激活连接"为准。
|
||||
//! 原生接口路径/认证(`music-token` Cookie、`x-access-code` 安全码、`/user/password-login` 登录)
|
||||
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
|
||||
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
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;
|
||||
mod fnos;
|
||||
pub mod proxy;
|
||||
|
||||
pub use cache::CacheManager;
|
||||
pub use fnconnect::{extract_fn_id, resolve_base_url};
|
||||
pub use fnos::FnOsSession;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前激活连接信息(运行期副本,与 `MusicSettings` 的激活连接一致)。
|
||||
struct Conn {
|
||||
base_url: String,
|
||||
token: String,
|
||||
username: String,
|
||||
device_id: String,
|
||||
access_code: String,
|
||||
insecure: bool,
|
||||
}
|
||||
|
||||
/// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。
|
||||
struct ClientSlot {
|
||||
insecure: bool,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub struct Feiniu {
|
||||
client: Mutex<Option<ClientSlot>>,
|
||||
conn: Mutex<Conn>,
|
||||
proxy: Mutex<Option<(u16, ProxyShared)>>,
|
||||
cache: CacheManager,
|
||||
/// fnOS 文件服务会话:connection id → 会话
|
||||
fnos_sessions: Mutex<HashMap<String, Arc<FnOsSession>>>,
|
||||
}
|
||||
|
||||
impl Default for Feiniu {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
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,
|
||||
}),
|
||||
proxy: Mutex::new(None),
|
||||
cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建
|
||||
fnos_sessions: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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。
|
||||
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()
|
||||
}
|
||||
|
||||
/// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。
|
||||
pub fn sync_with_settings(&self, s: &MusicSettings) {
|
||||
let (base_url, token, username, device_id, access_code, insecure) =
|
||||
match s.feiniu_active() {
|
||||
Some(c) => (
|
||||
c.base_url.clone(),
|
||||
c.token.clone(),
|
||||
c.username.clone(),
|
||||
c.device_id.clone(),
|
||||
c.access_code.clone(),
|
||||
c.insecure,
|
||||
),
|
||||
None => (
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
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;
|
||||
}
|
||||
// 确保 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) {
|
||||
let (_, client) = self.conn_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 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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_triple(&self) -> (String, String, String) {
|
||||
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(c.base_url.clone(), c.token.clone(), c.access_code.clone())
|
||||
}
|
||||
|
||||
/// 对某个 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 resp = self
|
||||
.client(insecure)
|
||||
.post(format!("{base}/music/api/v1/user/password-login"))
|
||||
.json(&body)
|
||||
.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 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 {
|
||||
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) = self.auth_triple();
|
||||
if base.is_empty() || token.is_empty() {
|
||||
return Err("未登录".into());
|
||||
}
|
||||
let (_, client) = self.conn_client();
|
||||
let mut rb = client.get(format!("{base}/music/api/v1/track/stream?guid={guid}"));
|
||||
rb = rb.header("cookie", format!("music-token={token}"));
|
||||
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))
|
||||
}
|
||||
|
||||
// ===== fnOS 文件服务(P6:上传/删除) =====
|
||||
|
||||
/// fnOS 登录:为指定连接建立文件服务会话。
|
||||
pub async fn fnos_login(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
let session = Arc::new(FnOsSession::connect(base_url, username, password).await?);
|
||||
if let Ok(mut m) = self.fnos_sessions.lock() {
|
||||
m.insert(connection_id.to_string(), session);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fnos_logout(&self, connection_id: &str) {
|
||||
if let Ok(mut m) = self.fnos_sessions.lock() {
|
||||
m.remove(connection_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fnos_logged_in(&self, connection_id: &str) -> bool {
|
||||
self.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.contains_key(connection_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 上传本地文件到 NAS(走 fnOS 会话;会话缺失返回 Err)。
|
||||
pub async fn fnos_upload(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
local_path: &std::path::Path,
|
||||
nas_path: &str,
|
||||
) -> Result<String, String> {
|
||||
let session = self
|
||||
.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.get(connection_id).cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "请先登录 NAS 文件服务(设置 → 连接 → fnOS 登录)")?;
|
||||
fnos::upload_file(&session, local_path, nas_path, 2).await
|
||||
}
|
||||
|
||||
/// 删除 NAS 文件。
|
||||
pub async fn fnos_delete(&self, connection_id: &str, nas_path: &str) -> Result<(), String> {
|
||||
let session = self
|
||||
.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.get(connection_id).cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "请先登录 NAS 文件服务")?;
|
||||
session.delete_file(nas_path).await
|
||||
}
|
||||
|
||||
/// 列出 NAS 目录。
|
||||
pub async fn fnos_list(&self, connection_id: &str, path: &str) -> Result<Value, String> {
|
||||
let session = self
|
||||
.fnos_sessions
|
||||
.lock()
|
||||
.map(|m| m.get(connection_id).cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| "请先登录 NAS 文件服务")?;
|
||||
session.list(path).await
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
let (_, client) = self.conn_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) = 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 !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())
|
||||
}
|
||||
}
|
||||
|
||||
/// 尽力从 /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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 飞牛音乐本地流代理(axum)。
|
||||
//!
|
||||
//! 原生 `/track/stream`、`/static/cover` 需要 `Cookie: music-token=<token>`,而
|
||||
//! WebView 的 `<audio>/<img>` 无法设置 Cookie。本模块绑定 `127.0.0.1:<动态端口>`,
|
||||
//! 转发请求时注入 Cookie 与可选的安全码头,前端直接用本地地址播放/显示,天然支持 Range/seek。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
use super::conn::normalize_base_url;
|
||||
|
||||
/// 由 Feiniu 运行期与代理 handler 共享的连接配置(登录更新、登出置空)。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ProxyCfg {
|
||||
pub base_url: String,
|
||||
pub token: String,
|
||||
pub access_code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyShared {
|
||||
pub client: reqwest::Client,
|
||||
pub cfg: Arc<Mutex<ProxyCfg>>,
|
||||
}
|
||||
|
||||
/// 启动本地流代理,绑定到 `127.0.0.1:0`(动态空闲端口),返回实际端口。
|
||||
/// 代理以独立 tokio 任务常驻应用存活期;token 变化经共享 `ProxyCfg` 即时生效,无需重启。
|
||||
pub async fn start(shared: ProxyShared) -> Result<u16, String> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("绑定本地端口失败: {e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| e.to_string())?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.route("/feiniu/stream", get(proxy_stream))
|
||||
.route("/feiniu/cover", get(proxy_cover))
|
||||
.with_state(shared);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
crate::logger::log_error("music", &format!("飞牛流代理异常: {e}"));
|
||||
}
|
||||
});
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn proxy_stream(
|
||||
State(s): State<ProxyShared>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let guid = q.get("guid").cloned().unwrap_or_default().trim().to_string();
|
||||
if guid.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "missing guid").into_response();
|
||||
}
|
||||
forward(
|
||||
&s,
|
||||
"/music/api/v1/track/stream",
|
||||
vec![("guid".to_string(), guid)],
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn proxy_cover(
|
||||
State(s): State<ProxyShared>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let cover_id = q
|
||||
.get("coverId")
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if cover_id.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "missing coverId").into_response();
|
||||
}
|
||||
let size = q.get("size").cloned().unwrap_or_else(|| "320".into());
|
||||
forward(
|
||||
&s,
|
||||
"/music/api/v1/static/cover",
|
||||
vec![
|
||||
("coverId".to_string(), cover_id),
|
||||
("size".to_string(), size),
|
||||
],
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 统一转发:向 NAS 发起上游 GET,注入 Cookie/安全码,透传 Range 与响应头,流式回传 body。
|
||||
async fn forward(
|
||||
s: &ProxyShared,
|
||||
path: &str,
|
||||
query: Vec<(String, String)>,
|
||||
headers: &HeaderMap,
|
||||
) -> Response {
|
||||
let cfg = match s.cfg.lock() {
|
||||
Ok(g) => g.clone(),
|
||||
Err(e) => e.into_inner().clone(),
|
||||
};
|
||||
if cfg.base_url.is_empty() || cfg.token.is_empty() {
|
||||
return (StatusCode::UNAUTHORIZED, "飞牛音乐未登录").into_response();
|
||||
}
|
||||
|
||||
let base = normalize_base_url(&cfg.base_url);
|
||||
let url = format!("{base}{path}");
|
||||
let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
|
||||
let mut rb = s.client.get(&url).query(&qrefs);
|
||||
rb = rb.header("cookie", format!("music-token={}", cfg.token));
|
||||
if !cfg.access_code.is_empty() {
|
||||
use base64::Engine;
|
||||
rb = rb
|
||||
.header(
|
||||
"x-access-code",
|
||||
base64::engine::general_purpose::STANDARD.encode(cfg.access_code.as_bytes()),
|
||||
)
|
||||
.header("x-access-source", "app");
|
||||
}
|
||||
if let Some(range) = headers.get(header::RANGE) {
|
||||
rb = rb.header(header::RANGE, range.clone());
|
||||
}
|
||||
drop(cfg);
|
||||
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("上游错误: {e}")).into_response(),
|
||||
};
|
||||
let status = resp.status();
|
||||
|
||||
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
|
||||
let cl = resp.headers().get(header::CONTENT_LENGTH).cloned();
|
||||
let cr = resp.headers().get(header::CONTENT_RANGE).cloned();
|
||||
let ar = resp.headers().get(header::ACCEPT_RANGES).cloned();
|
||||
|
||||
let body = axum::body::Body::from_stream(resp.bytes_stream());
|
||||
let mut out = Response::new(body);
|
||||
*out.status_mut() = status;
|
||||
let h = out.headers_mut();
|
||||
if let Some(v) = ct {
|
||||
h.insert(header::CONTENT_TYPE, v);
|
||||
}
|
||||
if let Some(v) = cl {
|
||||
h.insert(header::CONTENT_LENGTH, v);
|
||||
}
|
||||
if let Some(v) = cr {
|
||||
h.insert(header::CONTENT_RANGE, v);
|
||||
}
|
||||
if let Some(v) = ar {
|
||||
h.insert(header::ACCEPT_RANGES, v);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 便携 Python 运行时安装:流式下载 → 解压 → _pth 补丁 → get-pip 引导 → musicdl 安装。
|
||||
//! 子模块通过 `impl super::MusicManager` 追加方法。
|
||||
//!
|
||||
//! 依赖下载源(已验证):
|
||||
//! - https://www.python.org/ftp/python/{ver}/python-{ver}-embed-amd64.zip
|
||||
//! - https://bootstrap.pypa.io/get-pip.py
|
||||
//!
|
||||
//! 取消:`MusicManager::cancel()` 置标志 + taskkill 当前 pip/python 子进程。
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::{MusicInstallProgress, MusicManager, PIP_INDEX_URL};
|
||||
use crate::constants::events::MUSIC_RUNTIME_INSTALL_PROGRESS;
|
||||
|
||||
/// 用户主动取消安装的标记错误信息(前端据此静默处理)
|
||||
pub(crate) const RUNTIME_CANCELLED: &str = "安装已取消";
|
||||
|
||||
impl MusicManager {
|
||||
/// 安装便携 Python + musicdl(幂等:已就绪的步骤自动跳过),全程推送进度事件。
|
||||
pub async fn install_runtime(&self, app: &AppHandle) -> Result<(), String> {
|
||||
self.runtime_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
let result = self.install_runtime_inner(app).await;
|
||||
if let Err(ref e) = result {
|
||||
if e != RUNTIME_CANCELLED {
|
||||
let _ = app.emit(
|
||||
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||||
MusicInstallProgress {
|
||||
stage: "error".into(),
|
||||
percent: 0,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
message: e.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn install_runtime_inner(&self, app: &AppHandle) -> Result<(), String> {
|
||||
let runtime_dir = self.runtime_dir();
|
||||
fs::create_dir_all(&runtime_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let python_exe = self.bundled_python_exe();
|
||||
|
||||
// ---------- 1. 便携 Python 已就绪则跳过下载/解压 ----------
|
||||
if !(python_exe.exists() && super::run_python_version(&python_exe).is_some()) {
|
||||
self.emit_progress(app, "download", 2, 0, None, "开始下载便携 Python...").await;
|
||||
|
||||
// 下载 embeddable zip(~11MB,流式写入)
|
||||
let zip_path = runtime_dir.join(format!("python-{}-embed.zip", super::BUNDLED_PY_VERSION));
|
||||
let zip_url = format!(
|
||||
"https://www.python.org/ftp/python/{}/python-{}-embed-amd64.zip",
|
||||
super::BUNDLED_PY_VERSION,
|
||||
super::BUNDLED_PY_VERSION
|
||||
);
|
||||
self.download_stream(app, &zip_url, &zip_path).await?;
|
||||
|
||||
// 解压到 runtime/python/
|
||||
self.emit_progress(app, "extract", 42, 0, None, "正在解压便携 Python...").await;
|
||||
let python_dir = runtime_dir.join("python");
|
||||
if python_dir.exists() {
|
||||
fs::remove_dir_all(&python_dir).ok();
|
||||
}
|
||||
fs::create_dir_all(&python_dir).map_err(|e| e.to_string())?;
|
||||
self.extract_zip(&zip_path, &python_dir)?;
|
||||
fs::remove_file(&zip_path).ok();
|
||||
|
||||
// _pth 补丁:启用 site(否则无法识别 site-packages 与 pip)
|
||||
self.emit_progress(app, "patch", 52, 0, None, "正在配置 Python 环境...").await;
|
||||
self.patch_pth(&python_dir)?;
|
||||
|
||||
// 下载 get-pip.py 并引导 pip(只装 pip 本体,走官方源,包很小)
|
||||
self.emit_progress(app, "pip", 54, 0, None, "正在引导 pip...").await;
|
||||
let get_pip = runtime_dir.join("get-pip.py");
|
||||
self.download_bytes(&"https://bootstrap.pypa.io/get-pip.py".to_string(), &get_pip)
|
||||
.await?;
|
||||
let pip_py = python_exe.clone();
|
||||
let pip_py2 = pip_py.clone(); // 供 setuptools 步骤闭包使用(先于 move 克隆)
|
||||
let get_pip2 = get_pip.clone();
|
||||
self.run_blocking_step(
|
||||
app,
|
||||
"pip",
|
||||
55,
|
||||
60,
|
||||
move |pid| {
|
||||
let mut cmd = std::process::Command::new(&pip_py);
|
||||
cmd.arg(&get_pip2).args(["--no-warn-script-location"]);
|
||||
run_cmd_blocking(cmd, pid)
|
||||
},
|
||||
"正在安装 pip...",
|
||||
)
|
||||
.await?;
|
||||
fs::remove_file(&get_pip).ok();
|
||||
|
||||
// 安装 setuptools(musicdl 的 setup.py 构建依赖 setuptools.build_meta,
|
||||
// 必须先于 musicdl 就位,否则构建阶段报 BackendUnavailable)
|
||||
self.run_blocking_step(
|
||||
app,
|
||||
"pip",
|
||||
62,
|
||||
66,
|
||||
move |pid| {
|
||||
let mut cmd = std::process::Command::new(&pip_py2);
|
||||
cmd.args([
|
||||
"-m", "pip", "install", "--no-warn-script-location",
|
||||
"--timeout", "60",
|
||||
"--index-url", PIP_INDEX_URL,
|
||||
"setuptools",
|
||||
]);
|
||||
run_cmd_blocking(cmd, pid)
|
||||
},
|
||||
"正在安装 setuptools...",
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
self.emit_progress(app, "check", 2, 0, None, "便携 Python 已就绪").await;
|
||||
}
|
||||
|
||||
// ---------- 2. 安装 musicdl(幂等:已安装则跳过) ----------
|
||||
if !self.musicdl_ready(&python_exe).await {
|
||||
let pip_py = python_exe.clone();
|
||||
self.run_blocking_step(
|
||||
app,
|
||||
"musicdl",
|
||||
68,
|
||||
95,
|
||||
move |pid| {
|
||||
let mut cmd = std::process::Command::new(&pip_py);
|
||||
cmd.args([
|
||||
"-m", "pip", "install", "--no-warn-script-location",
|
||||
"--timeout", "60",
|
||||
"--index-url", PIP_INDEX_URL,
|
||||
&format!("musicdl=={}", super::MUSICDL_VERSION),
|
||||
]);
|
||||
run_cmd_blocking(cmd, pid)
|
||||
},
|
||||
"正在安装 musicdl(下载依赖较多,可能需几分钟)...",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.emit_progress(app, "done", 100, 0, None, "环境就绪").await;
|
||||
crate::logger::log_info("music", "便携 Python + musicdl 安装完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查便携 Python 能否导入 musicdl
|
||||
async fn musicdl_ready(&self, python_exe: &PathBuf) -> bool {
|
||||
let exe = python_exe.clone();
|
||||
let (ok, _) = super::check_musicdl(&exe);
|
||||
ok
|
||||
}
|
||||
|
||||
// ---------- 阶段工具 ----------
|
||||
async fn emit_progress(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
stage: &str,
|
||||
percent: u32,
|
||||
downloaded_bytes: u64,
|
||||
total_bytes: Option<u64>,
|
||||
message: &str,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||||
MusicInstallProgress {
|
||||
stage: stage.into(),
|
||||
percent,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
message: message.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 流式下载(带取消 + 进度事件,percent 0-40 区间),复用内核下载模式
|
||||
async fn download_stream(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
) -> Result<(), String> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||||
}
|
||||
let total: Option<u64> = resp.content_length();
|
||||
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_percent: u32 = 0;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if self.is_cancelled() {
|
||||
drop(file);
|
||||
fs::remove_file(dest).ok();
|
||||
return Err(RUNTIME_CANCELLED.to_string());
|
||||
}
|
||||
let chunk = chunk.map_err(|e| format!("下载中断: {}", e))?;
|
||||
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
let percent = total
|
||||
.filter(|t| *t > 0)
|
||||
.map(|t| ((downloaded as f64 / t as f64) * 38.0) as u32)
|
||||
.unwrap_or(0)
|
||||
.min(38);
|
||||
if percent >= last_percent + 1 {
|
||||
last_percent = percent;
|
||||
self.emit_progress(
|
||||
app,
|
||||
"download",
|
||||
percent,
|
||||
downloaded,
|
||||
total,
|
||||
&format!("正在下载便携 Python ({:.1} MB)", downloaded as f64 / 1048576.0),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
file.flush().ok();
|
||||
self.emit_progress(app, "download", 40, downloaded, total, "下载完成").await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 小文件整体下载(get-pip.py),无进度
|
||||
async fn download_bytes(&self, url: &str, dest: &Path) -> Result<(), String> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||||
}
|
||||
let bytes = resp.bytes().await.map_err(|e| format!("读取响应失败: {}", e))?;
|
||||
fs::write(dest, &bytes).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解压 zip(复用内核解压逻辑)
|
||||
fn extract_zip(&self, zip_path: &Path, dest: &Path) -> Result<(), String> {
|
||||
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("读取 zip 失败: {}", e))?;
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("读取条目失败: {}", e))?;
|
||||
let outpath = match entry.enclosed_name() {
|
||||
Some(p) => dest.join(p),
|
||||
None => continue,
|
||||
};
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
let n = entry.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
outfile.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 修改 pythonXY._pth:启用 `import site`(embeddable 默认注释掉,
|
||||
/// 不启用则无法识别 site-packages / pip 安装的包)
|
||||
fn patch_pth(&self, python_dir: &Path) -> Result<(), String> {
|
||||
let entries = fs::read_dir(python_dir).map_err(|e| e.to_string())?;
|
||||
let pth = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.find(|p| {
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("_pth"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.ok_or_else(|| "解压目录中未找到 ._pth 文件".to_string())?;
|
||||
|
||||
let content = fs::read_to_string(&pth).map_err(|e| e.to_string())?;
|
||||
let mut patched = content.replace("#import site", "import site");
|
||||
if !patched.contains("import site") {
|
||||
patched.push_str("import site\n");
|
||||
}
|
||||
// 显式把 site-packages 加入 sys.path(pip 默认安装位置)
|
||||
if !patched.contains("Lib\\site-packages") && !patched.contains("Lib/site-packages") {
|
||||
patched.push_str("Lib\\site-packages\n");
|
||||
}
|
||||
fs::write(&pth, patched).map_err(|e| format!("写入 _pth 失败: {}", e))?;
|
||||
crate::logger::log_info("music", &format!("已补丁 _pth: {}", pth.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行一个阻塞子进程步骤(get-pip / setuptools / musicdl),带进度推送、取消检查与超时看门狗。
|
||||
/// start_percent / end_percent:本步骤的进度区间(完成前推进到 end_percent)。
|
||||
/// run 闭包接收「子进程 pid 记录器」,供取消时 taskkill。
|
||||
async fn run_blocking_step<F>(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
stage: &str,
|
||||
start_percent: u32,
|
||||
end_percent: u32,
|
||||
run: F,
|
||||
msg: &str,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(Arc<std::sync::atomic::AtomicU64>) -> Result<(), String> + Send + 'static,
|
||||
{
|
||||
if self.is_cancelled() {
|
||||
return Err(RUNTIME_CANCELLED.to_string());
|
||||
}
|
||||
let pid_ref = self.install_pid.clone();
|
||||
self.emit_progress(app, stage, start_percent, 0, None, msg).await;
|
||||
let result = tauri::async_runtime::spawn_blocking(move || run(pid_ref.clone()))
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {}", e))?;
|
||||
self.install_pid.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
if self.is_cancelled() {
|
||||
return Err(RUNTIME_CANCELLED.to_string());
|
||||
}
|
||||
result?;
|
||||
self.emit_progress(app, stage, end_percent, 0, None, "完成").await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步运行子进程,带超时看门狗(超时 taskkill),并记录 pid 供取消。
|
||||
/// 输出(stdout+stderr 尾部)写入日志;失败返回错误信息。
|
||||
fn run_cmd_blocking(
|
||||
mut cmd: std::process::Command,
|
||||
pid_ref: Arc<std::sync::atomic::AtomicU64>,
|
||||
) -> Result<(), String> {
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdin(std::process::Stdio::null());
|
||||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||
|
||||
let child = cmd.spawn().map_err(|e| format!("启动子进程失败: {}", e))?;
|
||||
pid_ref.store(child.id() as u64, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
// 超时看门狗:30 分钟后仍未结束则强杀(慢网络下 pip 装 musicdl 依赖可能超过 10 分钟)
|
||||
let pid = child.id();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
let watcher = std::thread::spawn(move || {
|
||||
if rx.recv_timeout(std::time::Duration::from_secs(1800)).is_err() {
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
});
|
||||
|
||||
let output = child.wait_with_output();
|
||||
let _ = tx.send(());
|
||||
let _ = watcher.join();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
// 截取尾部 800 字符,便于定位 pip 报错
|
||||
let tail: String = text.chars().rev().take(800).collect::<String>().chars().rev().collect();
|
||||
Err(format!("子进程退出码 {}: {}", out.status.code().unwrap_or(-1), tail))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("读取子进程输出失败: {}", e)),
|
||||
}
|
||||
}
|
||||
@@ -127,8 +127,9 @@ pub fn setup_creation_flags(_cmd: &mut Command) {
|
||||
|
||||
/// 将已启动的子进程加入 Job Object(异常退出时自动清理)
|
||||
/// 在 Windows 上调用,非 Windows 平台为空操作
|
||||
/// pub(crate):音乐模块的桥接进程(自管 stdio,不走 ProcessManager)也需加入 Job
|
||||
#[cfg(windows)]
|
||||
fn assign_to_job(child: &Child) {
|
||||
pub(crate) fn assign_to_job(child: &Child) {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
if let Some(job) = get_job_handle() {
|
||||
let child_handle = child.as_raw_handle() as winapi::HANDLE;
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
use std::sync::Mutex;
|
||||
use windows_sys::Win32::Foundation::{BOOL, HWND, POINT, RECT};
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
|
||||
PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS,
|
||||
RGBQUAD, SRCCOPY,
|
||||
BitBlt, CombineRgn, CreateCompatibleBitmap, CreateCompatibleDC, CreateRectRgn, DeleteDC,
|
||||
DeleteObject, GetDC, GetDIBits, PatBlt, ReleaseDC, SelectObject, SetWindowRgn, BITMAPINFO,
|
||||
BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS, RGBQUAD, RGN_DIFF, SRCCOPY,
|
||||
};
|
||||
use windows_sys::Win32::Storage::Xps::PrintWindow;
|
||||
use windows_sys::Win32::System::DataExchange::{
|
||||
@@ -34,7 +34,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
WS_EX_TOOLWINDOW,
|
||||
};
|
||||
|
||||
use super::{CaptureData, ScreenRect, WindowInfo};
|
||||
use super::{CaptureData, ScreenRect, ScrollRegion, WindowInfo};
|
||||
|
||||
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
|
||||
pub struct CapturedImage {
|
||||
@@ -406,6 +406,71 @@ pub fn disable_window_transitions(hwnd: isize) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动截图模式:在覆盖层窗口上挖出选区带的"真孔"(内缩 INSET 保留蓝框),或清除恢复整窗。
|
||||
///
|
||||
/// 背景:Chromium 系浏览器(Edge/Chrome)的原生窗口遮挡检测(occlusion tracking)
|
||||
/// 会把被**完全覆盖**的窗口标记为 occluded 并暂停渲染合成——滚动截图时覆盖层
|
||||
/// 铺满全屏盖住目标窗口,网页"看起来完全不滚动",PrintWindow 抓到的也是静止帧。
|
||||
/// 覆盖层并非 layered 窗口,即使其内容视觉透明,窗口矩形对遮挡检测仍算不透明覆盖。
|
||||
/// 挖孔后目标窗口仅部分被覆盖(Chromium 要求完全覆盖才判 occluded,实测 60x60
|
||||
/// 的小孔即可解除),恢复渲染与滚轮响应,拼接匹配随之正常。
|
||||
///
|
||||
/// - `region = Some`:孔 = 选区带内缩 INSET(保留 2px 蓝框 + 1px 白描边);
|
||||
/// 选区过小(< 2*INSET+8)时不挖孔,避免退化区域
|
||||
/// - `region = None`:SetWindowRgn(NULL) 清除窗口区域(恢复整窗)
|
||||
///
|
||||
/// 坐标:region 为屏幕物理像素(与滚动会话同源),按覆盖层窗口原点换算成窗口坐标。
|
||||
/// 区域设置在窗口上持续有效,会话结束/新一轮截图开始时必须传 None 复位。
|
||||
pub fn set_scroll_hole(hwnd: isize, region: Option<ScrollRegion>) -> Result<(), String> {
|
||||
unsafe {
|
||||
match region {
|
||||
Some(r) => {
|
||||
let mut wr: RECT = std::mem::zeroed();
|
||||
if GetWindowRect(hwnd, &mut wr) == 0 {
|
||||
return Err("GetWindowRect 失败".into());
|
||||
}
|
||||
const INSET: i32 = 4;
|
||||
let hx1 = r.x - wr.left + INSET;
|
||||
let hy1 = r.y - wr.top + INSET;
|
||||
let hx2 = r.x + r.width - wr.left - INSET;
|
||||
let hy2 = r.y + r.height - wr.top - INSET;
|
||||
if hx2 - hx1 < 8 || hy2 - hy1 < 8 {
|
||||
// 选区太小:不挖孔(保持整窗)
|
||||
if SetWindowRgn(hwnd, 0, 1) == 0 {
|
||||
return Err("SetWindowRgn 失败".into());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let full = CreateRectRgn(0, 0, wr.right - wr.left, wr.bottom - wr.top);
|
||||
let hole = CreateRectRgn(hx1, hy1, hx2, hy2);
|
||||
if full == 0 || hole == 0 {
|
||||
if full != 0 {
|
||||
DeleteObject(full);
|
||||
}
|
||||
if hole != 0 {
|
||||
DeleteObject(hole);
|
||||
}
|
||||
return Err("CreateRectRgn 失败".into());
|
||||
}
|
||||
CombineRgn(full, full, hole, RGN_DIFF);
|
||||
// 组合结果 full 归 SetWindowRgn 所有;hole 用完即删
|
||||
DeleteObject(hole);
|
||||
if SetWindowRgn(hwnd, full, 1) == 0 {
|
||||
DeleteObject(full);
|
||||
return Err("SetWindowRgn 失败".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
if SetWindowRgn(hwnd, 0, 1) == 0 {
|
||||
return Err("SetWindowRgn 失败".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
|
||||
pub fn enum_visible_windows() -> Vec<WindowInfo> {
|
||||
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
//! - screenshot_show_overlay:一次 IPC 完成覆盖层 show + focus(关键路径减少往返)
|
||||
//! - screenshot_enum_windows:枚举可见顶层窗口
|
||||
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
|
||||
//! - screenshot_scroll_capture / screenshot_scroll_start / screenshot_scroll_finish /
|
||||
//! screenshot_scroll_cancel:滚动截图(同步一次调用 / 会话式:启动、完成、取消)
|
||||
//! - screenshot_set_scroll_hole:滚动模式遮罩挖孔(防 Chromium 遮挡检测冻结目标窗口)
|
||||
//! - screenshot_take_editor_image_raw:取出编辑器图片(raw IPC,滚动截图会话直接写入)
|
||||
//! - screenshot_compose_png / screenshot_compose_copy:raw RGBA → PNG(仅编码 / 剪贴板+编码)
|
||||
//! - screenshot_copy_image:写入剪贴板(CF_DIB)
|
||||
@@ -372,6 +375,54 @@ pub fn screenshot_scroll_cancel() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动模式遮罩挖孔:在截图覆盖层窗口上挖出选区带的真孔(region = None 时复位整窗)。
|
||||
///
|
||||
/// Chromium 系浏览器(Edge/Chrome)的窗口遮挡检测会把被完全覆盖的窗口标记为
|
||||
/// occluded 并暂停渲染——滚动截图时覆盖层铺满全屏,网页"看起来完全不滚动"。
|
||||
/// 挖孔后目标窗口仅部分被覆盖,恢复渲染与滚轮响应(详见 capture::set_scroll_hole)。
|
||||
/// 进入滚动模式时带选区调用,会话结束/新一轮截图开始时必须传 None 复位。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn screenshot_set_scroll_hole(
|
||||
app: AppHandle,
|
||||
region: Option<super::ScrollRegion>,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
|
||||
let mut hwnds: Vec<isize> = Vec::new();
|
||||
for (label, win) in app.webview_windows() {
|
||||
if label.starts_with(crate::constants::windows::SCREENSHOT_OVERLAY) {
|
||||
let hwnd = win
|
||||
.window_handle()
|
||||
.ok()
|
||||
.and_then(|h| match h.as_raw() {
|
||||
raw_window_handle::RawWindowHandle::Win32(w) => {
|
||||
Some(w.hwnd.get() as isize)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(h) = hwnd {
|
||||
hwnds.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
if hwnds.is_empty() {
|
||||
return Err("截图覆盖层窗口不存在".into());
|
||||
}
|
||||
for h in hwnds {
|
||||
super::capture::set_scroll_hole(h, region)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (app, region);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 取出编辑器图片(原始 PNG 字节,raw IPC → 前端 ArrayBuffer → Blob URL,取出即清除)
|
||||
///
|
||||
/// 长图(滚动截图)可达数十 MB:raw IPC 相比 base64 JSON 事件传输省 ~33% 体积,
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::download_engine::{DownloadEngine, ExtensionServer};
|
||||
use crate::logger::LogManager;
|
||||
use crate::mihomo_manager::MihomoManager;
|
||||
use crate::monitor_kernel::{MonitorKernel, check_and_relaunch_if_needed};
|
||||
use crate::music::MusicManager;
|
||||
use crate::network_monitor::NetworkMonitor;
|
||||
use crate::process_manager::{ProcessManager, start_monitoring_thread};
|
||||
|
||||
@@ -53,6 +54,13 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let monitor = MonitorKernel::new(app_data_dir.clone());
|
||||
app.manage(monitor);
|
||||
|
||||
// ===== 音乐模块:MusicManager(Python 运行时 + 桥接进程) =====
|
||||
// 仅注册状态,不主动启动桥接(由前端模块激活/首次请求时按需拉起)
|
||||
let music = MusicManager::new(app_data_dir.clone());
|
||||
// 注入 AppHandle:桥接 reader 线程把下载事件行转发给前端
|
||||
music.set_app(app.handle().clone());
|
||||
app.manage(music);
|
||||
|
||||
// 网速采样不依赖提权,应用启动即开始
|
||||
let network_monitor = Arc::new(NetworkMonitor::new());
|
||||
app.manage(network_monitor.clone());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "thing",
|
||||
"version": "26.8.5",
|
||||
"version": "26.9.1",
|
||||
"identifier": "thing.lfeng.me",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
Reference in New Issue
Block a user