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 0000000..f1982d9 Binary files /dev/null and b/src-tauri/src/music/__pycache__/bridge.cpython-312.pyc differ diff --git a/src-tauri/src/music/__pycache__/bridge.cpython-314.pyc b/src-tauri/src/music/__pycache__/bridge.cpython-314.pyc new file mode 100644 index 0000000..2a9b3a8 Binary files /dev/null and b/src-tauri/src/music/__pycache__/bridge.cpython-314.pyc differ 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 的 `