diff --git a/scripts/release.ps1 b/scripts/release.ps1 index f1f35bb..4e2fbed 100644 --- a/scripts/release.ps1 +++ b/scripts/release.ps1 @@ -54,17 +54,18 @@ $tauriConf = Join-Path $Root 'src-tauri\tauri.conf.json' $cargoToml = Join-Path $Root 'src-tauri\Cargo.toml' $pkgJson = Join-Path $Root 'package.json' -$t = Get-Content $tauriConf -Raw -$t = $t -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}" -[System.IO.File]::WriteAllText($tauriConf, $t, (New-Object System.Text.UTF8Encoding($false))) +# UTF-8 安全读写:`Get-Content` 默认按系统 ANSI(如 GBK) 解码,会把本就含中文/乱码的文件二次编码损坏(曾导致 +# Cargo.toml 的 keyring 依赖行被弄丢)。这里统一用显式 UTF-8 无 BOM 读写,保证逐字节稳定往返。 +function Set-Utf8Version([string]$Path, [string]$Pattern, [string]$NewVersion) { + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + $content = [System.IO.File]::ReadAllText($Path, $utf8NoBom) + $content = $content -replace $Pattern, "`${1}$NewVersion`${2}" + [System.IO.File]::WriteAllText($Path, $content, $utf8NoBom) +} -$c = Get-Content $cargoToml -Raw -$c = $c -replace '(?m)^(version\s*=\s*")[^"]*(")', "`${1}$Version`${2}" -[System.IO.File]::WriteAllText($cargoToml, $c, (New-Object System.Text.UTF8Encoding($false))) - -$p = Get-Content $pkgJson -Raw -$p = $p -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}" -[System.IO.File]::WriteAllText($pkgJson, $p, (New-Object System.Text.UTF8Encoding($false))) +Set-Utf8Version -Path $tauriConf -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version +Set-Utf8Version -Path $cargoToml -Pattern '(?m)^(version\s*=\s*")[^"]*(")' -NewVersion $Version +Set-Utf8Version -Path $pkgJson -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version Write-Step "版本号已同步:tauri.conf.json / Cargo.toml / package.json" # ---------- 2. 构建 ---------- diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3ccc4b4..9e7f2da 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3318,6 +3318,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "log" version = "0.4.33" @@ -3934,6 +3960,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6163,6 +6198,7 @@ dependencies = [ "image", "keyring", "librqbit", + "lofty", "md-5", "notify", "rand 0.8.8", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e8fd6d2..8c2ae66 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,8 @@ axum = "0.7" url = "2" zip = "2" dirs = "5" +# 音频标签解析(本地曲库元数据:标题/歌手/专辑/时长/内嵌封面) +lofty = "0.22" sysinfo = "0.32" rusqlite = { version = "0.32", features = ["bundled"] } base64 = "0.22" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 862e5ae..5f5d557 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,12 +45,15 @@ use monitor_kernel::{ use music::{ feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status, feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config, - feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric, + feiniu_list_connections, feiniu_list_audio_files, feiniu_list_tracks, feiniu_login, + feiniu_logout, feiniu_lyric, feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection, music_cancel_runtime_install, music_download, music_download_cancel, music_env_status, music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping, - music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete, - webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload, MusicManager, + music_resolve, music_save_settings, music_search, music_secret_get, music_secret_set, + music_stop_bridge, music_update_musicdl, webdav_delete, webdav_get_secret, + webdav_save_secret, webdav_test, + webdav_upload, MusicManager, }; use network_monitor::network_status; use osd_window::{ @@ -261,6 +264,7 @@ pub fn run() { music_env_status, music_install_runtime, music_cancel_runtime_install, + music_update_musicdl, music_ping, music_stop_bridge, music_get_sources, @@ -283,6 +287,7 @@ pub fn run() { feiniu_lyric, feiniu_media_prefix, feiniu_scan_local, + feiniu_list_audio_files, feiniu_cache_status, feiniu_cache_clear, feiniu_cache_fetch, @@ -292,6 +297,8 @@ pub fn run() { webdav_delete, webdav_get_secret, webdav_save_secret, + music_secret_get, + music_secret_set, feiniu_delete_local, network_status, osd_apply_overlay_style, diff --git a/src-tauri/src/music/__pycache__/bridge.cpython-312.pyc b/src-tauri/src/music/__pycache__/bridge.cpython-312.pyc deleted file mode 100644 index f1982d9..0000000 Binary files a/src-tauri/src/music/__pycache__/bridge.cpython-312.pyc and /dev/null differ diff --git a/src-tauri/src/music/__pycache__/bridge.cpython-314.pyc b/src-tauri/src/music/__pycache__/bridge.cpython-314.pyc deleted file mode 100644 index 2a9b3a8..0000000 Binary files a/src-tauri/src/music/__pycache__/bridge.cpython-314.pyc and /dev/null differ diff --git a/src-tauri/src/music/bridge.py b/src-tauri/src/music/bridge.py index f1b3292..3215030 100644 --- a/src-tauri/src/music/bridge.py +++ b/src-tauri/src/music/bridge.py @@ -10,12 +10,12 @@ Response (one JSON per line on stdout): Methods: ping -> {"version", "python"} - env_status -> {"musicdl": version|null} get_sources -> {"sources": [registered music client names]} search -> {"results": {source: [song...]}, "sources": [...]} - params: {"keyword", "sources": []} + params: {"keyword", "sources": [], "proxy": url-or-""} resolve -> {"songs": [resolved-song-or-null, ...], "resolved": n} params: {"song": {...}} or {"songs": [...]} + 可选 "proxy": url-or-"" (懒解析:对搜索结果歌曲执行真实解析链,返回带下载链接 的完整歌曲;输入顺序对齐,失败位为 null) download -> {"taskId"} (runs in background worker pool) @@ -62,6 +62,7 @@ import tempfile import threading import time import uuid +from urllib.parse import parse_qs, urlparse # Fallback sources used when the frontend passes an empty list DEFAULT_SOURCES = [ @@ -127,6 +128,31 @@ _LAZY_URL = "http://lazy.internal/unresolved" _PATCHED = False +# 当前命令生效的代理地址("" = 明确不走代理)。 +# +# 必须显式控制:requests 未指定 proxies 时会去读**系统代理**(环境变量,以及 +# Windows 下 urllib 读取注册表 Internet Settings 里 mihomo 写入的 ProxyEnable/ +# ProxyServer)。所以只把「没配置代理」理解为"不传 proxies"是不够的—— +# 用户开了代理模块的系统代理后,搜索/解析会被静默送进 mihomo; +# 国内音乐源经境外节点出去就会超时或返回空结果。 +_CURRENT_PROXY = "" + + +@contextlib.contextmanager +def _use_proxy(url): + """在块内决定所有「未显式指定代理」的 requests 请求怎么走。 + + `url` 为空串 = 强制直连(禁用系统代理);非空 = 统一走该代理。 + 显式传了 proxies 的请求(如下载路径)不受影响。 + """ + global _CURRENT_PROXY + prev = _CURRENT_PROXY + _CURRENT_PROXY = (url or "").strip() + try: + yield + finally: + _CURRENT_PROXY = prev + def _cap_timeout(timeout, stream): """按 stream 与否收紧超时,返回新的 timeout 值。""" @@ -157,6 +183,16 @@ def _patch_musicdl(): def _capped_request(self, method, url, **kwargs): kwargs["timeout"] = _cap_timeout(kwargs.get("timeout"), bool(kwargs.get("stream"))) + # 未显式指定代理时按当前命令的设置决定(见 _use_proxy)。 + # trust_env=False 是为了彻底屏蔽环境变量/系统(注册表)代理: + # 只传 {"http": None} 在 ALL_PROXY 存在时可能仍被代理接管。 + if kwargs.get("proxies") is None: + kwargs["proxies"] = ( + {"http": _CURRENT_PROXY, "https": _CURRENT_PROXY} + if _CURRENT_PROXY + else {"http": None, "https": None} + ) + self.trust_env = False return _orig_request(self, method, url, **kwargs) requests.Session.request = _capped_request @@ -378,12 +414,27 @@ def _resolve_song(src_client, search_result, target=None): # --------------------------------------------------------------------------- # 按目标音质解析(取 ≤ 所选的最优档) # -# musicdl 官方解析按「质量常量」循环、取首个有效档。这里在全局锁保护下临时替换 -# 各源质量常量(只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。 -# 由于替换的是模块级常量,必须用 _RESOLVE_CAP_LOCK 串行化所有封顶解析,避免并发竞态。 +# musicdl 官方解析按「质量常量」循环、取首个有效档。这里临时替换各源的质量常量 +# (只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。 # 有损码率目标用 lossless_quality_is_sufficient=False,使官方解析不采纳第三方 flac/hires。 +# +# 锁的粒度是**按源**而不是一把全局锁:被替换的是各源自己模块里的常量, +# 不同源之间不会互相干扰;而一把全局锁会把并发下载里所有源的解析串起来 +# (每个解析都含网络请求,持锁跨越请求 = 解析阶段吞吐退化成串行)。 +# 同一源内必须串行:否则 A 恢复常量时 B 还在解析,B 会拿到未封顶的档位。 # --------------------------------------------------------------------------- -_RESOLVE_CAP_LOCK = threading.Lock() +_RESOLVE_CAP_LOCKS = {} +_RESOLVE_CAP_LOCKS_GUARD = threading.Lock() + + +def _resolve_cap_lock(source): + """取该源的封顶解析锁(惰性创建;字典本身受 _RESOLVE_CAP_LOCKS_GUARD 保护)。""" + with _RESOLVE_CAP_LOCKS_GUARD: + lock = _RESOLVE_CAP_LOCKS.get(source) + if lock is None: + lock = threading.Lock() + _RESOLVE_CAP_LOCKS[source] = lock + return lock # QQ:SongFileType.SORTED_QUALITIES 前缀(F000=flac, O8xx/O6xx=ogg, M800=320K, M500=128K, C6xx=m4a) _QQ_CAPS = { @@ -488,7 +539,7 @@ def _resolve_to_quality(src_client, search_result, target): from musicdl.modules.utils.data import SongInfo official = getattr(src_client, "_real_parsewithofficialapiv1", None) or src_client._parsewithofficialapiv1 - with _RESOLVE_CAP_LOCK: + with _resolve_cap_lock(source): _swap_quality_constants(source, allowed) try: # 空 song_info_flac + lossless_quality_is_sufficient=False: @@ -511,28 +562,39 @@ _RESOLVE_CLIENTS = {} _RESOLVE_CLIENTS_LOCK = threading.Lock() -def _get_resolve_client(source): - """按源缓存的独立源客户端(resolve/试听/批量解析用),失败返回 None。""" +def _get_resolve_client(source, cookies=None): + """按源(+Cookie)缓存的独立源客户端(resolve/试听/批量解析用),失败返回 None。""" + cookie_dict = _cookie_str_to_dict(cookies) if source == "QQMusicClient" else None + cache_key = f"{source}|{json.dumps(cookie_dict, sort_keys=True) if cookie_dict else ''}" with _RESOLVE_CLIENTS_LOCK: - if source in _RESOLVE_CLIENTS: - return _RESOLVE_CLIENTS[source] + if cache_key in _RESOLVE_CLIENTS: + return _RESOLVE_CLIENTS[cache_key] try: from musicdl.musicdl import MusicClient + init_cfg = { + source: { + "maintain_session": True, + "max_retries": 1, + "work_dir": _SEARCH_WORK_DIR, + } + } + if cookie_dict: + init_cfg[source].update( + { + "default_search_cookies": cookie_dict, + "default_parse_cookies": cookie_dict, + "default_download_cookies": cookie_dict, + } + ) client = MusicClient( music_sources=[source], - init_music_clients_cfg={ - source: { - "maintain_session": True, - "max_retries": 1, - "work_dir": _SEARCH_WORK_DIR, - } - }, + init_music_clients_cfg=init_cfg, ) src_client = client.music_clients.get(source) except Exception: src_client = None - _RESOLVE_CLIENTS[source] = src_client + _RESOLVE_CLIENTS[cache_key] = src_client return src_client # Persistent MusicClient cache; keyed by the sorted source list because the @@ -557,10 +619,31 @@ def _out(): 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.""" +def _cookie_str_to_dict(value): + """把浏览器复制的 Cookie 字符串(``k=v; k2=v2``)解析成 dict。 + + musicdl 的部分接口(如 ``Credential.fromcookiesdict``)只接受 dict, + 而用户从 DevTools 复制到的是字符串,这里统一转换。已是 dict 时原样返回。 + """ + if isinstance(value, dict): + return value + if not isinstance(value, str) or not value.strip(): + return None + out = {} + for part in value.split(";"): + if "=" not in part: + continue + k, _, v = part.strip().partition("=") + if k: + out[k] = v + return out or None + + +def get_client(sources, cookies=None): + """Return a cached MusicClient, recreating it when the source set or cookies change.""" global _CLIENT, _CLIENT_KEY - key = "|".join(sorted(sources or [])) + cookie_dict = _cookie_str_to_dict(cookies) + key = "|".join(sorted(sources or [])) + f"|cookies={json.dumps(cookie_dict, sort_keys=True) if cookie_dict else ''}" if _CLIENT is None or _CLIENT_KEY != key: try: from musicdl.musicdl import MusicClient @@ -585,6 +668,18 @@ def get_client(sources): "maintain_session": True, "max_retries": 1, } + # 登录态:目前仅 QQ 音乐用得上(解析需登录的歌单 / VIP 音质)。 + # 三组 Cookie 都要给:search/lossless 判定用 default_search_cookies(即 + # self.default_cookies),歌单解析用 default_parse_cookies,下载用 default_download_cookies。 + # 注意 musicdl 的既有逻辑:配置 Cookie 后 QQ 会跳过第三方解析源,全走官方接口。 + if cookie_dict and "QQMusicClient" in init_cfg: + init_cfg["QQMusicClient"].update( + { + "default_search_cookies": cookie_dict, + "default_parse_cookies": cookie_dict, + "default_download_cookies": cookie_dict, + } + ) threadings = {src: _SEARCH_SIZE_PER_SOURCE for src in effective} _CLIENT = MusicClient( music_sources=effective, @@ -919,15 +1014,17 @@ def handle_search(params): 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) + # 搜索全程按模块设置决定是否走代理(缺省强制直连,避免被系统代理带走) + with _use_proxy(params.get("proxy")): + client = get_client(sources, params.get("cookies")) + # 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 = {} @@ -954,22 +1051,164 @@ def handle_get_sources(params): return {"sources": keys} +_NETEASE_HOSTS = ( + "music.163.com", + "y.music.163.com", + "m.music.163.com", + "3g.music.163.com", + "163cn.tv", +) + + +def _is_netease_url(url: str) -> bool: + try: + host = (urlparse(url).hostname or "").lower() + except Exception: + return False + return any(host == h or host.endswith("." + h) for h in _NETEASE_HOSTS) + + +def _netease_playlist_id(url: str): + """从查询串或 "#" 片段里取数字歌单 id(都取不到返回 None)。 + + 注意 "#" 片段常见形态是 ``/playlist?id=NNN``(带路径前缀), + 直接 parse_qs 会把键解析成 "/playlist",需先取 "?" 之后的部分。 + """ + try: + parts = urlparse(url) + except Exception: + return None + frag = parts.fragment + if "?" in frag: + frag = frag.split("?", 1)[1] + for source in (parts.query, frag): + pid = (parse_qs(source, keep_blank_values=True).get("id") or [None])[0] + if pid and str(pid).isdigit(): + return pid + return None + + +def _normalize_netease_playlist_url(url: str) -> str: + """把网易云歌单链接规范化成 musicdl 认得出的形式。 + + musicdl 的网易云 ``parseplaylist`` 只从 URL 的 **"#\" 片段**或**路径末段**取歌单 id, + 而「分享/复制链接」出来的地址普遍是 ``.../playlist?id=NNN``(id 在查询串里)—— + 会被当成路径末段 ``playlist`` 去查询,恒返回 0 首。 + 这里把 id 直接提出来,改写成它认得的 ``https://music.163.com/#/playlist?id=NNN``。 + """ + try: + parts = urlparse(url) + except Exception: + return url + host = (parts.hostname or "").lower() + if not any(host == h or host.endswith("." + h) for h in _NETEASE_HOSTS): + return url + for source in (parts.query, parts.fragment): + pid = (parse_qs(source, keep_blank_values=True).get("id") or [None])[0] + if pid and str(pid).isdigit(): + return f"https://music.163.com/#/playlist?id={pid}" + return url + + +def _parse_netease_playlist_lazy(client, url): + """网易云歌单:懒解析——只取曲目元数据,**全部返回**,下载/试听时再解析链接。 + + musicdl 的 ``parseplaylist`` 走完整急切解析链,且只保留解析出下载链接的歌, + 所以歌单会"少歌"(本例 7 首只剩 2 首)。这里与搜索对齐: + 1~2 个请求批量拉元数据(v6 歌单详情 + v3 歌曲详情),直接返回全部曲目; + ``rawSearch`` 为歌曲详情原始对象,试听/下载时由 resolve 走真实解析链。 + 返回 None 表示当前源集合里没有网易云客户端(交回通用路径处理)。 + """ + src_client = (client.music_clients or {}).get("NeteaseMusicClient") + extract = _META_EXTRACTORS.get("NeteaseMusicClient") + if src_client is None or extract is None: + return None + playlist_id = _netease_playlist_id(url) + if not playlist_id: + return None + + from musicdl.modules.utils import resp2json, safeextractfromdict + + try: + playlist_result = resp2json( + resp=src_client.post( + "https://music.163.com/api/v6/playlist/detail", data={"id": playlist_id} + ) + ) + except Exception as e: + print(f"[playlist] 网易云歌单详情请求失败: {e}", file=sys.stderr) + return [] + tracks = safeextractfromdict(playlist_result, ["playlist", "tracks"], []) or [] + # v6 响应顶层带 privileges(与曲目一一对应),合入以便无损/音质提示推断 + # (与搜索结果同构:_lossless_hint 读 raw_search.privilege.maxbr) + for r, p in zip(tracks, playlist_result.get("privileges") or []): + if isinstance(r, dict) and isinstance(p, dict) and p.get("id") in (None, r.get("id")): + r["privilege"] = p + if not tracks: + track_ids = safeextractfromdict(playlist_result, ["playlist", "trackIds"], []) or [] + if not track_ids: + return [] + # 歌单详情只回 trackIds(裸 id):批量补全元数据(v3 song/detail 支持一次传全部 id) + try: + detail = resp2json( + resp=src_client.post( + "https://interface3.music.163.com/api/v3/song/detail", + data={ + "c": json.dumps( + [{"id": t.get("id"), "v": 0} for t in track_ids if isinstance(t, dict) and t.get("id")] + ) + }, + ) + ) + except Exception as e: + print(f"[playlist] 网易云歌单曲目元数据请求失败: {e}", file=sys.stderr) + return [] + tracks = detail.get("songs") or [] + # privileges 与 songs 顺序对齐,合入便于音质档位/无损提示推断 + for r, p in zip(tracks, detail.get("privileges") or []): + if isinstance(r, dict) and isinstance(p, dict): + r["privilege"] = p + + out, seen = [], set() + for r in tracks: + if not isinstance(r, dict) or not r.get("id") or r["id"] in seen: + continue + seen.add(r["id"]) + try: + d = song_to_dict(_make_lazy_songinfo(src_client, extract, r)) + except Exception as e: + print(f"[playlist] 网易云歌单曲目元数据构造失败: {e}", file=sys.stderr) + continue + # 与搜索同一套脏数据过滤(空歌名 / "NULL" 元数据) + if d.get("songName") and not _meta_garbage(d): + out.append(d) + return out + + def handle_parse_playlist(params): - url = (params.get("url") or "").strip() + url = _normalize_netease_playlist_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) + # 与搜索一致:按模块设置决定是否走代理(缺省强制直连) + with _use_proxy(params.get("proxy")): + client = get_client(sources, params.get("cookies")) + # 网易云:懒解析——全部曲目都返回(含元数据),下载/试听时再逐首解析链接, + # 与搜索体验一致;musicdl 原生实现只保留解析出链接的歌,会让歌单"少歌"。 + lazy_songs = _parse_netease_playlist_lazy(client, url) if _is_netease_url(url) else None + if lazy_songs is not None: + return {"songs": lazy_songs, "count": len(lazy_songs)} + # 其他源沿用 musicdl 的急切解析(QQ 的实现先查查询串取 id,无此问题)。 + # 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 @@ -988,6 +1227,7 @@ def handle_resolve(params): if not songs: raise ValueError("songs 不能为空") target_quality = (params.get("quality") or "").strip() + resolve_cookies = params.get("cookies") or "" from concurrent.futures import ThreadPoolExecutor def _resolve_one(song): @@ -995,7 +1235,7 @@ def handle_resolve(params): raw_search = song.get("rawSearch") if not source or not isinstance(raw_search, dict): return None - src_client = _get_resolve_client(source) + src_client = _get_resolve_client(source, resolve_cookies) if src_client is None: return None # musicdl 的 rich 进度条写 sys.stdout,重定向到 stderr 保持 JSON 通道干净 @@ -1006,7 +1246,10 @@ def handle_resolve(params): return song_to_dict(resolved) with ThreadPoolExecutor(max_workers=min(4, len(songs))) as pool: - out = list(pool.map(_resolve_one, songs)) + # 解析同样要联网:按模块设置决定是否走代理(缺省强制直连)。 + # 工作线程读取的是同一个 _CURRENT_PROXY,块内一致。 + with _use_proxy(params.get("proxy")): + out = list(pool.map(_resolve_one, songs)) return {"songs": out, "resolved": sum(1 for r in out if r)} @@ -1017,6 +1260,7 @@ def handle_download(params): lyric = bool(params.get("lyric", True)) cover = bool(params.get("cover", True)) proxy_url = params.get("proxy") or "" + cookies = params.get("cookies") or "" max_concurrent = max(1, min(int(params.get("maxConcurrent") or 1), 16)) target_quality = (params.get("quality") or "").strip() if not songs: @@ -1026,7 +1270,7 @@ def handle_download(params): _TASKS[task_id] = state thread = threading.Thread( target=_download_supervisor, - args=(task_id, songs, savedir, lyric, cover, proxy_url, state, max_concurrent, target_quality), + args=(task_id, songs, savedir, lyric, cover, proxy_url, cookies, state, max_concurrent, target_quality), daemon=True, ) thread.start() @@ -1041,7 +1285,7 @@ def handle_cancel(params): return {"taskId": task_id, "cancelled": False} -def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, state, max_concurrent, target_quality=""): +def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, cookies, state, max_concurrent, target_quality=""): """下载监督线程:按 maxConcurrent 启动工作线程,逐首领取歌曲下载。 每个工作线程持有独立 MusicClient(互不共享、不与搜索客户端争锁), 因此搜索期间下载照常推进;取消为队列级(当前歌曲完成,其余标记取消)。""" @@ -1062,11 +1306,22 @@ def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, state # maintain_session=True 复用连接(默认每请求新建 Session); # max_retries=1 封顶死链重试(下载前链接均已验证) sources_list = sources or DEFAULT_SOURCES + worker_cfg = { + src: {"maintain_session": True, "max_retries": 1} for src in sources_list + } + # QQ 音乐 Cookie(VIP 音质 / 官方 vkey 接口) + cookie_dict = _cookie_str_to_dict(cookies) + if cookie_dict and "QQMusicClient" in worker_cfg: + worker_cfg["QQMusicClient"].update( + { + "default_search_cookies": cookie_dict, + "default_parse_cookies": cookie_dict, + "default_download_cookies": cookie_dict, + } + ) client = MusicClient( music_sources=sources_list, - init_music_clients_cfg={ - src: {"maintain_session": True, "max_retries": 1} for src in sources_list - }, + init_music_clients_cfg=worker_cfg, ) except Exception as exc: with lock: @@ -1303,8 +1558,6 @@ def handle(method, params): "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": @@ -1320,20 +1573,6 @@ def handle(method, 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), diff --git a/src-tauri/src/music/commands.rs b/src-tauri/src/music/commands.rs index de76f9b..4cb70d8 100644 --- a/src-tauri/src/music/commands.rs +++ b/src-tauri/src/music/commands.rs @@ -36,6 +36,27 @@ pub fn music_cancel_runtime_install(state: State<'_, MusicManager>) -> Result<() Ok(()) } +/// 把 musicdl 对齐到本应用锁定的版本(`force = true` 表示强制重装)。 +/// +/// 与「安装便携版」的区别:安装的闸门是「能否 import」,所以版本不一致时它什么都不做; +/// 更新则是显式对齐到 `MUSICDL_VERSION`。**只升到锁定版本,不升 PyPI 最新版** +/// (bridge.py 的 monkey patch 与 musicdl 版本强耦合,见 `update_musicdl` 的说明)。 +/// +/// 成功后顺手停掉桥接进程:它可能已经 import 了旧版 musicdl 并缓存了 MusicClient, +/// 不重启就会继续用旧代码跑。下一次搜索/下载会自动拉起新进程。 +/// +/// 返回更新后的环境状态,前端一次往返即可刷新面板。 +#[tauri::command] +pub async fn music_update_musicdl( + state: State<'_, MusicManager>, + app: AppHandle, + force: Option, +) -> Result { + state.update_musicdl(&app, force.unwrap_or(false)).await?; + state.stop_bridge(); + state.env_status().await +} + /// ping 桥接进程(未启动则自动拉起),返回 {"version","python"}; /// 返回 Value 且未标注 specta:前端直接按 JSON 使用 #[tauri::command] @@ -63,8 +84,17 @@ pub async fn music_search( state: State<'_, MusicManager>, keyword: String, sources: Option>, + proxy_url: Option, + qq_cookie: Option, ) -> Result { - let params = serde_json::json!({ "keyword": keyword, "sources": sources.unwrap_or_default() }); + // proxy_url 为空串表示「明确不走代理」(桥接侧会强制直连、屏蔽系统代理); + // qq_cookie 为空表示游客身份(桥接侧不注入 Cookie) + let params = serde_json::json!({ + "keyword": keyword, + "sources": sources.unwrap_or_default(), + "proxy": proxy_url.unwrap_or_default(), + "cookies": qq_cookie.unwrap_or_default(), + }); state .request_with_timeout("search", params, std::time::Duration::from_secs(90)) .await @@ -76,8 +106,15 @@ pub async fn music_parse_playlist( state: State<'_, MusicManager>, url: String, sources: Option>, + proxy_url: Option, + qq_cookie: Option, ) -> Result { - let params = serde_json::json!({ "url": url, "sources": sources.unwrap_or_default() }); + let params = serde_json::json!({ + "url": url, + "sources": sources.unwrap_or_default(), + "proxy": proxy_url.unwrap_or_default(), + "cookies": qq_cookie.unwrap_or_default(), + }); state .request_with_timeout("parse_playlist", params, std::time::Duration::from_secs(90)) .await @@ -91,13 +128,31 @@ pub fn music_get_settings(state: State<'_, MusicManager>) -> MusicSettings { } /// 保存音乐模块设置(立即生效) +/// +/// 飞牛音乐**连接相关字段一律以磁盘为准**,不接受前端传值: +/// 连接列表 / 激活连接 / 旧版单连接字段只由 `feiniu_save_connection`、 +/// `feiniu_activate_connection`、`feiniu_delete_connection`、`feiniu_login` +/// 等专用命令维护。 +/// +/// 原因:前端 `musicStore` 只在 init 时读一次整份设置并长期复用快照, +/// 若允许它整份回写,删除连接后任意一次设置保存(哪怕是切页触发的) +/// 都会把已删除的连接从旧快照里写回来。 #[tauri::command] #[specta::specta] pub fn music_save_settings( state: State<'_, MusicManager>, settings: MusicSettings, ) -> Result<(), String> { - state.save_settings(&settings) + let persisted = state.load_settings(); + let mut next = settings; + next.feiniu_connections = persisted.feiniu_connections; + next.feiniu_active_id = persisted.feiniu_active_id; + next.feiniu_base_url = persisted.feiniu_base_url; + next.feiniu_token = persisted.feiniu_token; + next.feiniu_username = persisted.feiniu_username; + next.feiniu_device_id = persisted.feiniu_device_id; + next.feiniu_access_code = persisted.feiniu_access_code; + state.save_settings(&next) } /// 解析歌曲真实下载链接(懒解析:搜索只取元数据,试听/下载前调用)。 @@ -108,6 +163,8 @@ pub async fn music_resolve( song: Option, songs: Option>, quality: Option, + proxy_url: Option, + qq_cookie: Option, ) -> Result { let mut list: Vec = songs.unwrap_or_default(); if let Some(s) = song { @@ -116,7 +173,12 @@ pub async fn music_resolve( if list.is_empty() { return Err("未提供歌曲".into()); } - let params = serde_json::json!({ "songs": list, "quality": quality.unwrap_or_default() }); + let params = serde_json::json!({ + "songs": list, + "quality": quality.unwrap_or_default(), + "proxy": proxy_url.unwrap_or_default(), + "cookies": qq_cookie.unwrap_or_default(), + }); state .request_with_timeout("resolve", params, std::time::Duration::from_secs(180)) .await @@ -136,6 +198,7 @@ pub async fn music_download( proxy_url: Option, max_concurrent: Option, quality: Option, + qq_cookie: Option, ) -> Result { if songs.is_empty() { return Err("未选择任何歌曲".into()); @@ -149,6 +212,7 @@ pub async fn music_download( "proxy": proxy_url.unwrap_or_default(), "maxConcurrent": max_concurrent.unwrap_or(1).clamp(1, 16), "quality": quality.unwrap_or_default(), + "cookies": qq_cookie.unwrap_or_default(), }); state .request_with_timeout("download", params, std::time::Duration::from_secs(15)) @@ -170,6 +234,7 @@ pub async fn music_download_cancel( // 全部命令返回 serde_json::Value、不加 specta:前端用裸 invoke,映射在 feiniuStore。 /// 连接列表 + 激活 id。返回 `{ activeId, list: [{id,name,kind,baseUrl,username,loggedIn,accessCode,insecure}] }`。 +/// `loggedIn` 的权威是系统凭据管理器里的 token(结构体字段只在凭据库不可用降级时才有值)。 #[tauri::command] pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result { let s = state.load_settings(); @@ -183,10 +248,11 @@ pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result, connection_id: String, ) -> Result { let mut settings = state.load_settings(); + let _ = crate::music::secrets::secret_delete(&crate::music::secrets::feiniu_token_key( + &connection_id, + )); if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) { c.token.clear(); } @@ -312,24 +404,37 @@ pub fn feiniu_logout( Ok(json!({ "ok": true })) } -/// 测试某连接是否能登录(不持久化 token),探测后恢复原激活连接的运行期状态。 +/// 测试一条连接**草案**能否登录(不持久化任何变更)。 +/// +/// 入参是编辑对话框里的完整草案而非连接 id:新建连接在保存前没有 id, +/// 若按 id 查库,对话框里的「测试」在保存前必然报 missing required key。 +/// 探测以「草案作为唯一连接」装备运行期,结束后恢复持久化的激活连接。 #[tauri::command] pub async fn feiniu_test_connection( state: State<'_, MusicManager>, - connection_id: String, + connection: FeiniuConnection, username: String, password: String, ) -> Result { + let mut conn = connection; + conn.base_url = normalize_base_url(&conn.base_url); + // fnconnect:先解析 fnId(解析结果只用于本次探测,不回写设置) + 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; + conn.relay = relay; + } + if conn.base_url.trim().is_empty() { + return Err("请先填写服务器地址或飞牛 ID".to_string()); + } + let settings = state.load_settings(); - let conn = settings - .feiniu_connections - .iter() - .find(|c| c.id == connection_id) - .cloned() - .ok_or_else(|| "连接不存在".to_string())?; let mut tmp = settings.clone(); + tmp.feiniu_connections = vec![conn.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()); @@ -390,9 +495,9 @@ pub async fn feiniu_media_prefix( Ok(json!({ "mediaPrefix": prefix })) } -/// 扫描本地曲库目录中的音频文件:{ items }(目录 = 下载 savedir + 用户自定义 dirs)。 -#[tauri::command] -pub fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result { +/// 允许访问的本地目录(下载目录 + 自定义曲库目录)。 +/// 本地文件的**列举与删除**都限定在其中——前端只应能操作曲库范围内的文件。 +fn allowed_local_roots(state: &MusicManager) -> Vec { let s = state.load_settings(); let mut dirs: Vec = vec![s.savedir.clone()]; for d in &s.feiniu_local_dirs { @@ -400,7 +505,55 @@ pub fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result bool { + let Ok(target) = std::fs::canonicalize(path) else { + return false; + }; + roots.iter().any(|r| { + std::fs::canonicalize(r) + .map(|root| target.starts_with(&root)) + .unwrap_or(false) + }) +} + +/// 列出某个下载目录中的音频文件(非递归):`{ items: [{path,name,size,mtim}] }`。 +/// +/// 供上传编排在下载目录里定位「刚落盘的文件」——用 `feiniu_scan_local` 会递归遍历 +/// 整个曲库并解析标签,为一次上传扫全库是纯浪费。不解析标签(匹配只需要文件名与时间)。 +#[tauri::command] +pub async fn feiniu_list_audio_files( + state: State<'_, MusicManager>, + dir: String, +) -> Result { + let roots = allowed_local_roots(&state); + if roots.is_empty() || !is_within_roots(std::path::Path::new(&dir), &roots) { + return Err(format!("拒绝列举曲库目录之外的路径:{dir}")); + } + tauri::async_runtime::spawn_blocking(move || crate::music::feiniu::list_audio_files(&dir)) + .await + .map_err(|e| format!("列举目录失败: {e}")) +} + +/// 扫描本地曲库目录中的音频文件:{ items }(目录 = 下载 savedir + 用户自定义 dirs)。 +/// 标签解析结果缓存在 CacheManager(size+mtim 未变即复用)。 +/// +/// **必须 async + spawn_blocking**:Tauri 中不带 async 的命令在主线程执行, +/// 而 walkdir 递归 + lofty 全量标签解析在万级曲库下会阻塞数秒——UI 直接冻住。 +/// 目录与缓存路径都先取出为自有值,避免把 `State` 借进 'static 的阻塞任务。 +#[tauri::command] +pub async fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result { + let dirs = allowed_local_roots(&state); + let tags_cache = state.feiniu.local_tags_path(); + tauri::async_runtime::spawn_blocking(move || { + crate::music::scan_local_dirs(&dirs, Some(tags_cache.as_path())) + }) + .await + .map_err(|e| format!("本地曲库扫描任务失败: {e}")) } /// 播放缓存状态:{ count, usedBytes, usedMb }。 @@ -478,73 +631,67 @@ pub async fn webdav_delete( Ok(json!({ "ok": true })) } -// ============ WebDAV 凭据加密存储(Windows 凭据管理器,DPAPI 保护) ============ - -/// 凭据以 JSON blob 存入系统凭据管理器:{"username","password"}。 -/// 明文仅存在于内存与系统凭据库,绝不写回 localStorage / 配置文件。 -#[cfg(windows)] -const WEBDAV_SECRET_SERVICE: &str = "Thing"; -#[cfg(windows)] -const WEBDAV_SECRET_USER: &str = "webdav-credentials"; - -#[cfg(windows)] -fn webdav_secret_entry() -> Result { - keyring::Entry::new(WEBDAV_SECRET_SERVICE, WEBDAV_SECRET_USER) - .map_err(|e| format!("无法访问系统凭据管理器: {e}")) -} - -#[cfg(windows)] -fn webdav_secret_read() -> Result, String> { - let entry = webdav_secret_entry()?; - match entry.get_password() { - Ok(blob) => serde_json::from_str(&blob) - .map(Some) - .map_err(|e| format!("凭据数据损坏: {e}")), - // 未配置过凭据不算错误 - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(format!("读取凭据失败: {e}")), - } -} - -#[cfg(windows)] -fn webdav_secret_write(username: &str, password: &str) -> Result<(), String> { - let blob = json!({ "username": username, "password": password }).to_string(); - let entry = webdav_secret_entry()?; - entry.set_password(&blob).map_err(|e| format!("保存凭据失败: {e}")) -} +// ============ 敏感串(系统凭据管理器,DPAPI 保护) ============ +// 统一实现在 crate::music::secrets:WebDAV 账号密码、QQ 音乐 Cookie、飞牛登录 token +// 三类凭据同构存放,不再出现「一类进凭据库、另一类明文落盘」的双标。 /// 读取 WebDAV 凭据。未配置时 username/password 为 null。 #[tauri::command] pub fn webdav_get_secret() -> Result { - #[cfg(windows)] - { - Ok(webdav_secret_read()?.unwrap_or_else(|| json!({ "username": null, "password": null }))) - } - #[cfg(not(windows))] - { - Ok(json!({ "username": null, "password": null })) - } + let parsed = crate::music::secrets::secret_read(crate::music::secrets::KEY_WEBDAV) + .unwrap_or(None) + .and_then(|raw| serde_json::from_str::(&raw).ok()); + Ok(parsed.unwrap_or_else(|| json!({ "username": null, "password": null }))) } /// 保存 WebDAV 凭据(账号 + 密码整体覆盖)。 #[tauri::command] pub fn webdav_save_secret(username: String, password: String) -> Result { - #[cfg(windows)] - { - webdav_secret_write(&username, &password)?; + let blob = json!({ "username": username, "password": password }).to_string(); + crate::music::secrets::secret_write(crate::music::secrets::KEY_WEBDAV, &blob)?; + Ok(json!({ "ok": true })) +} + +/// 校验前端可访问的凭据键:只允许白名单内的键(见 `secrets::FRONTEND_KEYS`)。 +/// +/// 白名单刻意不含 `webdav-credentials` 与飞牛 token—— +/// 前端因此无法通过这两个通用命令去读写它们,只能碰自己的 QQ 音乐 Cookie。 +fn assert_frontend_secret_key(key: &str) -> Result<(), String> { + if crate::music::secrets::frontend_key_allowed(key) { + Ok(()) + } else { + Err(format!("不允许访问的凭据键:{key}")) } - #[cfg(not(windows))] - { - let _ = (&username, &password); +} + +/// 读取一个音乐模块的敏感串(如 QQ 音乐 Cookie):{ value }(未设置 → null)。 +#[tauri::command] +pub fn music_secret_get(key: String) -> Result { + assert_frontend_secret_key(&key)?; + Ok(json!({ "value": crate::music::secrets::secret_read(&key)? })) +} + +/// 写入 / 清除一个音乐模块的敏感串。`value` 为空表示删除该凭据。 +#[tauri::command] +pub fn music_secret_set(key: String, value: String) -> Result { + assert_frontend_secret_key(&key)?; + if value.is_empty() { + crate::music::secrets::secret_delete(&key)?; + } else { + crate::music::secrets::secret_write(&key, &value)?; } Ok(json!({ "ok": true })) } /// 删除本地媒体文件(「下载到飞牛」落地即传流程的收尾)。 -/// 仅允许音频 / 歌词 / 封面扩展名,且拒绝目录——防止前端误删任意文件。 +/// 仅允许音频 / 歌词 / 封面扩展名,拒绝目录,且**必须落在已配置的下载/曲库目录内**—— +/// 只校验扩展名的话,前端一旦传错路径就能删掉用户的任意音乐文件。 #[tauri::command] #[specta::specta] -pub fn feiniu_delete_local(path: String) -> Result { +pub fn feiniu_delete_local( + state: State<'_, MusicManager>, + path: String, +) -> Result { const ALLOWED: [&str; 13] = [ "mp3", "flac", "wav", "m4a", "aac", "ogg", "ape", "wma", "lrc", "jpg", "jpeg", "png", "webp", ]; @@ -561,6 +708,9 @@ pub fn feiniu_delete_local(path: String) -> Result { if meta.is_dir() { return Err("拒绝删除目录".to_string()); } + if !is_within_roots(p, &allowed_local_roots(&state)) { + return Err(format!("拒绝删除曲库目录之外的文件:{path}")); + } std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))?; Ok(json!({ "ok": true })) } diff --git a/src-tauri/src/music/feiniu/cache.rs b/src-tauri/src/music/feiniu/cache.rs index 3072ceb..dc9d64c 100644 --- a/src-tauri/src/music/feiniu/cache.rs +++ b/src-tauri/src/music/feiniu/cache.rs @@ -34,6 +34,13 @@ impl CacheManager { Self { root, index_path } } + /// 本地曲库标签缓存文件:{cache_root}/local-tags.json。 + /// 记录 path -> {size, mtim, 标签},扫描时未变化的文件直接复用, + /// 避免万级曲库每次都重新解析音频标签。 + pub fn local_tags_path(&self) -> PathBuf { + self.root.join("local-tags.json") + } + fn load_index(&self) -> HashMap { fs::read_to_string(&self.index_path) .ok() diff --git a/src-tauri/src/music/feiniu/fnconnect.rs b/src-tauri/src/music/feiniu/fnconnect.rs index df2eb01..f1aead0 100644 --- a/src-tauri/src/music/feiniu/fnconnect.rs +++ b/src-tauri/src/music/feiniu/fnconnect.rs @@ -1,51 +1,86 @@ -//! FnConnect 远程连接解析(参考 feiniu-car-music `fn-api.js`)。 +//! FnConnect 远程连接解析。 //! -//! fnId → 网关 `https://5ddd.com/api/v1/fn/con`(authx md5 签名)→ 内网/公网/中继候选 → -//! 探测可达性 → 得到可用的 base_url(含 mode=relay 的中继地址)。 +//! 链路参考第三方客户端 FnMusic 的 `fn_connection_probe_service.dart`: +//! fnId → 网关 `<网关>/api/v1/fn/con`(authx md5 签名)→ 内网 / 公网 IPv6 / 公网 IPv4 / 中继 +//! 候选 → 并发探测(按优先级早停)→ 首个可达的 base_url。 +//! +//! 关键约束(实测确认): +//! 1. authx 原文为 `PREFIX_url_nonce_timestamp_md5(body)_API_KEY`, +//! md5 与 API_KEY 之间是**单个**下划线;多一个下划线网关即返回 `invalid sign`。 +//! 2. 中继地址(`.fnos.net`)必须携带 `Cookie: mode=relay` 才会被网关转发到 +//! NAS 音乐后端,否则网关直接 302 回登录页。 +use std::time::Duration; + +use futures_util::stream::{FuturesUnordered, StreamExt}; use md5::{Digest, Md5}; use rand::RngCore; use serde_json::{json, Value}; -/// 网关地址与签名常量(对齐 feiniu-car-music)。 -const FN_CONNECT_URL: &str = "https://5ddd.com/api/v1/fn/con"; +/// 网关主机(按序回退;5ddd.com 与 fnos.net 为同一服务的不同集群入口)。 +const FN_CONNECT_HOSTS: [&str; 2] = ["https://5ddd.com", "https://fnos.net"]; +/// 连接参数接口路径(签名原文中的 url 部分)。 +const FN_CON_PATH: &str = "/api/v1/fn/con"; +/// 签名常量(对齐第三方实现)。 const FN_AUTHX_PREFIX: &str = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh"; const FN_API_KEY: &str = "zIGtkc3dqZnJpd29qZXJqa2w7c"; +/// 网关查询超时。 +const FN_QUERY_TIMEOUT: Duration = Duration::from_secs(10); +/// 直连候选(内网/公网 IP)探测超时。 +const FN_PROBE_TIMEOUT_DIRECT: Duration = Duration::from_secs(3); +/// 中继候选探测超时(走公网网关,放宽)。 +const FN_PROBE_TIMEOUT_RELAY: Duration = Duration::from_secs(10); + fn md5_hex(input: &str) -> String { let mut h = Md5::new(); h.update(input.as_bytes()); format!("{:x}", h.finalize()) } -/// 从输入识别 fnId:`fnos.net/`、`.5ddd.com`、或裸 fnId。 +/// 从输入识别 fnId:`fnos.net/`、`5ddd.com/`、`.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()); + // 去协议后统一按「可能带路径的 host」处理 + let mut t = s; + for p in ["https://", "http://"] { + if let Some(r) = t.strip_prefix(p) { + t = r; + break; } } - 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()); + let t = t.trim_end_matches('/'); + // 形如 <网关>/ + for gw in ["fnos.net/", "5ddd.com/"] { + if let Some((_, rest)) = t.split_once(gw) { + let id = rest.split('/').next().unwrap_or("").trim(); + if !id.is_empty() { + return Some(id.to_string()); + } } } - if s.ends_with(".5ddd.com") { - return Some(s.trim_end_matches(".5ddd.com").to_string()); + // 形如 .<网关> + for suffix in [".fnos.net", ".5ddd.com"] { + if let Some(id) = t.strip_suffix(suffix) { + if !id.is_empty() { + return Some(id.to_string()); + } + } } // 裸 fnId - if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && !s.starts_with("http") { + if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { return Some(s.to_string()); } None } /// 计算网关 authx 签名。 +/// +/// 原文(下划线连接,**注意 md5 与 API_KEY 之间只有一个下划线**): +/// `PREFIX_url_nonce_timestamp_md5(body)_API_KEY` → 取 md5 作为 sign。 fn fn_authx(method: &str, url: &str, data: &Value) -> String { let body = if method.eq_ignore_ascii_case("get") { String::new() @@ -62,93 +97,199 @@ fn fn_authx(method: &str, url: &str, data: &Value) -> String { .map(|d| d.as_millis().to_string()) .unwrap_or_default(); let raw = format!( - "{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}__{FN_API_KEY}", + "{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}_{FN_API_KEY}", md5_hex(&body) ); format!("nonce={nonce}×tamp={timestamp}&sign={}", md5_hex(&raw)) } -/// 从网关查询 fnId 的连接参数。 +/// 从网关查询 fnId 的连接参数(多网关按序回退)。 pub async fn query_fn_connect(fn_id: &str) -> Result { let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) + .timeout(FN_QUERY_TIMEOUT) .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()); + let authx = fn_authx("post", FN_CON_PATH, &body); + + let mut last_err = String::from("FnConnect 网关不可达"); + for host in FN_CONNECT_HOSTS { + let resp = match client + .post(format!("{host}{FN_CON_PATH}")) + .header("Content-Type", "application/json") + .header("authx", authx.clone()) + .json(&body) + .send() + .await + { + Ok(r) => r, + Err(e) => { + last_err = format!("FnConnect 网关不可达: {e}"); + continue; + } + }; + let b: Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + last_err = format!("FnConnect 网关响应异常: {e}"); + continue; + } + }; + if b["code"].as_i64().unwrap_or(-1) != 0 { + last_err = b["msg"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "FnConnect 网关返回错误".to_string()); + continue; + } + return Ok(b["data"].clone()); } - Ok(b["data"].clone()) + Err(last_err) } -/// 构建候选 base_url 列表。返回 (url, is_relay)。 -pub fn build_candidates(data: &Value) -> Vec<(String, bool)> { +/// 去掉 `host:port` 形式的端口(仅用于中继域名,中继恒走 443)。 +fn strip_port(addr: &str) -> &str { + match addr.rsplit_once(':') { + Some((host, port)) if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => host, + _ => addr, + } +} + +/// 构建候选 base_url 列表。返回 `(url, is_relay)`,顺序即优先级: +/// 内网 IPv4 → 公网 IPv6 → 公网 IPv4 → 中继。 +/// +/// IP 直连地址 HTTP 优先、HTTPS 兜底(自签证书场景 HTTP 更易通); +/// `forbbidPublicIpv6` 为真时跳过公网 IPv6。 +pub fn build_candidates(data: &Value, fn_id: &str) -> 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 + let empty: Vec = Vec::new(); + + let strings = |key: &str| -> Vec { + data[key] + .as_array() + .unwrap_or(&empty) .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) + .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)); + + // 1) 内网 IPv4 + for ip in strings("ipv4") { + out.push((format!("http://{ip}:{http}"), false)); + out.push((format!("https://{ip}:{https}"), false)); + } + // 2) 公网 IPv6(NAS 侧可禁用) + if !data["forbbidPublicIpv6"].as_bool().unwrap_or(false) { + for ip in strings("publicIpv6") { + out.push((format!("http://[{ip}]:{http}"), false)); + out.push((format!("https://[{ip}]:{https}"), false)); + } + } + // 3) 公网 IPv4 + for ip in strings("publicIpv4") { + out.push((format!("http://{ip}:{http}"), false)); + out.push((format!("https://{ip}:{https}"), false)); + } + // 4) 中继:仅 HTTPS;网关未返回时按两种集群域名兜底 + let mut relays = strings("fn"); + if relays.is_empty() { + relays = vec![ + format!("{fn_id}.fnos.net"), + format!("{fn_id}.5ddd.com"), + ]; + } + for addr in relays { + let domain = strip_port(&addr); + if !domain.is_empty() { + 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, +/// 探测单个 base_url 是否可用(中继候选携带 `mode=relay` 才会被网关转发)。 +/// +/// 判据必须排除 3xx:重定向说明请求**没有真正落到 NAS 音乐后端** +/// (中继缺 `mode=relay` 时网关 302 回登录页;端口上实际是 fnOS Web UI 时同样 302)。 +/// 早先把「任何 < 500」都当可达,结果候选探测通过、紧接着登录报 `HTTP 302 Found`。 +async fn probe(client: &reqwest::Client, url: &str, relay: bool) -> bool { + let timeout = if relay { + FN_PROBE_TIMEOUT_RELAY + } else { + FN_PROBE_TIMEOUT_DIRECT + }; + let full = format!( + "{}/music/api/v1/track/list?page=1&size=1", + url.trim_end_matches('/') + ); + let mut rb = client.get(&full).timeout(timeout); + if relay { + rb = rb.header("cookie", "mode=relay"); + } + match rb.send().await { + // 未登录时中继会返回 401(已触达 NAS 音乐后端),直连返回 200/401, + // 均视为链路可达;3xx(重定向)与 5xx 视为不可达。 + Ok(resp) => { + let code = resp.status().as_u16(); + code < 500 && !(300..400).contains(&code) + } Err(_) => false, } } -/// 解析 fnId → 第一个可达的 base_url;返回 (base_url, is_relay)。 +/// 解析 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); + let candidates = build_candidates(&data, fn_id); if candidates.is_empty() { return Err("FnConnect 未返回可用地址".into()); } - for (url, relay) in &candidates { - if probe(url).await { - return Ok((url.clone(), *relay)); + + let client = reqwest::Client::builder() + .timeout(FN_PROBE_TIMEOUT_RELAY) + .build() + .map_err(|e| e.to_string())?; + + let mut pending = FuturesUnordered::new(); + for (i, (url, relay)) in candidates.iter().enumerate() { + let client = client.clone(); + let url = url.clone(); + let relay = *relay; + pending.push(async move { + let ok = probe(&client, &url, relay).await; + (i, url, relay, ok) + }); + } + + let mut undecided: Vec = (0..candidates.len()).collect(); + let mut best: Option<(usize, String, bool)> = None; + + while let Some((i, url, relay, ok)) = pending.next().await { + undecided.retain(|x| *x != i); + if ok && best.as_ref().map_or(true, |(bi, _, _)| i < *bi) { + best = Some((i, url, relay)); + } + // 早停:已有可达候选,且不存在索引更小(优先级更高)的未决候选 + if let Some((bi, _, _)) = best.as_ref() { + if !undecided.iter().any(|x| x < bi) { + break; + } } } - Err(format!("FnConnect 候选均不可达({} 个)", candidates.len())) + + match best { + Some((idx, url, relay)) => { + let _ = idx; + Ok((url, relay)) + } + None => Err(format!("FnConnect 候选均不可达({} 个)", candidates.len())), + } } diff --git a/src-tauri/src/music/feiniu/mod.rs b/src-tauri/src/music/feiniu/mod.rs index 5833c85..95e763c 100644 --- a/src-tauri/src/music/feiniu/mod.rs +++ b/src-tauri/src/music/feiniu/mod.rs @@ -5,11 +5,14 @@ //! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。 //! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。 -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use axum::http::StatusCode; +use lofty::file::{AudioFile, TaggedFileExt}; +use lofty::tag::Accessor; use serde::{Deserialize, Serialize}; use specta::Type; use serde_json::{json, Value}; @@ -48,6 +51,10 @@ pub struct FeiniuConnection { /// fnconnect 连接的 fnId(如 fnos.net/ 或裸 id) #[serde(default)] pub fn_id: String, + /// 是否经由 FnConnect 中继链路(`.fnos.net`)。 + /// 中继要求所有请求携带 `Cookie: mode=relay`,否则网关 302 回登录页。 + #[serde(default)] + pub relay: bool, } impl Default for FeiniuConnection { @@ -63,6 +70,7 @@ impl Default for FeiniuConnection { access_code: String::new(), insecure: false, fn_id: String::new(), + relay: false, } } } @@ -75,6 +83,8 @@ struct Conn { device_id: String, access_code: String, insecure: bool, + /// FnConnect 中继链路标记 + relay: bool, } /// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。 @@ -83,8 +93,26 @@ struct ClientSlot { client: reqwest::Client, } +/// 构建飞牛请求 client。 +/// +/// `streaming = true` 时不设**总超时**:reqwest 的 `timeout` 覆盖到响应体读完为止, +/// 用来取整首音频会把流式中途掐断(大文件/慢链路下播放器会一直缓冲)。 +/// 流式只限制建连时间。 +fn build_client(insecure: bool, streaming: bool) -> reqwest::Client { + let mut b = reqwest::Client::builder() + .no_proxy() + .connect_timeout(Duration::from_secs(10)) + .danger_accept_invalid_certs(insecure); + if !streaming { + b = b.timeout(Duration::from_secs(10)); + } + b.build().unwrap_or_else(|_| reqwest::Client::new()) +} + pub struct Feiniu { client: Mutex>, + /// 流代理专用 client(无总超时) + stream_client: Mutex>, conn: Mutex, proxy: Mutex>, cache: CacheManager, @@ -94,6 +122,7 @@ impl Default for Feiniu { fn default() -> Self { Self { client: Mutex::new(None), + stream_client: Mutex::new(None), conn: Mutex::new(Conn { base_url: String::new(), token: String::new(), @@ -101,6 +130,7 @@ impl Default for Feiniu { device_id: String::new(), access_code: String::new(), insecure: false, + relay: false, }), proxy: Mutex::new(None), cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建 @@ -108,40 +138,56 @@ impl Default for Feiniu { } } +/// 按 insecure 惰性构建 client(`streaming` 决定是否免总超时)。 +fn slot_client(slot: &Mutex>, insecure: bool, streaming: bool) -> reqwest::Client { + let mut g = slot.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: build_client(insecure, streaming), + }); + } + g.as_ref().unwrap().client.clone() +} + 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。 + /// 本地曲库标签缓存文件路径({cache_root}/local-tags.json)。 + pub fn local_tags_path(&self) -> PathBuf { + self.cache.local_tags_path() + } + /// 取(并惰性构建)对应 insecure 的 reqwest client(普通请求,10s 总超时)。 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() + slot_client(&self.client, insecure, false) + } + + /// 取流式请求专用 client(无总超时)。 + fn stream_client(&self, insecure: bool) -> reqwest::Client { + slot_client(&self.stream_client, insecure, true) } /// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。 pub fn sync_with_settings(&self, s: &MusicSettings) { - let (base_url, token, username, device_id, access_code, insecure) = + let (base_url, token, username, device_id, access_code, insecure, relay) = match s.feiniu_active() { Some(c) => ( c.base_url.clone(), - c.token.clone(), + // token 常态存放在系统凭据管理器(见 crate::music::secrets), + // 结构体字段为空;只有凭据库不可用(降级)时才回落到明文。 + if c.token.is_empty() { + super::secrets::read_feiniu_token(&c.id) + } else { + c.token.clone() + }, c.username.clone(), c.device_id.clone(), c.access_code.clone(), c.insecure, + c.relay, ), None => ( String::new(), @@ -150,6 +196,7 @@ impl Feiniu { String::new(), String::new(), false, + false, ), }; if let Ok(mut c) = self.conn.lock() { @@ -159,6 +206,7 @@ impl Feiniu { c.device_id = device_id; c.access_code = access_code; c.insecure = insecure; + c.relay = relay; } // 确保 client 构建到位(insecure 变化时重建) self.client(insecure); @@ -178,7 +226,8 @@ impl Feiniu { } fn sync_proxy_client(&self) { - let (_, client) = self.conn_client(); + // 代理必须用流式 client:普通 client 的 10s 总超时会把长音频流掐断 + let (_, client) = self.conn_stream_client(); if let Ok(mut g) = self.proxy.lock() { if let Some((_, shared)) = g.as_mut() { shared.client = client; @@ -191,18 +240,44 @@ impl Feiniu { (insecure, self.client(insecure)) } + fn conn_stream_client(&self) -> (bool, reqwest::Client) { + let insecure = self.conn.lock().unwrap_or_else(|e| e.into_inner()).insecure; + (insecure, self.stream_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(), + relay: c.relay, } } - fn auth_triple(&self) -> (String, String, String) { + /// 构造鉴权 Cookie 头。 + /// + /// 中继链路需把 `mode=relay` 与 `music-token` 合并进**同一个** Cookie 头 + /// (拆成两个 Cookie 头会互相覆盖);未登录的中继请求只带 `mode=relay`。 + /// 无 token 且非中继时返回 None,表示无需携带 Cookie。 + fn auth_cookie(token: &str, relay: bool) -> Option { + match (token.is_empty(), relay) { + (false, true) => Some(format!("music-token={token}; mode=relay")), + (false, false) => Some(format!("music-token={token}")), + (true, true) => Some("mode=relay".to_string()), + (true, false) => None, + } + } + + /// 鉴权上下文:`(base_url, token, access_code, relay)`。 + fn auth_triple(&self) -> (String, String, String, bool) { let c = self.conn.lock().unwrap_or_else(|e| e.into_inner()); - (c.base_url.clone(), c.token.clone(), c.access_code.clone()) + ( + c.base_url.clone(), + c.token.clone(), + c.access_code.clone(), + c.relay, + ) } /// 对某个 base_url 执行登录(探测/登录连接共用)。 @@ -228,10 +303,20 @@ impl Feiniu { "password": sha256_hex(password), "deviceId": device_id, }); - let resp = self + let relay = self + .conn + .lock() + .unwrap_or_else(|e| e.into_inner()) + .relay; + let mut rb = self .client(insecure) .post(format!("{base}/music/api/v1/user/password-login")) - .json(&body) + .json(&body); + // 中继链路:登录请求也需带 mode=relay,否则网关 302 回登录页 + if let Some(cookie) = Self::auth_cookie("", relay) { + rb = rb.header("cookie", cookie); + } + let resp = rb .send() .await .map_err(|e| { @@ -321,53 +406,6 @@ impl Feiniu { 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 { @@ -385,13 +423,16 @@ impl Feiniu { if let Some(hit) = self.cache.hit(guid) { return Ok(Some(hit)); } - let (base, token, access_code) = self.auth_triple(); + let (base, token, access_code, relay) = self.auth_triple(); if base.is_empty() || token.is_empty() { return Err("未登录".into()); } - let (_, client) = self.conn_client(); + // 整首拉取写入缓存:同样用无总超时的 client,否则大文件会被 10s 超时截断 + let (_, client) = self.conn_stream_client(); let mut rb = client.get(format!("{base}/music/api/v1/track/stream?guid={guid}")); - rb = rb.header("cookie", format!("music-token={token}")); + if let Some(cookie) = Self::auth_cookie(&token, relay) { + rb = rb.header("cookie", cookie); + } if !access_code.is_empty() { use base64::Engine; rb = rb @@ -439,7 +480,8 @@ impl Feiniu { return Ok(*port); } } - let (_, client) = self.conn_client(); + // 流代理用无总超时的 client(见 build_client) + let (_, client) = self.conn_stream_client(); let shared = ProxyShared { client, cfg: Arc::new(Mutex::new(self.current_cfg())), @@ -452,14 +494,16 @@ impl Feiniu { } async fn authed_get(&self, path: &str, query: Vec<(String, String)>) -> Result { - let (base, token, access_code) = self.auth_triple(); + let (base, token, access_code, relay) = 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 let Some(cookie) = Self::auth_cookie(&token, relay) { + rb = rb.header("cookie", cookie); + } if !access_code.is_empty() { use base64::Engine; rb = rb @@ -490,6 +534,190 @@ impl Feiniu { } } +/// 本地曲库 / 下载目录里认可的音频扩展名。 +/// 扫描(递归)与单目录列举共用同一份,避免两处判定不一致。 +pub const AUDIO_EXTS: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "ape"]; + +/// 该路径是否为认可的音频文件(只看扩展名,不校验存在性)。 +pub fn is_audio_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXTS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) +} + +/// 列出一个**目录**(非递归)下的音频文件:`{ items: [{path,name,size,mtim}] }`。 +/// +/// 与 [`scan_local_dirs`] 的分工:那个是「曲库全量扫描 + 标签解析」, +/// 用于曲库视图;这个是「只看一层目录、不读标签」的轻量列举, +/// 用于上传编排(只需在下载目录里找到刚落盘的文件)。 +/// 为一次上传去递归遍历整个曲库目录是纯浪费。 +pub fn list_audio_files(dir: &str) -> Value { + let mut items: Vec = Vec::new(); + let p = Path::new(dir); + if !p.is_dir() { + return json!({ "items": items }); + } + let Ok(entries) = fs::read_dir(p) else { + return json!({ "items": items }); + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || !is_audio_path(&path) { + 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(), + "name": name, + "size": size, + "mtim": mtim, + })); + } + json!({ "items": items }) +} + +/// 递归扫描本地曲库目录中的音频文件,返回轻量条目。 +/// +/// 元数据(标题/歌手/专辑/时长/是否有内嵌封面)用 lofty 解析音频标签; +/// 结果按 path 缓存(size+mtim 未变即复用),避免万级曲库每次全量重解析。 +/// `tags_cache_path` 为标签缓存文件路径(由命令层传入 CacheManager)。 +/// +/// **纯函数(不依赖 &self)**:全量解析是重活,命令层必须把它放进 +/// `spawn_blocking`(Tauri 的同步命令在主线程执行,会冻结 UI)。 +pub fn scan_local_dirs(dirs: &[String], tags_cache_path: Option<&Path>) -> Value { + // ---- 标签缓存 ---- + let mut tag_cache: serde_json::Map = tags_cache_path + .and_then(|p| fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let cache_hit = |cache: &serde_json::Map, + path: &str, + size: u64, + mtim: u64| + -> Option { + cache + .get(path) + .and_then(|v| v.as_object()) + .filter(|e| { + e.get("size").and_then(|x| x.as_u64()) == Some(size) + && e.get("mtim").and_then(|x| x.as_u64()) == Some(mtim) + }) + .map(|e| Value::Object(e.clone())) + }; + + let mut items: Vec = Vec::new(); + // 本次扫描命中的音频路径:用于修剪缓存(见函数末尾) + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + // 至少有一个目录可访问才允许修剪,避免目录临时不可用(外接盘未挂载)时清空整个缓存 + let mut scanned_any_dir = false; + for dir in dirs { + let p = Path::new(dir); + if !p.is_dir() { + continue; + } + scanned_any_dir = true; + 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(); + if !is_audio_path(path) { + 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 path_str = path.to_string_lossy().to_string(); + let name = path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("未知") + .to_string(); + seen.insert(path_str.clone()); + + // 标签:命中缓存直接复用;否则 lofty 解析并写回缓存 + let tags = match cache_hit(&tag_cache, &path_str, size, mtim) { + Some(hit) => hit, + None => { + let mut t = json!({ + "size": size, + "mtim": mtim, + "title": name, + "artist": "", + "album": "", + "durationS": 0, + "cover": false, + }); + if let Ok(tagged) = lofty::read_from_path(path) { + let tag = tagged.primary_tag().or_else(|| tagged.first_tag()); + if let Some(tag) = tag { + if let Some(v) = tag.title().filter(|s| !s.trim().is_empty()) { + t["title"] = json!(v.trim()); + } + if let Some(v) = tag.artist().filter(|s| !s.trim().is_empty()) { + t["artist"] = json!(v.trim()); + } + if let Some(v) = tag.album().filter(|s| !s.trim().is_empty()) { + t["album"] = json!(v.trim()); + } + t["cover"] = json!(!tag.pictures().is_empty()); + } + let secs = tagged.properties().duration().as_secs(); + if secs > 0 { + t["durationS"] = json!(secs); + } + } + tag_cache.insert(path_str.clone(), t.clone()); + t + } + }; + + items.push(json!({ + "path": path_str, + "name": name, + "title": tags.get("title").cloned().unwrap_or(json!(name)), + "artist": tags.get("artist").cloned().unwrap_or(json!("")), + "album": tags.get("album").cloned().unwrap_or(json!("")), + "durationS": tags.get("durationS").cloned().unwrap_or(json!(0)), + "cover": tags.get("cover").cloned().unwrap_or(json!(false)), + "size": size, + "mtim": mtim, + "dir": dir, + })); + } + } + // 修剪:删掉本次扫描中未再出现的缓存条目(文件已删除/改名/移出曲库目录)。 + // 只增不减的缓存会随使用时间无限膨胀,且每次扫描都要整份读写。 + if scanned_any_dir { + tag_cache.retain(|k, _| seen.contains(k)); + } + if let Some(p) = tags_cache_path { + if let Ok(s) = serde_json::to_string(&tag_cache) { + fs::write(p, s).ok(); + } + } + json!({ "items": items }) +} + /// 尽力从 /lyric/list 响应中取第一段歌词文本(响应结构未文档化,做宽松映射)。 fn extract_lyric_text(v: &Value) -> String { match v { diff --git a/src-tauri/src/music/feiniu/proxy.rs b/src-tauri/src/music/feiniu/proxy.rs index 023355c..817d2c1 100644 --- a/src-tauri/src/music/feiniu/proxy.rs +++ b/src-tauri/src/music/feiniu/proxy.rs @@ -15,6 +15,8 @@ use axum::{ Router, }; +use lofty::file::TaggedFileExt; + use super::conn::normalize_base_url; /// 由 Feiniu 运行期与代理 handler 共享的连接配置(登录更新、登出置空)。 @@ -23,6 +25,8 @@ pub struct ProxyCfg { pub base_url: String, pub token: String, pub access_code: String, + /// FnConnect 中继链路:所有请求需携带 `Cookie: mode=relay` + pub relay: bool, } #[derive(Clone)] @@ -44,6 +48,7 @@ pub async fn start(shared: ProxyShared) -> Result { let app = Router::new() .route("/feiniu/stream", get(proxy_stream)) .route("/feiniu/cover", get(proxy_cover)) + .route("/feiniu/local-cover", get(proxy_local_cover)) .with_state(shared); tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { @@ -53,6 +58,48 @@ pub async fn start(shared: ProxyShared) -> Result { Ok(port) } +/// 本地音频文件的内嵌封面:`GET /feiniu/local-cover?path=<绝对路径>`。 +/// +/// 供「本地曲库」列表显示封面(无需 NAS 登录)。只接受音频扩展名,且必须 +/// 能被 lofty 解析出内嵌图片——非音频文件在这里必然 404,不构成任意文件读取。 +/// 响应带 immutable 缓存头:path 不变时 WebView 直接复用。 +async fn proxy_local_cover(Query(q): Query>) -> Response { + const AUDIO_EXTS: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "ape"]; + let Some(path) = q.get("path") else { + return (StatusCode::BAD_REQUEST, "missing path").into_response(); + }; + let p = std::path::Path::new(path); + let ext_ok = p + .extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXTS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false); + if !ext_ok || !p.is_file() { + return StatusCode::NOT_FOUND.into_response(); + } + let picture = lofty::read_from_path(p).ok().and_then(|tagged| { + tagged + .primary_tag() + .or_else(|| tagged.first_tag()) + .and_then(|t| t.pictures().first()) + .cloned() + }); + let Some(pic) = picture else { + return StatusCode::NOT_FOUND.into_response(); + }; + // lofty 0.22 的 mime_type 返回 Option + let mime = pic + .mime_type() + .map(|m| m.to_string()) + .unwrap_or_else(|| "image/jpeg".to_string()); + let mut resp = ([(header::CONTENT_TYPE, mime)], pic.data().to_vec()).into_response(); + resp.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("public, max-age=2592000, immutable"), + ); + resp +} + async fn proxy_stream( State(s): State, Query(q): Query>, @@ -118,7 +165,15 @@ async fn forward( let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); let mut rb = s.client.get(&url).query(&qrefs); - rb = rb.header("cookie", format!("music-token={}", cfg.token)); + // 中继链路必须带 mode=relay,网关据此转发到 NAS(与 music-token 合并进同一个 Cookie 头)。 + rb = rb.header( + "cookie", + if cfg.relay { + format!("music-token={}; mode=relay", cfg.token) + } else { + format!("music-token={}", cfg.token) + }, + ); if !cfg.access_code.is_empty() { use base64::Engine; rb = rb @@ -143,6 +198,12 @@ async fn forward( let cl = resp.headers().get(header::CONTENT_LENGTH).cloned(); let cr = resp.headers().get(header::CONTENT_RANGE).cloned(); let ar = resp.headers().get(header::ACCEPT_RANGES).cloned(); + // 缓存头必须透传:NAS 的封面接口带 `public, max-age=2592000, immutable`, + // 丢掉它就等于告诉 WebView「这个响应不可缓存」——每次进曲库页都要重新下载全部封面。 + let cc = resp.headers().get(header::CACHE_CONTROL).cloned(); + let et = resp.headers().get(header::ETAG).cloned(); + let lm = resp.headers().get(header::LAST_MODIFIED).cloned(); + let ex = resp.headers().get(header::EXPIRES).cloned(); let body = axum::body::Body::from_stream(resp.bytes_stream()); let mut out = Response::new(body); @@ -160,5 +221,17 @@ async fn forward( if let Some(v) = ar { h.insert(header::ACCEPT_RANGES, v); } + if let Some(v) = cc { + h.insert(header::CACHE_CONTROL, v); + } + if let Some(v) = et { + h.insert(header::ETAG, v); + } + if let Some(v) = lm { + h.insert(header::LAST_MODIFIED, v); + } + if let Some(v) = ex { + h.insert(header::EXPIRES, v); + } out } \ No newline at end of file diff --git a/src-tauri/src/music/mod.rs b/src-tauri/src/music/mod.rs index a13ddaa..db61bf8 100644 --- a/src-tauri/src/music/mod.rs +++ b/src-tauri/src/music/mod.rs @@ -19,18 +19,23 @@ mod bridge; mod commands; mod feiniu; mod runtime; +mod secrets; -pub use feiniu::{extract_fn_id, normalize_base_url, resolve_base_url, Feiniu, FeiniuConnection}; +pub use feiniu::{ + extract_fn_id, normalize_base_url, resolve_base_url, scan_local_dirs, Feiniu, FeiniuConnection, +}; pub use commands::{ feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status, feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config, - feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric, + feiniu_list_connections, feiniu_list_audio_files, feiniu_list_tracks, feiniu_login, + feiniu_logout, feiniu_lyric, feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection, music_cancel_runtime_install, music_download, music_download_cancel, music_env_status, music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping, - music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete, - webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload, + music_resolve, music_save_settings, music_search, music_secret_get, music_secret_set, + music_stop_bridge, music_update_musicdl, webdav_delete, webdav_get_secret, + webdav_save_secret, webdav_test, webdav_upload, }; use serde::{Deserialize, Serialize}; @@ -84,6 +89,11 @@ pub struct MusicEnvStatus { pub musicdl_installed: bool, /// musicdl 版本 pub musicdl_version: Option, + /// 本应用锁定的 musicdl 版本(`MUSICDL_VERSION`):是否过期、更新到哪个版本都以它为准 + pub musicdl_expected: String, + /// 已装 musicdl 是否与锁定版本不一致。 + /// 未安装时恒为 false(那是「安装」引导的事,不是「更新」)。 + pub musicdl_outdated: bool, /// FFmpeg 是否可用(部分音源需要,非必需) pub ffmpeg: Option, /// 桥接进程是否在运行 @@ -125,6 +135,10 @@ pub struct MusicSettings { pub select_quality_on_download: bool, /// 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K") pub default_download_quality: String, + /// QQ 音乐 Cookie(可选):用于解析需要登录的歌单(含自己的隐私歌单)与 VIP 音质。 + /// 传给 musicdl 的 default_search/parse/download_cookies;空=游客身份。 + #[serde(default)] + pub qq_cookie: String, /// 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置) #[serde(default)] pub feiniu_base_url: String, @@ -140,7 +154,7 @@ pub struct MusicSettings { /// 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空) #[serde(default)] pub feiniu_access_code: String, - /// 飞牛音乐连接列表(多连接:本地 / frp / 预留 fnconnect) + /// 飞牛音乐连接列表(多连接:局域网 / FnConnect) #[serde(default)] pub feiniu_connections: Vec, /// 当前激活连接的 id @@ -175,32 +189,84 @@ impl MusicSettings { .or_else(|| self.feiniu_connections.first()) } - /// 兼容旧版单连接字段:若连接列表为空且存在旧 feiniu_* 字段,则迁移为一条默认连接。 - pub fn migrate_feiniu(&mut self) { - if self.feiniu_connections.is_empty() { - if !self.feiniu_base_url.trim().is_empty() { - let base = self.feiniu_base_url.clone(); - self.feiniu_connections.push(FeiniuConnection { - id: "default".to_string(), - name: base.clone(), - kind: "lan".to_string(), - base_url: base, - username: self.feiniu_username.clone(), - token: self.feiniu_token.clone(), - device_id: self.feiniu_device_id.clone(), - access_code: self.feiniu_access_code.clone(), - insecure: false, - fn_id: String::new(), - }); - self.feiniu_active_id = "default".to_string(); + /// 兼容旧版单连接字段:把旧字段迁移成列表里的一条连接,并**清除旧字段**。 + /// + /// 必须是「一次性」的:旧字段一旦残留,用户把连接删光后,下一次 `load_settings()` + /// 会再次命中「列表为空 + 旧字段非空」而重新造出一条连接, + /// 表现为「删掉的连接切个页又回来了」。因此迁移完成后要清空旧字段, + /// 且返回是否发生变更,由 `load_settings` 负责落盘。 + pub fn migrate_feiniu(&mut self) -> bool { + let mut changed = false; + + // 1) 旧单连接字段 → 连接列表(仅在列表为空时迁移) + if self.feiniu_connections.is_empty() && !self.feiniu_base_url.trim().is_empty() { + let base = self.feiniu_base_url.clone(); + self.feiniu_connections.push(FeiniuConnection { + id: "default".to_string(), + name: base.clone(), + kind: "lan".to_string(), + base_url: base, + username: self.feiniu_username.clone(), + token: self.feiniu_token.clone(), + device_id: self.feiniu_device_id.clone(), + access_code: self.feiniu_access_code.clone(), + insecure: false, + fn_id: String::new(), + relay: false, + }); + self.feiniu_active_id = "default".to_string(); + changed = true; + } + + // 2) 无论列表是否为空,旧字段都已无意义(内容已并入连接,或本就没有连接), + // 一律清空并落盘,杜绝其再次触发迁移。 + for v in [ + &mut self.feiniu_base_url, + &mut self.feiniu_token, + &mut self.feiniu_username, + &mut self.feiniu_device_id, + &mut self.feiniu_access_code, + ] { + if !v.is_empty() { + v.clear(); + changed = true; } - } else if self.feiniu_active_id.is_empty() + } + + // 3) 激活 id 兜底(内存态修正,无需落盘) + if self.feiniu_active_id.is_empty() || !self.feiniu_connections.iter().any(|c| c.id == self.feiniu_active_id) { if let Some(c) = self.feiniu_connections.first() { self.feiniu_active_id = c.id.clone(); } } + + changed + } + + /// 把明文凭据迁入系统凭据管理器,并从结构体里清掉(返回是否有变更)。 + /// + /// 与 `migrate_feiniu` 同一套「一次性迁移」原则:**凭据库写入成功才清明文**, + /// 并由 `load_settings` 落盘,避免每次读取反复尝试。 + /// 写失败时保留明文——宁可姿态不一致,也不能让用户莫名掉登录态。 + pub fn migrate_secrets(&mut self) -> bool { + let mut changed = false; + for c in self.feiniu_connections.iter_mut() { + let token = c.token.trim().to_string(); + if token.is_empty() { + continue; + } + let key = secrets::feiniu_token_key(&c.id); + // 凭据库已有值时不覆盖:它可能比 settings.json 里的明文更新 + let stored = secrets::secret_read(&key).ok().flatten().unwrap_or_default(); + if stored.is_empty() && !secrets::try_store(&key, &token) { + continue; + } + c.token.clear(); + changed = true; + } + changed } } @@ -292,6 +358,7 @@ impl MusicManager { download_engine: "musicdl".to_string(), select_quality_on_download: false, default_download_quality: "最高".to_string(), // 默认下载最高音质 + qq_cookie: String::new(), feiniu_base_url: String::new(), feiniu_token: String::new(), feiniu_username: String::new(), @@ -338,14 +405,22 @@ impl MusicManager { if settings.sources.is_empty() { settings.sources = defaults.sources; } - // 飞牛音乐多连接迁移:旧单连接字段 → 连接列表 - settings.migrate_feiniu(); + // 飞牛音乐多连接迁移:旧单连接字段 → 连接列表,并清除旧字段。 + // 变更必须落盘,否则每次读取都会重新迁移,导致删掉的连接"复活"。 + let migrated = settings.migrate_feiniu(); + // 明文凭据(连接 token)→ 系统凭据管理器,成功后从结构体清除。 + let secrets_migrated = settings.migrate_secrets(); if let Ok(mut cache) = self.settings_cache.lock() { *cache = Some(SettingsCacheEntry { read_at: Instant::now(), settings: settings.clone(), }); } + if migrated || secrets_migrated { + if let Err(e) = self.save_settings(&settings) { + crate::logger::log_error("music", &format!("迁移飞牛连接设置落盘失败: {e}")); + } + } settings } @@ -412,7 +487,10 @@ impl MusicManager { python_source: probe.python_source.to_string(), bundled_python: probe.bundled_python, musicdl_installed: probe.musicdl_installed, + musicdl_outdated: probe.musicdl_installed + && !version_matches(probe.musicdl_version.as_deref(), MUSICDL_VERSION), musicdl_version: probe.musicdl_version, + musicdl_expected: MUSICDL_VERSION.to_string(), ffmpeg: probe.ffmpeg, bridge_running, runtime_dir, @@ -554,6 +632,16 @@ fn parse_python_version(text: &str) -> Option { }) } +/// 已装版本是否就是本应用锁定的版本。 +/// +/// `unknown` / 空值一律视为**不匹配**:`check_musicdl` 拿不到 `__version__` 时 +/// 无法确认它是不是受支持的那一版,宁可提示更新。 +/// 兼容 `v2.13.11` 这类带前缀的写法。 +fn version_matches(installed: Option<&str>, expected: &str) -> bool { + let v = installed.unwrap_or("").trim().trim_start_matches('v'); + !v.is_empty() && !v.eq_ignore_ascii_case("unknown") && v == expected +} + /// 检查指定 Python 能否导入 musicdl(同步子进程调用,仅在设置页触发) fn check_musicdl(exe: &PathBuf) -> (bool, Option) { let mut cmd = std::process::Command::new(exe); diff --git a/src-tauri/src/music/runtime.rs b/src-tauri/src/music/runtime.rs index d7ffa1f..b2911b0 100644 --- a/src-tauri/src/music/runtime.rs +++ b/src-tauri/src/music/runtime.rs @@ -158,6 +158,86 @@ impl MusicManager { ok } + /// 把已装的 musicdl 对齐到本应用锁定的版本(`MUSICDL_VERSION`)。 + /// + /// 为什么必须有这个动作:`install_runtime_inner` 的闸门是「能否 import」而不是 + /// 「版本是否一致」——仅升级应用(哪怕代码里的锁定版本提高了)**不会**触发 pip, + /// 已装环境会永远停在旧版本。这里显式执行 `pip install --upgrade musicdl==`; + /// `force = true` 用 `--force-reinstall` 兜住「能 import 但依赖已损坏」的灰区 + /// (`check_musicdl` 只验证 `import musicdl` 与 `__version__`,证明不了子模块可用)。 + /// + /// 只允许升到**锁定版本**,绝不升到 PyPI 最新:`bridge.py` 对 musicdl 的 monkey patch + /// 与版本强耦合(第三方解析链方法名、各源搜索字段映射、音质常量前缀), + /// 任意升版会静默破坏解析链。升锁定版本时必须同步核对那些补丁。 + pub async fn update_musicdl(&self, app: &AppHandle, force: bool) -> Result<(), String> { + self.runtime_cancel.store(false, std::sync::atomic::Ordering::SeqCst); + let result = self.update_musicdl_inner(app, force).await; + if let Err(ref e) = result { + if e != RUNTIME_CANCELLED { + let _ = app.emit( + MUSIC_RUNTIME_INSTALL_PROGRESS, + MusicInstallProgress { + stage: "error".into(), + percent: 0, + downloaded_bytes: 0, + total_bytes: None, + message: e.clone(), + }, + ); + } + } + result + } + + async fn update_musicdl_inner(&self, app: &AppHandle, force: bool) -> Result<(), String> { + let python_exe = self.bundled_python_exe(); + // 便携 Python 不可用(或跑不起来):直接走完整安装流程, + // 它会把 Python / pip / setuptools / musicdl 一次装齐 + if !(python_exe.exists() && super::run_python_version(&python_exe).is_some()) { + return self.install_runtime_inner(app).await; + } + + let mut args: Vec = vec![ + "-m".into(), + "pip".into(), + "install".into(), + "--no-warn-script-location".into(), + "--timeout".into(), + "60".into(), + "--index-url".into(), + PIP_INDEX_URL.into(), + ]; + args.push(if force { + "--force-reinstall".into() + } else { + "--upgrade".into() + }); + args.push(format!("musicdl=={}", super::MUSICDL_VERSION)); + let msg = if force { + "正在修复 musicdl(强制重装,依赖较多,可能需几分钟)...".to_string() + } else { + format!("正在更新 musicdl 到 {}...", super::MUSICDL_VERSION) + }; + + let exe = python_exe.clone(); + self.run_blocking_step( + app, + "musicdl", + 10, + 95, + move |pid| { + let mut cmd = std::process::Command::new(&exe); + cmd.args(&args); + run_cmd_blocking(cmd, pid) + }, + &msg, + ) + .await?; + self.emit_progress(app, "done", 100, 0, None, "musicdl 已对齐到锁定版本") + .await; + Ok(()) + } + // ---------- 阶段工具 ---------- async fn emit_progress( &self, diff --git a/src-tauri/src/music/secrets.rs b/src-tauri/src/music/secrets.rs new file mode 100644 index 0000000..cd491a1 --- /dev/null +++ b/src-tauri/src/music/secrets.rs @@ -0,0 +1,124 @@ +//! 敏感串的统一存放处(Windows 系统凭据管理器,DPAPI 保护)。 +//! +//! 存在的理由:本项目里出现过**两种安全姿态**——WebDAV 账号密码走凭据管理器, +//! 而飞牛登录 token 与 QQ 音乐 Cookie 明文躺在 `settings.json` / localStorage。 +//! 同样是可冒充身份的凭据,不该区别对待。 +//! +//! 约定: +//! - 一律使用 `Thing` 作为凭据服务名,`key` 作为用户名(Entry 的 account)。 +//! - 明文只允许存在于内存与系统凭据库,禁止回写 `settings.json` / localStorage。 +//! - 迁移采用「先写凭据库成功、再清明文」的顺序;**写失败时保留明文**, +//! 宁可牺牲一致性也不能把用户已登录的会话弄丢。 +//! - 非 Windows 平台没有凭据管理器:读取返回 None、写入报错, +//! 调用方据此退化为「明文存 settings」(功能优先)。 + +use crate::logger; + +/// 凭据服务名(与历史实现一致,改动会导致已有 WebDAV 凭据读不到)。 +#[cfg(windows)] +const SERVICE: &str = "Thing"; + +/// WebDAV 凭据的 key(**历史值,不可更改**)。 +pub const KEY_WEBDAV: &str = "webdav-credentials"; +/// QQ 音乐 Cookie 的 key(前端 `musicStore` 使用)。 +pub const KEY_QQ_COOKIE: &str = "music-qq-cookie"; + +/// 允许**前端**通过 `music_secret_*` 命令读写的凭据键白名单。 +/// +/// 用白名单而不是前缀匹配:前端不该有能力枚举/试探凭据库, +/// `webdav-credentials` 与飞牛 token 因此都在前端的可达范围之外。 +/// 新增键必须在此显式登记(改 Rust 代码),这是一道有意的闸门。 +pub const FRONTEND_KEYS: [&str; 1] = [KEY_QQ_COOKIE]; + +/// 飞牛连接 token 的 key 前缀(**只由后端使用**,不暴露给前端)。 +const TOKEN_KEY_PREFIX: &str = "music-feiniu-token-"; + +/// 某条飞牛连接的登录 token 的 key。 +pub fn feiniu_token_key(connection_id: &str) -> String { + format!("{TOKEN_KEY_PREFIX}{connection_id}") +} + +/// 前端是否允许访问该凭据键。 +pub fn frontend_key_allowed(key: &str) -> bool { + FRONTEND_KEYS.contains(&key) +} + +/// 读取明文(未配置 → `Ok(None)`)。 +#[cfg(windows)] +pub fn secret_read(key: &str) -> Result, String> { + let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; + match entry.get_password() { + Ok(v) => Ok(Some(v)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("读取凭据失败: {e}")), + } +} + +/// 写入明文(覆盖式)。 +#[cfg(windows)] +pub fn secret_write(key: &str, value: &str) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; + entry.set_password(value).map_err(|e| format!("保存凭据失败: {e}")) +} + +/// 删除凭据(不存在视为成功)。 +#[cfg(windows)] +pub fn secret_delete(key: &str) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("删除凭据失败: {e}")), + } +} + +#[cfg(not(windows))] +pub fn secret_read(_key: &str) -> Result, String> { + Ok(None) +} + +#[cfg(not(windows))] +pub fn secret_write(_key: &str, _value: &str) -> Result<(), String> { + Err("当前平台不支持系统凭据管理器".to_string()) +} + +#[cfg(not(windows))] +pub fn secret_delete(_key: &str) -> Result<(), String> { + Ok(()) +} + +/// 把明文**尽力**迁入凭据库(失败只记日志,不抛错)。 +/// +/// 返回「明文是否可以安全清除」:只有写入成功才为 true。 +/// 调用方用这个返回值决定要不要清空内存/配置里的明文。 +pub fn try_store(key: &str, value: &str) -> bool { + if value.is_empty() { + return true; + } + match secret_write(key, value) { + Ok(()) => true, + Err(e) => { + logger::log_error( + "music", + &format!("凭据 {key} 写入系统凭据管理器失败(保留明文作为降级): {e}"), + ); + false + } + } +} + +/// 读取飞牛连接 token;未配置或读取失败 → 空串(等价于未登录)。 +pub fn read_feiniu_token(connection_id: &str) -> String { + if connection_id.is_empty() { + return String::new(); + } + secret_read(&feiniu_token_key(connection_id)) + .ok() + .flatten() + .unwrap_or_default() +} + +/// 该连接是否已有可用 token(供前端展示「已登录」)。 +pub fn has_feiniu_token(connection_id: &str) -> bool { + !read_feiniu_token(connection_id).is_empty() +} diff --git a/src/components/layout/NowPlayingDialog.vue b/src/components/layout/NowPlayingDialog.vue index 526aa73..bb7b6c9 100644 --- a/src/components/layout/NowPlayingDialog.vue +++ b/src/components/layout/NowPlayingDialog.vue @@ -1,6 +1,6 @@ - @@ -395,13 +584,18 @@ const listEmptyHint = computed(() => { 暂无本地音乐 {{ listEmptyHint }} - + + +
+ + + +
diff --git a/src/modules/music/components/TrackItem.vue b/src/modules/music/components/TrackItem.vue index 8ba6a7f..cc7ab61 100644 --- a/src/modules/music/components/TrackItem.vue +++ b/src/modules/music/components/TrackItem.vue @@ -106,26 +106,26 @@ async function uploadToFeiniu() {