From d702ed0d313ea8db1df00145417dc9edaaac316d Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Sat, 12 Sep 2026 11:05:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=EF=BC=8C=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + package.json | 2 +- probe_q.py | 16 + probe_target.py | 21 + src-tauri/Cargo.lock | 234 ++- src-tauri/Cargo.toml | 27 +- src-tauri/src/constants.rs | 4 + src-tauri/src/lib.rs | 60 +- .../music/__pycache__/bridge.cpython-312.pyc | Bin 0 -> 58294 bytes .../music/__pycache__/bridge.cpython-314.pyc | Bin 0 -> 67819 bytes src-tauri/src/music/bridge.py | 1377 ++++++++++++++++ src-tauri/src/music/bridge.rs | 262 +++ src-tauri/src/music/commands.rs | 550 +++++++ src-tauri/src/music/feiniu/cache.rs | 155 ++ src-tauri/src/music/feiniu/conn.rs | 31 + src-tauri/src/music/feiniu/fnconnect.rs | 166 ++ src-tauri/src/music/feiniu/fnos.rs | 410 +++++ src-tauri/src/music/feiniu/mod.rs | 588 +++++++ src-tauri/src/music/feiniu/proxy.rs | 164 ++ src-tauri/src/music/mod.rs | 595 +++++++ src-tauri/src/music/runtime.rs | 398 +++++ src-tauri/src/process_manager.rs | 3 +- src-tauri/src/screenshot/capture.rs | 73 +- src-tauri/src/screenshot/commands.rs | 51 + src-tauri/src/setup.rs | 8 + src-tauri/tauri.conf.json | 2 +- src/lib/bindings.ts | 115 ++ src/modules/devtools/DevToolsModule.vue | 157 ++ .../devtools/components/ResultArea.vue | 75 + src/modules/devtools/components/Segmented.vue | 40 + src/modules/devtools/index.ts | 27 + src/modules/devtools/registry.ts | 59 + src/modules/devtools/tools/Base64Tools.vue | 166 ++ src/modules/devtools/tools/CaseTools.vue | 87 + src/modules/devtools/tools/CharCountTools.vue | 129 ++ src/modules/devtools/tools/ColorTools.vue | 211 +++ src/modules/devtools/tools/CronTools.vue | 228 +++ src/modules/devtools/tools/DiffTools.vue | 146 ++ src/modules/devtools/tools/EolTools.vue | 64 + src/modules/devtools/tools/EscapeTools.vue | 103 ++ src/modules/devtools/tools/HashTools.vue | 249 +++ .../devtools/tools/HttpStatusTools.vue | 195 +++ src/modules/devtools/tools/IpTools.vue | 189 +++ src/modules/devtools/tools/JsonTools.vue | 96 ++ src/modules/devtools/tools/JwtTools.vue | 131 ++ src/modules/devtools/tools/PasswordTools.vue | 165 ++ src/modules/devtools/tools/RegexTools.vue | 192 +++ src/modules/devtools/tools/ReplaceTools.vue | 82 + src/modules/devtools/tools/TimestampTools.vue | 138 ++ src/modules/devtools/tools/UrlTools.vue | 187 +++ src/modules/devtools/tools/UuidTools.vue | 121 ++ src/modules/devtools/tools/base/BaseTools.vue | 77 + src/modules/devtools/tools/index.ts | 171 ++ src/modules/icons.ts | 8 +- src/modules/index.ts | 6 +- src/modules/monitor/OsdWindow.vue | 1 + src/modules/music/MusicModule.vue | 1421 +++++++++++++++++ src/modules/music/SourcePicker.vue | 70 + .../music/components/MyMusicLibrary.vue | 269 ++++ src/modules/music/components/PlayerBar.vue | 142 ++ src/modules/music/components/PlayerFull.vue | 176 ++ src/modules/music/components/TrackItem.vue | 120 ++ .../components/settings/CacheSettings.vue | 83 + .../settings/ConnectionsSettings.vue | 257 +++ src/modules/music/index.ts | 47 + src/modules/music/sources.ts | 81 + src/modules/screenshot/ScreenshotEditor.vue | 668 ++++++-- src/modules/screenshot/ScreenshotOverlay.vue | 249 +-- src/modules/screenshot/annotations.ts | 242 +++ src/stores/feiniuStore.ts | 799 +++++++++ src/stores/musicStore.ts | 595 +++++++ 71 files changed, 13647 insertions(+), 387 deletions(-) create mode 100644 probe_q.py create mode 100644 probe_target.py create mode 100644 src-tauri/src/music/__pycache__/bridge.cpython-312.pyc create mode 100644 src-tauri/src/music/__pycache__/bridge.cpython-314.pyc create mode 100644 src-tauri/src/music/bridge.py create mode 100644 src-tauri/src/music/bridge.rs create mode 100644 src-tauri/src/music/commands.rs create mode 100644 src-tauri/src/music/feiniu/cache.rs create mode 100644 src-tauri/src/music/feiniu/conn.rs create mode 100644 src-tauri/src/music/feiniu/fnconnect.rs create mode 100644 src-tauri/src/music/feiniu/fnos.rs create mode 100644 src-tauri/src/music/feiniu/mod.rs create mode 100644 src-tauri/src/music/feiniu/proxy.rs create mode 100644 src-tauri/src/music/mod.rs create mode 100644 src-tauri/src/music/runtime.rs create mode 100644 src/modules/devtools/DevToolsModule.vue create mode 100644 src/modules/devtools/components/ResultArea.vue create mode 100644 src/modules/devtools/components/Segmented.vue create mode 100644 src/modules/devtools/index.ts create mode 100644 src/modules/devtools/registry.ts create mode 100644 src/modules/devtools/tools/Base64Tools.vue create mode 100644 src/modules/devtools/tools/CaseTools.vue create mode 100644 src/modules/devtools/tools/CharCountTools.vue create mode 100644 src/modules/devtools/tools/ColorTools.vue create mode 100644 src/modules/devtools/tools/CronTools.vue create mode 100644 src/modules/devtools/tools/DiffTools.vue create mode 100644 src/modules/devtools/tools/EolTools.vue create mode 100644 src/modules/devtools/tools/EscapeTools.vue create mode 100644 src/modules/devtools/tools/HashTools.vue create mode 100644 src/modules/devtools/tools/HttpStatusTools.vue create mode 100644 src/modules/devtools/tools/IpTools.vue create mode 100644 src/modules/devtools/tools/JsonTools.vue create mode 100644 src/modules/devtools/tools/JwtTools.vue create mode 100644 src/modules/devtools/tools/PasswordTools.vue create mode 100644 src/modules/devtools/tools/RegexTools.vue create mode 100644 src/modules/devtools/tools/ReplaceTools.vue create mode 100644 src/modules/devtools/tools/TimestampTools.vue create mode 100644 src/modules/devtools/tools/UrlTools.vue create mode 100644 src/modules/devtools/tools/UuidTools.vue create mode 100644 src/modules/devtools/tools/base/BaseTools.vue create mode 100644 src/modules/devtools/tools/index.ts create mode 100644 src/modules/music/MusicModule.vue create mode 100644 src/modules/music/SourcePicker.vue create mode 100644 src/modules/music/components/MyMusicLibrary.vue create mode 100644 src/modules/music/components/PlayerBar.vue create mode 100644 src/modules/music/components/PlayerFull.vue create mode 100644 src/modules/music/components/TrackItem.vue create mode 100644 src/modules/music/components/settings/CacheSettings.vue create mode 100644 src/modules/music/components/settings/ConnectionsSettings.vue create mode 100644 src/modules/music/index.ts create mode 100644 src/modules/music/sources.ts create mode 100644 src/modules/screenshot/annotations.ts create mode 100644 src/stores/feiniuStore.ts create mode 100644 src/stores/musicStore.ts diff --git a/.gitignore b/.gitignore index e589482..cde4cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ lerna-debug.log* # 发布暂存目录 release_stage/ +# musicdl 手动测试产物(search_results.pkl 等) +musicdl_outputs/ + node_modules dist dist-ssr diff --git a/package.json b/package.json index a1a9c0c..c0b8bba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "thing", "private": true, - "version": "26.8.5", + "version": "26.9.1", "type": "module", "scripts": { "dev": "vite", diff --git a/probe_q.py b/probe_q.py new file mode 100644 index 0000000..c23f300 --- /dev/null +++ b/probe_q.py @@ -0,0 +1,16 @@ +import sys, importlib.util +sys.stdout.reconfigure(encoding="utf-8") +from musicdl.modules.sources.qq import QQMusicClient +from musicdl.modules.sources.netease import NeteaseMusicClient +from musicdl.modules.sources.kugou import KugouMusicClient +from musicdl.modules.utils.qqutils import SongFileType +# find MUSIC_QUALITIES in source modules +for mod in (QQMusicClient, NeteaseMusicClient, KugouMusicClient): + src = mod.__module__ + m = getattr(mod, 'source', '?') + names = [x for x in mod.__init__.__globals__.keys() if 'QUALIT' in x.upper()] + print("SRC", m, "quality-names-in-globals:", names) + for n in names: + v = mod.__init__.__globals__[n] + print(" ", n, "=", v) +print("QQ SORTED_QUALITIES:", SongFileType.SORTED_QUALITIES.value) \ No newline at end of file diff --git a/probe_target.py b/probe_target.py new file mode 100644 index 0000000..cacd6f5 --- /dev/null +++ b/probe_target.py @@ -0,0 +1,21 @@ +import sys, json, importlib.util +sys.stdout.reconfigure(encoding="utf-8") +spec = importlib.util.spec_from_file_location("bridge", r"d:\Atie\Gitea\Thing\src-tauri\src\music\bridge.py") +b = importlib.util.module_from_spec(spec); spec.loader.exec_module(b) +client = b.get_client(["QQMusicClient"]) +res = client.search("海阔天空") +s = res["QQMusicClient"][0] +rs = (s.raw_data or {}).get("search") +src = b._get_resolve_client("QQMusicClient") +print("raw song:", s.song_name, "| id", s.identifier) +# official alone, lossless_quality_is_sufficient=False, no cap +from musicdl.modules.utils.data import SongInfo +official = src._parsewithofficialapiv1 +r2 = official(search_result=rs, song_info_flac=SongInfo(source="QQMusicClient"), lossless_quality_is_sufficient=False) +print("official-only uncapped -> ext=", getattr(r2,"ext",None), "valid=", getattr(r2,"with_valid_download_url",None)) +for target in ("", "最高", "无损", "320K", "128K"): + r = b._resolve_song(src, rs, target) + print("target=%r ->" % target, "ext=", getattr(r,"ext",None), "bitrate=", getattr(r,"bitrate",None), "valid=", getattr(r,"with_valid_download_url",None)) +# verify constant restored +import musicdl.modules.utils.qqutils as qq +print("restored SORTED len:", len(qq.SongFileType.SORTED_QUALITIES.value)) \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6d0fa78..e63c544 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a3502e9..3a078b4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thing" -version = "26.8.5" +version = "26.9.1" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -32,7 +32,7 @@ specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_js specta-typescript = "0.0.12" tauri-specta = { version = "=2.0.0-rc.25", features = ["typescript"] } chrono = "0.4" -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.12", features = ["json", "stream", "multipart"] } futures-util = "0.3" tokio = { version = "1", features = ["io-util", "time", "rt-multi-thread", "macros", "sync", "fs", "net"] } axum = "0.7" @@ -42,14 +42,21 @@ dirs = "5" sysinfo = "0.32" rusqlite = { version = "0.32", features = ["bundled"] } base64 = "0.22" +sha2 = "0.10" +md-5 = "0.10" +hmac = "0.12" +aes = "0.8" +cbc = "0.1" +rsa = { version = "0.9", features = ["pem"] } +rand = "0.8" +tokio-tungstenite = "0.24" image = { version = "0.25", default-features = false, features = ["png"] } walkdir = "2" notify = { version = "6", features = [] } librqbit = "9" bytes = "1" -# HDR 截图:Windows 高级色彩(HDR)下 BitBlt 会把 scRGB 线性像素当 sRGB 直出导致过曝/失真, -# 需改用 Windows Graphics Capture(Rgba16F)+ God→sRGB 色调映射。仅在检测到 HDR 时启用。 windows-capture = "2.0.1" +# HDR 隰鯉スェ陜暦スセ繝サ蝠擁ndows 鬯ョ蛟。・コ・ァ豼カ・イ陟厄スゥ繝サ繝サDR繝サ謌托スク繝サBitBlt 闔ィ螢ス貊・scRGB 驛、・ソ隲、・ァ陷貞・・エ・ー陟悶・sRGB 騾カ・エ陷・スコ陝・スシ髢セ・エ髴代・螻・陞滂スア騾オ貊ゑスシ繝サ# 鬮エツ€隰セ・ケ騾包スィ Windows Graphics Capture繝サ繝サgba16F繝サ繝サ God遶顔龍RGB 豼カ・イ髫ケ繝サ荳占氣繝サツ€繧・スサ繝サ諠隴スツ€雎ャ蜿・闃ヲ HDR 隴鯉スカ陷キ・ッ騾包スィ邵イ繝サwindows-capture = "2.0.1" [target.'cfg(windows)'.dependencies] winreg = "0.52" @@ -57,6 +64,7 @@ raw-window-handle = "0.6" windows-sys = { version = "0.52", features = [ "Win32_Networking_WinInet", "Win32_Foundation", + "Win32_Globalization", "Win32_Security", "Win32_System_Threading", "Win32_System_Ole", @@ -73,7 +81,7 @@ windows-sys = { version = "0.52", features = [ "Win32_Storage_FileSystem", "Win32_Devices_Display", ] } -# Explorer 鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・ャ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ョ・」髮具スサ繝サ・ス繝サ・ィ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ郢晢スサ邵コ・、・つ€鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・カ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ鬮ョ迢暦スソ・ォ郢晢スサ髫エ螢ス・コ・ス繝サ・ケ隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・」鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・」鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬩搾スオ繝サ・コ郢晢スサ繝サ・、郢晢スサ邵コ・、・つ€鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ョ鬯ョ・ョ隲幢スカ繝サ・ス繝サ・」驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ャ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ鬯ョ・」鬲・シ夲スス・ス繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サShellWindows COM鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ョ繝サ・」鬮ョ蜈キ・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・ィ鬯ョ・ッ隶灘・・スス・サ郢ァ謇假スス・ス繝サ・ス郢晢スサ繝サ・イ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ョ繝サ・ッ髫カ蜴・スス・ク郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ鬯ョ・「繝サ・ァ郢晢スサ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・コ鬮ッ蛹コ・サ繧托スス・ス繝サ・・鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・セ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・ケ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・セ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サfeature鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・エ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ョ・エ隰・∞・ス・ス繝サ・ケ鬮ッ讖ク・ス・「郢晢スサ繝サ・シ驛「譎「・ス・サ郢晢スサ繝サ・エ鬯ョ・エ陜捺サ・スア蝣、・ケ譎「・ス・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・カ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ゥ鬮ッ譎「・ス・カ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・ク鬮ッ譎「・ス・カ髯キ・キ繝サ・カ驛「譎「・ス・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・ソ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ェ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ョ・ッ繝サ・キ鬮」魃会スス・ィ郢晢スサ繝サ・ス郢晢スサ繝サ・キ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ +# Explorer 鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・ォ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ャ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・」鬯ョ・ョ陷茨スキ繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬩搾スオ繝サ・コ郢晢スサ繝サ・、郢晢スサ邵コ・、・つ€鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・カ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ッ・ョ繝サ・ョ髴托ス「隴会スヲ繝サ・ス繝サ・ソ郢晢スサ繝サ・ォ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬯ョ・ォ繝サ・エ髯橸ス「繝サ・ス郢晢スサ繝サ・コ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ケ鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・」鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・」鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ隰ウ・セ繝サ・ス繝サ・オ驛「譎「・ス・サ郢晢スサ繝サ・コ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・、鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬩搾スオ繝サ・コ郢晢スサ繝サ・、郢晢スサ邵コ・、・つ€鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ョ鬮ォ・イ陝キ・「繝サ・ス繝サ・カ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・」鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ャ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・」鬯ッ・イ郢晢スサ繝サ・シ陞滂スイ繝サ・ス繝サ・ス郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ゥ鬮ッ譎「・ス・キ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ鬯ョ・ョ隲幢スカ繝サ・ス繝サ・」驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サShellWindows COM鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ゥ鬮ッ譎「・ス・キ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ鬯ョ・ョ隲幢スカ繝サ・ス繝サ・」驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・」鬯ッ・ョ繝サ・ョ髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ鬮ォ・カ霓」蛛・スス・・郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・サ鬩幢ス「繝サ・ァ髫ー繝サ竏槭・・ス繝サ・ス郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・イ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ョ・ォ繝サ・カ髯キ・エ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ク鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ゥ鬮ッ譎「・ス・キ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ鬯ョ・ョ隲幢スカ繝サ・ス繝サ・」驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ァ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・エ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ッ・ョ繝サ・ッ髯具スケ繝サ・コ郢晢スサ繝サ・サ驛「・ァ隰・∞・ス・ス繝サ・ス郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・・鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・セ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ鬮ッ・キ髣鯉スィ繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ケ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ鬯ョ・」鬲・シ夲スス・ス繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・セ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ゥ鬮ッ譎「・ス・キ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ鬯ョ・ョ隲幢スカ繝サ・ス繝サ・」驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サfeature鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ゥ鬮ッ譎「・ス・キ郢晢スサ繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ鬯ョ・ョ隲幢スカ繝サ・ス繝サ・」驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・「鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・ォ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・エ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・エ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・「鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・エ鬮ォ・ー郢晢スサ遶乗ァュ繝サ繝サ・ス郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ケ鬯ッ・ョ繝サ・ッ髫カ蜴・スス・ク郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・エ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・エ鬮ッ諛育袖繝サ・サ郢晢スサ繝サ・ス繝サ・ア髯懶ス」繝サ・、郢晢スサ繝サ・ケ髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・カ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ゥ鬯ッ・ョ繝サ・ッ髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・カ鬯ョ・ッ隴趣ス「繝サ・ス繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ク鬯ッ・ョ繝サ・ッ髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・カ鬯ョ・ッ繝サ・キ郢晢スサ繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・カ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ソ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ェ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ョ繝サ・」鬯イ繝サ・シ螟イ・ス・ス繝サ・ス郢晢スサ繝サ・ィ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・キ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・「鬯ョ・ォ繝サ・エ鬮ョ諛カ・ス・」郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ windows = { version = "0.52", features = [ "Win32_Foundation", "Win32_System_Com", @@ -81,8 +89,7 @@ windows = { version = "0.52", features = [ "Win32_System_Variant", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", - # HDR 检测:枚举 DXGI 输出色彩空间(advanced color 时输出 G2084/PQ 空间) - "Win32_Graphics_Dxgi", + # HDR 隴スツ€雎ャ蜈キ・シ螢ス讀「闕ウ・セ DXGI 髴守§繝サ豼カ・イ陟厄スゥ驕ィ・コ鬮」・エ繝サ繝サdvanced color 隴鯉スカ髴守§繝サ G2084/PQ 驕ィ・コ鬮」・エ繝サ繝サ "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", ] } @@ -90,14 +97,14 @@ windows = { version = "0.52", features = [ [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" -# ===== 鬯ゥ蟶幢スク蟶吶・郢晢スサ繝サ・ッ髯キ・ソ繝サ・ー郢晢スサ繝サ・シ髣費スィ隲幢スキ陝・・===== -# dev 鬮ォ・エ繝サ・ォ驛「譎「・ス・サ郢晢スサ繝サ・サ郢晢スサ繝サ・コ鬮ッ・キ繝サ・キ郢晢スサ繝サ・ッ鬯ィ・セ陋ケ繝サ・ス・ス繝サ・ィ鬮ッ貅倥・繝サ・ィ繝サ・」驛、繝サ・ス・シ鬯ゥ蟶幢スク蟶吶・郢晢スサ繝サ・ッ驛「譎「・ス・サ+ 鬯ョ・ッ繝サ・ヲ髫ー逍イ・サ轣假スキ・ョ鬯ゥ蟷「・ス・、郢晢スサ繝サ・ァ鬯ョ・ォ繝サ・ケ驛「譎「・ス・サ郢晢スサ繝サ・ッ髯具スケ郢晢スサ繝サ・ス繝サ・シ髯懈瑳・コ・ス繝サ・ス繝サ・イ鬯ッ・ィ繝サ・セ髮矩醍袖邵コ貊・活繝サ・ィ郢晢スサ繝サ・ー鬯ョ・エ隰・∞・ス・ス繝サ・ュ鬮」豈費スシ螟イ・ス・ス繝サ・」驛「譎「・ス・サ髣費スィ陞滂スイ繝サ・ス繝サ・シ髯溷桁・ス・。郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・ア鬮ッ・キ繝サ・ゥ驛「譎「・ス・サ`tauri dev` +# ===== 鬯ッ・ッ繝サ・ゥ髯晢スカ陝キ・「繝サ・ス繝サ・ク髯晢スカ陷キ・カ郢晢スサ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ョ・ッ繝サ・キ郢晢スサ繝サ・ソ驛「譎「・ス・サ郢晢スサ繝サ・ー鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ョ・」髮具スサ繝サ・ス繝サ・ィ鬮ォ・イ陝キ・「繝サ・ス繝サ・キ鬮ッ譏エ繝サ郢晢スサ===== +# dev 鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・ォ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・キ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ッ・ッ繝サ・ィ郢晢スサ繝サ・セ鬮ッ蜈キ・ス・ケ驛「譎「・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ィ鬯ッ・ョ繝サ・ッ髮九・ツ€・・郢晢スサ驛「譎「・ス・サ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・」鬯ゥ蟷「・ス・、驛「譎「・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・シ鬯ッ・ッ繝サ・ゥ髯晢スカ陝キ・「繝サ・ス繝サ・ク髯晢スカ陷キ・カ郢晢スサ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ+ 鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ョ・ォ繝サ・ー鬨セ謳セ・ス・イ郢晢スサ繝サ・サ髴難ス」陋帙・・ス・ス繝サ・キ郢晢スサ繝サ・ョ鬯ッ・ッ繝サ・ゥ髯晢スキ繝サ・「郢晢スサ繝サ・ス郢晢スサ繝サ・、鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ォ驛「譎「・ス・サ郢晢スサ繝サ・ケ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ョ・ッ陷茨スキ繝サ・ス繝サ・ケ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ョ・ッ隲幄ご陲也ケ晢スサ繝サ・コ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・イ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・セ鬯ョ・ョ驕擾スゥ繝サ・ー鬩「蟠趣ス「螟懊@繝サ・コ髮狗ソォ繝サ雎「・サ驛「譎「・ス・サ郢晢スサ繝サ・ィ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・エ鬮ォ・ー郢晢スサ遶乗ァュ繝サ繝サ・ス郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ュ鬯ッ・ョ繝サ・」髮朱メ・イ・サ繝サ・ス繝サ・シ髯樊サゑスス・イ郢晢スサ繝サ・ス郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・」鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬯ョ・」髮具スサ繝サ・ス繝サ・ィ鬮ッ讓奇スサ繧托スス・ス繝サ・イ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ョ・ッ雋・スキ隴ッ竏壹・繝サ・ス郢晢スサ繝サ・。鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ア鬯ッ・ョ繝サ・ッ郢晢スサ繝サ・キ驛「譎「・ス・サ郢晢スサ繝サ・ゥ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ`tauri dev` [profile.dev] incremental = true debug = "line-tables-only" -# release 鬯ョ・ォ繝サ・エ郢晢スサ繝サ・ォ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ッ・ィ繝サ・セ髯キ・エ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ヲ鬯ッ・ョ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・ス郢晢スサ繝サ・ォ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ッ讖ク・ス・「郢晢スサ繝サ・シ鬮ォ・ー驕貞、ァ・エ貅ッ闊峨・・キ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ョ・ッ繝サ・キ郢晢スサ繝サ・ソ驛「譎「・ス・サ郢晢スサ繝サ・キ + LTO鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・ー騾搾スイ繝サ・コ陋滂スゥ繝サ・ゥ陞ウ・」陷蛾・・ケ譎「・ス・サ郢晢スサ繝サ・ク髫カ蜴・スス・ク郢晢スサ繝サ・ス郢晢スサ繝サ・ョ鬯ョ・」郢晢スサ繝サ・ス繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・」鬩幢ス「隴趣ス「繝サ・ス繝サ・サ鬮ォ・エ繝サ・ッ驕カ謫セ・ス・ャ髫ー螢ケ繝サ繝サ・ェ繝サ・ー髯キ闌ィ・ス・キ郢晢スサ繝サ・ス郢晢スサ繝サ・ァ驛「譎「・ス・サ郢晢スサ繝サ・ッ +# release 鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ォ驛「譎「・ス・サ郢晢スサ繝サ・エ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ォ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・コ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ィ驛「譎「・ス・サ郢晢スサ繝サ・セ鬯ョ・ッ繝サ・キ郢晢スサ繝サ・エ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ッ・ッ繝サ・ッ郢晢スサ繝サ・ョ驛「譎「・ス・サ郢晢スサ繝サ・エ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ォ鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ョ繝サ・ッ髫カ蜴・スス・ク郢晢スサ繝サ・ス郢晢スサ繝サ・「鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・シ鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・ー鬯ゥ謌奇スイ讖ク・ス・、繝サ・ァ郢晢スサ繝サ・エ髮九・・ス・ッ鬮」髮・スウ・ィ郢晢スサ郢晢スサ繝サ・キ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ヲ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・ッ驛「譎「・ス・サ郢晢スサ繝サ・キ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ソ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・キ + LTO鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・ー鬯ィ・セ隰ウ・セ繝サ・ス繝サ・イ驛「譎「・ス・サ郢晢スサ繝サ・コ鬮ッ蛹コ・サ繧托スス・ス繝サ・ゥ驛「譎「・ス・サ郢晢スサ繝サ・ゥ鬮ッ讖ク・ス・ウ郢晢スサ繝サ・」鬮ッ・キ髯具スセ郢晢スサ郢晢スサ繝サ・ケ髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ク鬯ョ・ォ繝サ・カ髯キ・エ郢晢スサ繝サ・ス繝サ・ス郢晢スサ繝サ・ク鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ョ鬯ッ・ッ繝サ・ョ郢晢スサ繝サ・」鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・ス鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・」鬯ッ・ゥ陝キ・「繝サ・ス繝サ・「鬮ォ・エ髮懶ス」繝サ・ス繝サ・「驛「譎「・ス・サ郢晢スサ繝サ・ス驛「譎「・ス・サ郢晢スサ繝サ・サ鬯ッ・ョ繝サ・ォ郢晢スサ繝サ・エ驛「譎「・ス・サ郢晢スサ繝サ・ッ鬯ゥ蛹・スス・カ髫ー・ォ繝サ・セ郢晢スサ繝サ・ス郢晢スサ繝サ・ャ鬯ョ・ォ繝サ・ー髯橸ス「繝サ・ケ驛「譎「・ス・サ驛「譎「・ス・サ郢晢スサ繝サ・ェ驛「譎「・ス・サ郢晢スサ繝サ・ー鬯ョ・ッ繝サ・キ鬮」魃会スス・ィ郢晢スサ繝サ・ス郢晢スサ繝サ・キ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ス鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ァ鬯ゥ蟷「・ス・「髫エ雜」・ス・「郢晢スサ繝サ・ス郢晢スサ繝サ・サ鬩幢ス「隴趣ス「繝サ・ス繝サ・サ驛「譎「・ス・サ郢晢スサ繝サ・ッ [profile.release] strip = true lto = true diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 0163ede..531ff3d 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -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"; // 应用更新进度 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 29acdd9..42f82f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::() { + // 停止音乐桥接进程(kill 快速返回,wait 在后台线程完成) + music.cleanup_on_exit(); + } if let Some(clip) = app.try_state::() { clip.stop(); } diff --git a/src-tauri/src/music/__pycache__/bridge.cpython-312.pyc b/src-tauri/src/music/__pycache__/bridge.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1982d9fb2bea883dc86dd065ef8b751ec11792c GIT binary patch literal 58294 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 QRIPuy28!b9EVc#zAO1Gn$N&HU literal 0 HcmV?d00001 diff --git a/src-tauri/src/music/bridge.py b/src-tauri/src/music/bridge.py new file mode 100644 index 0000000..f1b3292 --- /dev/null +++ b/src-tauri/src/music/bridge.py @@ -0,0 +1,1377 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""musicdl bridge process: stdio JSON-Lines protocol. + +Request (one JSON per line on stdin): + {"id": 1, "method": "ping", "params": {...}} +Response (one JSON per line on stdout): + {"id": 1, "ok": true, "result": {...}} + {"id": 1, "ok": false, "error": "..."} + +Methods: + ping -> {"version", "python"} + env_status -> {"musicdl": version|null} + get_sources -> {"sources": [registered music client names]} + search -> {"results": {source: [song...]}, "sources": [...]} + params: {"keyword", "sources": []} + resolve -> {"songs": [resolved-song-or-null, ...], "resolved": n} + params: {"song": {...}} or {"songs": [...]} + (懒解析:对搜索结果歌曲执行真实解析链,返回带下载链接 + 的完整歌曲;输入顺序对齐,失败位为 null) + download -> {"taskId"} (runs in background worker pool) + params: {"taskId", "songs": [song dicts], "savedir", + "lyric", "cover", "proxy": url-or-"", + "maxConcurrent": n} + cancel -> {"taskId": true} + params: {"taskId"} (queue-level cancel) + +Events (no "id", forwarded verbatim to the frontend by the Rust side): + {"event":"download","taskId":T,"type":"resolving","key":K,"songName":S,"singers":G} + {"event":"download","taskId":T,"type":"start","key":K,"songName":S,"singers":G,"ext":E,"total":N} + {"event":"download","taskId":T,"type":"progress","key":K,"downloaded":N,"total":N} + {"event":"download","taskId":T,"type":"done","key":K} + {"event":"download","taskId":T,"type":"error","key":K,"message":M} + {"event":"download","taskId":T,"type":"cancelled","key":K} + {"event":"download","taskId":T,"type":"finished","done":N,"total":M} + +Protocol notes: +- stdin must be UTF-8 (Rust writes raw UTF-8 bytes, serde_json does not escape + non-ASCII); main() reconfigures stdin/stdout to UTF-8 because Windows pipes + otherwise default to the locale codec (GBK on Chinese systems). +- stdout is parsed by Rust as UTF-8; all responses use ensure_ascii=True + (pure ASCII, immune to locale/GBK encoding issues). +- Event lines from background threads are written under a lock to avoid + interleaving partial JSON lines. +- musicdl's rich progress bars write to sys.stdout and would corrupt the + JSON channel, so search/download bodies run under redirect_stdout(stderr). +- Download progress is reported by polling each song's target file size + (musicdl writes directly to song.save_path, no temp files). + +Embedded via include_str! in src-tauri/src/music/bridge.rs; keep stdlib-only +(no third-party imports at module level - musicdl is imported lazily so the +bridge can start without it). +""" + +import contextlib +import hashlib +import json +import os +import re +import sys +import tempfile +import threading +import time +import uuid + +# Fallback sources used when the frontend passes an empty list +DEFAULT_SOURCES = [ + "NeteaseMusicClient", + "QQMusicClient", + "KugouMusicClient", +] + +# --------------------------------------------------------------------------- +# musicdl 运行时补丁(monkey-patch,仅影响本桥接进程;musicdl 锁定 2.13.11) +# +# 背景:musicdl 的 search = 官方搜索 + 逐首解析下载链接。每个源的解析链按 +# l1→l4 顺序串行尝试最多 15+ 个第三方 API(单个超时 10~30s),且一页内的 +# 候选歌是串行解析的。大量第三方 API 已死或不稳定: +# - 慢:死 API 的超时被逐个吃满,一次搜索动辄 1-2 分钟; +# - 错:l3/l4「不稳定账号」API 会返回无关歌曲/空元数据(legalizestring(None) +# 产出字面量 "NULL"),即搜索结果里 NULL 歌名、错误歌手的来源。 +# 补丁:1) HTTP 超时封顶;2) 修剪解析链,只保留 l1/l2 头部可靠源; +# 3) 链接探测复用会话;4) 会话连接复用 + 限重试(init cfg,见 get_client); +# 5) 懒解析:搜索只取元数据,解析链推迟到下载/试听(见 _apply_lazy_search_patch)。 +# --------------------------------------------------------------------------- + +# 非流式请求(搜索/解析/歌词等 JSON API)的超时上限 (connect, read) +_TIMEOUT_CAP = (3, 8) +# 流式请求(真实下载/链接探测)仅收紧连接超时,读取超时保持原值 +_STREAM_CONNECT_CAP = 3 + +# 每源并行分页数:懒解析源(有 _META_EXTRACTORS 提取器)搜索 = 每源 1 个请求 +# (单页 10 条结果,本地提取元数据);旧的 per_page=1 × 10 页并行是为绕过页内 +# 逐首串行解析设计的,且 10 个并行请求会触发 QQ 搜索接口限速(~9s/间歇全失败)。 +# 无提取器的源(酷我/咪咕等,仍急切解析)继续用 per_page=1 × 多页并行。 +_SEARCH_SIZE_PER_SOURCE = 10 + +# 修剪后的第三方解析链(保持 musicdl 原 l1/l2 顺序;未列出的源不修剪) +_PRUNED_THIRDPARTY_PARSERS = { + "QQMusicClient": ( + "_parsewithvkeysapi", + "_parsewithxcvtsapi", + "_parsewithchkszapi", + ), + "NeteaseMusicClient": ( + "_parsewithtmetuapi", + "_parsewithxuanluogeapi", + "_parsewithkangqiovoapi", + "_parsewithchkszapi", + "_parsewithznnuapi", + "_parsewithxiaoqinapi", + ), + "KugouMusicClient": ( + "_parsewithbakaapi", + "_parsewithqqovoapi", + "_parsewithlzmhhhapi", + "_parsewithxianyuwapi", + ), +} + +# 搜索缓存 pkl 的落盘目录(musicdl 会往 work_dir 写 search_results.pkl,对桥接无用) +_SEARCH_WORK_DIR = os.path.join(tempfile.gettempdir(), "musicdl-search") + +# 懒解析占位 URL:musicdl 的 with_valid_download_url 要求 download_url 以 http 开头, +# 未解析条目用它标记(song_to_dict 输出时会置空 downloadUrl 并附带 rawSearch) +_LAZY_URL = "http://lazy.internal/unresolved" + +_PATCHED = False + + +def _cap_timeout(timeout, stream): + """按 stream 与否收紧超时,返回新的 timeout 值。""" + if stream: + if isinstance(timeout, tuple): + return (min(timeout[0], _STREAM_CONNECT_CAP), timeout[1]) + if isinstance(timeout, (int, float)): + return (_STREAM_CONNECT_CAP, timeout) + return (_STREAM_CONNECT_CAP, 30) + if isinstance(timeout, tuple): + return (min(timeout[0], _TIMEOUT_CAP[0]), min(timeout[1], _TIMEOUT_CAP[1])) + if isinstance(timeout, (int, float)): + return (_TIMEOUT_CAP[0], min(timeout, _TIMEOUT_CAP[1])) + return _TIMEOUT_CAP + + +def _patch_musicdl(): + """对已安装的 musicdl 应用性能/正确性补丁(幂等,首次 import 后调用)。""" + global _PATCHED + if _PATCHED: + return + _PATCHED = True + import requests + + # --- 1. 超时封顶:所有请求(含各 source 模块级 requests.get/post 与 + # AudioLinkTester 的 session)都经由 requests.Session.request,在此统一收紧 + _orig_request = requests.Session.request + + def _capped_request(self, method, url, **kwargs): + kwargs["timeout"] = _cap_timeout(kwargs.get("timeout"), bool(kwargs.get("stream"))) + return _orig_request(self, method, url, **kwargs) + + requests.Session.request = _capped_request + + # --- 3. 链接探测复用会话:AudioLinkTester.test 默认 renew_session=True, + # 每次探测前先 close 旧 Session 再新建(多付一次 TCP+TLS 握手)。 + # 各源 officialapi 解析每首歌至少探测一次,改为复用可叠加提速 + from musicdl.modules.sources.kugou import KugouMusicClient + from musicdl.modules.sources.netease import NeteaseMusicClient + from musicdl.modules.sources.qq import QQMusicClient + from musicdl.modules.utils.data import SongInfo + from musicdl.modules.utils.misc import AudioLinkTester + + _orig_test = AudioLinkTester.test + + def _reused_session_test(self, url, request_overrides=None, renew_session=True): + return _orig_test(self, url, request_overrides=request_overrides, renew_session=False) + + AudioLinkTester.test = _reused_session_test + + # --- 2. 修剪第三方解析链:跳过 l3/l4 不稳定账号 API + def _make_pruned_thirdpartapis(parsers): + def _parsewiththirdpartapis(self, search_result, request_overrides=None): + request_overrides = request_overrides or {} + if self.default_cookies or request_overrides.get("cookies"): + return SongInfo(source=self.source) + for name in parsers: + song_info = SongInfo( + source=self.source, + raw_data={"search": search_result, "download": {}, "lyric": {}}, + ) + with contextlib.suppress(Exception): + song_info = getattr(self, name)(search_result, request_overrides) + if song_info.with_valid_download_url and song_info.ext in AudioLinkTester.VALID_AUDIO_EXTS: + return song_info + return SongInfo(source=self.source) + + return _parsewiththirdpartapis + + for _cls in (QQMusicClient, NeteaseMusicClient, KugouMusicClient): + _parsers = _PRUNED_THIRDPARTY_PARSERS.get(_cls.source) + if _parsers: + _cls._parsewiththirdpartapis = _make_pruned_thirdpartapis(_parsers) + + +# --------------------------------------------------------------------------- +# 懒解析(lazy resolve) +# +# musicdl 把「搜索元数据」和「解析下载链接」绑死在 search() 里:每首歌都要跑 +# 完整解析链(第三方 l1/l2 + 官方音质阶梯 + 链接探测,1~3s/首)才进结果列表。 +# 懒解析把两者拆开:搜索阶段只调官方搜索 API 提取元数据(每页 1 个请求), +# 下载/试听时才对被选中的歌执行真实解析链。 +# +# 机制:对「搜索客户端」的各源实例做实例级方法覆盖(_parsewiththirdpartapis / +# _parsewithofficialapiv1 换成纯元数据版本),类方法不动——因此下载 worker、 +# resolve 专用客户端(全新实例)走的仍是真实解析链。 +# 无提取器的源(酷我/咪咕等)不打补丁,保持原急切解析(有 URL,行为不变)。 +# 注意:歌单解析(parse_playlist)会临时关掉懒解析——网易云歌单接口只回 +# trackIds(仅 id 无元数据),懒元数据提取会得到空结果。 +# 元数据提取器的字段映射复制自 musicdl 2.13.11 各源 _parsewithofficialapiv1 +# 的 SongInfo 构造,升级 musicdl 版本时需同步核对(与 _PRUNED 列表一起)。 +# --------------------------------------------------------------------------- + + +def _extract_netease_meta(r): + from musicdl.modules.utils import SongInfoUtils, legalizestring, safeextractfromdict + + duration_s = float(r.get("dt", 0) or 0) / 1000 + return { + "song_name": legalizestring(r.get("name")), + "singers": legalizestring( + ", ".join( + [s.get("name") for s in (safeextractfromdict(r, ["ar"], []) or []) if isinstance(s, dict) and s.get("name")] + ) + ), + "album": legalizestring(safeextractfromdict(r, ["al", "name"], None)), + "duration_s": duration_s, + "duration": SongInfoUtils.seconds2hms(duration_s), + "cover_url": safeextractfromdict(r, ["al", "picUrl"], None), + "identifier": r.get("id"), + } + + +def _extract_qq_meta(r): + from musicdl.modules.utils import SongInfoUtils, legalizestring, safeextractfromdict + + duration_s = int(float(r.get("interval", 0) or 0)) + return { + "song_name": legalizestring(r.get("title") or r.get("songname")), + "singers": legalizestring( + ", ".join([s.get("name") for s in (r.get("singer", []) or []) if isinstance(s, dict) and s.get("name")]) + ), + "album": legalizestring(safeextractfromdict(r, ["album", "title"], None) or r.get("albumname")), + "duration_s": duration_s, + "duration": SongInfoUtils.seconds2hms(duration_s), + "cover_url": "https://y.gtimg.cn/music/photo_new/T002R800x800M000{}.jpg".format( + safeextractfromdict(r, ["album", "mid"], "") or r.get("albummid") + ), + "identifier": r.get("mid") or r.get("songmid"), + } + + +def _extract_kugou_meta(r): + from musicdl.modules.utils import SongInfoUtils, legalizestring, safeextractfromdict + + duration_s = float(r.get("duration", 0) or r.get("Duration", 0) or 0) or (float(r.get("timelen", 0) or 0) / 1000) + cover = safeextractfromdict(r, ["trans_param", "union_cover"], None) or r.get("cover_url") or r.get("Image") + if cover and isinstance(cover, str) and "{size}" in cover: + cover = cover.format(size=300) + return { + "song_name": legalizestring( + r.get("songname") + or r.get("SongName") + or r.get("songname_original") + or r.get("OriSongName") + or r.get("filename") + or r.get("FileName") + or r.get("name") + or r.get("Name") + ), + "singers": legalizestring( + r.get("singername") + or r.get("SingerName") + or ", ".join( + [s.get("name") for s in (r.get("singerinfo") or r.get("Singers") or []) if isinstance(s, dict) and s.get("name")] + ) + ), + "album": legalizestring(r.get("album_name") or r.get("AlbumName") or safeextractfromdict(r, ["albuminfo", "name"], None)), + "duration_s": duration_s, + "duration": SongInfoUtils.seconds2hms(duration_s), + "cover_url": cover, + "identifier": r.get("hash") or r.get("FileHash"), + } + + +_META_EXTRACTORS = { + "NeteaseMusicClient": _extract_netease_meta, + "QQMusicClient": _extract_qq_meta, + "KugouMusicClient": _extract_kugou_meta, +} + + +def _make_lazy_songinfo(src_client, extract, search_result): + """懒解析 SongInfo:仅元数据 + 占位 download_url(with_valid_download_url 要求 http 开头)。""" + from musicdl.modules.utils.data import SongInfo + + try: + meta = extract(search_result) or {} + except Exception: + meta = {} + identifier = meta.pop("identifier", None) + if identifier in (None, "", 0): + # identifier 缺失会被 _removeduplicates 全部合并成一首,用内容哈希兜底 + identifier = hashlib.md5( + json.dumps(search_result, sort_keys=True, default=str).encode("utf-8") + ).hexdigest()[:12] + return SongInfo( + raw_data={"search": search_result, "download": {}, "lyric": {}}, + source=src_client.source, + identifier=str(identifier), + download_url=_LAZY_URL, + download_url_status={"ok": True, "lazy": True}, + **meta, + ) + + +def _apply_lazy_search_patch(client): + """对搜索客户端的各源实例打懒解析补丁(实例级覆盖,只影响本客户端)。 + 原始解析方法保存为 _real_*,供 _resolve_song / 歌单急切解析复用。""" + for source, src_client in client.music_clients.items(): + extract = _META_EXTRACTORS.get(source) + if extract is None: + continue + src_client._real_parsewiththirdpartapis = src_client._parsewiththirdpartapis + src_client._real_parsewithofficialapiv1 = src_client._parsewithofficialapiv1 + + def _lazy_thirdparty(search_result, request_overrides=None, _c=src_client, _e=extract): + return _make_lazy_songinfo(_c, _e, search_result) + + def _lazy_official( + search_result, song_info_flac=None, lossless_quality_is_sufficient=True, + lossless_quality_definitions=None, request_overrides=None, _c=src_client, _e=extract, + ): + return song_info_flac if song_info_flac is not None else _make_lazy_songinfo(_c, _e, search_result) + + src_client._lazy_thirdparty = _lazy_thirdparty + src_client._lazy_official = _lazy_official + src_client._parsewiththirdpartapis = _lazy_thirdparty + src_client._parsewithofficialapiv1 = _lazy_official + + +def _set_lazy_search(client, enabled): + """切换搜索客户端的懒解析开关(须持有 _CLIENT_LOCK;歌单解析前临时关闭)。""" + for src_client in client.music_clients.values(): + if not hasattr(src_client, "_lazy_thirdparty"): + continue + if enabled: + src_client._parsewiththirdpartapis = src_client._lazy_thirdparty + src_client._parsewithofficialapiv1 = src_client._lazy_official + else: + src_client._parsewiththirdpartapis = src_client._real_parsewiththirdpartapis + src_client._parsewithofficialapiv1 = src_client._real_parsewithofficialapiv1 + + +def _resolve_song(src_client, search_result, target=None): + """执行真实解析链(第三方 + 官方音质阶梯),返回 SongInfo(可能无有效链接)。 + src_client 为懒解析搜索客户端时用 _real_* 引用,全新客户端则类方法即真实链。 + target 为音质 label(""/"最高"/"Hi-Res" 取最高);其余按「≤所选最优档」封顶解析。""" + target = (target or "").strip() + if target and target != "最高": + return _resolve_to_quality(src_client, search_result, target) + thirdparty = getattr(src_client, "_real_parsewiththirdpartapis", None) or src_client._parsewiththirdpartapis + official = getattr(src_client, "_real_parsewithofficialapiv1", None) or src_client._parsewithofficialapiv1 + song_info_flac = thirdparty(search_result=search_result) + song_info = official(search_result=search_result, song_info_flac=song_info_flac) + return song_info if song_info.with_valid_download_url else song_info_flac + + +# --------------------------------------------------------------------------- +# 按目标音质解析(取 ≤ 所选的最优档) +# +# musicdl 官方解析按「质量常量」循环、取首个有效档。这里在全局锁保护下临时替换 +# 各源质量常量(只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。 +# 由于替换的是模块级常量,必须用 _RESOLVE_CAP_LOCK 串行化所有封顶解析,避免并发竞态。 +# 有损码率目标用 lossless_quality_is_sufficient=False,使官方解析不采纳第三方 flac/hires。 +# --------------------------------------------------------------------------- +_RESOLVE_CAP_LOCK = threading.Lock() + +# QQ:SongFileType.SORTED_QUALITIES 前缀(F000=flac, O8xx/O6xx=ogg, M800=320K, M500=128K, C6xx=m4a) +_QQ_CAPS = { + "Hi-Res": None, # 全部 + "无损": {"F000", "O801", "O800", "O600", "O400", "M800", "M500", "C600", "C400", "C200"}, + "320K": {"M800", "M500", "C600", "C400", "C200"}, + "192K": {"M500", "C600", "C400", "C200"}, + "128K": {"M500", "C200"}, + "96K": {"C200"}, +} +# 网易:MUSIC_QUALITIES(lossless=flac, exhigh=320K, standard=128K) +_NETEASE_CAPS = { + "Hi-Res": None, + "无损": {"lossless", "dolby", "exhigh", "standard"}, + "320K": {"exhigh", "standard"}, + "192K": {"standard"}, + "128K": {"standard"}, + "96K": {"standard"}, +} +# 酷狗:MUSIC_QUALITIES(flac=无损, high≈320K, 320, 128) +_KUGOU_CAPS = { + "Hi-Res": None, + "无损": {"flac", "high", "320", "128"}, + "320K": {"high", "320", "128"}, + "192K": {"128"}, + "128K": {"128"}, + "96K": {"128"}, +} + + +class _ValueList: + """包装为 .value 的假 list(QQ SongFileType.SORTED_QUALITIES.value 形态)。""" + + def __init__(self, items): + self._items = items + + @property + def value(self): + return self._items + + +class _QQSongFileTypeProxy: + """代理 QQ SongFileType,仅替换 SORTED_QUALITIES.value,其余属性转发真实枚举。""" + + def __init__(self, capped_tuples, real): + self._capped = _ValueList(capped_tuples) + self._real = real + + @property + def SORTED_QUALITIES(self): + return self._capped + + def __getattr__(self, name): + return getattr(self._real, name) + + +_QUALITY_CONST_BACKUP = {} + + +def _swap_quality_constants(source, allowed): + """临时把源的质量常量换成「只含 ≤ 目标档位」的列表;真正原值存入备份用于恢复。""" + import importlib + + if source == "QQMusicClient": + import musicdl.modules.sources.qq as m + + real = m.SongFileType + capped = [(p, e) for (p, e) in real.SORTED_QUALITIES.value if p in allowed] + m.SongFileType = _QQSongFileTypeProxy(capped, real) + _QUALITY_CONST_BACKUP[source] = (m, "SongFileType", real) + return + modname = {"NeteaseMusicClient": "netease", "KugouMusicClient": "kugou"}[source] + m = importlib.import_module("musicdl.modules.sources." + modname) + real = m.MUSIC_QUALITIES + m.MUSIC_QUALITIES = [q for q in real if q in allowed] + _QUALITY_CONST_BACKUP[source] = (m, "MUSIC_QUALITIES", real) + + +def _restore_quality_constants(source): + entry = _QUALITY_CONST_BACKUP.pop(source, None) + if entry is None: + return + mod, attr, original = entry + setattr(mod, attr, original) + + +def _resolve_to_quality(src_client, search_result, target): + """按目标音质解析(≤目标最优档);无 ≤目标 档位时返回无有效链接。 + 无损级目标(Hi-Res/无损/最高)直接取最高(本就 ≤ 无损);有损码率目标封顶官方阶梯, + QQ 等免费源依赖第三方解析(只回最佳档)时封顶拿不到,遂回退取最高保证能下载。""" + source = src_client.source + if source == "QQMusicClient": + allowed = _QQ_CAPS.get(target) + elif source == "NeteaseMusicClient": + allowed = _NETEASE_CAPS.get(target) + elif source == "KugouMusicClient": + allowed = _KUGOU_CAPS.get(target) + else: + allowed = None + if allowed is None or target in ("Hi-Res", "无损"): + return _resolve_song(src_client, search_result) # 最高/无损级 → 原最高路径 + from musicdl.modules.utils.data import SongInfo + + official = getattr(src_client, "_real_parsewithofficialapiv1", None) or src_client._parsewithofficialapiv1 + with _RESOLVE_CAP_LOCK: + _swap_quality_constants(source, allowed) + try: + # 空 song_info_flac + lossless_quality_is_sufficient=False: + # 官方解析不采纳第三方 flac/hires,只按封顶后的官方阶梯取 ≤target 档 + song_info = official( + search_result=search_result, + song_info_flac=SongInfo(source=source), + lossless_quality_is_sufficient=False, + ) + finally: + _restore_quality_constants(source) + if song_info.with_valid_download_url: + return song_info + # 封顶拿不到(如 QQ 免费源只能即时解析链取最佳档)→ 回退取最高,保证能下载 + return _resolve_song(src_client, search_result) + + +# resolve 专用客户端缓存(按源;不打懒解析补丁,maintain_session 复用连接) +_RESOLVE_CLIENTS = {} +_RESOLVE_CLIENTS_LOCK = threading.Lock() + + +def _get_resolve_client(source): + """按源缓存的独立源客户端(resolve/试听/批量解析用),失败返回 None。""" + with _RESOLVE_CLIENTS_LOCK: + if source in _RESOLVE_CLIENTS: + return _RESOLVE_CLIENTS[source] + try: + from musicdl.musicdl import MusicClient + + client = MusicClient( + music_sources=[source], + init_music_clients_cfg={ + source: { + "maintain_session": True, + "max_retries": 1, + "work_dir": _SEARCH_WORK_DIR, + } + }, + ) + src_client = client.music_clients.get(source) + except Exception: + src_client = None + _RESOLVE_CLIENTS[source] = src_client + return src_client + +# Persistent MusicClient cache; keyed by the sorted source list because the +# client is constructed with a fixed source set (re-created on change). +_CLIENT = None +_CLIENT_KEY = None + +# Serialize access to the shared MusicClient (search vs download threads). +_CLIENT_LOCK = threading.Lock() +# Serialize stdout writes (response line + background event lines). +_WRITE_LOCK = threading.Lock() +# Active download tasks: taskId -> {"cancel": bool} +_TASKS = {} +# The REAL stdout captured at startup. musicdl 的 rich 进度条通过 redirect_stdout +# 把 sys.stdout 全局重定向到 stderr(跨线程生效),因此协议写入(响应/事件)必须 +# 走这个捕获的引用,而不是 sys.stdout,否则会一并写进 stderr 丢失。 +_PROTOCOL_STDOUT = None + + +def _out(): + """返回真实 stdout(协议写入用)""" + return _PROTOCOL_STDOUT if _PROTOCOL_STDOUT is not None else sys.stdout + + +def get_client(sources): + """Return a cached MusicClient, recreating it when the source set changes.""" + global _CLIENT, _CLIENT_KEY + key = "|".join(sorted(sources or [])) + if _CLIENT is None or _CLIENT_KEY != key: + try: + from musicdl.musicdl import MusicClient + from musicdl.modules.utils.data import SongInfo + except ImportError: + raise RuntimeError("musicdl 未安装,请先在设置页安装环境") + _patch_musicdl() + effective = sources or DEFAULT_SOURCES + # 懒解析源(有元数据提取器):单页 10 条 = 每源 1 个请求,本地提取元数据; + # 急切解析源(无提取器):per_page=1 × 10 页并行,避免页内逐首串行解析。 + # maintain_session=True:默认 False 时每个 get/post 都 _initsession() 新建 + # Session(零 TCP/TLS 连接复用,每请求多付 100~300ms 握手); + # max_retries=1:默认 3 会让死 API 最坏吃满 3 轮超时。 + # 搜索缓存 pkl 落临时目录。 + init_cfg = {} + for src in effective: + lazy = src in _META_EXTRACTORS + init_cfg[src] = { + "search_size_per_source": _SEARCH_SIZE_PER_SOURCE, + "search_size_per_page": _SEARCH_SIZE_PER_SOURCE if lazy else 1, + "work_dir": _SEARCH_WORK_DIR, + "maintain_session": True, + "max_retries": 1, + } + threadings = {src: _SEARCH_SIZE_PER_SOURCE for src in effective} + _CLIENT = MusicClient( + music_sources=effective, + init_music_clients_cfg=init_cfg, + clients_threadings=threadings, + ) + # 懒解析:搜索阶段只取元数据(实例级补丁,下载/resolve 客户端不受影响) + _apply_lazy_search_patch(_CLIENT) + _CLIENT_KEY = key + return _CLIENT + + +def _emit_event(event): + """写一条事件行到真实 stdout(加锁防交错)""" + line = json.dumps(event, ensure_ascii=True) + with _WRITE_LOCK: + _out().write(line + "\n") + _out().flush() + + +# 无损扩展名(与前端 LOSSLESS_EXTS 保持一致;musicdl 的 ext 无前导点) +_LOSSLESS_EXTS = {"flac", "wav", "alac", "ape", "wv", "tta", "dsf", "dff"} +# 网易 privilege.maxbr 无损门槛(bps):128k/192k/320k 为有损,flac/hires 为 999000 +_NETEASE_LOSSLESS_MAXBR = 740000 + + +def _to_num(v): + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + +def _lossless_hint(s): + """推断歌曲是否具备无损音质。急切解析歌看 ext;懒解析歌从 raw_search 的 + 官方音质字段推断;无法推断返回 None(表示未知)。""" + ext = str(getattr(s, "ext", None) or "").strip().lower().lstrip(".") + if ext: + return ext in _LOSSLESS_EXTS + rs = (getattr(s, "raw_data", None) or {}).get("search") if isinstance(getattr(s, "raw_data", None), dict) else None + if not isinstance(rs, dict): + return None + # QQ:搜索结果无音质字段,用 handle_search 的批量详情补查结果 + if "_qq_lossless" in rs: + return bool(rs["_qq_lossless"]) + # 酷狗:SQ(flac)/ Res(hires)文件大小 + if "SQFileSize" in rs or "ResFileSize" in rs: + return _to_num(rs.get("SQFileSize")) > 0 or _to_num(rs.get("ResFileSize")) > 0 + # 网易:privilege.maxbr 为该账号可用的最高码率 + priv = rs.get("privilege") + if isinstance(priv, dict) and "maxbr" in priv: + return _to_num(priv.get("maxbr")) >= _NETEASE_LOSSLESS_MAXBR + return None + + +def _fill_qq_lossless_hints(songs, batch_size=10): + """QQ 懒解析歌批量补查音质提示:musicu.fcg 一次 POST 查多首详情 + (搜索 API 的 item_song 不含音质/大小字段)。结果写入 raw_search + 的 _qq_lossless(bool);失败静默(保持未知)。""" + lazy = [ + s for s in songs + if getattr(s, "download_url", None) == _LAZY_URL + and isinstance(getattr(s, "raw_data", None), dict) + and isinstance(s.raw_data.get("search"), dict) + and s.identifier + ] + for start in range(0, len(lazy), batch_size): + batch = lazy[start:start + batch_size] + payload = { + "songinfo%d" % i: { + "method": "get_song_detail_yqq", + "module": "music.pf_song_detail_svr", + "param": {"song_mid": s.identifier}, + } + for i, s in enumerate(batch) + } + try: + import requests + + resp = requests.post( + "https://u.y.qq.com/cgi-bin/musicu.fcg", + json=payload, + headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, + timeout=(3, 8), + ) + resp.raise_for_status() + data = resp.json() + except Exception: + return # 本次搜索放弃补查(不影响结果,仅音质提示缺失) + for i, s in enumerate(batch): + try: + info = data.get("songinfo%d" % i) or {} + track = (info.get("data") or {}).get("track_info") or {} + f = track.get("file") or {} + s.raw_data["search"]["_qq_lossless"] = ( + _to_num(f.get("size_flac")) > 0 or _to_num(f.get("size_hires")) > 0 + ) + # 保留完整 file 对象,供 _quality_tiers_qq 组装全音质档位(含各档大小) + s.raw_data["search"]["_qq_file"] = f + except Exception: + continue + + +# --------------------------------------------------------------------------- +# 搜索阶段音质档位(格式 + 大小 + 无损):懒解析歌从各源官方搜索接口直接组装, +# 不跑解析链、不额外请求。统一档位表示 {label, ext, bitrate, sizeBytes, size, lossless}, +# 其中 label 为统一音质类型词表(Hi-Res / 无损 / {K}K / AAC),前端据此统一渲染。 +# --------------------------------------------------------------------------- + + +def _fmt_size(bytes_): + """字节数 → 人类可读大小(<1MB 用 KB,否则用一位小数 MB);无效返回 None。""" + try: + n = _to_num(bytes_) + except Exception: + n = 0.0 + if n <= 0: + return None + if n < 1048576: + return "%.0f KB" % (n / 1024) + return "%.1f MB" % (n / 1048576) + + +def _quality(label, ext, size_bytes, bitrate=None): + """构造单个档位 dict。label=统一类型词表;bitrate 统一为 kbps 整数;无损按 ext 判定。""" + return { + "label": label, + "ext": ext, + "bitrate": bitrate, + "sizeBytes": int(_to_num(size_bytes)) if _to_num(size_bytes) > 0 else None, + "size": _fmt_size(size_bytes), + "lossless": (ext or "").lower().lstrip(".") in _LOSSLESS_EXTS, + } + + +# QQ:musicu.fcg 详情返回的 file 各音质大小键 → (标签, ext)。自上而下为显示顺序(高→低) +_QQ_SIZE_KEYS = ( + ("size_hires", "Hi-Res", "flac"), + ("size_flac", "无损", "flac"), + ("size_320mp3", "320K", "mp3"), + ("size_192aac", "192K", "aac"), + ("size_192ogg", "192K", "ogg"), + ("size_128mp3", "128K", "mp3"), + ("size_96aac", "96K", "aac"), +) + + +def _quality_tiers_qq(s): + rs = (getattr(s, "raw_data", None) or {}).get("search") + f = (rs or {}).get("_qq_file") + if not isinstance(f, dict): + return None + tiers = [] + for key, label, ext in _QQ_SIZE_KEYS: + size = _to_num(f.get(key, 0)) + if size <= 0: + continue + # 同类档位取已有最大体积的(如 192K 在 aac/ogg 都可用时只留一条) + if tiers and tiers[-1]["label"] == label: + if size > (tiers[-1].get("sizeBytes") or 0): + tiers[-1] = _quality(label, ext, size, _QQ_LABEL_BITRATE.get(label)) + continue + tiers.append(_quality(label, ext, size, _QQ_LABEL_BITRATE.get(label))) + return tiers or None + + +_QQ_LABEL_BITRATE = {"320K": 320, "192K": 192, "128K": 128, "96K": 96} + + +def _quality_tiers_netease(s): + """网易云搜索项自带小写 h/m/l/sq 质量对象(各含 br/size),无需详情接口。""" + rs = (getattr(s, "raw_data", None) or {}).get("search") + if not isinstance(rs, dict): + return None + tiers = [] + # sq=无损/Hi-Res(flac) + sq = rs.get("sq") + if isinstance(sq, dict) and _to_num(sq.get("size")) > 0: + br = int(_to_num(sq.get("br"))) or None + label = "Hi-Res" if (br or 0) >= 900000 else "无损" + tiers.append(_quality(label, "flac", sq.get("size"), (br // 1000) if br else None)) + # h/m/l = 320K/192K/128K(mp3/aac) + for key, label, ext in (("h", "320K", "mp3"), ("m", "192K", "mp3"), ("l", "128K", "mp3")): + obj = rs.get(key) + if isinstance(obj, dict) and _to_num(obj.get("size")) > 0: + tiers.append(_quality(label, ext, obj.get("size"), int(_to_num(obj.get("br"))) // 1000 or None)) + return tiers or None + + +def _quality_tiers_kugou(s): + """酷狗搜索项自带各音质大小(Bitrate 等为 kbps),无需额外请求。 + 档位高→低:Hi-Res(Res) / 无损(SQ) / 320K(HQ) / {Bitrate}K(FileSize)。""" + rs = (getattr(s, "raw_data", None) or {}).get("search") + if not isinstance(rs, dict): + return None + heads = ( + ("ResFileSize", "ResExtName", "Hi-Res", "flac", "ResBitrate"), + ("SQFileSize", "SQExtName", "无损", "flac", "SQBitrate"), + ("HQFileSize", "HQExtName", "320K", None, "HQBitrate"), + ("FileSize", "ExtName", None, None, "Bitrate"), + ) + tiers = [] + for size_key, ext_key, fixed_label, fixed_ext, br_key in heads: + size = _to_num(rs.get(size_key)) + if size <= 0: + continue + bitrate = int(_to_num(rs.get(br_key))) or None + ext = (str(rs.get(ext_key) or "") or "").lower().lstrip(".") + if fixed_ext: + ext = ext or fixed_ext + if fixed_label: + label = fixed_label + elif bitrate: + label = "%dK" % bitrate + elif ext: + label = (ext or "").upper() + else: + label = "标准" + tiers.append(_quality(label, ext or "mp3", size, bitrate)) + return tiers or None + + +_QUALITY_TIER_FN = { + "QQMusicClient": _quality_tiers_qq, + "NeteaseMusicClient": _quality_tiers_netease, + "KugouMusicClient": _quality_tiers_kugou, +} + + +def _quality_tiers(s): + """懒解析歌的音质档位数组;急切解析/无档位数据的歌返回 None。""" + if getattr(s, "download_url", None) != _LAZY_URL: + return None + fn = _QUALITY_TIER_FN.get(getattr(s, "source", None)) + if fn is None: + return None + try: + return fn(s) + except Exception: + return None + + +def song_to_dict(s): + """Serialize a musicdl SongInfo into a JSON-safe dict (missing fields -> null).""" + download_url = getattr(s, "download_url", None) + lazy = download_url == _LAZY_URL + # 原始搜索数据(解析链输入):懒解析条目 resolve 必需;已解析条目也携带, + # 供下载前重新解析(CDN 链接有时效,搜索时的 URL 放久了会过期) + raw_search = None + raw_data = getattr(s, "raw_data", None) + if isinstance(raw_data, dict): + rs = raw_data.get("search") + if isinstance(rs, dict): + raw_search = rs + # 懒解析歌的音质档位(格式+大小+无损);急切解析歌为 None(ext/fileSize 已确定) + qualities = _quality_tiers(s) + best = qualities[0] if qualities else None + # 懒解析歌原先 ext/大小/码率未知,先用最高档位回填,保证旧消费点(下载进度总量、 + # 行内大小列、音质筛选)继续可用 + ext = getattr(s, "ext", None) + file_size = getattr(s, "file_size", None) + file_size_bytes = getattr(s, "file_size_bytes", None) + bitrate = getattr(s, "bitrate", None) + if lazy and best: + if not ext: + ext = best.get("ext") + if not file_size and best.get("size"): + file_size = best.get("size") + if not file_size_bytes and best.get("sizeBytes"): + file_size_bytes = best.get("sizeBytes") + if not bitrate and best.get("bitrate"): + bitrate = best.get("bitrate") + return { + "songName": getattr(s, "song_name", None), + "singers": getattr(s, "singers", None), + "album": getattr(s, "album", None), + "duration": getattr(s, "duration", None), + "durationS": getattr(s, "duration_s", None), + "fileSize": file_size, + "fileSizeBytes": file_size_bytes, + "ext": ext, + "source": getattr(s, "source", None), + "rootSource": getattr(s, "root_source", None), + "downloadUrl": None if lazy else download_url, + "valid": bool(getattr(s, "with_valid_download_url", True)), + "coverUrl": getattr(s, "cover_url", None), + "bitrate": bitrate, + # 下载所需:identifier(save_path 命名用)+ 默认请求头/cookies(rust 引擎直传用) + "identifier": getattr(s, "identifier", None), + "defaultDownloadHeaders": getattr(s, "default_download_headers", None) or {}, + "defaultDownloadCookies": getattr(s, "default_download_cookies", None) or {}, + # resolve / 重新解析所需的原始搜索数据 + "rawSearch": raw_search, + # 音质提示:懒解析歌 ext 未知时由 raw_search 推断(酷狗 SQ/ResFileSize、 + # 网易 privilege.maxbr、QQ 批量详情补查);急切解析歌按 ext 判定。 + # None = 无法推断(前端"仅无损"筛选不显示、"仅MP3"显示) + "lossless": _lossless_hint(s), + # 全音质档位(多档展示):懒解析歌为数组,急切解析/无数据为 null + "qualities": qualities, + } + + +# legalizestring(None) 会产出字面量 "NULL"(而非 None),脏解析结果的常见特征 +_INVALID_META = {"", "null", "none", "n/a", "-"} + + +def _meta_garbage(d): + """歌名/歌手为 "NULL" 之类占位值的条目视为解析失败的脏数据。""" + for field in ("songName", "singers"): + if str(d.get(field) or "").strip().lower() in _INVALID_META: + return True + return False + + +def _irrelevant(keyword, d): + """关键词相关性兜底:结果歌名+歌手与关键词毫无重叠时视为解析跑偏 + (第三方 API 偶发返回完全无关的歌曲)。中文按字、拉丁按词(≥2 字母) + 宽松匹配,只要命中任意一个即视为相关。""" + text = "%s %s" % (d.get("songName") or "", d.get("singers") or "") + cjk = {ch for ch in keyword if "\u4e00" <= ch <= "\u9fff"} + if len(cjk) >= 2: + return not any(ch in text for ch in cjk) + words = {w.lower() for w in re.split(r"\W+", keyword) if len(w) >= 2 and w.isascii()} + if words: + low = text.lower() + return not any(w in low for w in words) + return False + + +def handle_search(params): + keyword = (params.get("keyword") or "").strip() + if not keyword: + raise ValueError("keyword 不能为空") + sources = params.get("sources") or DEFAULT_SOURCES + client = get_client(sources) + # musicdl 的 rich 进度条写 sys.stdout,必须重定向到 stderr 保持 JSON 通道干净 + with _CLIENT_LOCK: + with contextlib.redirect_stdout(sys.stderr): + results = client.search(keyword) + # 懒解析歌批量补查音质档位:QQ 一次批量详情(其余源搜索项自带档位);不逐首解析 + for source, songs in results.items(): + if source == "QQMusicClient": + _fill_qq_lossless_hints(songs) + # 过滤脏条目:source 接口偶发返回 parsed 失败/字段缺失或跑偏的条目 + # (空歌名、"NULL" 歌名/歌手、与关键词毫无关系的无关歌曲),直接剔除 + out = {} + for source, songs in results.items(): + kept = [] + for s in songs: + d = song_to_dict(s) + if not d.get("songName") or _meta_garbage(d) or _irrelevant(keyword, d): + continue + kept.append(d) + if kept: + out[source] = kept + total = sum(len(v) for v in out.values()) + return {"results": out, "total": total, "sources": list(out.keys())} + + +def handle_get_sources(params): + try: + from musicdl.modules import MusicClientBuilder + + keys = sorted(MusicClientBuilder.REGISTERED_MODULES.keys()) + except Exception: + keys = [] + return {"sources": keys} + + +def handle_parse_playlist(params): + url = (params.get("url") or "").strip() + if not url: + raise ValueError("url 不能为空") + sources = params.get("sources") or DEFAULT_SOURCES + client = get_client(sources) + # musicdl 会按源依次尝试解析(第一个成功的源 break),rich 进度写 stdout 需重定向。 + # 歌单解析须临时关闭懒解析:网易云歌单接口只回 trackIds(仅 id 无元数据), + # 懒元数据提取会得到空结果;此处保持急切解析(歌曲自带解析好的链接) + with _CLIENT_LOCK: + _set_lazy_search(client, False) + try: + with contextlib.redirect_stdout(sys.stderr): + songs = client.parseplaylist(url) + finally: + _set_lazy_search(client, True) + # 同搜索:剔除 "NULL" 元数据的脏条目(歌单场景无关键词,不做相关性过滤) + out = [ + d + for s in songs + if (d := song_to_dict(s)) and d.get("songName") and not _meta_garbage(d) + ] + return {"songs": out, "count": len(out)} + + +def handle_resolve(params): + """懒解析:对搜索结果歌曲执行真实解析链(试听/rust 引擎批量下载前调用)。 + 支持单曲 {"song"} 或批量 {"songs"};返回与输入顺序对齐的列表,失败位为 null。""" + songs = params.get("songs") or [] + if not songs and params.get("song"): + songs = [params["song"]] + if not songs: + raise ValueError("songs 不能为空") + target_quality = (params.get("quality") or "").strip() + from concurrent.futures import ThreadPoolExecutor + + def _resolve_one(song): + source = song.get("source") or "" + raw_search = song.get("rawSearch") + if not source or not isinstance(raw_search, dict): + return None + src_client = _get_resolve_client(source) + if src_client is None: + return None + # musicdl 的 rich 进度条写 sys.stdout,重定向到 stderr 保持 JSON 通道干净 + with contextlib.redirect_stdout(sys.stderr): + resolved = _resolve_song(src_client, raw_search, target_quality) + if not resolved.with_valid_download_url: + return None + return song_to_dict(resolved) + + with ThreadPoolExecutor(max_workers=min(4, len(songs))) as pool: + out = list(pool.map(_resolve_one, songs)) + return {"songs": out, "resolved": sum(1 for r in out if r)} + + +def handle_download(params): + task_id = params.get("taskId") or str(uuid.uuid4()) + songs = params.get("songs") or [] + savedir = params.get("savedir") or "." + lyric = bool(params.get("lyric", True)) + cover = bool(params.get("cover", True)) + proxy_url = params.get("proxy") or "" + max_concurrent = max(1, min(int(params.get("maxConcurrent") or 1), 16)) + target_quality = (params.get("quality") or "").strip() + if not songs: + raise ValueError("songs 不能为空") + os.makedirs(savedir, exist_ok=True) + state = {"cancel": False} + _TASKS[task_id] = state + thread = threading.Thread( + target=_download_supervisor, + args=(task_id, songs, savedir, lyric, cover, proxy_url, state, max_concurrent, target_quality), + daemon=True, + ) + thread.start() + return {"taskId": task_id, "queued": len(songs)} + + +def handle_cancel(params): + task_id = params.get("taskId") or "" + if task_id in _TASKS: + _TASKS[task_id]["cancel"] = True + return {"taskId": task_id, "cancelled": True} + return {"taskId": task_id, "cancelled": False} + + +def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, state, max_concurrent, target_quality=""): + """下载监督线程:按 maxConcurrent 启动工作线程,逐首领取歌曲下载。 + 每个工作线程持有独立 MusicClient(互不共享、不与搜索客户端争锁), + 因此搜索期间下载照常推进;取消为队列级(当前歌曲完成,其余标记取消)。""" + try: + from musicdl.modules.utils.data import SongInfo + + sources = sorted({s.get("source") for s in songs if s.get("source")}) + n_workers = max(1, min(max_concurrent, len(songs))) + next_idx = [0] + done = [0] + first_error = [None] + lock = threading.Lock() # 保护 next_idx / done / first_error + + def worker(): + try: + from musicdl.musicdl import MusicClient + + # maintain_session=True 复用连接(默认每请求新建 Session); + # max_retries=1 封顶死链重试(下载前链接均已验证) + sources_list = sources or DEFAULT_SOURCES + client = MusicClient( + music_sources=sources_list, + init_music_clients_cfg={ + src: {"maintain_session": True, "max_retries": 1} for src in sources_list + }, + ) + except Exception as exc: + with lock: + if first_error[0] is None: + first_error[0] = "初始化下载客户端失败: %s" % exc + return + while True: + with lock: + if first_error[0] is not None: + return # 其他 worker 已失败,尽快退出 + i = next_idx[0] + next_idx[0] += 1 + if i >= len(songs): + return + song = songs[i] + if state["cancel"]: + _emit_event( + {"event": "download", "taskId": task_id, "type": "cancelled", "key": _song_key(song, i)} + ) + continue + _download_one(client, SongInfo, task_id, i, song, savedir, lyric, cover, proxy_url, state, target_quality) + with lock: + done[0] += 1 + + threads = [threading.Thread(target=worker, daemon=True) for _ in range(n_workers)] + for t in threads: + t.start() + for t in threads: + t.join() + if first_error[0] is not None: + _emit_event({"event": "download", "taskId": task_id, "type": "error", "message": first_error[0]}) + else: + _emit_event({"event": "download", "taskId": task_id, "type": "finished", "done": done[0], "total": len(songs)}) + except Exception as exc: + _emit_event({"event": "download", "taskId": task_id, "type": "error", "message": str(exc)}) + finally: + _TASKS.pop(task_id, None) + + +def _song_key(song, idx): + # source 缺失时用空串,与前端 `${s.source ?? ''}|${idx}` 的 key 保持一致 + return f"{song.get('source') or ''}|{idx}" + + +# SongInfo.fromdict 按 snake_case 字段名匹配,而 song_to_dict 输出 camelCase, +# 下载前需把键名映射回 snake_case,否则重建后字段全丢(song_name=None)。 +_CAMEL_TO_SNAKE = { + "songName": "song_name", + "fileSize": "file_size", + "fileSizeBytes": "file_size_bytes", + "durationS": "duration_s", + "downloadUrl": "download_url", + "coverUrl": "cover_url", + "rootSource": "root_source", + "defaultDownloadHeaders": "default_download_headers", + "defaultDownloadCookies": "default_download_cookies", +} + + +def _to_songinfo_dict(song): + d = {_CAMEL_TO_SNAKE.get(k, k): v for k, v in song.items()} + # rawSearch(原始搜索数据)→ raw_data["search"],懒解析 resolve 的输入 + raw_search = d.pop("rawSearch", None) + if raw_search is not None: + d["raw_data"] = {"search": raw_search} + return d + + +# Windows / 通用非法文件名字符,跨平台文件名清洗用 +_INVALID_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def _clean_filename(part): + """清洗用于文件名的片段:剔除非法字符与首尾点/空格,为空时给占位。""" + return _INVALID_FILE_CHARS.sub("_", str(part or "")).strip().strip(" .") or "未知" + + +def _rename_downloaded_files(si, target): + """下载落盘为「歌名 - <哈希标识>.ext」,改名为可读的「歌名 - 歌手.ext」(同步改名 .lrc)。 + + 重命名失败不阻断下载完成上报。 + """ + try: + singers = si.singers + if isinstance(singers, (list, tuple)): + singers = " / ".join(str(s) for s in singers if s) + base = _clean_filename(si.song_name) + if singers: + base = f"{base} - {_clean_filename(singers)}" + d = os.path.dirname(target) or "." + ext = (si.ext or "").lstrip(".") or os.path.splitext(target)[1].lstrip(".") or "mp3" + if not os.path.exists(target): + return + new_audio = os.path.join(d, f"{base}.{ext}") + if os.path.abspath(new_audio) != os.path.abspath(target): + i = 1 + while os.path.exists(new_audio): + new_audio = os.path.join(d, f"{base} ({i}).{ext}") + i += 1 + os.replace(target, new_audio) + old_lrc = os.path.splitext(target)[0] + ".lrc" + if os.path.exists(old_lrc): + os.replace(old_lrc, os.path.splitext(new_audio)[0] + ".lrc") + except Exception: + pass + + +def _resolved_quality_label(si): + """解析结果的实际音质档位 label(与前端档位词表一致:Hi-Res/无损/320K/...)。 + 按解析出的 ext + 码率推断;码率单位自适应(bps > 10000 视为 bps 转 kbps)。""" + ext = str(getattr(si, "ext", "") or "").lower().lstrip(".") + br = _to_num(getattr(si, "bitrate", None)) + kbps = br / 1000 if br > 10000 else br + if ext in _LOSSLESS_EXTS: + # flac 码率 ≥1500kbps 视为 Hi-Res(96k/24bit ≈ 2200+,44.1/16 ≈ 800~1000) + return "Hi-Res" if kbps >= 1500 else "无损" + if kbps >= 320: + return "320K" + if kbps >= 192: + return "192K" + if kbps >= 128: + return "128K" + return ext.upper() if ext else "" + + +def _download_one(client, song_info_cls, task_id, idx, song, savedir, lyric, cover, proxy_url, state, target_quality=""): + key = _song_key(song, idx) + try: + si = song_info_cls.fromdict(_to_songinfo_dict(song)) + # 懒解析:搜索阶段只取了元数据(或前端丢弃了可能过期的链接),下载前先解析 + if si.download_url == _LAZY_URL or not si.with_valid_download_url: + search_result = (si.raw_data or {}).get("search") + src_client = client.music_clients.get(si.source) if isinstance(search_result, dict) else None + if src_client is None: + _emit_event({"event": "download", "taskId": task_id, "type": "error", "key": key, "message": "无有效下载链接"}) + return + _emit_event({ + "event": "download", "taskId": task_id, "type": "resolving", "key": key, + "songName": si.song_name, "singers": si.singers, + }) + # worker 的 client 未打懒解析补丁,类方法即真实解析链;target_quality 封顶音质 + with contextlib.redirect_stdout(sys.stderr): + si = _resolve_song(src_client, search_result, target_quality) + if not si.with_valid_download_url: + _emit_event({ + "event": "download", "taskId": task_id, "type": "error", "key": key, + "message": "解析下载链接失败(可能为付费歌曲或源暂时不可用)", + }) + return + elif si.download_url: + # 搜索时已解析:fromdict 重建后 download_url_status 为空, + # 导致 with_valid_download_url=False 被 musicdl 的下载过滤器剔除,需强制置为可下载 + si.download_url_status = {"ok": True} + si.work_dir = savedir + # identifier 缺失时用 URL 哈希兜底,否则 save_path 会生成 "歌名 - None.ext" + if si.identifier is None: + si.identifier = hashlib.md5(str(si.download_url or si.song_name).encode("utf-8")).hexdigest()[:8] + # 预计算目标路径(save_path 属性会建目录并缓存 _save_path) + target = si.save_path + total = si.file_size_bytes or 0 + _emit_event({ + "event": "download", "taskId": task_id, "type": "start", "key": key, + "songName": si.song_name, "singers": si.singers, "ext": si.ext, "total": total, + "quality": _resolved_quality_label(si), + }) + + # 进度轮询线程:每 300ms 上报目标文件大小(下载完成时停) + stop_poll = threading.Event() + poller = threading.Thread( + target=_poll_progress, args=(task_id, key, target, total, stop_poll), daemon=True + ) + poller.start() + try: + source = si.source + src_client = client.music_clients.get(source) + if src_client is None: + raise ValueError(f"源 {source} 不在当前客户端中") + overrides = {} + if proxy_url: + overrides["proxies"] = {"http": proxy_url, "https": proxy_url} + # auto_supplement_song=False 时跳过歌词/封面/标签写入 + supplement = lyric or cover + # client 为本 worker 独立实例,无需 _CLIENT_LOCK(不与搜索/其他 worker 争锁); + # 并发的 redirect_stdout 均重定向到 stderr,协议写入走 _PROTOCOL_STDOUT 不受影响 + with contextlib.redirect_stdout(sys.stderr): + results = src_client.download( + song_infos=[si], + num_threadings=1, + request_overrides=overrides, + auto_supplement_song=supplement, + ) + ok = len(results) > 0 + finally: + stop_poll.set() + + # musicdl 每次 download 结束都会往保存目录写 download_results.pkl(对桥接无用),清理掉 + try: + _pkl = os.path.join(savedir, "download_results.pkl") + if os.path.exists(_pkl): + os.remove(_pkl) + except Exception: + pass + + if ok: + _rename_downloaded_files(si, target) + _emit_event({"event": "download", "taskId": task_id, "type": "done", "key": key}) + else: + _emit_event({"event": "download", "taskId": task_id, "type": "error", "key": key, "message": "下载未产生有效结果"}) + except Exception as exc: + _emit_event({"event": "download", "taskId": task_id, "type": "error", "key": key, "message": str(exc)}) + + +def _poll_progress(task_id, key, target, total, stop_poll): + """轮询目标文件大小并上报进度,直到 stop_poll 置位。""" + last = 0 + while not stop_poll.is_set(): + try: + size = os.path.getsize(target) if os.path.exists(target) else 0 + except Exception: + size = 0 + if size != last: + last = size + _emit_event({ + "event": "download", "taskId": task_id, "type": "progress", + "key": key, "downloaded": size, "total": total, + }) + time.sleep(0.3) + + +def handle(method, params): + """Dispatch a request. Raise ValueError for unknown methods.""" + if method == "ping": + return { + "version": "0.1.0", + "python": sys.version.split()[0], + } + if method == "env_status": + return check_env() + if method == "get_sources": + return handle_get_sources(params) + if method == "parse_playlist": + return handle_parse_playlist(params) + if method == "resolve": + return handle_resolve(params) + if method == "search": + return handle_search(params) + if method == "download": + return handle_download(params) + if method == "cancel": + return handle_cancel(params) + raise ValueError("unknown method: %s" % method) + + +def check_env(): + """Report whether musicdl is importable and its version.""" + result = {"musicdl": None} + try: + import musicdl + + result["musicdl"] = getattr(musicdl, "__version__", "unknown") + except Exception as exc: + # Not installed / broken deps: report None so the frontend can prompt + # the user to install the runtime first. + sys.stderr.write("musicdl import failed: %s\n" % exc) + return result + + +def main(): + global _PROTOCOL_STDOUT + # 编码对齐:Rust 侧以 UTF-8 字节写入 stdin(serde_json 序列化不转义非 ASCII), + # 而 Windows 下 Python 管道默认按 locale(中文系统为 GBK)解码 stdin, + # 中文关键词会变成乱码导致搜索结果错乱,必须显式切换为 UTF-8 + for stream in (sys.stdin, sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") + except Exception: + pass + _PROTOCOL_STDOUT = sys.stdout + for line in sys.stdin: + line = line.strip() + if not line: + continue + req_id = None + try: + req = json.loads(line) + req_id = req.get("id") + method = req.get("method", "") + params = req.get("params") or {} + result = handle(method, params) + with _WRITE_LOCK: + _out().write( + json.dumps({"id": req_id, "ok": True, "result": result}, ensure_ascii=True) + + "\n" + ) + _out().flush() + except SystemExit: + raise + except Exception as exc: + with _WRITE_LOCK: + _out().write( + json.dumps({"id": req_id, "ok": False, "error": str(exc)}, ensure_ascii=True) + + "\n" + ) + _out().flush() + + +if __name__ == "__main__": + main() diff --git a/src-tauri/src/music/bridge.rs b/src-tauri/src/music/bridge.rs new file mode 100644 index 0000000..d78f9c5 --- /dev/null +++ b/src-tauri/src/music/bridge.rs @@ -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::(&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 { + 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 { + 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 { + let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (tx, rx) = tokio::sync::oneshot::channel::(); + 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 { + let v = self.request("ping", serde_json::Value::Null).await?; + crate::logger::log_info("music-bridge", &format!("ping 成功: {}", v)); + Ok(v) + } +} diff --git a/src-tauri/src/music/commands.rs b/src-tauri/src/music/commands.rs new file mode 100644 index 0000000..a40e54c --- /dev/null +++ b/src-tauri/src/music/commands.rs @@ -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 { + 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 { + 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 { + 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 { + 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>, +) -> Result { + 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>, +) -> Result { + 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, + songs: Option>, + quality: Option, +) -> Result { + let mut list: Vec = 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, + savedir: String, + lyric: Option, + cover: Option, + proxy_url: Option, + max_concurrent: Option, + quality: Option, +) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + size: Option, + keyword: Option, +) -> Result { + 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 { + 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 { + 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 { + let s = state.load_settings(); + let mut dirs: Vec = 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 { + Ok(state.feiniu.cache_status()) +} + +/// 清空播放缓存。 +#[tauri::command] +pub fn feiniu_cache_clear(state: State<'_, MusicManager>) -> Result { + state.feiniu.cache_clear(); + Ok(json!({ "ok": true })) +} + +/// 缓存一首歌(命中则直接返回):{ path } 或 { cached }。 +#[tauri::command] +pub async fn feiniu_cache_fetch( + state: State<'_, MusicManager>, + guid: String, +) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let _ = &state; + let (url, relay) = resolve_base_url(&fn_id).await?; + Ok(json!({ "baseUrl": url, "relay": relay })) +} + diff --git a/src-tauri/src/music/feiniu/cache.rs b/src-tauri/src/music/feiniu/cache.rs new file mode 100644 index 0000000..3072ceb --- /dev/null +++ b/src-tauri/src/music/feiniu/cache.rs @@ -0,0 +1,155 @@ +//! 飞牛音乐播放缓存(容量限制 + LRU 逐出)。 +//! +//! 缓存目录:`{app_data}/music/cache`,文件 `guid.`(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 { + 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) { + 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 { + 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( + &self, + guid: &str, + ext: &str, + max_gb: u32, + mut stream: impl futures_util::Stream> + Unpin, + ) -> Result { + 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() +} diff --git a/src-tauri/src/music/feiniu/conn.rs b/src-tauri/src/music/feiniu/conn.rs new file mode 100644 index 0000000..f79ac28 --- /dev/null +++ b/src-tauri/src/music/feiniu/conn.rs @@ -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() +} \ No newline at end of file diff --git a/src-tauri/src/music/feiniu/fnconnect.rs b/src-tauri/src/music/feiniu/fnconnect.rs new file mode 100644 index 0000000..c9c6b7b --- /dev/null +++ b/src-tauri/src/music/feiniu/fnconnect.rs @@ -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/`、`.5ddd.com`、或裸 fnId。 +pub fn extract_fn_id(input: &str) -> Option { + 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 { + 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 = 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) +} diff --git a/src-tauri/src/music/feiniu/fnos.rs b/src-tauri/src/music/feiniu/fnos.rs new file mode 100644 index 0000000..1b2fcd2 --- /dev/null +++ b/src-tauri/src/music/feiniu/fnos.rs @@ -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; +type WsStream = WebSocketStream>; + +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 { + 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 { + 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 = ::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, + sign_key: Mutex>, + ws: Arc>>, + pending: Arc>>>, + reader: Arc, +} + +impl FnOsSession { + /// 建立会话(新 WS 连接 + 登录)。 + pub async fn connect(base_url: &str, username: &str, password: &str) -> Result { + 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::(&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 { + 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::(); + 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 { + 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 { + 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 { + 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", + } +} \ No newline at end of file diff --git a/src-tauri/src/music/feiniu/mod.rs b/src-tauri/src/music/feiniu/mod.rs new file mode 100644 index 0000000..fd7e1f0 --- /dev/null +++ b/src-tauri/src/music/feiniu/mod.rs @@ -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) + #[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>, + conn: Mutex, + proxy: Mutex>, + cache: CacheManager, + /// fnOS 文件服务会话:connection id → 会话 + fnos_sessions: Mutex>>, +} + +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 { + 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=`。 + pub async fn lyric(&self, guid: &str) -> Result { + 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:/feiniu`。首次调用惰性启动代理。 + pub async fn media_prefix(&self) -> Result { + 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 = 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, 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 { + 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 { + 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 { + 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 { + 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(), + } +} \ No newline at end of file diff --git a/src-tauri/src/music/feiniu/proxy.rs b/src-tauri/src/music/feiniu/proxy.rs new file mode 100644 index 0000000..023355c --- /dev/null +++ b/src-tauri/src/music/feiniu/proxy.rs @@ -0,0 +1,164 @@ +//! 飞牛音乐本地流代理(axum)。 +//! +//! 原生 `/track/stream`、`/static/cover` 需要 `Cookie: music-token=`,而 +//! WebView 的 `